diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 426b80a..c54021f 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -49,6 +49,7 @@ type reviewOptions struct { maxTokensBudget int noFilter bool preview bool + summary bool } var reviewOpts reviewOptions @@ -212,6 +213,7 @@ func executeReviewContext(ctx context.Context, opts reviewOptions) error { SealedInput: sealedInput, MaxTokensBudget: int64(opts.maxTokensBudget), SkipFilter: opts.noFilter, + SummaryEnabled: opts.summary, RuntimeConfig: rt.RuntimeConfig, }) diff --git a/cmd/opencodereview/shared_flags.go b/cmd/opencodereview/shared_flags.go index fd58242..5dece9a 100644 --- a/cmd/opencodereview/shared_flags.go +++ b/cmd/opencodereview/shared_flags.go @@ -179,6 +179,7 @@ func registerReviewFlags(cmd *cobra.Command, opts *reviewOptions) { addProviderFlag(cmd, &opts.provider) addModelFlag(cmd, &opts.model) cmd.Flags().BoolVar(&opts.noFilter, "no-filter", false, "keep all review comments without LLM post-filtering") + cmd.Flags().BoolVar(&opts.summary, "summary", false, "generate a project-level summary consolidating all review comments") addPreviewFlag(cmd, &opts.preview) } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index bc9d4e6..77b3bad 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -154,6 +154,10 @@ type Args struct { // defines one. Set via the --no-filter CLI flag. SkipFilter bool + // SummaryEnabled enables the post-run PROJECT_SUMMARY_TASK that consolidates + // all per-file comments into a project-level summary. Set via --summary. + SummaryEnabled bool + // RuntimeConfig carries the non-secret, allowlisted runtime settings that // identify how this run was configured, for the manifest's // runtime_config_sha256. It is populated by the cmd layer from the resolved @@ -189,6 +193,7 @@ type Agent struct { runner *llmloop.Runner resumeInfo *ResumeInfo budgetExceeded bool // set when a token/tool-call budget gate stopped dispatch + projectSummary string // inputResolution holds this run's frozen commit endpoints (resolved_base/ // head/exact_range), and repoRemoteIdentity the credential-free repository @@ -376,6 +381,10 @@ func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) { if len(comments) > 0 { telemetry.RecordCommentsGenerated(ctx, int64(len(comments))) } + + // Step 3: Generate project-level summary if enabled. + a.maybeRunProjectSummary(ctx, comments) + // Join background memory compression before anything freezes run-level // state. Those jobs are cancelled rather than awaited when a conversation // ends, so their LLM request can still be in flight here; a retry report @@ -462,10 +471,9 @@ func (a *Agent) TotalCacheReadTokens() int64 { return a.runner.TotalCacheReadTok // TotalCacheWriteTokens returns the accumulated cache write tokens from all LLM calls. func (a *Agent) TotalCacheWriteTokens() int64 { return a.runner.TotalCacheWriteTokens() } -// ProjectSummary returns the markdown project-level summary. Always empty -// for the diff-review path; defined so *Agent satisfies the -// cmd/opencodereview.ResultProvider interface that scan.Agent also implements. -func (a *Agent) ProjectSummary() string { return "" } +// ProjectSummary returns the markdown project-level summary generated when +// --summary is enabled. Empty when summary is disabled or no comments were produced. +func (a *Agent) ProjectSummary() string { return a.projectSummary } // Warnings returns a copy of non-fatal warnings recorded during review. func (a *Agent) Warnings() []AgentWarning { return a.runner.Warnings() } @@ -1897,3 +1905,82 @@ func BuildToolDefs(entries []toolsconfig.ToolConfigEntry, planOnly bool) []llm.T } return defs } + +// maybeRunProjectSummary runs the PROJECT_SUMMARY_TASK over the collected +// comments when --summary is enabled. Best-effort: any error or empty input +// silently leaves projectSummary unset. +func (a *Agent) maybeRunProjectSummary(ctx context.Context, comments []model.LlmComment) { + if !a.args.SummaryEnabled { + return + } + pt := a.args.Template.ProjectSummaryTask + if pt == nil || len(pt.Messages) == 0 { + return + } + if len(comments) == 0 { + return + } + if ctx.Err() != nil || a.budgetExceeded { + return + } + + fileSet := make(map[string]struct{}, len(comments)) + for _, c := range comments { + fileSet[c.Path] = struct{}{} + } + payload := buildSummaryCommentsList(comments) + + messages := make([]llm.Message, 0, len(pt.Messages)) + for _, m := range pt.Messages { + content := m.Content + content = strings.ReplaceAll(content, "{{comment_count}}", fmt.Sprintf("%d", len(comments))) + content = strings.ReplaceAll(content, "{{file_count}}", fmt.Sprintf("%d", len(fileSet))) + content = strings.ReplaceAll(content, "{{all_comments}}", payload) + messages = append(messages, llm.NewTextMessage(m.Role, content)) + } + + const pathKey = "__review_project_summary__" + fs := a.session.GetOrCreateFileSession(pathKey) + rec := fs.AppendTaskRecord(session.MemoryCompressionTask, messages) + ctx = llm.ContextWithSessionKey(ctx, + llm.SessionTaskKey(a.session.SessionID, string(session.MemoryCompressionTask), pathKey)) + startTime := time.Now() + + resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ + Model: a.args.Model, + Messages: messages, + MaxTokens: a.args.Template.CompletionTokenLimit(), + }) + if err != nil { + rec.SetError(err, time.Since(startTime)) + fmt.Fprintf(stdout.Writer(), "[ocr] project summary failed: %v\n", err) + return + } + rec.SetResponse(resp, time.Since(startTime)) + a.runner.RecordUsage(resp.Usage) + + body := strings.TrimSpace(llmloop.StripMarkdownFences(resp.Content())) + if body == "" { + return + } + a.projectSummary = body +} + +// buildSummaryCommentsList renders comments as a compact path-anchored +// markdown list for embedding in the PROJECT_SUMMARY_TASK prompt. +func buildSummaryCommentsList(comments []model.LlmComment) string { + const maxRunes = 280 + var sb strings.Builder + for _, c := range comments { + sb.WriteString("- `") + sb.WriteString(c.Path) + sb.WriteString("`: ") + oneLine := strings.ReplaceAll(c.Content, "\n", " ") + if r := []rune(oneLine); len(r) > maxRunes { + oneLine = string(r[:maxRunes]) + "..." + } + sb.WriteString(oneLine) + sb.WriteString("\n") + } + return sb.String() +} diff --git a/internal/config/template/prompts/project_summary_task_system.md b/internal/config/template/prompts/project_summary_task_system.md new file mode 100644 index 0000000..366c16b --- /dev/null +++ b/internal/config/template/prompts/project_summary_task_system.md @@ -0,0 +1,24 @@ +You are a senior code reviewer producing a project-level summary of a diff-based code review. + +You are given the union of per-file review comments generated for a pull request or commit. Your job is to identify cross-cutting patterns and highlight the most important findings. Do NOT restate every individual comment verbatim. + +## Output Format + +Produce a single Markdown document with these sections. Omit any section that has nothing meaningful to say. + +### Top Issues +The 3-5 most consequential findings, ranked by impact. Group findings when the same root cause repeats across files. Reference file paths. + +### Cross-Cutting Concerns +Patterns that appear across multiple files (e.g. inconsistent error handling, missing validation, repeated unsafe patterns). Cite representative file paths. + +### Quick Wins +Low-effort, high-leverage fixes based on the review comments. + +### Overall Assessment +One or two sentences on whether the change is ready to merge, needs minor fixes, or has blocking issues. + +## Guidelines +- Be concrete; always reference file paths when citing an issue. +- Do not include generic praise or filler text. +- Keep the summary concise — aim for under 500 words total. \ No newline at end of file diff --git a/internal/config/template/prompts/project_summary_task_user.md b/internal/config/template/prompts/project_summary_task_user.md new file mode 100644 index 0000000..b10ee77 --- /dev/null +++ b/internal/config/template/prompts/project_summary_task_user.md @@ -0,0 +1,7 @@ +Total comments: {{comment_count}} across {{file_count}} file(s). + + +{{all_comments}} + + +Produce the project-level summary now. \ No newline at end of file diff --git a/internal/config/template/task_template.json b/internal/config/template/task_template.json index 8a9c1dd..0c31138 100644 --- a/internal/config/template/task_template.json +++ b/internal/config/template/task_template.json @@ -29,6 +29,12 @@ { "role": "user", "prompt_file": "re_location_task_user.md" } ] }, + "PROJECT_SUMMARY_TASK": { + "messages": [ + { "role": "system", "prompt_file": "project_summary_task_system.md" }, + { "role": "user", "prompt_file": "project_summary_task_user.md" } + ] + }, "MAX_TOOL_REQUEST_TIMES": 30, "PLAN_MODE_LINE_THRESHOLD": 50, "MAX_TOKENS": 58888 diff --git a/internal/config/template/template.go b/internal/config/template/template.go index 2ca8f30..27a97c6 100644 --- a/internal/config/template/template.go +++ b/internal/config/template/template.go @@ -25,6 +25,7 @@ type Template struct { PlanModeLineThreshold int `json:"PLAN_MODE_LINE_THRESHOLD"` ReLocationTask *LlmConversation `json:"RE_LOCATION_TASK,omitempty"` ReviewFilterTask *LlmConversation `json:"REVIEW_FILTER_TASK,omitempty"` + ProjectSummaryTask *LlmConversation `json:"PROJECT_SUMMARY_TASK,omitempty"` } // ScanTemplate holds the full-file scan task template configuration loaded @@ -90,6 +91,7 @@ type templateManifest struct { PlanModeLineThreshold int `json:"PLAN_MODE_LINE_THRESHOLD"` ReLocationTask *manifestConversation `json:"RE_LOCATION_TASK,omitempty"` ReviewFilterTask *manifestConversation `json:"REVIEW_FILTER_TASK,omitempty"` + ProjectSummaryTask *manifestConversation `json:"PROJECT_SUMMARY_TASK,omitempty"` } func resolveConversation(m manifestConversation) (LlmConversation, error) { @@ -150,6 +152,9 @@ func LoadDefault() (*Template, error) { if tpl.ReviewFilterTask, err = resolveOptionalConversation(m.ReviewFilterTask, "REVIEW_FILTER_TASK"); err != nil { return nil, err } + if tpl.ProjectSummaryTask, err = resolveOptionalConversation(m.ProjectSummaryTask, "PROJECT_SUMMARY_TASK"); err != nil { + return nil, err + } return &tpl, nil } @@ -188,6 +193,9 @@ func (t *Template) ApplyLanguage(lang string) { applyLanguage(t.PlanTask, instruction) } applyLanguage(&t.MemoryCompressionTask, instruction) + if t.ProjectSummaryTask != nil { + applyLanguage(t.ProjectSummaryTask, instruction) + } } // ApplyLanguage injects a language directive into all system-role messages