182 lines
5.3 KiB
Go
182 lines
5.3 KiB
Go
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
|
||
} |