Add council review report command
This commit is contained in:
@@ -107,6 +107,32 @@ type CouncilTallyResult struct {
|
||||
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"`
|
||||
@@ -797,6 +823,107 @@ func (s *OrchStore) TallyCouncil(ctx context.Context, input CouncilTallyInput) (
|
||||
}, 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 {
|
||||
@@ -847,6 +974,110 @@ func (s *OrchStore) collectCouncilFindings(ctx context.Context, runID string, re
|
||||
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 {
|
||||
@@ -1040,6 +1271,192 @@ func groupCouncilFindings(runID string, findings []CouncilFinding, reviewers []C
|
||||
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" {
|
||||
|
||||
Reference in New Issue
Block a user