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 }