From 6c0b903794ebe6114da7cef3440f593ee49b7ab7 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Mon, 24 Aug 2026 18:05:06 +0500 Subject: [PATCH] =?UTF-8?q?feat(analyst):=20=D1=8D=D1=82=D0=B0=D0=BF=D1=8B?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=E2=80=94=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BD?= =?UTF-8?q?=D0=B0=20steps=20=D1=81=20=D0=BA=D1=80=D0=B8=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC=D0=B8=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8,=20dev=20=D0=B8=D0=B4=D1=91=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BD=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/analyst/analyst.go | 42 +++++++++++++++++-- internal/analyst/analyst_test.go | 66 +++++++++++++++++++++++++----- internal/analyst/prompt.go | 13 +++++- internal/storage/models.go | 70 ++++++++++++++++++++++---------- internal/storage/storage.go | 9 +++- internal/storage/storage_test.go | 38 +++++++++++++++++ internal/storage/tasks.go | 28 ++++++++----- internal/worker/prompt.go | 11 ++++- internal/worker/worker.go | 1 + internal/worker/worker_test.go | 58 ++++++++++++++++++++++++++ 10 files changed, 286 insertions(+), 50 deletions(-) diff --git a/internal/analyst/analyst.go b/internal/analyst/analyst.go index cf28aea..ad453c5 100644 --- a/internal/analyst/analyst.go +++ b/internal/analyst/analyst.go @@ -53,10 +53,11 @@ type AnalystResponse struct { Phase string `json:"phase"` Title string `json:"title"` Goal string `json:"goal"` - Repo string `json:"repo"` // одиночный репо (обратная совместимость) - Repos json.RawMessage `json:"repos"` // список репо (основной); устойчив к строке + Repo string `json:"repo"` // одиночный репо (обратная совместимость) + Repos json.RawMessage `json:"repos"` // список репо (основной); устойчив к строке Why string `json:"why"` AC string `json:"ac"` + Steps []storage.Step `json:"steps"` Questions []string `json:"questions"` ChatReply string `json:"chat_reply"` AbortReason string `json:"abort_reason"` @@ -86,6 +87,22 @@ func (r *AnalystResponse) reposList() []string { return out } +// stepsList возвращает этапы: модель может вернуть массив объектов либо +// пустой/отсутствующий — тогда nil. Записи без title отбрасываются. +func (r *AnalystResponse) stepsList() []storage.Step { + if r.Steps == nil { + return nil + } + var out []storage.Step + for _, st := range r.Steps { + if st.Title == "" { + continue + } + out = append(out, st) + } + 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 @@ -109,6 +126,7 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor Repos: draft.EffectiveRepos(), Why: draft.Why, AC: draft.AC, + Steps: draft.Steps, History: hist, Force: force, } @@ -176,6 +194,9 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor if ar.AC != "" { dec.Draft.AC = ar.AC } + if steps := ar.stepsList(); len(steps) > 0 { + dec.Draft.Steps = steps + } return dec, nil } @@ -204,7 +225,7 @@ func validateResponse(ar *AnalystResponse) error { return fmt.Errorf("phase=ask, но нет ни chat_reply, ни questions") } case "propose": - if ar.Title == "" && ar.Goal == "" && len(ar.Repos) == 0 && ar.Why == "" && ar.AC == "" { + if ar.Title == "" && ar.Goal == "" && len(ar.Repos) == 0 && ar.Why == "" && ar.AC == "" && len(ar.stepsList()) == 0 { return fmt.Errorf("phase=propose, но нет ни одного изменённого поля") } case "ready": @@ -257,9 +278,22 @@ func formatVerdict(ar *AnalystResponse) string { b.WriteString(", ac=") b.WriteString(ar.AC) } + if steps := ar.stepsList(); len(steps) > 0 { + b.WriteString(", steps=[") + var parts []string + for _, st := range steps { + s := st.Title + if st.AC != "" { + s += " → " + st.AC + } + parts = append(parts, s) + } + b.WriteString(strings.Join(parts, " | ")) + b.WriteString("]") + } if ar.AbortReason != "" { b.WriteString(", abort_reason=") b.WriteString(ar.AbortReason) } return b.String() -} \ No newline at end of file +} diff --git a/internal/analyst/analyst_test.go b/internal/analyst/analyst_test.go index 7002670..02f0d0e 100644 --- a/internal/analyst/analyst_test.go +++ b/internal/analyst/analyst_test.go @@ -24,7 +24,7 @@ func (m *mockRunner) Run(_ context.Context, _, _, _, _ string) (*opencode.Result func TestDecideAsk(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Уточню про репозиторий\",\"questions\":[\"Где лежит код?\",\"Какая цель?\"]}"}}`, }}, Worktree: "/tmp"} @@ -46,7 +46,7 @@ func TestDecideAsk(t *testing.T) { func TestDecidePropose(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"goal\":\"Сделать веб-калькулятор\",\"repo\":\"tools/calc\",\"why\":\"Нужен для учёта\",\"ac\":\"Работает + - * /\",\"chat_reply\":\"Готово!\"}"}}`, }}, Worktree: "/tmp"} @@ -72,7 +72,7 @@ func TestDecidePropose(t *testing.T) { func TestDecideAbort(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"abort\",\"chat_reply\":\"Это не про код.\",\"abort_reason\":\"Тема не подходит opencode\"}"}}`, }}, Worktree: "/tmp"} @@ -92,7 +92,7 @@ func TestDecideAbort(t *testing.T) { func TestDecideReady(t *testing.T) { // ready с пустыми изменёнными полями — ВАЛИДНО (черновик готов как есть) a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ready\",\"chat_reply\":\"Черновик готов, запускаю.\"}"}}`, }}, Worktree: "/tmp"} @@ -147,7 +147,7 @@ func TestEmptyHistory(t *testing.T) { func TestForceEmptyHistory(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Опишите задачу.\"}"}}`, }}, Worktree: "/tmp"} @@ -159,7 +159,7 @@ func TestForceEmptyHistory(t *testing.T) { func TestInvalidPhase(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"unknown\"}"}}`, }}, Worktree: "/tmp"} @@ -188,7 +188,7 @@ func TestNonZeroExit(t *testing.T) { func TestProposeEmptyFields(t *testing.T) { // propose без изменённых полей — A3 a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"chat_reply\":\"ok\"}"}}`, }}, Worktree: "/tmp"} @@ -205,7 +205,7 @@ func TestProposeEmptyFields(t *testing.T) { func TestAskEmptyReplyAndQuestions(t *testing.T) { // ask без chat_reply и questions — A3 a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\"}"}}`, }}, Worktree: "/tmp"} @@ -223,7 +223,7 @@ func TestAskEmptyReplyAndQuestions(t *testing.T) { // парсер должен нормализовать и не падать. func TestDecideProposeStringRepos(t *testing.T) { a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ - RC: 0, + RC: 0, Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"repos\":\"tools/calc, tools/ui\"}"}}`, }}, Worktree: "/tmp"} @@ -240,6 +240,52 @@ func TestDecideProposeStringRepos(t *testing.T) { } } +// TestDecideProposeSteps — аналитик разложил задачу на этапы с критериями. +func TestDecideProposeSteps(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"steps\":[{\"title\":\"Модель\",\"ac\":\"операции + - * /\"},{\"title\":\"UI\"}]}"}}`, + }}, 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 != "propose" { + t.Errorf("Phase = %q, want propose", dec.Phase) + } + if len(dec.Draft.Steps) != 2 { + t.Fatalf("len(Steps) = %d, want 2", len(dec.Draft.Steps)) + } + if dec.Draft.Steps[0].Title != "Модель" || dec.Draft.Steps[0].AC != "операции + - * /" { + t.Errorf("Steps[0] = %q/%q, want Модель/операции + - * /", dec.Draft.Steps[0].Title, dec.Draft.Steps[0].AC) + } + if dec.Draft.Steps[1].Title != "UI" || dec.Draft.Steps[1].AC != "" { + t.Errorf("Steps[1] = %q/%q, want UI/(пусто)", dec.Draft.Steps[1].Title, dec.Draft.Steps[1].AC) + } +} + +// TestProposeOnlyStepsValid — propose меняет только steps → валидно. +func TestProposeOnlyStepsValid(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"steps\":[{\"title\":\"Шаг 1\"}],\"chat_reply\":\"Разбил на этапы\"}"}}`, + }}, Worktree: "/tmp"} + + history := []core.Message{{Role: "user", Content: "test"}} + 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 len(dec.Draft.Steps) != 1 { + t.Fatalf("len(Steps) = %d, want 1", len(dec.Draft.Steps)) + } +} + // TestFormatVerdict — человекочитаемое описание вердикта аналитика. func TestFormatVerdict(t *testing.T) { tests := []struct { @@ -301,4 +347,4 @@ func TestFormatVerdict(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/analyst/prompt.go b/internal/analyst/prompt.go index 28134a5..d786b7e 100644 --- a/internal/analyst/prompt.go +++ b/internal/analyst/prompt.go @@ -3,6 +3,8 @@ package analyst import ( "strings" "text/template" + + "github.com/kamelion/ratatoskr-go/internal/storage" ) // promptTemplate — шаблон промпта для аналитика (opencode analyst-agent). @@ -23,6 +25,7 @@ var promptTemplate = template.Must(template.New("analyst").Parse(`Ты — ан | repos | список репозиториев (имена на git-хосте; для связанных — все сразу) | | why | зачем это нужно, контекст | | ac | acceptance criteria — конкретный результат, что считается готовым | +| steps | (опционально) разбиение задачи на этапы: список {title, ac} с критерием готовности каждого этапа | Изменяй в JSON только те поля, которые надо поменять; что менять не надо — пустой строкой. @@ -51,6 +54,12 @@ var promptTemplate = template.Must(template.New("analyst").Parse(`Ты — ан {{- else}} repos: (не задано){{end}} {{if .Why}} why: {{.Why}}{{else}} why: (не задано){{end}} {{if .AC}} ac: {{.AC}}{{else}} ac: (не задано){{end}} +{{if .Steps}} + steps: +{{- range .Steps}} + - {{.Title}}{{if .AC}} → {{.AC}}{{end}} +{{- end}} +{{- else}} steps: (не задано){{end}} **Ответь строго JSON-объектом, без лишнего текста:** { @@ -60,6 +69,7 @@ var promptTemplate = template.Must(template.New("analyst").Parse(`Ты — ан "repos": ["имя_репо_1", "имя_репо_2"], "why": "зачем (только если меняешь)", "ac": "критерии (только если меняешь)", + "steps": [{"title": "этап 1", "ac": "критерий этапа 1"}], "questions": ["вопрос 1", "вопрос 2"], "chat_reply": "твой ответ пользователю (на русском, естественно)", "abort_reason": "если phase=abort — причина" @@ -73,6 +83,7 @@ type TemplateData struct { Repos []string Why string AC string + Steps []storage.Step History string // отформатированная переписка Force bool } @@ -84,4 +95,4 @@ func RenderPrompt(data TemplateData) (string, error) { return "", err } return buf.String(), nil -} \ No newline at end of file +} diff --git a/internal/storage/models.go b/internal/storage/models.go index 8bed32c..01b82d1 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -14,17 +14,17 @@ type Status = model.Status // Статусы задачи — re-export из model. const ( - StatusDraft = model.StatusDraft - StatusCollecting = model.StatusCollecting - StatusReady = model.StatusReady - StatusApproved = model.StatusApproved - StatusRunning = model.StatusRunning - StatusSuccess = model.StatusSuccess - StatusFailed = model.StatusFailed - StatusTimeout = model.StatusTimeout - StatusCancelled = model.StatusCancelled - StatusAborted = model.StatusAborted - StatusClosed = model.StatusClosed + StatusDraft = model.StatusDraft + StatusCollecting = model.StatusCollecting + StatusReady = model.StatusReady + StatusApproved = model.StatusApproved + StatusRunning = model.StatusRunning + StatusSuccess = model.StatusSuccess + StatusFailed = model.StatusFailed + StatusTimeout = model.StatusTimeout + StatusCancelled = model.StatusCancelled + StatusAborted = model.StatusAborted + StatusClosed = model.StatusClosed ) // AllStatuses — все возможные статусы для валидации. @@ -40,17 +40,24 @@ func IsTerminal(s Status) bool { return model.IsTerminal(s) } +// Step — этап задачи с собственным критерием готовности. +type Step struct { + Title string `json:"title"` + AC string `json:"ac"` // acceptance criterion этапа +} + // Task — запись задачи в БД. type Task struct { ID int64 `json:"id"` - ChatID string `json:"chat_id"` // tg:// + ChatID string `json:"chat_id"` // tg:// Title string `json:"title"` Goal string `json:"goal"` - Repo string `json:"repo"` // обратная совместимость: одиночный репозиторий - Repos []string `json:"repos"` // список репозиториев (основной) + Repo string `json:"repo"` // обратная совместимость: одиночный репозиторий + Repos []string `json:"repos"` // список репозиториев (основной) Why string `json:"why"` - AC string `json:"ac"` // acceptance criteria - TaskTag string `json:"task_tag"` // UUID, стабильный на всю жизнь + AC string `json:"ac"` // acceptance criteria + Steps []Step `json:"steps"` // этапы задачи (опционально) + TaskTag string `json:"task_tag"` // UUID, стабильный на всю жизнь Status Status `json:"status"` CreatedAt SQLiteTime `json:"created_at"` UpdatedAt SQLiteTime `json:"updated_at"` @@ -88,6 +95,25 @@ func (t *Task) SetReposFromDB(repos string) { _ = json.Unmarshal([]byte(repos), &t.Repos) } +// StepsJoined возвращает steps как одну строку (JSON-массив) для хранения в БД. +// Пустой список → пустая строка. +func (t *Task) StepsJoined() string { + if len(t.Steps) == 0 { + return "" + } + b, _ := json.Marshal(t.Steps) + return string(b) +} + +// SetStepsFromDB заполняет Steps из сохранённой строки (JSON). +func (t *Task) SetStepsFromDB(steps string) { + if steps == "" { + t.Steps = nil + return + } + _ = json.Unmarshal([]byte(steps), &t.Steps) +} + // TraceStatus — алиас доменного статуса трассировки. type TraceStatus = model.TraceStatus @@ -105,7 +131,7 @@ type Trace struct { Agent string `json:"agent"` // analyst | researcher | dev | reviewer SessionID string `json:"session_id"` // opencode session_id Prompt string `json:"prompt"` - Output string `json:"output"` // полный NDJSON или summary + Output string `json:"output"` // полный NDJSON или summary Status TraceStatus `json:"status"` StartedAt SQLiteTime `json:"started_at"` FinishedAt NullSQLiteTime `json:"finished_at,omitempty"` @@ -113,8 +139,8 @@ type Trace struct { // TaskFilter — параметры фильтрации списка задач. type TaskFilter struct { - ChatID string - Status Status - Limit int - Offset int -} \ No newline at end of file + ChatID string + Status Status + Limit int + Offset int +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 3b6faf3..bd0d59f 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -173,6 +173,13 @@ func (s *Storage) migrate(ctx context.Context) error { return fmt.Errorf("%w: migrate add repos: %w", ErrDB, err) } } + // Доп. колонка steps (этапы задачи). Idempotent. + if _, err := s.db.ExecContext(ctx, + `ALTER TABLE tasks ADD COLUMN steps TEXT NOT NULL DEFAULT ''`); err != nil { + if !isDuplicateColumn(err) { + return fmt.Errorf("%w: migrate add steps: %w", ErrDB, err) + } + } return nil } @@ -185,4 +192,4 @@ func isDuplicateColumn(err error) bool { // Now возвращает текущее время UTC как SQLiteTime. func Now() SQLiteTime { return SQLiteTime(time.Now().UTC()) -} \ No newline at end of file +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 5ab0936..47668e5 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -150,6 +150,44 @@ func TestUpdateTaskNotFound(t *testing.T) { } } +func TestCreateAndGetTask_Steps(t *testing.T) { + s, ctx := setupTestDB(t) + task := &Task{ + ChatID: "tg://steps", + Title: "Steps task", + TaskTag: "steps-1", + Steps: []Step{ + {Title: "Реализовать модель", AC: "структура готова"}, + {Title: "Добавить API", AC: "эндпоинт отвечает"}, + }, + } + id, err := s.CreateTask(ctx, task) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + got, err := s.GetTask(ctx, id) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if len(got.Steps) != 2 { + t.Fatalf("steps len = %d, want 2", len(got.Steps)) + } + if got.Steps[0].Title != "Реализовать модель" || got.Steps[0].AC != "структура готова" { + t.Fatalf("steps[0] = %q / %q, want модель / структура готова", got.Steps[0].Title, got.Steps[0].AC) + } + + // апдейт этапов + got.Steps = append(got.Steps, Step{Title: "Ревью", AC: "пройден review"}) + if err := s.UpdateTask(ctx, got); err != nil { + t.Fatalf("UpdateTask steps: %v", err) + } + got2, _ := s.GetTask(ctx, id) + if len(got2.Steps) != 3 { + t.Fatalf("steps len after update = %d, want 3", len(got2.Steps)) + } +} + func TestListTasks(t *testing.T) { s, ctx := setupTestDB(t) for i := 0; i < 5; i++ { diff --git a/internal/storage/tasks.go b/internal/storage/tasks.go index 8043e3c..6571a1a 100644 --- a/internal/storage/tasks.go +++ b/internal/storage/tasks.go @@ -30,10 +30,10 @@ func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) { } now := Now() res, err := s.db.ExecContext(ctx, ` - INSERT INTO tasks (chat_id, title, goal, repo, repos, why, ac, task_tag, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + INSERT INTO tasks (chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, t.ChatID, t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC, - t.TaskTag, StatusDraft, now, now, + t.StepsJoined(), t.TaskTag, StatusDraft, now, now, ) if err != nil { return 0, fmt.Errorf("%w: create task: %w", ErrDB, err) @@ -53,11 +53,12 @@ func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) { func (s *Storage) GetTask(ctx context.Context, id int64) (*Task, error) { t := &Task{} var reposStr string + var stepsStr string err := s.db.QueryRowContext(ctx, ` - SELECT id, chat_id, title, goal, repo, repos, why, ac, task_tag, status, created_at, updated_at + SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at FROM tasks WHERE id = ?`, id).Scan( &t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr, - &t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt, + &t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: task %d", ErrNotFound, id) @@ -66,6 +67,7 @@ func (s *Storage) GetTask(ctx context.Context, id int64) (*Task, error) { return nil, fmt.Errorf("%w: get task %d: %w", ErrDB, id, err) } t.SetReposFromDB(reposStr) + t.SetStepsFromDB(stepsStr) return t, nil } @@ -94,9 +96,9 @@ func (s *Storage) UpdateTask(ctx context.Context, t *Task) error { now := Now() res, err := s.db.ExecContext(ctx, ` UPDATE tasks - SET title=?, goal=?, repo=?, repos=?, why=?, ac=?, status=?, updated_at=? + SET title=?, goal=?, repo=?, repos=?, why=?, ac=?, steps=?, status=?, updated_at=? WHERE id=?`, - t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC, t.Status, now, t.ID, + t.Title, t.Goal, t.Repo, t.ReposJoined(), t.Why, t.AC, t.StepsJoined(), t.Status, now, t.ID, ) if err != nil { return fmt.Errorf("%w: update task %d: %w", ErrDB, t.ID, err) @@ -114,13 +116,14 @@ func (s *Storage) UpdateTask(ctx context.Context, t *Task) error { func (s *Storage) GetActiveTaskByChatID(ctx context.Context, chatID string) (*Task, error) { t := &Task{} var reposStr string + var stepsStr string err := s.db.QueryRowContext(ctx, ` - SELECT id, chat_id, title, goal, repo, repos, why, ac, task_tag, status, created_at, updated_at + SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at FROM tasks WHERE chat_id = ? AND status NOT IN ('success','cancelled','aborted','closed') ORDER BY updated_at DESC LIMIT 1`, chatID).Scan( &t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr, - &t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt) + &t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: no active task for chat %s", ErrNotFound, chatID) } @@ -128,6 +131,7 @@ func (s *Storage) GetActiveTaskByChatID(ctx context.Context, chatID string) (*Ta return nil, fmt.Errorf("%w: get active task %s: %w", ErrDB, chatID, err) } t.SetReposFromDB(reposStr) + t.SetStepsFromDB(stepsStr) return t, nil } @@ -149,7 +153,7 @@ func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, er args = append(args, filter.Limit, filter.Offset) rows, err := s.db.QueryContext(ctx, ` - SELECT id, chat_id, title, goal, repo, repos, why, ac, task_tag, status, created_at, updated_at + SELECT id, chat_id, title, goal, repo, repos, why, ac, steps, task_tag, status, created_at, updated_at FROM tasks WHERE `+where+` ORDER BY updated_at DESC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, fmt.Errorf("%w: list tasks: %w", ErrDB, err) @@ -160,11 +164,13 @@ func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, er for rows.Next() { t := &Task{} var reposStr string + var stepsStr string if err := rows.Scan(&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &reposStr, - &t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil { + &t.Why, &t.AC, &stepsStr, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil { return nil, fmt.Errorf("%w: scan task: %w", ErrDB, err) } t.SetReposFromDB(reposStr) + t.SetStepsFromDB(stepsStr) tasks = append(tasks, t) } return tasks, rows.Err() diff --git a/internal/worker/prompt.go b/internal/worker/prompt.go index a518da9..21ed5d0 100644 --- a/internal/worker/prompt.go +++ b/internal/worker/prompt.go @@ -3,6 +3,8 @@ package worker import ( "strings" "text/template" + + "github.com/kamelion/ratatoskr-go/internal/storage" ) // devPromptTemplate — промпт для dev-агента при запуске задачи. @@ -20,6 +22,12 @@ var devPromptTemplate = template.Must(template.New("dev").Parse(`Ты — dev-а {{if .Why}}Зачем: {{.Why}}{{end}} {{if .AC}}Критерии готовности: {{.AC}}{{end}} +{{if .Steps}} +**Этапы (выполняй по порядку, у каждого свой критерий готовности):** +{{- range .Steps}} +{{.Title}}{{if .AC}} — готово, когда: {{.AC}}{{end}} +{{- end}} +{{end}} **Инструкции:** 1. Рабочий каталог — общий корень, в котором лежат все репозитории по именам. @@ -38,6 +46,7 @@ type DevPromptData struct { Repos []string Why string AC string + Steps []storage.Step // ReviewFeedback — замечания ревьюера при повторном прогоне dev // (не пусто → dev должен исправить именно это). @@ -124,4 +133,4 @@ func reviewFeedbackList(branch string, comments []string) []string { return nil } return []string{s} -} \ No newline at end of file +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index de6fdae..bd1f110 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -255,6 +255,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) { Repos: repos, Why: task.Why, AC: task.AC, + Steps: task.Steps, Branch: branch, ReviewFeedback: reviewFeedbackList(branch, feedback), } diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 3411dc6..32be316 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -725,6 +725,7 @@ func TestWorkerBadStatus(t *testing.T) { } } +// TestWorkerPromptRendered — dev-промпт собирается из полей задачи. func TestWorkerPromptRendered(t *testing.T) { s := setupWorkerDB(t) task := createReadyTask(t, s, "prompt-test") @@ -758,6 +759,63 @@ func TestWorkerPromptRendered(t *testing.T) { } } +// TestWorkerPromptIncludesSteps — этапы задачи попадают в dev-промпт по порядку +// с критериями готовности. +func TestWorkerPromptIncludesSteps(t *testing.T) { + s := setupWorkerDB(t) + task := createReadyTask(t, s, "steps-test") + + ctx := context.Background() + task.Steps = []storage.Step{ + {Title: "Модель", AC: "операции готовы"}, + {Title: "UI"}, + } + if err := s.UpdateTask(ctx, task); err != nil { + t.Fatalf("update steps: %v", err) + } + + w := &Worker{ + Store: s, + Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "ok", SessionID: "s"}}, + Worktree: t.TempDir(), + } + seedFakeRepo(t, w.Worktree, "steps-test") + + _ = w.runTask(ctx, task) + + traces, err := s.GetTraces(ctx, task.ID) + if err != nil { + t.Fatalf("get traces: %v", err) + } + tr := traces[0] + for _, want := range []string{"Этапы", "Модель", "операции готовы", "UI"} { + if !strings.Contains(tr.Prompt, want) { + t.Errorf("dev-промпт не содержит %q", want) + } + } +} + +// TestRenderDevPromptSteps — прямой рендер dev-промпта с этапами. +func TestRenderDevPromptSteps(t *testing.T) { + prompt, err := RenderDevPrompt(DevPromptData{ + Title: "Калькулятор", + AC: "работает", + Steps: []storage.Step{ + {Title: "Модель", AC: "операции готовы"}, + {Title: "UI"}, + }, + Branch: "feat/abc", + }) + if err != nil { + t.Fatalf("render: %v", err) + } + for _, want := range []string{"Этапы", "Модель — готово, когда: операции готовы", "UI"} { + if !strings.Contains(prompt, want) { + t.Errorf("prompt не содержит %q", want) + } + } +} + func TestFeatureBranchName(t *testing.T) { cases := []struct { tag string