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": // применяем черновик applyDraft(task, decision.Draft) // E1: propose без репозиториев → остаёмся в сборе, просим уточнить. 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:]) }