Files
ratatoskr-go/internal/core/core.go
Hermes dfe374dd55
All checks were successful
build-test / build (push) Successful in 1m15s
internal/analyst: Decider через opencode (A1–A4)
- Analyst struct: строит Go-template промпт из history+draft
- opencode.Runner.Run (agent=analyst) → ExtractVerdict → ExtractJSON
- Валидация phase/полей → core.Decision
- A1 DecodeFail, A2 RunError, A3 Validation, A4 NotReady
- MockRunner в тестах (11 тестов, зелёные)
- core.runDecide оборачивает ошибки аналитика в D1 ErrDecideFailed
- allow ready→collecting (фикс для правки готового черновика)
2026-08-15 00:32:49 +05:00

431 lines
13 KiB
Go
Raw 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 *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{}, 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
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:])
}