Files
ratatoskr-go/internal/analyst/analyst.go
Hermes 46065f84fd
Some checks failed
CI / test (push) Failing after 52s
CI / build-and-package (amd64, linux) (push) Failing after 27s
CI / build-and-package (amd64, windows) (push) Failing after 28s
feat: фаза ready — черновик готов как есть, без требований к изменённым полям
Аналитик теперь может вернуть phase=ready, когда черновик уже полный и
менять нечего. Раньше модель не могла это выразить и возвращала пустой
propose, который валидатор резал A3 (нет изменённых полей).

- analyst: case ready в validateResponse (без требований к полям)
- core: propose и ready обрабатываются одинаково (applyDraft + E1 + ready)
- prompt/analyst.md: контракт фаз обновлён (ask|propose|ready|abort)
- тест TestDecideReady + README: раздел фаз аналитика
2026-08-17 14:07:05 +05:00

184 lines
5.4 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 "ready":
// черновик уже полный и готов как есть — изменённых полей НЕ требуется
case "abort":
// abort_reason — не обязателен, но желателен
default:
return fmt.Errorf("неизвестный phase=%q", ar.Phase)
}
return nil
}