internal/core: ядро-машина состояний (ProcessTurn поверх storage) + история задач в БД
All checks were successful
build-test / build (push) Successful in 1m11s

- status = единственный источник истины (collecting/ready как фазы диалога)
- команды /start /cancel /skip /retry N /status N /continue N
- Decider интерфейс (аналитик→opencode, мок для тестов)
- task_history таблица для промпта аналитика
- allow ready→collecting (правка черновика)
This commit is contained in:
Hermes
2026-08-15 00:27:16 +05:00
parent 089e196dbc
commit 2957bf0d63
8 changed files with 866 additions and 1 deletions

430
internal/core/core.go Normal file
View File

@@ -0,0 +1,430 @@
package core
import (
"context"
"strings"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// Core — ядро Ratatoskr: машина состояний поверх storage.
type Core struct {
Store *storage.Storage
Decide Decider
MaxTurns int // D3
MaxConfirmCycles int // D4
MaxQuestionsPerTurn int
}
// 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)
}
return Result{
Reply: "Задача #" + itoa(id) + ": " + string(task.Status),
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{}, 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
applyDraft(task, decision.Draft)
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 dec.Why != "" {
task.Why = dec.Why
}
if dec.AC != "" {
task.AC = dec.AC
}
}
// 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 t.Repo != "" {
b.WriteString("**Репозиторий:** " + t.Repo + "\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:])
}