feat: фаза ready — черновик готов как есть, без требований к изменённым полям
Some checks failed
CI / test (push) Failing after 52s
CI / build-and-package (amd64, linux) (push) Failing after 27s
CI / build-and-package (amd64, windows) (push) Failing after 28s

Аналитик теперь может вернуть phase=ready, когда черновик уже полный и
менять нечего. Раньше модель не могла это выразить и возвращала пустой
propose, который валидатор резал A3 (нет изменённых полей).

- analyst: case ready в validateResponse (без требований к полям)
- core: propose и ready обрабатываются одинаково (applyDraft + E1 + ready)
- prompt/analyst.md: контракт фаз обновлён (ask|propose|ready|abort)
- тест TestDecideReady + README: раздел фаз аналитика
This commit is contained in:
Hermes
2026-08-17 14:07:05 +05:00
parent 168b0042c7
commit 46065f84fd
7 changed files with 58 additions and 10 deletions

View File

@@ -129,6 +129,23 @@ update:
| `/status` | версия бинаря + есть ли доступное обновление | | `/status` | версия бинаря + есть ли доступное обновление |
| `/help` | справка по всем командам | | `/help` | справка по всем командам |
## Фазы аналитика
Аналитик (`internal/analyst`) возвращает JSON-вердикт с полем `phase`:
| phase | Смысл | Требования валидатора |
|---|---|---|
| `ask` | данных не хватает — задаёт уточняющие вопросы | есть `chat_reply` или `questions` |
| `propose` | черновик достаточно заполнен, вносятся правки | хотя бы одно изменённое поле (`title`/`goal`/`repos`/`why`/`ac`) |
| `ready` | черновик уже полный и готов как есть — менять нечего | изменённых полей не требуется |
| `abort` | тема не про код / не подходит | опционально `abort_reason` |
`ready` был добавлен как явная фаза для случая, когда аналитик видит полностью готовый
черновик и просто запускает задачу агенту-кодеру (раньше модель не могла это выразить и
возвращала пустой `propose`, который валидатор резал как «нет изменённых полей»).
В `internal/core` фазы `propose` и `ready` обрабатываются одинаково (применить черновик,
проверить репозитории, поставить `ready` и отдать резюме).
## Автообновление из Gitea Packages ## Автообновление из Gitea Packages
Бинарь умеет сам себя обновлять из generic-пакета в Gitea. Модель: Бинарь умеет сам себя обновлять из generic-пакета в Gitea. Модель:

View File

