internal/analyst: Decider через opencode (A1–A4)
All checks were successful
build-test / build (push) Successful in 1m15s
All checks were successful
build-test / build (push) Successful in 1m15s
- Analyst struct: строит Go-template промпт из history+draft - opencode.Runner.Run (agent=analyst) → ExtractVerdict → ExtractJSON - Валидация phase/полей → core.Decision - A1 DecodeFail, A2 RunError, A3 Validation, A4 NotReady - MockRunner в тестах (11 тестов, зелёные) - core.runDecide оборачивает ошибки аналитика в D1 ErrDecideFailed - allow ready→collecting (фикс для правки готового черновика)
This commit is contained in:
160
internal/analyst/analyst.go
Normal file
160
internal/analyst/analyst.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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"`
|
||||||
|
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,
|
||||||
|
Repo: draft.Repo,
|
||||||
|
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 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 == "" && ar.Repo == "" && ar.Why == "" && ar.AC == "" {
|
||||||
|
return fmt.Errorf("phase=propose, но нет ни одного изменённого поля")
|
||||||
|
}
|
||||||
|
case "abort":
|
||||||
|
// abort_reason — не обязателен, но желателен
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("неизвестный phase=%q", ar.Phase)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
201
internal/analyst/analyst_test.go
Normal file
201
internal/analyst/analyst_test.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package analyst
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/core"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
||||||
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockRunner — тестовый OpenCodeRunner с задаваемым поведением.
|
||||||
|
type mockRunner struct {
|
||||||
|
result *opencode.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockRunner) Run(_ context.Context, _, _, _, _ string) (*opencode.Result, error) {
|
||||||
|
return m.result, m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAsk(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Уточню про репозиторий\",\"questions\":[\"Где лежит код?\",\"Какая цель?\"]}"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
history := []core.Message{{Role: "user", Content: "Сделай калькулятор"}}
|
||||||
|
dec, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide err: %v", err)
|
||||||
|
}
|
||||||
|
if dec.Phase != "ask" {
|
||||||
|
t.Errorf("Phase = %q, want ask", dec.Phase)
|
||||||
|
}
|
||||||
|
if len(dec.Questions) != 2 {
|
||||||
|
t.Errorf("len(Questions) = %d, want 2", len(dec.Questions))
|
||||||
|
}
|
||||||
|
if dec.ChatReply == "" {
|
||||||
|
t.Error("ChatReply пустой")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecidePropose(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"goal\":\"Сделать веб-калькулятор\",\"repo\":\"tools/calc\",\"why\":\"Нужен для учёта\",\"ac\":\"Работает + - * /\",\"chat_reply\":\"Готово!\"}"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
history := []core.Message{
|
||||||
|
{Role: "user", Content: "Сделай калькулятор"},
|
||||||
|
{Role: "assistant", Content: "Где репозиторий?"},
|
||||||
|
{Role: "user", Content: "tools/calc"},
|
||||||
|
}
|
||||||
|
dec, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide err: %v", err)
|
||||||
|
}
|
||||||
|
if dec.Phase != "propose" {
|
||||||
|
t.Errorf("Phase = %q, want propose", dec.Phase)
|
||||||
|
}
|
||||||
|
if dec.Draft.Title != "Калькулятор" {
|
||||||
|
t.Errorf("Draft.Title = %q, want Калькулятор", dec.Draft.Title)
|
||||||
|
}
|
||||||
|
if dec.Draft.Repo != "tools/calc" {
|
||||||
|
t.Errorf("Draft.Repo = %q", dec.Draft.Repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAbort(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"abort\",\"chat_reply\":\"Это не про код.\",\"abort_reason\":\"Тема не подходит opencode\"}"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
history := []core.Message{{Role: "user", Content: "Почини принтер"}}
|
||||||
|
dec, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide err: %v", err)
|
||||||
|
}
|
||||||
|
if dec.Phase != "abort" {
|
||||||
|
t.Errorf("Phase = %q, want abort", dec.Phase)
|
||||||
|
}
|
||||||
|
if dec.ChatReply != "Это не про код." {
|
||||||
|
t.Errorf("ChatReply = %q", dec.ChatReply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeFail(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"не JSON, а просто текст"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
history := []core.Message{{Role: "user", Content: "тест"}}
|
||||||
|
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected A1 error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrDecodeFail) {
|
||||||
|
t.Errorf("err = %v, want A1", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunError(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{err: errors.New("opencode not found")}, Worktree: "/tmp"}
|
||||||
|
history := []core.Message{{Role: "user", Content: "тест"}}
|
||||||
|
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected A2 error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrRunError) {
|
||||||
|
t.Errorf("err = %v, want A2", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyHistory(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{RC: 0}}}
|
||||||
|
_, err := a.Decide(context.Background(), nil, storage.Task{}, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected A4 error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNotReady) {
|
||||||
|
t.Errorf("err = %v, want A4", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForceEmptyHistory(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Опишите задачу.\"}"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
_, err := a.Decide(context.Background(), nil, storage.Task{}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide(force=true) err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidPhase(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"unknown\"}"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
history := []core.Message{{Role: "user", Content: "test"}}
|
||||||
|
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected A3 error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrValidation) {
|
||||||
|
t.Errorf("err = %v, want A3", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonZeroExit(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{RC: 1, Stdout: "fail"}}, Worktree: "/tmp"}
|
||||||
|
history := []core.Message{{Role: "user", Content: "test"}}
|
||||||
|
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected A2 error for rc=1")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrRunError) {
|
||||||
|
t.Errorf("err = %v, want A2", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeEmptyFields(t *testing.T) {
|
||||||
|
// propose без изменённых полей — A3
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"chat_reply\":\"ok\"}"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
history := []core.Message{{Role: "user", Content: "test"}}
|
||||||
|
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected A3 error for propose with no fields")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrValidation) {
|
||||||
|
t.Errorf("err = %v, want A3", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskEmptyReplyAndQuestions(t *testing.T) {
|
||||||
|
// ask без chat_reply и questions — A3
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\"}"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
history := []core.Message{{Role: "user", Content: "test"}}
|
||||||
|
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected A3 error for empty ask")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrValidation) {
|
||||||
|
t.Errorf("err = %v, want A3", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/analyst/errors.go
Normal file
18
internal/analyst/errors.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package analyst
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Классы ошибок A1–A4.
|
||||||
|
var (
|
||||||
|
// A1 — ответ аналитика не содержит валидного JSON.
|
||||||
|
ErrDecodeFail = errors.New("A1: decode analyst response")
|
||||||
|
|
||||||
|
// A2 — ошибка запуска opencode run (включая таймаут rc=-1).
|
||||||
|
ErrRunError = errors.New("A2: analyst run error")
|
||||||
|
|
||||||
|
// A3 — JSON от аналитика есть, но поля невалидны.
|
||||||
|
ErrValidation = errors.New("A3: invalid analyst response")
|
||||||
|
|
||||||
|
// A4 — аналитик ещё не может работать (нет истории).
|
||||||
|
ErrNotReady = errors.New("A4: analyst not ready")
|
||||||
|
)
|
||||||
74
internal/analyst/prompt.go
Normal file
74
internal/analyst/prompt.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package analyst
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
// promptTemplate — шаблон промпта для аналитика (opencode analyst-agent).
|
||||||
|
// Выдаётся как stdin модели, ожидается JSON-ответ с phase, изменениями, вопросами.
|
||||||
|
var promptTemplate = template.Must(template.New("analyst").Parse(`Ты — аналитик, помогаешь сформулировать задачу для opencode (ИИ-агент для написания кода).
|
||||||
|
|
||||||
|
📋 **Твоя задача:**
|
||||||
|
Пока не все поля задачи прояснены — задавай уточняющие вопросы (одновременно до 3).
|
||||||
|
Когда достаточно данных — предложи готовый черновик задачи.
|
||||||
|
Если тема явно не про код или не подходит opencode — abort.
|
||||||
|
|
||||||
|
**Поля задачи:**
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| title | краткое название (1–4 слова) |
|
||||||
|
| goal | цель задачи: что именно нужно сделать |
|
||||||
|
| repo | путь к репозиторию (относительно /opt/data/src/) |
|
||||||
|
| why | зачем это нужно, контекст |
|
||||||
|
| ac | acceptance criteria — конкретный результат, что считается готовым |
|
||||||
|
|
||||||
|
{{- if .Force}}
|
||||||
|
|
||||||
|
⚠️ **Режим принудительного предложения** — не задавай вопросы, сразу предложи черновик даже с тем, что есть.
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
**Переписка с пользователем:**
|
||||||
|
{{.History}}
|
||||||
|
|
||||||
|
**Текущий черновик:**
|
||||||
|
{{if .Title}} title: {{.Title}}{{else}} title: (не задано){{end}}
|
||||||
|
{{if .Goal}} goal: {{.Goal}}{{else}} goal: (не задано){{end}}
|
||||||
|
{{if .Repo}} repo: {{.Repo}}{{else}} repo: (не задано){{end}}
|
||||||
|
{{if .Why}} why: {{.Why}}{{else}} why: (не задано){{end}}
|
||||||
|
{{if .AC}} ac: {{.AC}}{{else}} ac: (не задано){{end}}
|
||||||
|
|
||||||
|
**Ответь строго JSON-объектом, без лишнего текста:**
|
||||||
|
{
|
||||||
|
"phase": "ask|propose|abort",
|
||||||
|
"title": "название (только если меняешь)",
|
||||||
|
"goal": "цель (только если меняешь)",
|
||||||
|
"repo": "путь к репозиторию (только если меняешь)",
|
||||||
|
"why": "зачем (только если меняешь)",
|
||||||
|
"ac": "критерии (только если меняешь)",
|
||||||
|
"questions": ["вопрос 1", "вопрос 2"],
|
||||||
|
"chat_reply": "твой ответ пользователю (на русском, естественно)",
|
||||||
|
"abort_reason": "если phase=abort — причина"
|
||||||
|
}
|
||||||
|
`))
|
||||||
|
|
||||||
|
// TemplateData — данные для рендера шаблона промпта.
|
||||||
|
type TemplateData struct {
|
||||||
|
Title string
|
||||||
|
Goal string
|
||||||
|
Repo string
|
||||||
|
Why string
|
||||||
|
AC string
|
||||||
|
History string // отформатированная переписка
|
||||||
|
Force bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPrompt собирает промпт из истории и черновика.
|
||||||
|
func RenderPrompt(data TemplateData) (string, error) {
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := promptTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
@@ -301,7 +302,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
|||||||
|
|
||||||
decision, err := c.Decide.Decide(ctx, msgs, *task, force)
|
decision, err := c.Decide.Decide(ctx, msgs, *task, force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, fmt.Errorf("%w: %v", ErrDecideFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch decision.Phase {
|
switch decision.Phase {
|
||||||
|
|||||||
Reference in New Issue
Block a user