internal/analyst: Decider через opencode (A1–A4)
All checks were successful
build-test / build (push) Successful in 1m15s
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:
160
internal/analyst/analyst.go
Normal file
160
internal/analyst/analyst.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user