@@ -16,15 +16,19 @@ mode: primary
1. Пока неясны цель, репозиторий или критерии готовности — задай до 3 уточняющих вопросов 1. Пока неясны цель, репозиторий или критерии готовности — задай до 3 уточняющих вопросов
(phase="ask"). Не задавай лишних вопросов, если поле уже заполнено. (phase="ask"). Не задавай лишних вопросов, если поле уже заполнено.
2. Когда данных достаточно (понятны цель, репозиторий, ≥23 проверяемых критерия) — 2. Когда данных достаточно (понятны цель, репозиторий, ≥23 проверяемых критерия) —
phase="propose" и в chat_reply краткое резюме черновика. phase="propose" и в chat_reply краткое резюме черновика. Заполни те поля, которые
3. Если тема явно не про разработку кода или не подходит для агента-кодера — phase="abort" меняешь; остальные — пустой строкой.
3. Если черновик УЖЕ полный и готов как есть, менять нечего — phase="ready": все поля
изменений пустыми, в chat_reply — резюме и подтверждение готовности. Задачу можно
отправлять агенту-кодеру.
4. Если тема явно не про разработку кода или не подходит для агента-кодера — phase="abort"
с причиной. с причиной.
4. Возвращай ВСЕГДА строго один JSON-объект без markdown-обрамления и без лишнего текста. 5. Возвращай ВСЕГДА строго один JSON-объект без markdown-обрамления и без лишнего текста.
5. Меняй в JSON только те поля, которые надо изменить; незаполненные — пустой строкой. 6. Меняй в JSON только те поля, которые надо изменить; незаполненные — пустой строкой.
Формат ответа: Формат ответа:
{ {
"phase": "ask|propose|abort", "phase": "ask|propose|ready|abort",
"title": "", "title": "",
"goal": "", "goal": "",
"repo": "", "repo": "",

View File

@@ -173,6 +173,8 @@ func validateResponse(ar *AnalystResponse) error {
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 == "" {
return fmt.Errorf("phase=propose, но нет ни одного изменённого поля") return fmt.Errorf("phase=propose, но нет ни одного изменённого поля")
} }
case "ready":
// черновик уже полный и готов как есть — изменённых полей НЕ требуется
case "abort": case "abort":
// abort_reason — не обязателен, но желателен // abort_reason — не обязателен, но желателен
default: default:

View File

@@ -87,6 +87,23 @@ func TestDecideAbort(t *testing.T) {
} }
} }
func TestDecideReady(t *testing.T) {
// ready с пустыми изменёнными полями — ВАЛИДНО (черновик готов как есть)
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ready\",\"chat_reply\":\"Черновик готов, запускаю.\"}"}}`,
}}, 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 != "ready" {
t.Errorf("Phase = %q, want ready", dec.Phase)
}
}
func TestDecodeFail(t *testing.T) { func TestDecodeFail(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0, RC: 0,

View File

@@ -24,6 +24,14 @@ var promptTemplate = template.Must(template.New("analyst").Parse(`Ты — ан
| why | зачем это нужно, контекст | | why | зачем это нужно, контекст |
| ac | acceptance criteria — конкретный результат, что считается готовым | | ac | acceptance criteria — конкретный результат, что считается готовым |
Изменяй в JSON только те поля, которые надо поменять; что менять не надо — пустой строкой.
**Фаза ответа (phase):**
- `ask` — данных не хватает: задай вопросы (до 3), черновик не меняешь.
- `propose` — черновик ДОСТАТОЧЕН и ты ВНОСИШЬ правки: заполни изменяемые поля (title/goal/repos/why/ac), в chat_reply — краткое резюме.
- `ready` — черновик уже ПОЛНЫЙ и готов как есть, менять НЕЧЕГО: phase="ready", все поля изменений оставь пустыми, в chat_reply — резюме и подтверждение готовности. Задачу можно отправлять агенту-кодеру.
- `abort` — тема не про код / не подходит: phase="abort" с abort_reason.
{{- if .Force}} {{- if .Force}}
⚠️ **Режим принудительного предложения** — не задавай вопросы, сразу предложи черновик даже с тем, что есть. ⚠️ **Режим принудительного предложения** — не задавай вопросы, сразу предложи черновик даже с тем, что есть.
@@ -46,7 +54,7 @@ var promptTemplate = template.Must(template.New("analyst").Parse(`Ты — ан
**Ответь строго JSON-объектом, без лишнего текста:** **Ответь строго JSON-объектом, без лишнего текста:**
{ {
"phase": "ask|propose|abort", "phase": "ask|propose|ready|abort",
"title": "название (только если меняешь)", "title": "название (только если меняешь)",
"goal": "цель (только если меняешь)", "goal": "цель (только если меняешь)",
"repos": ["имя_репо_1", "имя_репо_2"], "repos": ["имя_репо_1", "имя_репо_2"],

View File

@@ -333,10 +333,10 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
} }
return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil
case "propose": case "propose", "ready":
// применяем черновик // применяем черновик (для ready — текущий, без изменений)
applyDraft(task, decision.Draft) applyDraft(task, decision.Draft)
// E1: propose без репозиториев → остаёмся в сборе, просим уточнить. // E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить.
if len(task.EffectiveRepos()) == 0 { if len(task.EffectiveRepos()) == 0 {
task.Status = storage.StatusCollecting task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil { if err := c.Store.UpdateTask(ctx, task); err != nil {

View File

@@ -15,7 +15,7 @@ type Message struct {
// Decision — вердикт аналитика. // Decision — вердикт аналитика.
type Decision struct { type Decision struct {
Phase string // "ask" | "propose" | "abort" Phase string // "ask" | "propose" | "ready" | "abort"
Draft storage.Task // обновлённые поля черновика Draft storage.Task // обновлённые поля черновика
Questions []string // вопросы для phase=ask Questions []string // вопросы для phase=ask
ChatReply string // ответ пользователю ChatReply string // ответ пользователю