1504 lines
44 KiB
Go
1504 lines
44 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var councilReviewerRoles = []string{
|
|
"architecture-reviewer",
|
|
"implementation-reviewer",
|
|
"risk-reviewer",
|
|
}
|
|
|
|
type CouncilRun struct {
|
|
RunID string `json:"run_id"`
|
|
Mode string `json:"mode"`
|
|
TargetType string `json:"target_type"`
|
|
OutputMode string `json:"output_mode"`
|
|
OnlyUnanimous bool `json:"only_unanimous"`
|
|
}
|
|
|
|
type CouncilInput struct {
|
|
RunID string `json:"run_id"`
|
|
Prompt string `json:"prompt,omitempty"`
|
|
TargetFile string `json:"target_file,omitempty"`
|
|
RepoPath string `json:"repo_path,omitempty"`
|
|
TargetTaskID string `json:"task_id,omitempty"`
|
|
}
|
|
|
|
type CouncilReviewer struct {
|
|
ReviewerRole string `json:"reviewer_role"`
|
|
TaskID string `json:"task_id"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type CouncilStartInput struct {
|
|
RunID string
|
|
Target string
|
|
TargetFile string
|
|
RepoPath string
|
|
TargetTaskID string
|
|
TargetType string
|
|
Mode string
|
|
OutputMode string
|
|
OnlyUnanimous bool
|
|
}
|
|
|
|
type CouncilStartResult struct {
|
|
Run CouncilRun `json:"run"`
|
|
Input CouncilInput `json:"input"`
|
|
Reviewers []CouncilReviewer `json:"reviewers"`
|
|
}
|
|
|
|
type CouncilWaitInput struct {
|
|
RunID string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type CouncilWaitResult struct {
|
|
Woke bool `json:"woke"`
|
|
RunID string `json:"run_id"`
|
|
AllComplete bool `json:"all_complete"`
|
|
ReviewerStatuses []CouncilReviewer `json:"reviewers"`
|
|
}
|
|
|
|
type CouncilFinding struct {
|
|
RunID string `json:"run_id"`
|
|
ReviewerRole string `json:"reviewer_role"`
|
|
FindingID string `json:"finding_id"`
|
|
Title string `json:"title"`
|
|
Summary string `json:"summary"`
|
|
Proposal string `json:"proposal"`
|
|
Rationale string `json:"rationale"`
|
|
Confidence string `json:"confidence"`
|
|
TagsJSON json.RawMessage `json:"tags_json"`
|
|
TargetRefsJSON json.RawMessage `json:"target_refs_json"`
|
|
}
|
|
|
|
type CouncilGroup struct {
|
|
RunID string `json:"run_id"`
|
|
GroupID string `json:"group_id"`
|
|
Proposal string `json:"proposal"`
|
|
Bucket string `json:"bucket"`
|
|
SupportCount int `json:"support_count"`
|
|
SupportersJSON json.RawMessage `json:"supporters_json"`
|
|
DissentersJSON json.RawMessage `json:"dissenters_json"`
|
|
RationaleSummary string `json:"rationale_summary"`
|
|
TagsJSON json.RawMessage `json:"tags_json"`
|
|
SourceFindingIDsJSON json.RawMessage `json:"source_finding_ids_json"`
|
|
}
|
|
|
|
type CouncilTallyInput struct {
|
|
RunID string
|
|
Similarity string
|
|
}
|
|
|
|
type CouncilTallyResult struct {
|
|
RunID string `json:"run_id"`
|
|
Similarity string `json:"similarity"`
|
|
Counts map[string]int `json:"counts"`
|
|
GroupedRecommendations []CouncilGroup `json:"grouped_recommendations"`
|
|
}
|
|
|
|
type CouncilReportInput struct {
|
|
RunID string
|
|
Show string
|
|
}
|
|
|
|
type CouncilReportArtifact struct {
|
|
Kind string `json:"kind"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type CouncilReportResult struct {
|
|
RunID string `json:"run_id"`
|
|
Show []string `json:"show"`
|
|
Summary map[string]int `json:"summary"`
|
|
GroupedRecommendations []CouncilGroup `json:"grouped_recommendations"`
|
|
Markdown string `json:"markdown,omitempty"`
|
|
ReportArtifacts []CouncilReportArtifact `json:"report_artifacts,omitempty"`
|
|
}
|
|
|
|
type CouncilPersistReportInput struct {
|
|
RunID string
|
|
Show []string
|
|
Summary map[string]int
|
|
MarkdownPath string
|
|
}
|
|
|
|
type councilReviewerOutput struct {
|
|
ReviewerRole string `json:"reviewer_role"`
|
|
Findings []councilFindingOutput `json:"findings"`
|
|
}
|
|
|
|
type councilFindingOutput struct {
|
|
Title string `json:"title"`
|
|
Summary string `json:"summary"`
|
|
Proposal string `json:"proposal"`
|
|
Rationale string `json:"rationale"`
|
|
Confidence string `json:"confidence"`
|
|
Tags json.RawMessage `json:"tags"`
|
|
TargetRefs json.RawMessage `json:"target_refs"`
|
|
}
|
|
|
|
func (s *OrchStore) StartCouncil(ctx context.Context, input CouncilStartInput) (CouncilStartResult, error) {
|
|
runID := strings.TrimSpace(input.RunID)
|
|
if runID == "" {
|
|
return CouncilStartResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
councilInput, err := normalizeCouncilInput(input)
|
|
if err != nil {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
|
|
councilRun, err := normalizeCouncilRun(input)
|
|
if err != nil {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return CouncilStartResult{}, fmt.Errorf("begin council start transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, runID); err == nil {
|
|
return CouncilStartResult{}, fmt.Errorf("%w: run %s already exists", ErrInvalidState, runID)
|
|
} else if !errors.Is(err, ErrRunNotFound) {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
|
|
goal := buildCouncilRunGoal(councilInput)
|
|
summary := buildCouncilRunSummary(councilRun, councilInput)
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO runs (run_id, goal, summary, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
runID,
|
|
goal,
|
|
summary,
|
|
"active",
|
|
formatTime(now),
|
|
formatTime(now),
|
|
)
|
|
if err != nil {
|
|
return CouncilStartResult{}, fmt.Errorf("insert council run into runs: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: runID,
|
|
Source: "orch",
|
|
EventType: "run_initialized",
|
|
Summary: summary,
|
|
PayloadJSON: marshalJSON(map[string]any{"goal": goal, "summary": summary}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO council_runs (
|
|
run_id, mode, target_type, output_mode, only_unanimous, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
runID,
|
|
councilRun.Mode,
|
|
councilRun.TargetType,
|
|
councilRun.OutputMode,
|
|
boolToInt(councilRun.OnlyUnanimous),
|
|
formatTime(now),
|
|
formatTime(now),
|
|
)
|
|
if err != nil {
|
|
return CouncilStartResult{}, fmt.Errorf("insert council run metadata: %w", err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO council_inputs (
|
|
run_id, prompt, target_file, repo_path, target_task_id, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
runID,
|
|
councilInput.Prompt,
|
|
councilInput.TargetFile,
|
|
councilInput.RepoPath,
|
|
councilInput.TargetTaskID,
|
|
formatTime(now),
|
|
formatTime(now),
|
|
)
|
|
if err != nil {
|
|
return CouncilStartResult{}, fmt.Errorf("insert council input metadata: %w", err)
|
|
}
|
|
|
|
reviewers := make([]CouncilReviewer, 0, len(councilReviewerRoles))
|
|
for i, reviewerRole := range councilReviewerRoles {
|
|
taskID := fmt.Sprintf("CR%d", i+1)
|
|
task := Task{
|
|
RunID: runID,
|
|
TaskID: taskID,
|
|
Title: buildCouncilTaskTitle(reviewerRole),
|
|
Summary: buildCouncilTaskSummary(reviewerRole),
|
|
Status: "ready",
|
|
DefaultTo: reviewerRole,
|
|
Priority: "normal",
|
|
AcceptanceJSON: []byte(buildCouncilTaskAcceptanceJSON(councilRun, councilInput, reviewerRole)),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO tasks (
|
|
run_id, task_id, title, summary, status, default_to, priority,
|
|
acceptance_json, latest_attempt_no, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
|
|
task.RunID,
|
|
task.TaskID,
|
|
task.Title,
|
|
task.Summary,
|
|
task.Status,
|
|
nullIfEmpty(task.DefaultTo),
|
|
task.Priority,
|
|
string(task.AcceptanceJSON),
|
|
formatTime(task.CreatedAt),
|
|
formatTime(task.UpdatedAt),
|
|
)
|
|
if err != nil {
|
|
return CouncilStartResult{}, fmt.Errorf("insert council reviewer task: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: runID,
|
|
TaskID: taskID,
|
|
Source: "orch",
|
|
EventType: "task_added",
|
|
Summary: task.Title,
|
|
PayloadJSON: marshalJSON(map[string]any{"title": task.Title, "priority": task.Priority}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: runID,
|
|
TaskID: taskID,
|
|
Source: "orch",
|
|
EventType: "task_ready",
|
|
Summary: task.Title,
|
|
PayloadJSON: marshalJSON(map[string]any{"task_id": taskID}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
|
|
dispatchResult, finalizeWorkspace, err := s.dispatchTaskTx(
|
|
ctx,
|
|
tx,
|
|
task,
|
|
reviewerRole,
|
|
buildCouncilTaskBody(councilRun, councilInput, reviewerRole),
|
|
"",
|
|
nil,
|
|
now,
|
|
)
|
|
if err != nil {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
defer finalizeWorkspace(false)
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO council_reviewers (run_id, reviewer_role, task_id, status)
|
|
VALUES (?, ?, ?, ?)`,
|
|
runID,
|
|
reviewerRole,
|
|
taskID,
|
|
dispatchResult.Task.Status,
|
|
)
|
|
if err != nil {
|
|
return CouncilStartResult{}, fmt.Errorf("insert council reviewer row: %w", err)
|
|
}
|
|
|
|
reviewers = append(reviewers, CouncilReviewer{
|
|
ReviewerRole: reviewerRole,
|
|
TaskID: taskID,
|
|
Status: dispatchResult.Task.Status,
|
|
})
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: runID,
|
|
Source: "orch",
|
|
EventType: "council_started",
|
|
Summary: "council reviewers dispatched",
|
|
PayloadJSON: marshalJSON(map[string]any{
|
|
"mode": councilRun.Mode,
|
|
"target_type": councilRun.TargetType,
|
|
"output_mode": councilRun.OutputMode,
|
|
"only_unanimous": councilRun.OnlyUnanimous,
|
|
"reviewers": reviewers,
|
|
}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
|
|
if err := updateRunAggregateStatus(ctx, tx, runID, now); err != nil {
|
|
return CouncilStartResult{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return CouncilStartResult{}, fmt.Errorf("commit council start transaction: %w", err)
|
|
}
|
|
|
|
return CouncilStartResult{
|
|
Run: councilRun,
|
|
Input: councilInput,
|
|
Reviewers: reviewers,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeCouncilRun(input CouncilStartInput) (CouncilRun, error) {
|
|
mode := defaultString(strings.TrimSpace(input.Mode), "brainstorm")
|
|
switch mode {
|
|
case "brainstorm", "review":
|
|
default:
|
|
return CouncilRun{}, fmt.Errorf("%w: mode must be brainstorm or review", ErrInvalidInput)
|
|
}
|
|
|
|
targetType := defaultString(strings.TrimSpace(input.TargetType), "mixed")
|
|
switch targetType {
|
|
case "text", "repo", "mixed":
|
|
default:
|
|
return CouncilRun{}, fmt.Errorf("%w: target-type must be text, repo, or mixed", ErrInvalidInput)
|
|
}
|
|
|
|
outputMode := defaultString(strings.TrimSpace(input.OutputMode), "both")
|
|
switch outputMode {
|
|
case "markdown", "json", "both":
|
|
default:
|
|
return CouncilRun{}, fmt.Errorf("%w: output must be markdown, json, or both", ErrInvalidInput)
|
|
}
|
|
|
|
return CouncilRun{
|
|
RunID: strings.TrimSpace(input.RunID),
|
|
Mode: mode,
|
|
TargetType: targetType,
|
|
OutputMode: outputMode,
|
|
OnlyUnanimous: input.OnlyUnanimous,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeCouncilInput(input CouncilStartInput) (CouncilInput, error) {
|
|
result := CouncilInput{
|
|
RunID: strings.TrimSpace(input.RunID),
|
|
Prompt: strings.TrimSpace(input.Target),
|
|
TargetFile: strings.TrimSpace(input.TargetFile),
|
|
RepoPath: strings.TrimSpace(input.RepoPath),
|
|
TargetTaskID: strings.TrimSpace(input.TargetTaskID),
|
|
}
|
|
|
|
if result.Prompt == "" && result.TargetFile == "" && result.RepoPath == "" && result.TargetTaskID == "" {
|
|
return CouncilInput{}, fmt.Errorf("%w: at least one of target, target-file, repo-path, or task-id is required", ErrInvalidInput)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func buildCouncilRunGoal(input CouncilInput) string {
|
|
switch {
|
|
case input.Prompt != "":
|
|
return "Council review: " + truncateSingleLine(input.Prompt, 80)
|
|
case input.TargetTaskID != "":
|
|
return "Council review for task " + input.TargetTaskID
|
|
case input.TargetFile != "":
|
|
return "Council review for " + input.TargetFile
|
|
case input.RepoPath != "":
|
|
return "Council review for repo " + input.RepoPath
|
|
default:
|
|
return "Council review"
|
|
}
|
|
}
|
|
|
|
func buildCouncilRunSummary(run CouncilRun, input CouncilInput) string {
|
|
return fmt.Sprintf("%s council (%s)", run.Mode, run.TargetType)
|
|
}
|
|
|
|
func buildCouncilTaskTitle(reviewerRole string) string {
|
|
switch reviewerRole {
|
|
case "architecture-reviewer":
|
|
return "Council architecture review"
|
|
case "implementation-reviewer":
|
|
return "Council implementation review"
|
|
case "risk-reviewer":
|
|
return "Council risk review"
|
|
default:
|
|
return "Council review"
|
|
}
|
|
}
|
|
|
|
func buildCouncilTaskSummary(reviewerRole string) string {
|
|
switch reviewerRole {
|
|
case "architecture-reviewer":
|
|
return "Review the target for architecture, boundaries, and interfaces"
|
|
case "implementation-reviewer":
|
|
return "Review the target for simplicity, maintainability, and practicality"
|
|
case "risk-reviewer":
|
|
return "Review the target for regressions, correctness, and operability risks"
|
|
default:
|
|
return "Review the target"
|
|
}
|
|
}
|
|
|
|
func buildCouncilTaskAcceptanceJSON(run CouncilRun, input CouncilInput, reviewerRole string) string {
|
|
return marshalJSON(map[string]any{
|
|
"mode": "analysis",
|
|
"council": map[string]any{
|
|
"reviewer_role": reviewerRole,
|
|
"council_mode": run.Mode,
|
|
"target_type": run.TargetType,
|
|
"output_mode": run.OutputMode,
|
|
"only_unanimous": run.OnlyUnanimous,
|
|
"target": map[string]any{
|
|
"prompt": input.Prompt,
|
|
"target_file": input.TargetFile,
|
|
"repo_path": input.RepoPath,
|
|
"task_id": input.TargetTaskID,
|
|
},
|
|
"response_format": map[string]any{
|
|
"reviewer_role": reviewerRole,
|
|
"findings": []map[string]any{
|
|
{
|
|
"title": "string",
|
|
"summary": "string",
|
|
"proposal": "string",
|
|
"rationale": "string",
|
|
"confidence": "low|medium|high",
|
|
"tags": []string{},
|
|
"target_refs": map[string]any{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func buildCouncilTaskBody(run CouncilRun, input CouncilInput, reviewerRole string) string {
|
|
parts := []string{
|
|
fmt.Sprintf("Reviewer role: %s", reviewerRole),
|
|
fmt.Sprintf("Council mode: %s", run.Mode),
|
|
fmt.Sprintf("Target type: %s", run.TargetType),
|
|
"Analyze the target from your assigned reviewer perspective.",
|
|
"Return structured findings with title, summary, proposal, rationale, confidence, tags, and optional target references.",
|
|
}
|
|
|
|
if input.Prompt != "" {
|
|
parts = append(parts, "", "Prompt:", input.Prompt)
|
|
}
|
|
if input.TargetFile != "" {
|
|
parts = append(parts, "", "Target file:", input.TargetFile)
|
|
}
|
|
if input.RepoPath != "" {
|
|
parts = append(parts, "", "Repo path:", input.RepoPath)
|
|
}
|
|
if input.TargetTaskID != "" {
|
|
parts = append(parts, "", "Related task id:", input.TargetTaskID)
|
|
}
|
|
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func truncateSingleLine(value string, maxLen int) string {
|
|
value = strings.TrimSpace(value)
|
|
value = strings.ReplaceAll(value, "\n", " ")
|
|
value = strings.ReplaceAll(value, "\r", " ")
|
|
value = strings.Join(strings.Fields(value), " ")
|
|
if maxLen <= 0 || len(value) <= maxLen {
|
|
return value
|
|
}
|
|
if maxLen <= 3 {
|
|
return value[:maxLen]
|
|
}
|
|
return value[:maxLen-3] + "..."
|
|
}
|
|
|
|
func boolToInt(value bool) int {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s *OrchStore) WaitForCouncil(ctx context.Context, input CouncilWaitInput) (CouncilWaitResult, error) {
|
|
runID := strings.TrimSpace(input.RunID)
|
|
if runID == "" {
|
|
return CouncilWaitResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
if _, err := s.GetCouncilRun(ctx, runID); err != nil {
|
|
return CouncilWaitResult{}, err
|
|
}
|
|
|
|
waitCtx := ctx
|
|
cancel := func() {}
|
|
if input.Timeout > 0 {
|
|
waitCtx, cancel = context.WithTimeout(ctx, input.Timeout)
|
|
}
|
|
defer cancel()
|
|
|
|
for {
|
|
reviewers, allComplete, err := s.GetCouncilReviewerStatuses(waitCtx, runID)
|
|
if err != nil {
|
|
if isDeadlineExceeded(waitCtx) {
|
|
return CouncilWaitResult{
|
|
Woke: false,
|
|
RunID: runID,
|
|
AllComplete: false,
|
|
ReviewerStatuses: reviewers,
|
|
}, nil
|
|
}
|
|
return CouncilWaitResult{}, err
|
|
}
|
|
if allComplete {
|
|
return CouncilWaitResult{
|
|
Woke: true,
|
|
RunID: runID,
|
|
AllComplete: true,
|
|
ReviewerStatuses: reviewers,
|
|
}, nil
|
|
}
|
|
|
|
if _, err := s.ReconcileRun(waitCtx, runID); err != nil {
|
|
if isSQLiteBusyError(err) {
|
|
ok, waitErr := waitForNextPoll(waitCtx, 25*time.Millisecond)
|
|
if waitErr != nil {
|
|
if errors.Is(waitErr, context.DeadlineExceeded) {
|
|
reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID)
|
|
return CouncilWaitResult{
|
|
Woke: false,
|
|
RunID: runID,
|
|
AllComplete: false,
|
|
ReviewerStatuses: reviewers,
|
|
}, nil
|
|
}
|
|
return CouncilWaitResult{}, waitErr
|
|
}
|
|
if !ok {
|
|
reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID)
|
|
return CouncilWaitResult{
|
|
Woke: false,
|
|
RunID: runID,
|
|
AllComplete: false,
|
|
ReviewerStatuses: reviewers,
|
|
}, nil
|
|
}
|
|
continue
|
|
}
|
|
if isDeadlineExceeded(waitCtx) {
|
|
reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID)
|
|
return CouncilWaitResult{
|
|
Woke: false,
|
|
RunID: runID,
|
|
AllComplete: false,
|
|
ReviewerStatuses: reviewers,
|
|
}, nil
|
|
}
|
|
return CouncilWaitResult{}, err
|
|
}
|
|
|
|
ok, err := waitForNextPoll(waitCtx, 200*time.Millisecond)
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID)
|
|
return CouncilWaitResult{
|
|
Woke: false,
|
|
RunID: runID,
|
|
AllComplete: false,
|
|
ReviewerStatuses: reviewers,
|
|
}, nil
|
|
}
|
|
return CouncilWaitResult{}, err
|
|
}
|
|
if !ok {
|
|
reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID)
|
|
return CouncilWaitResult{
|
|
Woke: false,
|
|
RunID: runID,
|
|
AllComplete: false,
|
|
ReviewerStatuses: reviewers,
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *OrchStore) GetCouncilRun(ctx context.Context, runID string) (CouncilRun, error) {
|
|
row := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT run_id, mode, target_type, output_mode, only_unanimous
|
|
FROM council_runs
|
|
WHERE run_id = ?`,
|
|
runID,
|
|
)
|
|
|
|
var (
|
|
run CouncilRun
|
|
onlyUnanimous int
|
|
)
|
|
err := row.Scan(&run.RunID, &run.Mode, &run.TargetType, &run.OutputMode, &onlyUnanimous)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return CouncilRun{}, fmt.Errorf("%w: council run %s not found", ErrRunNotFound, runID)
|
|
}
|
|
if err != nil {
|
|
return CouncilRun{}, fmt.Errorf("scan council run: %w", err)
|
|
}
|
|
run.OnlyUnanimous = onlyUnanimous != 0
|
|
return run, nil
|
|
}
|
|
|
|
func (s *OrchStore) GetCouncilReviewerStatuses(ctx context.Context, runID string) ([]CouncilReviewer, bool, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT cr.reviewer_role, cr.task_id, t.status
|
|
FROM council_reviewers cr
|
|
JOIN tasks t
|
|
ON t.run_id = cr.run_id
|
|
AND t.task_id = cr.task_id
|
|
WHERE cr.run_id = ?
|
|
ORDER BY cr.reviewer_role ASC`,
|
|
runID,
|
|
)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("query council reviewer statuses: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
reviewers := make([]CouncilReviewer, 0, len(councilReviewerRoles))
|
|
allComplete := true
|
|
for rows.Next() {
|
|
var reviewer CouncilReviewer
|
|
if err := rows.Scan(&reviewer.ReviewerRole, &reviewer.TaskID, &reviewer.Status); err != nil {
|
|
return nil, false, fmt.Errorf("scan council reviewer status: %w", err)
|
|
}
|
|
if reviewer.Status != "done" && reviewer.Status != "failed" && reviewer.Status != "cancelled" {
|
|
allComplete = false
|
|
}
|
|
reviewers = append(reviewers, reviewer)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, false, fmt.Errorf("iterate council reviewer statuses: %w", err)
|
|
}
|
|
if len(reviewers) == 0 {
|
|
return nil, false, fmt.Errorf("%w: council reviewers for run %s not found", ErrRunNotFound, runID)
|
|
}
|
|
|
|
return reviewers, allComplete, nil
|
|
}
|
|
|
|
func (s *OrchStore) TallyCouncil(ctx context.Context, input CouncilTallyInput) (CouncilTallyResult, error) {
|
|
runID := strings.TrimSpace(input.RunID)
|
|
if runID == "" {
|
|
return CouncilTallyResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
similarity := defaultString(strings.TrimSpace(input.Similarity), "normal")
|
|
if similarity != "normal" && similarity != "strict" {
|
|
return CouncilTallyResult{}, fmt.Errorf("%w: similarity must be strict or normal", ErrInvalidInput)
|
|
}
|
|
|
|
if _, err := s.GetCouncilRun(ctx, runID); err != nil {
|
|
return CouncilTallyResult{}, err
|
|
}
|
|
|
|
if _, err := s.ReconcileRun(ctx, runID); err != nil && !isSQLiteBusyError(err) {
|
|
return CouncilTallyResult{}, err
|
|
}
|
|
|
|
reviewers, allComplete, err := s.GetCouncilReviewerStatuses(ctx, runID)
|
|
if err != nil {
|
|
return CouncilTallyResult{}, err
|
|
}
|
|
if !allComplete {
|
|
return CouncilTallyResult{}, fmt.Errorf("%w: council reviewers are not complete yet", ErrInvalidState)
|
|
}
|
|
|
|
findings, err := s.collectCouncilFindings(ctx, runID, reviewers)
|
|
if err != nil {
|
|
return CouncilTallyResult{}, err
|
|
}
|
|
groups := groupCouncilFindings(runID, findings, reviewers, similarity)
|
|
counts := make(map[string]int)
|
|
for _, group := range groups {
|
|
counts[group.Bucket]++
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return CouncilTallyResult{}, fmt.Errorf("begin council tally transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM council_findings WHERE run_id = ?`, runID); err != nil {
|
|
return CouncilTallyResult{}, fmt.Errorf("clear council findings: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM council_groups WHERE run_id = ?`, runID); err != nil {
|
|
return CouncilTallyResult{}, fmt.Errorf("clear council groups: %w", err)
|
|
}
|
|
|
|
for _, finding := range findings {
|
|
if _, err := tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO council_findings (
|
|
run_id, reviewer_role, finding_id, title, summary, proposal, rationale,
|
|
confidence, tags_json, target_refs_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
finding.RunID,
|
|
finding.ReviewerRole,
|
|
finding.FindingID,
|
|
finding.Title,
|
|
finding.Summary,
|
|
finding.Proposal,
|
|
finding.Rationale,
|
|
finding.Confidence,
|
|
string(finding.TagsJSON),
|
|
string(finding.TargetRefsJSON),
|
|
); err != nil {
|
|
return CouncilTallyResult{}, fmt.Errorf("insert council finding: %w", err)
|
|
}
|
|
}
|
|
|
|
for _, group := range groups {
|
|
if _, err := tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO council_groups (
|
|
run_id, group_id, proposal, bucket, support_count, supporters_json,
|
|
dissenters_json, rationale_summary, tags_json, source_finding_ids_json
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
group.RunID,
|
|
group.GroupID,
|
|
group.Proposal,
|
|
group.Bucket,
|
|
group.SupportCount,
|
|
string(group.SupportersJSON),
|
|
string(group.DissentersJSON),
|
|
group.RationaleSummary,
|
|
string(group.TagsJSON),
|
|
string(group.SourceFindingIDsJSON),
|
|
); err != nil {
|
|
return CouncilTallyResult{}, fmt.Errorf("insert council group: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: runID,
|
|
Source: "orch",
|
|
EventType: "council_tallied",
|
|
Summary: "council recommendations grouped",
|
|
PayloadJSON: marshalJSON(map[string]any{
|
|
"similarity": similarity,
|
|
"counts": counts,
|
|
}),
|
|
CreatedAt: nowUTC(),
|
|
}); err != nil {
|
|
return CouncilTallyResult{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return CouncilTallyResult{}, fmt.Errorf("commit council tally transaction: %w", err)
|
|
}
|
|
|
|
return CouncilTallyResult{
|
|
RunID: runID,
|
|
Similarity: similarity,
|
|
Counts: counts,
|
|
GroupedRecommendations: groups,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) BuildCouncilReport(ctx context.Context, input CouncilReportInput) (CouncilReportResult, error) {
|
|
runID := strings.TrimSpace(input.RunID)
|
|
if runID == "" {
|
|
return CouncilReportResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
run, err := s.GetCouncilRun(ctx, runID)
|
|
if err != nil {
|
|
return CouncilReportResult{}, err
|
|
}
|
|
councilInput, err := s.GetCouncilInput(ctx, runID)
|
|
if err != nil {
|
|
return CouncilReportResult{}, err
|
|
}
|
|
|
|
show, err := normalizeCouncilReportShow(input.Show, run.OnlyUnanimous)
|
|
if err != nil {
|
|
return CouncilReportResult{}, err
|
|
}
|
|
|
|
groups, tallied, err := s.ListCouncilGroups(ctx, runID)
|
|
if err != nil {
|
|
return CouncilReportResult{}, err
|
|
}
|
|
if !tallied {
|
|
return CouncilReportResult{}, fmt.Errorf("%w: council groups are not available; run council tally first", ErrInvalidState)
|
|
}
|
|
|
|
summary := councilGroupSummary(groups)
|
|
selectedGroups := selectCouncilGroupsForReport(groups, show)
|
|
markdown := renderCouncilReportMarkdown(run, councilInput, show, summary, selectedGroups)
|
|
|
|
return CouncilReportResult{
|
|
RunID: runID,
|
|
Show: show,
|
|
Summary: summary,
|
|
GroupedRecommendations: selectedGroups,
|
|
Markdown: markdown,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) PersistCouncilReport(ctx context.Context, input CouncilPersistReportInput) error {
|
|
runID := strings.TrimSpace(input.RunID)
|
|
if runID == "" {
|
|
return fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if _, err := s.GetCouncilRun(ctx, runID); err != nil {
|
|
return err
|
|
}
|
|
|
|
showJSON := marshalJSON(input.Show)
|
|
summaryJSON := marshalJSON(normalizeCouncilSummary(input.Summary))
|
|
markdownPath := strings.TrimSpace(input.MarkdownPath)
|
|
now := nowUTC()
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin council report transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO council_reports (
|
|
run_id, show_json, summary_json, markdown_path, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(run_id) DO UPDATE SET
|
|
show_json = excluded.show_json,
|
|
summary_json = excluded.summary_json,
|
|
markdown_path = excluded.markdown_path,
|
|
updated_at = excluded.updated_at`,
|
|
runID,
|
|
showJSON,
|
|
summaryJSON,
|
|
markdownPath,
|
|
formatTime(now),
|
|
formatTime(now),
|
|
); err != nil {
|
|
return fmt.Errorf("persist council report metadata: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: runID,
|
|
Source: "orch",
|
|
EventType: "council_reported",
|
|
Summary: "council report generated",
|
|
PayloadJSON: marshalJSON(map[string]any{
|
|
"show": input.Show,
|
|
"markdown_path": markdownPath,
|
|
}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit council report transaction: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *OrchStore) collectCouncilFindings(ctx context.Context, runID string, reviewers []CouncilReviewer) ([]CouncilFinding, error) {
|
|
findings := make([]CouncilFinding, 0)
|
|
for _, reviewer := range reviewers {
|
|
if reviewer.Status != "done" {
|
|
return nil, fmt.Errorf("%w: reviewer %s did not finish successfully", ErrInvalidState, reviewer.ReviewerRole)
|
|
}
|
|
|
|
message, err := s.loadCouncilReviewerResultMessage(ctx, runID, reviewer.TaskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output, err := parseCouncilReviewerOutput(reviewer.ReviewerRole, message)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i, finding := range output.Findings {
|
|
tagsJSON, err := normalizeOptionalJSONArray(finding.Tags)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: reviewer %s finding %d tags must be a JSON array", ErrInvalidInput, reviewer.ReviewerRole, i+1)
|
|
}
|
|
targetRefsJSON, err := normalizeOptionalJSONObject(finding.TargetRefs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: reviewer %s finding %d target_refs must be a JSON object", ErrInvalidInput, reviewer.ReviewerRole, i+1)
|
|
}
|
|
confidence := strings.TrimSpace(finding.Confidence)
|
|
switch confidence {
|
|
case "low", "medium", "high":
|
|
default:
|
|
return nil, fmt.Errorf("%w: reviewer %s finding %d confidence must be low, medium, or high", ErrInvalidInput, reviewer.ReviewerRole, i+1)
|
|
}
|
|
if strings.TrimSpace(finding.Proposal) == "" {
|
|
return nil, fmt.Errorf("%w: reviewer %s finding %d proposal is required", ErrInvalidInput, reviewer.ReviewerRole, i+1)
|
|
}
|
|
findings = append(findings, CouncilFinding{
|
|
RunID: runID,
|
|
ReviewerRole: reviewer.ReviewerRole,
|
|
FindingID: fmt.Sprintf("f%02d", i+1),
|
|
Title: strings.TrimSpace(finding.Title),
|
|
Summary: strings.TrimSpace(finding.Summary),
|
|
Proposal: strings.TrimSpace(finding.Proposal),
|
|
Rationale: strings.TrimSpace(finding.Rationale),
|
|
Confidence: confidence,
|
|
TagsJSON: json.RawMessage(tagsJSON),
|
|
TargetRefsJSON: json.RawMessage(targetRefsJSON),
|
|
})
|
|
}
|
|
}
|
|
return findings, nil
|
|
}
|
|
|
|
func (s *OrchStore) GetCouncilInput(ctx context.Context, runID string) (CouncilInput, error) {
|
|
row := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT run_id, prompt, target_file, repo_path, target_task_id
|
|
FROM council_inputs
|
|
WHERE run_id = ?`,
|
|
runID,
|
|
)
|
|
|
|
var input CouncilInput
|
|
if err := row.Scan(
|
|
&input.RunID,
|
|
&input.Prompt,
|
|
&input.TargetFile,
|
|
&input.RepoPath,
|
|
&input.TargetTaskID,
|
|
); errors.Is(err, sql.ErrNoRows) {
|
|
return CouncilInput{RunID: runID}, nil
|
|
} else if err != nil {
|
|
return CouncilInput{}, fmt.Errorf("scan council input: %w", err)
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func (s *OrchStore) ListCouncilGroups(ctx context.Context, runID string) ([]CouncilGroup, bool, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT
|
|
run_id, group_id, proposal, bucket, support_count, supporters_json,
|
|
dissenters_json, rationale_summary, tags_json, source_finding_ids_json
|
|
FROM council_groups
|
|
WHERE run_id = ?
|
|
ORDER BY
|
|
CASE bucket
|
|
WHEN 'consensus' THEN 1
|
|
WHEN 'majority' THEN 2
|
|
WHEN 'minority' THEN 3
|
|
ELSE 4
|
|
END,
|
|
support_count DESC,
|
|
group_id ASC`,
|
|
runID,
|
|
)
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("query council groups: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
groups := make([]CouncilGroup, 0)
|
|
for rows.Next() {
|
|
var group CouncilGroup
|
|
var supportersJSON string
|
|
var dissentersJSON string
|
|
var tagsJSON string
|
|
var sourceFindingIDsJSON string
|
|
if err := rows.Scan(
|
|
&group.RunID,
|
|
&group.GroupID,
|
|
&group.Proposal,
|
|
&group.Bucket,
|
|
&group.SupportCount,
|
|
&supportersJSON,
|
|
&dissentersJSON,
|
|
&group.RationaleSummary,
|
|
&tagsJSON,
|
|
&sourceFindingIDsJSON,
|
|
); err != nil {
|
|
return nil, false, fmt.Errorf("scan council group: %w", err)
|
|
}
|
|
group.SupportersJSON = json.RawMessage(supportersJSON)
|
|
group.DissentersJSON = json.RawMessage(dissentersJSON)
|
|
group.TagsJSON = json.RawMessage(tagsJSON)
|
|
group.SourceFindingIDsJSON = json.RawMessage(sourceFindingIDsJSON)
|
|
groups = append(groups, group)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, false, fmt.Errorf("iterate council groups: %w", err)
|
|
}
|
|
|
|
if len(groups) > 0 {
|
|
return groups, true, nil
|
|
}
|
|
|
|
tallied, err := s.hasCouncilTallyEvent(ctx, runID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return groups, tallied, nil
|
|
}
|
|
|
|
func (s *OrchStore) hasCouncilTallyEvent(ctx context.Context, runID string) (bool, error) {
|
|
var count int
|
|
if err := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT COUNT(*)
|
|
FROM events
|
|
WHERE run_id = ? AND event_type = 'council_tallied'`,
|
|
runID,
|
|
).Scan(&count); err != nil {
|
|
return false, fmt.Errorf("query council tally events: %w", err)
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func (s *OrchStore) loadCouncilReviewerResultMessage(ctx context.Context, runID, taskID string) (Message, error) {
|
|
task, err := selectTask(ctx, s.db, runID, taskID)
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
if task.LatestAttemptNo == 0 {
|
|
return Message{}, fmt.Errorf("%w: reviewer task %s has no attempt", ErrInvalidState, taskID)
|
|
}
|
|
|
|
attempt, err := selectAttempt(ctx, s.db, runID, taskID, task.LatestAttemptNo)
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
|
|
row := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT
|
|
message_id, thread_id, from_agent, to_agent, kind, summary, body,
|
|
payload_json, created_at
|
|
FROM messages
|
|
WHERE thread_id = ? AND kind = 'result'
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
attempt.ThreadID,
|
|
)
|
|
message, err := scanMessage(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Message{}, fmt.Errorf("%w: reviewer task %s has no result message", ErrInvalidState, taskID)
|
|
}
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
func parseCouncilReviewerOutput(expectedRole string, message Message) (councilReviewerOutput, error) {
|
|
candidates := []string{strings.TrimSpace(message.Body), strings.TrimSpace(string(message.PayloadJSON))}
|
|
var lastErr error
|
|
for _, candidate := range candidates {
|
|
if candidate == "" || candidate == "{}" {
|
|
continue
|
|
}
|
|
var output councilReviewerOutput
|
|
if err := json.Unmarshal([]byte(candidate), &output); err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if strings.TrimSpace(output.ReviewerRole) == "" {
|
|
return councilReviewerOutput{}, fmt.Errorf("%w: reviewer output must include reviewer_role", ErrInvalidInput)
|
|
}
|
|
if output.ReviewerRole != expectedRole {
|
|
return councilReviewerOutput{}, fmt.Errorf("%w: reviewer output role %s does not match expected %s", ErrInvalidInput, output.ReviewerRole, expectedRole)
|
|
}
|
|
return output, nil
|
|
}
|
|
if lastErr != nil {
|
|
return councilReviewerOutput{}, fmt.Errorf("%w: reviewer output must be valid JSON", ErrInvalidInput)
|
|
}
|
|
return councilReviewerOutput{}, fmt.Errorf("%w: reviewer result message did not contain council output JSON", ErrInvalidInput)
|
|
}
|
|
|
|
func normalizeOptionalJSONArray(raw json.RawMessage) (string, error) {
|
|
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" {
|
|
return "[]", nil
|
|
}
|
|
var value []any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return "", err
|
|
}
|
|
return marshalJSON(value), nil
|
|
}
|
|
|
|
func normalizeOptionalJSONObject(raw json.RawMessage) (string, error) {
|
|
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" {
|
|
return "{}", nil
|
|
}
|
|
var value map[string]any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return "", err
|
|
}
|
|
return marshalJSON(value), nil
|
|
}
|
|
|
|
func groupCouncilFindings(runID string, findings []CouncilFinding, reviewers []CouncilReviewer, similarity string) []CouncilGroup {
|
|
type groupedFinding struct {
|
|
key string
|
|
proposal string
|
|
findings []CouncilFinding
|
|
}
|
|
|
|
order := make([]string, 0)
|
|
groupsByKey := make(map[string]*groupedFinding)
|
|
for _, finding := range findings {
|
|
key := councilProposalGroupKey(finding.Proposal, similarity)
|
|
group, ok := groupsByKey[key]
|
|
if !ok {
|
|
group = &groupedFinding{
|
|
key: key,
|
|
proposal: finding.Proposal,
|
|
}
|
|
groupsByKey[key] = group
|
|
order = append(order, key)
|
|
}
|
|
group.findings = append(group.findings, finding)
|
|
}
|
|
|
|
sortedReviewers := make([]string, 0, len(reviewers))
|
|
for _, reviewer := range reviewers {
|
|
sortedReviewers = append(sortedReviewers, reviewer.ReviewerRole)
|
|
}
|
|
sort.Strings(sortedReviewers)
|
|
|
|
result := make([]CouncilGroup, 0, len(order))
|
|
for idx, key := range order {
|
|
group := groupsByKey[key]
|
|
supporterSet := make(map[string]struct{})
|
|
tagSet := make(map[string]struct{})
|
|
sourceFindingIDs := make([]string, 0, len(group.findings))
|
|
rationaleSummary := ""
|
|
|
|
for _, finding := range group.findings {
|
|
supporterSet[finding.ReviewerRole] = struct{}{}
|
|
sourceFindingIDs = append(sourceFindingIDs, finding.ReviewerRole+":"+finding.FindingID)
|
|
if rationaleSummary == "" && finding.Rationale != "" {
|
|
rationaleSummary = finding.Rationale
|
|
}
|
|
|
|
var tags []string
|
|
if len(finding.TagsJSON) > 0 {
|
|
_ = json.Unmarshal(finding.TagsJSON, &tags)
|
|
}
|
|
for _, tag := range tags {
|
|
tag = strings.TrimSpace(tag)
|
|
if tag != "" {
|
|
tagSet[tag] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
supporters := make([]string, 0, len(supporterSet))
|
|
for _, reviewer := range sortedReviewers {
|
|
if _, ok := supporterSet[reviewer]; ok {
|
|
supporters = append(supporters, reviewer)
|
|
}
|
|
}
|
|
dissenters := make([]string, 0, len(sortedReviewers)-len(supporters))
|
|
for _, reviewer := range sortedReviewers {
|
|
if _, ok := supporterSet[reviewer]; !ok {
|
|
dissenters = append(dissenters, reviewer)
|
|
}
|
|
}
|
|
|
|
tags := make([]string, 0, len(tagSet))
|
|
for tag := range tagSet {
|
|
tags = append(tags, tag)
|
|
}
|
|
sort.Strings(tags)
|
|
sort.Strings(sourceFindingIDs)
|
|
|
|
supportCount := len(supporters)
|
|
bucket := "minority"
|
|
if supportCount == 3 {
|
|
bucket = "consensus"
|
|
} else if supportCount == 2 {
|
|
bucket = "majority"
|
|
}
|
|
|
|
result = append(result, CouncilGroup{
|
|
RunID: runID,
|
|
GroupID: fmt.Sprintf("grp_%02d", idx+1),
|
|
Proposal: group.proposal,
|
|
Bucket: bucket,
|
|
SupportCount: supportCount,
|
|
SupportersJSON: json.RawMessage(marshalJSON(supporters)),
|
|
DissentersJSON: json.RawMessage(marshalJSON(dissenters)),
|
|
RationaleSummary: rationaleSummary,
|
|
TagsJSON: json.RawMessage(marshalJSON(tags)),
|
|
SourceFindingIDsJSON: json.RawMessage(marshalJSON(sourceFindingIDs)),
|
|
})
|
|
}
|
|
|
|
sort.SliceStable(result, func(i, j int) bool {
|
|
if result[i].SupportCount != result[j].SupportCount {
|
|
return result[i].SupportCount > result[j].SupportCount
|
|
}
|
|
return result[i].Proposal < result[j].Proposal
|
|
})
|
|
for i := range result {
|
|
result[i].GroupID = fmt.Sprintf("grp_%02d", i+1)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeCouncilReportShow(raw string, onlyUnanimous bool) ([]string, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
if onlyUnanimous {
|
|
return []string{"consensus"}, nil
|
|
}
|
|
return []string{"consensus", "majority"}, nil
|
|
}
|
|
|
|
parts := strings.Split(raw, ",")
|
|
show := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for _, part := range parts {
|
|
value := strings.ToLower(strings.TrimSpace(part))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if value == "all" {
|
|
return []string{"consensus", "majority", "minority"}, nil
|
|
}
|
|
switch value {
|
|
case "consensus", "majority", "minority":
|
|
default:
|
|
return nil, fmt.Errorf("%w: show must contain consensus, majority, minority, or all", ErrInvalidInput)
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
show = append(show, value)
|
|
}
|
|
if len(show) == 0 {
|
|
return nil, fmt.Errorf("%w: show must contain at least one bucket", ErrInvalidInput)
|
|
}
|
|
return show, nil
|
|
}
|
|
|
|
func councilGroupSummary(groups []CouncilGroup) map[string]int {
|
|
summary := normalizeCouncilSummary(nil)
|
|
for _, group := range groups {
|
|
summary[group.Bucket]++
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func normalizeCouncilSummary(summary map[string]int) map[string]int {
|
|
result := map[string]int{
|
|
"consensus": 0,
|
|
"majority": 0,
|
|
"minority": 0,
|
|
}
|
|
for key, value := range summary {
|
|
result[key] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
func selectCouncilGroupsForReport(groups []CouncilGroup, show []string) []CouncilGroup {
|
|
groupedByBucket := make(map[string][]CouncilGroup, len(show))
|
|
for _, group := range groups {
|
|
groupedByBucket[group.Bucket] = append(groupedByBucket[group.Bucket], group)
|
|
}
|
|
|
|
selected := make([]CouncilGroup, 0, len(groups))
|
|
for _, bucket := range show {
|
|
selected = append(selected, groupedByBucket[bucket]...)
|
|
}
|
|
return selected
|
|
}
|
|
|
|
func renderCouncilReportMarkdown(run CouncilRun, input CouncilInput, show []string, summary map[string]int, groups []CouncilGroup) string {
|
|
var builder strings.Builder
|
|
|
|
builder.WriteString("# Council Review Report\n\n")
|
|
builder.WriteString(fmt.Sprintf("- Run ID: `%s`\n", run.RunID))
|
|
builder.WriteString(fmt.Sprintf("- Mode: `%s`\n", run.Mode))
|
|
builder.WriteString(fmt.Sprintf("- Target Type: `%s`\n", run.TargetType))
|
|
builder.WriteString(fmt.Sprintf("- Report Buckets: `%s`\n\n", strings.Join(show, "`, `")))
|
|
|
|
builder.WriteString("## Target\n\n")
|
|
if strings.TrimSpace(input.Prompt) != "" {
|
|
builder.WriteString(fmt.Sprintf("- Prompt: %s\n", input.Prompt))
|
|
}
|
|
if strings.TrimSpace(input.TargetFile) != "" {
|
|
builder.WriteString(fmt.Sprintf("- Target File: `%s`\n", input.TargetFile))
|
|
}
|
|
if strings.TrimSpace(input.RepoPath) != "" {
|
|
builder.WriteString(fmt.Sprintf("- Repo Path: `%s`\n", input.RepoPath))
|
|
}
|
|
if strings.TrimSpace(input.TargetTaskID) != "" {
|
|
builder.WriteString(fmt.Sprintf("- Task ID: `%s`\n", input.TargetTaskID))
|
|
}
|
|
if strings.TrimSpace(input.Prompt) == "" &&
|
|
strings.TrimSpace(input.TargetFile) == "" &&
|
|
strings.TrimSpace(input.RepoPath) == "" &&
|
|
strings.TrimSpace(input.TargetTaskID) == "" {
|
|
builder.WriteString("- No explicit target metadata was recorded.\n")
|
|
}
|
|
builder.WriteString("\n")
|
|
|
|
builder.WriteString("## Summary\n\n")
|
|
builder.WriteString(fmt.Sprintf("- Consensus: %d\n", summary["consensus"]))
|
|
builder.WriteString(fmt.Sprintf("- Majority: %d\n", summary["majority"]))
|
|
builder.WriteString(fmt.Sprintf("- Minority: %d\n\n", summary["minority"]))
|
|
|
|
groupedByBucket := make(map[string][]CouncilGroup, len(show))
|
|
for _, group := range groups {
|
|
groupedByBucket[group.Bucket] = append(groupedByBucket[group.Bucket], group)
|
|
}
|
|
|
|
for _, bucket := range show {
|
|
builder.WriteString(fmt.Sprintf("## %s\n\n", councilBucketHeading(bucket)))
|
|
bucketGroups := groupedByBucket[bucket]
|
|
if len(bucketGroups) == 0 {
|
|
builder.WriteString(fmt.Sprintf("No %s recommendations.\n\n", bucket))
|
|
continue
|
|
}
|
|
|
|
for _, group := range bucketGroups {
|
|
supporters := decodeCouncilStringSlice(group.SupportersJSON)
|
|
dissenters := decodeCouncilStringSlice(group.DissentersJSON)
|
|
tags := decodeCouncilStringSlice(group.TagsJSON)
|
|
sourceFindingIDs := decodeCouncilStringSlice(group.SourceFindingIDsJSON)
|
|
|
|
builder.WriteString(fmt.Sprintf("### %s\n\n", group.GroupID))
|
|
builder.WriteString(group.Proposal)
|
|
builder.WriteString("\n\n")
|
|
builder.WriteString(fmt.Sprintf("- Support: %d of 3 reviewers", group.SupportCount))
|
|
if len(supporters) > 0 {
|
|
builder.WriteString(fmt.Sprintf(" (`%s`)", strings.Join(supporters, "`, `")))
|
|
}
|
|
builder.WriteString("\n")
|
|
if len(dissenters) > 0 {
|
|
builder.WriteString(fmt.Sprintf("- Dissenters: `%s`\n", strings.Join(dissenters, "`, `")))
|
|
}
|
|
if strings.TrimSpace(group.RationaleSummary) != "" {
|
|
builder.WriteString(fmt.Sprintf("- Rationale: %s\n", group.RationaleSummary))
|
|
}
|
|
if len(tags) > 0 {
|
|
builder.WriteString(fmt.Sprintf("- Tags: `%s`\n", strings.Join(tags, "`, `")))
|
|
}
|
|
if len(sourceFindingIDs) > 0 {
|
|
builder.WriteString(fmt.Sprintf("- Source Findings: `%s`\n", strings.Join(sourceFindingIDs, "`, `")))
|
|
}
|
|
builder.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
func councilBucketHeading(bucket string) string {
|
|
switch bucket {
|
|
case "consensus":
|
|
return "Consensus"
|
|
case "majority":
|
|
return "Majority"
|
|
case "minority":
|
|
return "Minority"
|
|
default:
|
|
if bucket == "" {
|
|
return "Recommendations"
|
|
}
|
|
return strings.ToUpper(bucket[:1]) + bucket[1:]
|
|
}
|
|
}
|
|
|
|
func decodeCouncilStringSlice(raw json.RawMessage) []string {
|
|
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" {
|
|
return nil
|
|
}
|
|
|
|
var values []string
|
|
if err := json.Unmarshal(raw, &values); err != nil {
|
|
return nil
|
|
}
|
|
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func councilProposalGroupKey(proposal, similarity string) string {
|
|
tokens := proposalTokens(proposal)
|
|
if similarity == "strict" {
|
|
return strings.Join(tokens, " ")
|
|
}
|
|
|
|
stopWords := map[string]struct{}{
|
|
"a": {}, "an": {}, "the": {}, "to": {}, "into": {}, "and": {}, "or": {}, "of": {}, "for": {}, "in": {}, "on": {}, "with": {}, "from": {}, "that": {}, "this": {}, "it": {}, "is": {}, "are": {}, "be": {}, "by": {}, "as": {}, "keep": {}, "use": {}, "add": {},
|
|
}
|
|
set := make(map[string]struct{})
|
|
filtered := make([]string, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
if _, stop := stopWords[token]; stop {
|
|
continue
|
|
}
|
|
if len(token) <= 2 {
|
|
continue
|
|
}
|
|
if _, seen := set[token]; seen {
|
|
continue
|
|
}
|
|
set[token] = struct{}{}
|
|
filtered = append(filtered, token)
|
|
}
|
|
sort.Strings(filtered)
|
|
if len(filtered) == 0 {
|
|
return strings.Join(tokens, " ")
|
|
}
|
|
return strings.Join(filtered, " ")
|
|
}
|
|
|
|
func proposalTokens(value string) []string {
|
|
lower := strings.ToLower(strings.TrimSpace(value))
|
|
fields := strings.FieldsFunc(lower, func(r rune) bool {
|
|
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
|
|
})
|
|
result := make([]string, 0, len(fields))
|
|
for _, field := range fields {
|
|
if field != "" {
|
|
result = append(result, field)
|
|
}
|
|
}
|
|
return result
|
|
}
|