265 lines
7.7 KiB
Go
265 lines
7.7 KiB
Go
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"`
|
||
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
|
||
}
|
||
|
||
// 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,
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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 ar.AbortReason != "" {
|
||
b.WriteString(", abort_reason=")
|
||
b.WriteString(ar.AbortReason)
|
||
}
|
||
return b.String()
|
||
} |