package analyst import ( "context" "encoding/json" "fmt" "log" "strings" "github.com/kamelion/ratatoskr-go/internal/core" "github.com/kamelion/ratatoskr-go/internal/events" "github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/storage" ) func truncate(s string, n int) string { if len(s) <= n { return s } if n <= 0 { return "" } return s[len(s)-n:] } // 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") // Events — издатель доменных событий для UI. nil — события выключены. Events events.Publisher } // publish отправляет доменное событие, если задан издатель. func (a *Analyst) publish(e events.Event) { if a.Events != nil { a.Events.Publish(e) } } // AnalystResponse — структура JSON-ответа аналитика. type AnalystResponse struct { Phase string `json:"phase"` Title string `json:"title"` Goal string `json:"goal"` Repo string `json:"repo"` // одиночный репо (обратная совместимость) Repos json.RawMessage `json:"repos"` // список репо (основной); устойчив к строке Why string `json:"why"` AC string `json:"ac"` Steps json.RawMessage `json:"steps"` // список этапов; устойчив к строке Questions []string `json:"questions"` ChatReply string `json:"chat_reply"` AbortReason string `json:"abort_reason"` } // reposList нормализует поле repos: модель может вернуть либо массив // ["a","b"], либо строку "a,b" (иногда с пробелами). Пустое значение → nil. func (r *AnalystResponse) reposList() []string { if r.Repos == nil { return nil } var arr []string if err := json.Unmarshal(r.Repos, &arr); err == nil { return arr } var s string if err := json.Unmarshal(r.Repos, &s); err != nil { return nil } var out []string for _, p := range strings.Split(s, ",") { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } // validSteps отбрасывает записи без title. func validSteps(arr []storage.Step) []storage.Step { out := make([]storage.Step, 0, len(arr)) for _, st := range arr { if st.Title == "" { continue } out = append(out, st) } return out } // stepsList возвращает этапы, устойчиво к формату модели: массив объектов // {"title","ac"} либо строка (пустая → nil; содержащая JSON-массив → парсится; // иначе игнорируется). Записи без title отбрасываются. func (r *AnalystResponse) stepsList() []storage.Step { if r.Steps == nil { return nil } var arr []storage.Step if err := json.Unmarshal(r.Steps, &arr); err == nil { return validSteps(arr) } var s string if err := json.Unmarshal(r.Steps, &s); err != nil { return nil } if strings.TrimSpace(s) == "" { return nil } if err := json.Unmarshal([]byte(s), &arr); err == nil { return validSteps(arr) } return nil } // 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" } a.publish(events.AgentActivity{TaskID: draft.ID, Agent: agent, Stage: "decide"}) 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, Repos: draft.EffectiveRepos(), Why: draft.Why, AC: draft.AC, Steps: draft.Steps, 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) } log.Printf("analyst: вердикт: %s", truncate(formatVerdict(&ar), 2000)) // 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 repos := ar.reposList(); len(repos) > 0 { dec.Draft.Repos = repos // Синхронизируем одиночный repo для старых потребителей. dec.Draft.Repo = strings.Join(repos, ",") } if ar.Why != "" { dec.Draft.Why = ar.Why } if ar.AC != "" { dec.Draft.AC = ar.AC } if steps := ar.stepsList(); len(steps) > 0 { dec.Draft.Steps = steps } 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 == "" && len(ar.Repos) == 0 && ar.Why == "" && ar.AC == "" && len(ar.stepsList()) == 0 { return fmt.Errorf("phase=propose, но нет ни одного изменённого поля") } case "ready": // черновик уже полный и готов как есть — изменённых полей НЕ требуется case "abort": // abort_reason — не обязателен, но желателен default: 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 repos := ar.reposList(); len(repos) > 0 { b.WriteString(", repos=[") b.WriteString(strings.Join(repos, ", ")) b.WriteString("]") } if ar.Why != "" { b.WriteString(", why=") b.WriteString(ar.Why) } if ar.AC != "" { b.WriteString(", ac=") b.WriteString(ar.AC) } if steps := ar.stepsList(); len(steps) > 0 { b.WriteString(", steps=[") var parts []string for _, st := range steps { s := st.Title if st.AC != "" { s += " → " + st.AC } parts = append(parts, s) } b.WriteString(strings.Join(parts, " | ")) b.WriteString("]") } if ar.AbortReason != "" { b.WriteString(", abort_reason=") b.WriteString(ar.AbortReason) } return b.String() }