Files
ratatoskr-go/internal/core/core.go
Hermes 46065f84fd
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
feat: фаза ready — черновик готов как есть, без требований к изменённым полям
Аналитик теперь может вернуть phase=ready, когда черновик уже полный и
менять нечего. Раньше модель не могла это выразить и возвращала пустой
propose, который валидатор резал A3 (нет изменённых полей).

- analyst: case ready в validateResponse (без требований к полям)
- core: propose и ready обрабатываются одинаково (applyDraft + E1 + ready)
- prompt/analyst.md: контракт фаз обновлён (ask|propose|ready|abort)
- тест TestDecideReady + README: раздел фаз аналитика
2026-08-17 14:07:05 +05:00

469 lines
14 KiB
Go
Raw Permalink 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 core
import (
"context"
"fmt"
"strings"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// Core — ядро Ratatoskr: машина состояний поверх storage.
type Core struct {
// Store — хранилище задач и трасс.
Store *storage.Storage
Decide Decider
MaxTurns int // D3
MaxConfirmCycles int // D4
MaxQuestionsPerTurn int
// Live — опциональный просмотр живой сессии задачи (для /status N).
Live LiveProber
}
// LiveProber — абстракция за журналом живых сессий (реализация — *opencode.LiveRegistry).
// Возвращает текстовое описание текущего состояния сессии задачи и ok=была ли активна.
type LiveProber interface {
Probe(taskID int64) (description string, ok bool)
}
// New создаёт Core с дефолтными лимитами.
func New(store *storage.Storage, decide Decider) *Core {
return &Core{
Store: store,
Decide: decide,
MaxTurns: 15,
MaxConfirmCycles: 3,
MaxQuestionsPerTurn: 5,
}
}
// ProcessTurn обрабатывает один ход для задачи. Меняет состояние в БД.
func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Result, error) {
text = strings.TrimSpace(text)
// 1. команды — обрабатываются до выборки «текущей» задачи,
// т.к. /retry N /status N /continue N ссылаются на другую задачу
if strings.HasPrefix(text, "/") {
return c.handleCommand(ctx, taskID, text)
}
task, err := c.Store.GetTask(ctx, taskID)
if err != nil {
return Result{}, err
}
// 2. согласие в фазе ready → создание
if task.Status == storage.StatusReady {
if isConsent(text) {
return c.handleConsent(ctx, task)
}
// правка черновика → снова сбор
return c.handleEdit(ctx, task, text)
}
// 3. обычный ход: накопление + аналитик
return c.handleTurn(ctx, task, text)
}
// greeting — приветствие для /start.
const greeting = "⚡ Ratatoskr на связи. Опишите задачу — я помогу её продумать. " +
"(команды: /cancel — отмена, /skip — хватит вопросов, /retry N — перезапустить задачу, /status N — статус)"
// handleCommand обрабатывает "/"-команды. Минимальный набор.
// taskID — текущая задача пользователя (может не существовать для "чужих" команд).
func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Result, error) {
cmd := text
if i := strings.IndexByte(cmd, ' '); i > 0 {
cmd = cmd[:i]
}
cmd = strings.ToLower(cmd)
rest := strings.TrimSpace(text[len(cmd):])
switch cmd {
case "/start":
return c.handleStart(ctx, taskID)
case "/cancel":
return c.handleCancel(ctx, taskID)
case "/skip":
return c.handleSkip(ctx, taskID)
case "/retry":
return c.handleRetry(ctx, rest)
case "/status":
return c.handleStatus(ctx, rest)
case "/continue":
return c.handleContinue(ctx, rest)
default:
return Result{
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
Action: "send",
TaskID: taskID,
}, nil
}
}
// handleStart переводит задачу в сбор (collecting) и чистит историю.
// Возвращает greeting. task_tag переиспользуется (стабильный UUID задачи).
func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
task, err := c.Store.GetTask(ctx, taskID)
if err != nil {
return Result{}, err
}
task.Status = storage.StatusCollecting
if err := c.Store.ClearHistory(ctx, task.ID); err != nil {
return Result{}, err
}
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
return Result{
Reply: greeting,
Action: "greeting",
TaskID: task.ID,
Status: task.Status,
}, nil
}
// handleCancel отменяет задачу (терминальный статус cancelled).
func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
task, err := c.Store.GetTask(ctx, taskID)
if err != nil {
return Result{}, err
}
task.Status = storage.StatusCancelled
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
return Result{
Reply: "🚫 Отменил.",
Action: "drop",
TaskID: task.ID,
Status: task.Status,
}, nil
}
// handleSkip принудительно предлагает черновик в фазе collecting, либо подсказывает.
func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
task, err := c.Store.GetTask(ctx, taskID)
if err != nil {
return Result{}, err
}
if task.Status == storage.StatusReady {
return Result{
Reply: "Напишите «создавай» — или правьте текст.",
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
}
// принудительный вызов аналитика
return c.runDecide(ctx, task, true)
}
// parseTaskID извлекает целое из строки после команды.
func parseTaskID(s string) (int64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
var id int64
for _, r := range s {
if r < '0' || r > '9' {
return 0, false
}
id = id*10 + int64(r-'0')
}
return id, id > 0
}
// handleRetry перезапускает задачу N: чистит историю и ставит collecting.
func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/retry 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
if err != nil {
return c.notFoundReply(ctx, id, err)
}
task.Status = storage.StatusCollecting
if err := c.Store.ClearHistory(ctx, id); err != nil {
return Result{}, err
}
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
return Result{
Reply: "Задача перезапущена. Опишите, что меняем:",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
}
// handleStatus возвращает статус задачи N.
func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/status 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
if err != nil {
return c.notFoundReply(ctx, id, err)
}
reply := "Задача #" + itoa(id) + ": " + string(task.Status)
// Для выполняемой задачи прикладываем живое состояние сессии (если настроено).
if task.Status == storage.StatusRunning && c.Live != nil {
if desc, ok := c.Live.Probe(id); ok && desc != "" {
reply += "\n" + desc
}
}
return Result{
Reply: reply,
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
}
// handleContinue — заглушка (резюм opencode-сессии), подключим позже.
func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/continue 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
if err != nil {
return c.notFoundReply(ctx, id, err)
}
return Result{
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
". Резюм сессии — пока не реализован.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
}
// notFoundReply формирует reply для ненайденной задачи.
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
return Result{
Reply: "Задача #" + itoa(id) + " не найдена.",
Action: "send",
}, nil
}
// handleConsent создаёт задачу (статус ready → ...). Пока — подтверждение готовности.
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
task.Status = storage.StatusReady
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
return Result{
Reply: "✅ Задача #" + itoa(task.ID) + " готова к запуску.",
Action: "created:" + itoa(task.ID),
TaskID: task.ID,
Status: task.Status,
}, nil
}
// handleEdit — правка черновика в фазе ready → снова сбор + аналитик.
func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string) (Result, error) {
task.Status = storage.StatusCollecting
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
return Result{}, err
}
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
return c.runDecide(ctx, task, false)
}
// handleTurn — обычный ход в фазе collecting: накопление + аналитик.
func (c *Core) handleTurn(ctx context.Context, task *storage.Task, text string) (Result, error) {
// переход draft→collecting нужно персистить до вызова аналитика,
// иначе propose сделает draft→ready (невалидно)
if task.Status != storage.StatusCollecting {
task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
}
if text != "" {
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
return Result{}, err
}
}
return c.runDecide(ctx, task, false)
}
// runDecide вызывает аналитика и применяет вердикт.
func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (Result, error) {
history, err := c.Store.GetHistory(ctx, task.ID)
if err != nil {
return Result{}, err
}
msgs := make([]Message, 0, len(history))
for _, h := range history {
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
}
decision, err := c.Decide.Decide(ctx, msgs, *task, force)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrDecideFailed, err)
}
switch decision.Phase {
case "abort":
task.Status = storage.StatusAborted
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
reply := decision.ChatReply
if reply == "" {
reply = "Недостаточно данных. Начните заново (/start)."
}
return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil
case "propose", "ready":
// применяем черновик (для ready — текущий, без изменений)
applyDraft(task, decision.Draft)
// E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить.
if len(task.EffectiveRepos()) == 0 {
task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
}
task.Status = storage.StatusReady
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
return Result{
Reply: formatSummary(*task),
Action: "summary",
TaskID: task.ID,
Status: task.Status,
}, nil
default: // "ask"
applyDraft(task, decision.Draft)
task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
}
}
// applyDraft копирует непустые draft-поля из вердикта в задачу.
func applyDraft(task *storage.Task, dec storage.Task) {
if dec.Title != "" {
task.Title = dec.Title
}
if dec.Goal != "" {
task.Goal = dec.Goal
}
if dec.Repo != "" {
task.Repo = dec.Repo
}
if len(dec.Repos) > 0 {
task.Repos = dec.Repos
task.Repo = strings.Join(dec.Repos, ",")
}
if dec.Why != "" {
task.Why = dec.Why
}
if dec.AC != "" {
task.AC = dec.AC
}
}
// decChatReply возвращает ChatReply из вердикта или переданный дефолт.
func decChatReply(dec Decision, fallback string) string {
if dec.ChatReply != "" {
return dec.ChatReply
}
return fallback
}
// buildAskReply формирует ответ с вопросами.
func buildAskReply(dec Decision, max int) string {
reply := dec.ChatReply
if reply == "" {
reply = "Уточню детали."
}
if len(dec.Questions) == 0 {
return reply
}
qs := dec.Questions
if len(qs) > max {
qs = qs[:max]
}
lines := []string{reply}
for i, q := range qs {
lines = append(lines, itoa(int64(i+1))+". "+q)
}
if len(dec.Questions) > max {
lines = append(lines, "(и ещё вопросы по ходу)")
}
return strings.Join(lines, "\n")
}
// formatSummary возвращает сводку черновика в фазе ready.
func formatSummary(t storage.Task) string {
var b strings.Builder
b.WriteString("📋 **Черновик задачи #" + itoa(t.ID) + "** готов.\n\n")
if t.Title != "" {
b.WriteString("**Название:** " + t.Title + "\n")
}
if len(t.EffectiveRepos()) > 0 {
b.WriteString("**Репозитории:** " + strings.Join(t.EffectiveRepos(), ", ") + "\n")
}
if t.Goal != "" {
b.WriteString("**Цель:** " + t.Goal + "\n")
}
if t.Why != "" {
b.WriteString("**Зачем:** " + t.Why + "\n")
}
if t.AC != "" {
b.WriteString("**Критерии:**\n" + t.AC + "\n")
}
b.WriteString("\nНапишите «создавай» — или правьте текст.")
return b.String()
}
// itoa — мини int→string без strconv.
func itoa(n int64) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}