From 94521488b69c6b3c41faf381c85fcd2995ed1203 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Mon, 24 Aug 2026 10:33:45 +0500 Subject: [PATCH] =?UTF-8?q?feat(agents):=20=D1=87=D0=B5=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D0=B0,=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=D0=B5=D1=80=D0=B0,=20dev=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D1=81=D1=82=D0=BC=D0=BE=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=B2=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=C2=AB=D0=9B=D0=BE=D0=B3=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/analyst/analyst.go | 54 ++++++++++++++++++++++++--- internal/analyst/analyst_test.go | 64 ++++++++++++++++++++++++++++++++ internal/worker/postmortem.go | 1 + internal/worker/review.go | 37 ++++++++++++++++++ internal/worker/worker.go | 2 + internal/worker/worker_test.go | 43 +++++++++++++++++++++ 6 files changed, 196 insertions(+), 5 deletions(-) diff --git a/internal/analyst/analyst.go b/internal/analyst/analyst.go index d7e61e4..b8f62eb 100644 --- a/internal/analyst/analyst.go +++ b/internal/analyst/analyst.go @@ -104,12 +104,7 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor // 4. парсим вердикт verdict := opencode.ExtractVerdict(res.Stdout) - log.Printf("analyst: verdict tail: %s", truncate(verdict, 2000)) obj, ok := opencode.ExtractJSON(verdict) - if ok { - b, _ := json.Marshal(obj) - log.Printf("analyst: extracted json: %s", truncate(string(b), 1000)) - } if !ok { return core.Decision{}, fmt.Errorf("%w: нет JSON в выводе аналитика", ErrDecodeFail) } @@ -123,6 +118,8 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor return core.Decision{}, fmt.Errorf("%w: %v", ErrDecodeFail, err) } + log.Printf("analyst: вердикт: %s", truncate(formatVerdict(&ar), 2000)) + // 5. валидация if err := validateResponse(&ar); err != nil { return core.Decision{}, fmt.Errorf("%w: %v", ErrValidation, err) @@ -194,4 +191,51 @@ func validateResponse(ar *AnalystResponse) error { return fmt.Errorf("неизвестный phase=%q", ar.Phase) } return nil +} + +// formatVerdict собирает человекочитаемое однострочное описание вердикта +// аналитика (без JSON-разметки) для панели «Логи». +func formatVerdict(ar *AnalystResponse) string { + var b strings.Builder + b.WriteString("phase=" + ar.Phase) + + if ar.ChatReply != "" { + b.WriteString(", chat_reply=") + b.WriteString(ar.ChatReply) + } + if len(ar.Questions) > 0 { + b.WriteString(", questions=[") + b.WriteString(strings.Join(ar.Questions, " | ")) + b.WriteString("]") + } + if ar.Title != "" { + b.WriteString(", title=") + b.WriteString(ar.Title) + } + if ar.Goal != "" { + b.WriteString(", goal=") + b.WriteString(ar.Goal) + } + if ar.Repo != "" { + b.WriteString(", repo=") + b.WriteString(ar.Repo) + } + if len(ar.Repos) > 0 { + b.WriteString(", repos=[") + b.WriteString(strings.Join(ar.Repos, ", ")) + b.WriteString("]") + } + if ar.Why != "" { + b.WriteString(", why=") + b.WriteString(ar.Why) + } + if ar.AC != "" { + b.WriteString(", ac=") + b.WriteString(ar.AC) + } + if ar.AbortReason != "" { + b.WriteString(", abort_reason=") + b.WriteString(ar.AbortReason) + } + return b.String() } \ No newline at end of file diff --git a/internal/analyst/analyst_test.go b/internal/analyst/analyst_test.go index f49d754..6bfa140 100644 --- a/internal/analyst/analyst_test.go +++ b/internal/analyst/analyst_test.go @@ -3,6 +3,7 @@ package analyst import ( "context" "errors" + "strings" "testing" "github.com/kamelion/ratatoskr-go/internal/core" @@ -215,4 +216,67 @@ func TestAskEmptyReplyAndQuestions(t *testing.T) { if !errors.Is(err, ErrValidation) { t.Errorf("err = %v, want A3", err) } +} + +// TestFormatVerdict — человекочитаемое описание вердикта аналитика. +func TestFormatVerdict(t *testing.T) { + tests := []struct { + name string + ar *AnalystResponse + want []string + }{ + { + name: "propose with fields", + ar: &AnalystResponse{ + Phase: "propose", + Title: "Калькулятор", + Goal: "Сделать веб-калькулятор", + Repos: []string{"tools/calc", "tools/ui"}, + Why: "Нужен для учёта", + AC: "Работает + - * /", + ChatReply: "Готово!", + }, + want: []string{ + "phase=propose", + "chat_reply=Готово!", + "repos=[tools/calc, tools/ui]", + "title=Калькулятор", + "goal=Сделать веб-калькулятор", + "why=Нужен для учёта", + "ac=Работает + - * /", + }, + }, + { + name: "ask with questions", + ar: &AnalystResponse{ + Phase: "ask", + ChatReply: "Уточню", + Questions: []string{"Где код?", "Какая цель?"}, + }, + want: []string{"phase=ask", "chat_reply=Уточню", "questions=[Где код? | Какая цель?]"}, + }, + { + name: "abort with reason", + ar: &AnalystResponse{ + Phase: "abort", + AbortReason: "Тема не про код", + }, + want: []string{"phase=abort", "abort_reason=Тема не про код"}, + }, + { + name: "empty verdict", + ar: &AnalystResponse{}, + want: []string{"phase="}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatVerdict(tt.ar) + for _, w := range tt.want { + if !strings.Contains(got, w) { + t.Errorf("formatVerdict = %q, want contain %q", got, w) + } + } + }) + } } \ No newline at end of file diff --git a/internal/worker/postmortem.go b/internal/worker/postmortem.go index 100b861..18fb759 100644 --- a/internal/worker/postmortem.go +++ b/internal/worker/postmortem.go @@ -186,6 +186,7 @@ func (w *Worker) postMortem(ctx context.Context, task *storage.Task) { output = "(постмортем-агент не вернул текст)" } } + log.Printf("worker: task %d: постмортем: %s", task.ID, truncateTrace(output, 2000)) w.finalizeTrace(ctx, traceID, status, output) if res.RC == 0 && output != "" { diff --git a/internal/worker/review.go b/internal/worker/review.go index f7dfe0c..1d2a550 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "path/filepath" "strings" @@ -96,6 +97,10 @@ func (w *Worker) runReviewer(ctx context.Context, taskID int64, cwd, prompt stri } w.finalizeTrace(ctx, traceID, ts, output) + if v != nil { + log.Printf("reviewer: вердикт: %s", formatReviewVerdict(v)) + } + if v == nil { // Невалидный/пустой вывод или RC!=0: не парсится — сигнал на retry. return nil, output, traceID, nil @@ -119,3 +124,35 @@ func parseReviewVerdict(out string) (*reviewVerdict, error) { } return &v, nil } + +// formatReviewVerdict собирает человекочитаемое однострочное описание вердикта +// ревьюера (без JSON-разметки) для панели «Логи». +func formatReviewVerdict(v *reviewVerdict) string { + var b strings.Builder + b.WriteString("passed=") + b.WriteString(boolElse(v.Passed, "yes", "no")) + if len(v.CriticalIssues) > 0 { + b.WriteString(", critical=[") + b.WriteString(strings.Join(v.CriticalIssues, " | ")) + b.WriteString("]") + } + if len(v.SolidViolations) > 0 { + b.WriteString(", solid=[") + b.WriteString(strings.Join(v.SolidViolations, " | ")) + b.WriteString("]") + } + if len(v.Comments) > 0 { + b.WriteString(", comments=[") + b.WriteString(strings.Join(v.Comments, " | ")) + b.WriteString("]") + } + return b.String() +} + +// boolElse возвращает строку для логического значения (без вызова тернарника). +func boolElse(cond bool, yes, no string) string { + if cond { + return yes + } + return no +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 9d4115d..69f6963 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -281,6 +281,8 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) { output := res.Stdout + log.Printf("worker: task %d: dev вердикт (хвост): %s", task.ID, truncateTrace(output, 2000)) + // 5b. dev не завершился успешно (RC!=0) → фиксируем без ревью. switch res.RC { case 0: diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index ca04fd9..4812068 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -1090,3 +1090,46 @@ func TestRenderPostMortemPrompt(t *testing.T) { } } } + +// TestFormatReviewVerdict — человекочитаемое описание вердикта ревьюера. +func TestFormatReviewVerdict(t *testing.T) { + tests := []struct { + name string + v *reviewVerdict + want []string + }{ + { + name: "passed with everything", + v: &reviewVerdict{ + Passed: true, + CriticalIssues: []string{"bug A"}, + SolidViolations: []string{"v1", "v2"}, + Comments: []string{"comment"}, + }, + want: []string{"passed=yes", "critical=[bug A]", "solid=[v1 | v2]", "comments=[comment]"}, + }, + { + name: "failed with critical only", + v: &reviewVerdict{ + Passed: false, + CriticalIssues: []string{"bug A", "bug B"}, + }, + want: []string{"passed=no", "critical=[bug A | bug B]"}, + }, + { + name: "empty verdict", + v: &reviewVerdict{}, + want: []string{"passed=no"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatReviewVerdict(tt.v) + for _, w := range tt.want { + if !strings.Contains(got, w) { + t.Errorf("formatReviewVerdict = %q, want contain %q", got, w) + } + } + }) + } +}