Files
ratatoskr-go/internal/analyst/analyst.go
Hermes e9f7017571
All checks were successful
CI / test (push) Successful in 46s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 39s
debug: логировать хвост вердикта аналитика и извлечённый JSON (диагностика A3 phase="")
2026-08-17 11:36:49 +05:00

182 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package analyst
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"github.com/kamelion/ratatoskr-go/internal/core"
"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")
}
// AnalystResponse — структура JSON-ответа аналитика.
type AnalystResponse struct {
Phase string `json:"phase"`
Title string `json:"title"`
Goal string `json:"goal"`
Repo string `json:"repo"` // одиночный репо (обратная совместимость)
Repos []string `json:"repos"` // список репо (основной)
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,
Repos: draft.EffectiveRepos(),
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)
log.Printf("analyst: verdict tail: %s", truncate(verdict, 2000))
obj, ok := opencode.ExtractJSON(verdict)
if ok {
b, _ := json.Marshal(obj)
log.Printf("analyst: extracted json: %s", truncate(string(b), 1000))
}
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 len(ar.Repos) > 0 {
dec.Draft.Repos = ar.Repos
// Синхронизируем одиночный repo для старых потребителей.
dec.Draft.Repo = strings.Join(ar.Repos, ",")
}
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 == "" && len(ar.Repos) == 0 && ar.Why == "" && ar.AC == "" {
return fmt.Errorf("phase=propose, но нет ни одного изменённого поля")
}
case "abort":
// abort_reason — не обязателен, но желателен
default:
return fmt.Errorf("неизвестный phase=%q", ar.Phase)
}
return nil
}