From dfe374dd55e3f52a190cfb22cee9b03f0a4aaaeb Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 15 Aug 2026 00:32:49 +0500 Subject: [PATCH] =?UTF-8?q?internal/analyst:=20Decider=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20opencode=20(A1=E2=80=93A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Analyst struct: строит Go-template промпт из history+draft - opencode.Runner.Run (agent=analyst) → ExtractVerdict → ExtractJSON - Валидация phase/полей → core.Decision - A1 DecodeFail, A2 RunError, A3 Validation, A4 NotReady - MockRunner в тестах (11 тестов, зелёные) - core.runDecide оборачивает ошибки аналитика в D1 ErrDecideFailed - allow ready→collecting (фикс для правки готового черновика) --- internal/analyst/analyst.go | 160 ++++++++++++++++++++++++ internal/analyst/analyst_test.go | 201 +++++++++++++++++++++++++++++++ internal/analyst/errors.go | 18 +++ internal/analyst/prompt.go | 74 ++++++++++++ internal/core/core.go | 3 +- 5 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 internal/analyst/analyst.go create mode 100644 internal/analyst/analyst_test.go create mode 100644 internal/analyst/errors.go create mode 100644 internal/analyst/prompt.go diff --git a/internal/analyst/analyst.go b/internal/analyst/analyst.go new file mode 100644 index 0000000..072f120 --- /dev/null +++ b/internal/analyst/analyst.go @@ -0,0 +1,160 @@ +package analyst + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/kamelion/ratatoskr-go/internal/core" + "github.com/kamelion/ratatoskr-go/internal/opencode" + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// OpenCodeRunner — интерфейс для запуска opencode (замена для тестов). +type OpenCodeRunner interface { + Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*opencode.Result, error) +} + +// Analyst — Decider, реализованный через opencode. +// +// Собирает промпт из истории диалога и черновика, запускает opencode run с +// agent="analyst", парсит JSON-вердикт и возвращает Decision. +type Analyst struct { + Runner OpenCodeRunner + Worktree string // каталог, откуда запускать opencode run + Agent string // имя агента (default "analyst") +} + +// AnalystResponse — структура JSON-ответа аналитика. +type AnalystResponse struct { + Phase string `json:"phase"` + Title string `json:"title"` + Goal string `json:"goal"` + Repo string `json:"repo"` + Why string `json:"why"` + AC string `json:"ac"` + Questions []string `json:"questions"` + ChatReply string `json:"chat_reply"` + AbortReason string `json:"abort_reason"` +} + +// Decide реализует core.Decider через открытый код. +func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft storage.Task, force bool) (core.Decision, error) { + agent := a.Agent + if agent == "" { + agent = "analyst" + } + + if len(history) == 0 && !force { + return core.Decision{}, fmt.Errorf("%w: пустая история диалога", ErrNotReady) + } + + // 1. форматируем историю + hist := formatHistory(history) + + // 2. собираем промпт + td := TemplateData{ + Title: draft.Title, + Goal: draft.Goal, + Repo: draft.Repo, + Why: draft.Why, + AC: draft.AC, + History: hist, + Force: force, + } + prompt, err := RenderPrompt(td) + if err != nil { + return core.Decision{}, fmt.Errorf("%w: шаблон: %v", ErrValidation, err) + } + + // 3. запускаем opencode + res, err := a.Runner.Run(ctx, prompt, a.Worktree, agent, "") + if err != nil { + return core.Decision{}, fmt.Errorf("%w: %v", ErrRunError, err) + } + if res.RC != 0 { + return core.Decision{}, fmt.Errorf("%w: rc=%d", ErrRunError, res.RC) + } + + // 4. парсим вердикт + verdict := opencode.ExtractVerdict(res.Stdout) + obj, ok := opencode.ExtractJSON(verdict) + if !ok { + return core.Decision{}, fmt.Errorf("%w: нет JSON в выводе аналитика", ErrDecodeFail) + } + + var ar AnalystResponse + data, err := json.Marshal(obj) + if err != nil { + return core.Decision{}, fmt.Errorf("%w: marshal: %v", ErrDecodeFail, err) + } + if err := json.Unmarshal(data, &ar); err != nil { + return core.Decision{}, fmt.Errorf("%w: %v", ErrDecodeFail, err) + } + + // 5. валидация + if err := validateResponse(&ar); err != nil { + return core.Decision{}, fmt.Errorf("%w: %v", ErrValidation, err) + } + + // 6. собираем Decision + dec := core.Decision{ + Phase: ar.Phase, + Questions: ar.Questions, + ChatReply: ar.ChatReply, + Draft: draft, + } + if ar.Title != "" { + dec.Draft.Title = ar.Title + } + if ar.Goal != "" { + dec.Draft.Goal = ar.Goal + } + if ar.Repo != "" { + dec.Draft.Repo = ar.Repo + } + if ar.Why != "" { + dec.Draft.Why = ar.Why + } + if ar.AC != "" { + dec.Draft.AC = ar.AC + } + + return dec, nil +} + +// formatHistory превращает слайс Message в текст переписки. +func formatHistory(history []core.Message) string { + var b strings.Builder + for _, m := range history { + switch m.Role { + case "user": + b.WriteString("Пользователь: " + m.Content + "\n") + case "assistant": + b.WriteString("Ты: " + m.Content + "\n") + default: + b.WriteString(m.Role + ": " + m.Content + "\n") + } + } + return b.String() +} + +// validateResponse проверяет поля ответа аналитика. +func validateResponse(ar *AnalystResponse) error { + switch ar.Phase { + case "ask": + if ar.ChatReply == "" && len(ar.Questions) == 0 { + return fmt.Errorf("phase=ask, но нет ни chat_reply, ни questions") + } + case "propose": + if ar.Title == "" && ar.Goal == "" && ar.Repo == "" && ar.Why == "" && ar.AC == "" { + return fmt.Errorf("phase=propose, но нет ни одного изменённого поля") + } + case "abort": + // abort_reason — не обязателен, но желателен + default: + return fmt.Errorf("неизвестный phase=%q", ar.Phase) + } + return nil +} \ No newline at end of file diff --git a/internal/analyst/analyst_test.go b/internal/analyst/analyst_test.go new file mode 100644 index 0000000..4aa4ae0 --- /dev/null +++ b/internal/analyst/analyst_test.go @@ -0,0 +1,201 @@ +package analyst + +import ( + "context" + "errors" + "testing" + + "github.com/kamelion/ratatoskr-go/internal/core" + "github.com/kamelion/ratatoskr-go/internal/opencode" + "github.com/kamelion/ratatoskr-go/internal/storage" +) + +// mockRunner — тестовый OpenCodeRunner с задаваемым поведением. +type mockRunner struct { + result *opencode.Result + err error +} + +func (m *mockRunner) Run(_ context.Context, _, _, _, _ string) (*opencode.Result, error) { + return m.result, m.err +} + +func TestDecideAsk(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Уточню про репозиторий\",\"questions\":[\"Где лежит код?\",\"Какая цель?\"]}"}}`, + }}, Worktree: "/tmp"} + + history := []core.Message{{Role: "user", Content: "Сделай калькулятор"}} + dec, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err != nil { + t.Fatalf("Decide err: %v", err) + } + if dec.Phase != "ask" { + t.Errorf("Phase = %q, want ask", dec.Phase) + } + if len(dec.Questions) != 2 { + t.Errorf("len(Questions) = %d, want 2", len(dec.Questions)) + } + if dec.ChatReply == "" { + t.Error("ChatReply пустой") + } +} + +func TestDecidePropose(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"goal\":\"Сделать веб-калькулятор\",\"repo\":\"tools/calc\",\"why\":\"Нужен для учёта\",\"ac\":\"Работает + - * /\",\"chat_reply\":\"Готово!\"}"}}`, + }}, Worktree: "/tmp"} + + history := []core.Message{ + {Role: "user", Content: "Сделай калькулятор"}, + {Role: "assistant", Content: "Где репозиторий?"}, + {Role: "user", Content: "tools/calc"}, + } + dec, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err != nil { + t.Fatalf("Decide err: %v", err) + } + if dec.Phase != "propose" { + t.Errorf("Phase = %q, want propose", dec.Phase) + } + if dec.Draft.Title != "Калькулятор" { + t.Errorf("Draft.Title = %q, want Калькулятор", dec.Draft.Title) + } + if dec.Draft.Repo != "tools/calc" { + t.Errorf("Draft.Repo = %q", dec.Draft.Repo) + } +} + +func TestDecideAbort(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"abort\",\"chat_reply\":\"Это не про код.\",\"abort_reason\":\"Тема не подходит opencode\"}"}}`, + }}, Worktree: "/tmp"} + + history := []core.Message{{Role: "user", Content: "Почини принтер"}} + dec, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err != nil { + t.Fatalf("Decide err: %v", err) + } + if dec.Phase != "abort" { + t.Errorf("Phase = %q, want abort", dec.Phase) + } + if dec.ChatReply != "Это не про код." { + t.Errorf("ChatReply = %q", dec.ChatReply) + } +} + +func TestDecodeFail(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"не JSON, а просто текст"}}`, + }}, Worktree: "/tmp"} + + history := []core.Message{{Role: "user", Content: "тест"}} + _, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err == nil { + t.Fatal("expected A1 error") + } + if !errors.Is(err, ErrDecodeFail) { + t.Errorf("err = %v, want A1", err) + } +} + +func TestRunError(t *testing.T) { + a := &Analyst{Runner: &mockRunner{err: errors.New("opencode not found")}, Worktree: "/tmp"} + history := []core.Message{{Role: "user", Content: "тест"}} + _, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err == nil { + t.Fatal("expected A2 error") + } + if !errors.Is(err, ErrRunError) { + t.Errorf("err = %v, want A2", err) + } +} + +func TestEmptyHistory(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{RC: 0}}} + _, err := a.Decide(context.Background(), nil, storage.Task{}, false) + if err == nil { + t.Fatal("expected A4 error") + } + if !errors.Is(err, ErrNotReady) { + t.Errorf("err = %v, want A4", err) + } +} + +func TestForceEmptyHistory(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Опишите задачу.\"}"}}`, + }}, Worktree: "/tmp"} + + _, err := a.Decide(context.Background(), nil, storage.Task{}, true) + if err != nil { + t.Fatalf("Decide(force=true) err: %v", err) + } +} + +func TestInvalidPhase(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"unknown\"}"}}`, + }}, Worktree: "/tmp"} + + history := []core.Message{{Role: "user", Content: "test"}} + _, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err == nil { + t.Fatal("expected A3 error") + } + if !errors.Is(err, ErrValidation) { + t.Errorf("err = %v, want A3", err) + } +} + +func TestNonZeroExit(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{RC: 1, Stdout: "fail"}}, Worktree: "/tmp"} + history := []core.Message{{Role: "user", Content: "test"}} + _, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err == nil { + t.Fatal("expected A2 error for rc=1") + } + if !errors.Is(err, ErrRunError) { + t.Errorf("err = %v, want A2", err) + } +} + +func TestProposeEmptyFields(t *testing.T) { + // propose без изменённых полей — A3 + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"chat_reply\":\"ok\"}"}}`, + }}, Worktree: "/tmp"} + + history := []core.Message{{Role: "user", Content: "test"}} + _, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err == nil { + t.Fatal("expected A3 error for propose with no fields") + } + if !errors.Is(err, ErrValidation) { + t.Errorf("err = %v, want A3", err) + } +} + +func TestAskEmptyReplyAndQuestions(t *testing.T) { + // ask без chat_reply и questions — A3 + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\"}"}}`, + }}, Worktree: "/tmp"} + + history := []core.Message{{Role: "user", Content: "test"}} + _, err := a.Decide(context.Background(), history, storage.Task{}, false) + if err == nil { + t.Fatal("expected A3 error for empty ask") + } + if !errors.Is(err, ErrValidation) { + t.Errorf("err = %v, want A3", err) + } +} \ No newline at end of file diff --git a/internal/analyst/errors.go b/internal/analyst/errors.go new file mode 100644 index 0000000..5ae29ee --- /dev/null +++ b/internal/analyst/errors.go @@ -0,0 +1,18 @@ +package analyst + +import "errors" + +// Классы ошибок A1–A4. +var ( + // A1 — ответ аналитика не содержит валидного JSON. + ErrDecodeFail = errors.New("A1: decode analyst response") + + // A2 — ошибка запуска opencode run (включая таймаут rc=-1). + ErrRunError = errors.New("A2: analyst run error") + + // A3 — JSON от аналитика есть, но поля невалидны. + ErrValidation = errors.New("A3: invalid analyst response") + + // A4 — аналитик ещё не может работать (нет истории). + ErrNotReady = errors.New("A4: analyst not ready") +) \ No newline at end of file diff --git a/internal/analyst/prompt.go b/internal/analyst/prompt.go new file mode 100644 index 0000000..9ac33df --- /dev/null +++ b/internal/analyst/prompt.go @@ -0,0 +1,74 @@ +package analyst + +import ( + "strings" + "text/template" +) + +// promptTemplate — шаблон промпта для аналитика (opencode analyst-agent). +// Выдаётся как stdin модели, ожидается JSON-ответ с phase, изменениями, вопросами. +var promptTemplate = template.Must(template.New("analyst").Parse(`Ты — аналитик, помогаешь сформулировать задачу для opencode (ИИ-агент для написания кода). + +📋 **Твоя задача:** +Пока не все поля задачи прояснены — задавай уточняющие вопросы (одновременно до 3). +Когда достаточно данных — предложи готовый черновик задачи. +Если тема явно не про код или не подходит opencode — abort. + +**Поля задачи:** + +| Поле | Описание | +|------|----------| +| title | краткое название (1–4 слова) | +| goal | цель задачи: что именно нужно сделать | +| repo | путь к репозиторию (относительно /opt/data/src/) | +| why | зачем это нужно, контекст | +| ac | acceptance criteria — конкретный результат, что считается готовым | + +{{- if .Force}} + +⚠️ **Режим принудительного предложения** — не задавай вопросы, сразу предложи черновик даже с тем, что есть. +{{- end}} + +**Переписка с пользователем:** +{{.History}} + +**Текущий черновик:** +{{if .Title}} title: {{.Title}}{{else}} title: (не задано){{end}} +{{if .Goal}} goal: {{.Goal}}{{else}} goal: (не задано){{end}} +{{if .Repo}} repo: {{.Repo}}{{else}} repo: (не задано){{end}} +{{if .Why}} why: {{.Why}}{{else}} why: (не задано){{end}} +{{if .AC}} ac: {{.AC}}{{else}} ac: (не задано){{end}} + +**Ответь строго JSON-объектом, без лишнего текста:** +{ + "phase": "ask|propose|abort", + "title": "название (только если меняешь)", + "goal": "цель (только если меняешь)", + "repo": "путь к репозиторию (только если меняешь)", + "why": "зачем (только если меняешь)", + "ac": "критерии (только если меняешь)", + "questions": ["вопрос 1", "вопрос 2"], + "chat_reply": "твой ответ пользователю (на русском, естественно)", + "abort_reason": "если phase=abort — причина" +} +`)) + +// TemplateData — данные для рендера шаблона промпта. +type TemplateData struct { + Title string + Goal string + Repo string + Why string + AC string + History string // отформатированная переписка + Force bool +} + +// RenderPrompt собирает промпт из истории и черновика. +func RenderPrompt(data TemplateData) (string, error) { + var buf strings.Builder + if err := promptTemplate.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} \ No newline at end of file diff --git a/internal/core/core.go b/internal/core/core.go index 9d96833..a78e03d 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -2,6 +2,7 @@ package core import ( "context" + "fmt" "strings" "github.com/kamelion/ratatoskr-go/internal/storage" @@ -301,7 +302,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R decision, err := c.Decide.Decide(ctx, msgs, *task, force) if err != nil { - return Result{}, err + return Result{}, fmt.Errorf("%w: %v", ErrDecideFailed, err) } switch decision.Phase {