Files
ratatoskr-go/internal/analyst/analyst.go
Hermes bd3d825738
All checks were successful
CI / test (push) Successful in 46s
CI / build-and-package (amd64, darwin) (push) Successful in 36s
CI / build-and-package (amd64, linux) (push) Successful in 37s
CI / build-and-package (amd64, windows) (push) Successful in 44s
CI / build-and-package (arm64, darwin) (push) Successful in 35s
CI / build-and-package (arm64, linux) (push) Successful in 36s
feat: множественные репозитории (Repos) и клонирование в воркере
- Task.Repos []string (XML-колонка repos, обратная совместимость с repo)
- config: блок git {base_url, token}
- аналитик: ответ repos[], шаблон показывает список
- core: propose без repos → возврат в сбор (E1)
- worker вариант A: один dev из общего cwd, prepareRepos клонирует
  недостающие репо (git clone), validateRepoName (E3), ErrRepoNotGit (E4)
- ошибки E1-E4 в worker/errors.go
2026-08-16 09:18:15 +05:00

166 lines
5.0 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"
"strings"
"github.com/kamelion/ratatoskr-go/internal/core"
"github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// 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)
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)
}
// 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
}