internal/analyst: Decider через opencode (A1–A4)
All checks were successful
build-test / build (push) Successful in 1m15s

- 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 (фикс для правки готового черновика)
This commit is contained in:
Hermes
2026-08-15 00:32:49 +05:00
parent 2957bf0d63
commit dfe374dd55
5 changed files with 455 additions and 1 deletions

160
internal/analyst/analyst.go Normal file
View File

@@ -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
}