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:])
}

228
internal/core/core_test.go Normal file
View File

@@ -0,0 +1,228 @@
package core
import (
"context"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// mockDecider — тестовый Decider с заданным поведением.
type mockDecider struct {
fn func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error)
}
func (m *mockDecider) Decide(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
if m.fn != nil {
return m.fn(ctx, history, draft, force)
}
return Decision{Phase: "ask", ChatReply: "ok"}, nil
}
func setupCore(t *testing.T, fn func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error)) (*Core, context.Context, *storage.Storage) {
t.Helper()
ctx := context.Background()
store, err := storage.Open(ctx, ":memory:")
if err != nil {
t.Fatalf("storage.Open: %v", err)
}
t.Cleanup(func() { store.Close() })
c := New(store, &mockDecider{fn: fn})
return c, ctx, store
}
func mkTask(t *testing.T, store *storage.Storage, ctx context.Context, chatID string) int64 {
t.Helper()
id, err := store.CreateTask(ctx, &storage.Task{ChatID: chatID, TaskTag: "tag-" + chatID})
if err != nil {
t.Fatalf("CreateTask: %v", err)
}
return id
}
func TestStartCreatesCollecting(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/start")
if err != nil {
t.Fatalf("ProcessTurn /start: %v", err)
}
if res.Action != "greeting" {
t.Fatalf("action = %q, want greeting", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCollecting {
t.Fatalf("status = %s, want collecting", task.Status)
}
}
func TestCancelSetsCancelled(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/cancel")
if err != nil {
t.Fatalf("ProcessTurn /cancel: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCancelled {
t.Fatalf("status = %s, want cancelled", task.Status)
}
}
func TestSingleTurnPropose(t *testing.T) {
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
return Decision{
Phase: "propose",
ChatReply: "Готово",
Draft: storage.Task{Title: "Новое", Goal: "Сделать", Repo: "acme/app"},
}, nil
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusReady {
t.Fatalf("status = %s, want ready", task.Status)
}
if task.Title != "Новое" {
t.Fatalf("title = %q, want Новое", task.Title)
}
}
func TestAskReturnsQuestions(t *testing.T) {
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
return Decision{
Phase: "ask",
ChatReply: "Уточню",
Questions: []string{"Какой язык?", "Какой срок?"},
}, nil
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "Напиши парсер")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
t.Fatalf("reply = %q", res.Reply)
}
// в фазе collecting — продолжaeм сбор
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCollecting {
t.Fatalf("status = %s, want collecting", task.Status)
}
}
func TestAbortReturnsDrop(t *testing.T) {
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
return Decision{Phase: "abort", ChatReply: "Не хватает данных"}, nil
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "привет")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusAborted {
t.Fatalf("status = %s, want aborted", task.Status)
}
}
func TestConsentInReady(t *testing.T) {
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
return Decision{Phase: "propose", Draft: storage.Task{Title: "X"}}, nil
})
id := mkTask(t, store, ctx, "u1")
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
res, err := c.ProcessTurn(ctx, id, "создавай")
if err != nil {
t.Fatalf("ProcessTurn создавай: %v", err)
}
if res.Action != "created:"+itoa(id) {
t.Fatalf("action = %q, want created:%d", res.Action, id)
}
}
func TestEditInReadyGoesCollecting(t *testing.T) {
var calls int
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
calls++
return Decision{Phase: "propose", Draft: storage.Task{Title: "X"}}, nil
})
id := mkTask(t, store, ctx, "u1")
_, _ = c.ProcessTurn(ctx, id, "сделай X")
// в ready пишем правку, не согласие
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
if err != nil {
t.Fatalf("ProcessTurn edit: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusReady {
t.Fatalf("status = %s, want ready", task.Status)
}
}
func TestRetryNotFound(t *testing.T) {
c, ctx, _ := setupCore(t, nil)
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
if err != nil {
t.Fatalf("ProcessTurn /retry: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
}
func TestUnknownCommand(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/bogus")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
}
func TestParseTaskID(t *testing.T) {
if v, ok := parseTaskID("5"); !ok || v != 5 {
t.Fatalf("parseTaskID(5) = %d,%v", v, ok)
}
if v, ok := parseTaskID("123"); !ok || v != 123 {
t.Fatalf("parseTaskID(123) = %d,%v", v, ok)
}
if _, ok := parseTaskID(""); ok {
t.Fatal("parseTaskID('') should be invalid")
}
if _, ok := parseTaskID("abc"); ok {
t.Fatal("parseTaskID(abc) should be invalid")
}
}

49
internal/core/decider.go Normal file
View File

@@ -0,0 +1,49 @@
package core
import (
"context"
"strings"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// Message — запись диалога.
type Message struct {
Role string // "user" | "assistant"
Content string
}
// Decision — вердикт аналитика.
type Decision struct {
Phase string // "ask" | "propose" | "abort"
Draft storage.Task // обновлённые поля черновика
Questions []string // вопросы для phase=ask
ChatReply string // ответ пользователю
}
// Decider — интерфейс для вызова аналитика (opencode).
type Decider interface {
Decide(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error)
}
// Result — результат одного хода.
type Result struct {
Reply string
Action string // send | summary | created:N | abort | drop | greeting
TaskID int64
Status storage.Status
}
// consentPhrases — слова и фразы согласия.
var consentPhrases = []string{"создавай", "подтверждаю", "создать", "ок", "давай", "go"}
// isConsent проверяет, является ли текст согласием на создание.
func isConsent(text string) bool {
t := strings.ToLower(strings.TrimSpace(text))
for _, p := range consentPhrases {
if t == p {
return true
}
}
return false
}

23
internal/core/errors.go Normal file
View File

@@ -0,0 +1,23 @@
package core
import (
"errors"
)
// Классы ошибок: D1D5
var (
// D1 — сбой вызова аналитика
ErrDecideFailed = errors.New("D1: decide failed")
// D2 — таймаут аналитика
ErrDecideTimeout = errors.New("D2: decide timeout")
// D3 — превышен лимит ходов
ErrMaxTurns = errors.New("D3: max turns reached")
// D4 — превышен лимит правок
ErrMaxConfirmCycles = errors.New("D4: max confirm cycles reached")
// D5 — нераспознанная команда (reply, не ошибка)
ErrCommandUnknown = errors.New("D5: unknown command")
)

View File

@@ -0,0 +1,55 @@
package storage
import (
"context"
"fmt"
)
// HistoryMsg — одна запись истории диалога.
type HistoryMsg struct {
Role string `json:"role"` // "user" | "assistant"
Content string `json:"content"`
}
// AppendHistory добавляет сообщение в историю задачи.
func (s *Storage) AppendHistory(ctx context.Context, taskID int64, role, content string) error {
now := Now()
_, err := s.db.ExecContext(ctx, `
INSERT INTO task_history (task_id, role, content, created_at)
VALUES (?, ?, ?, ?)`, taskID, role, content, now,
)
if err != nil {
return fmt.Errorf("%w: append history: %w", ErrDB, err)
}
return nil
}
// GetHistory возвращает историю задачи, отсортированную по времени.
func (s *Storage) GetHistory(ctx context.Context, taskID int64) ([]HistoryMsg, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT role, content FROM task_history
WHERE task_id = ? ORDER BY id ASC`, taskID)
if err != nil {
return nil, fmt.Errorf("%w: get history: %w", ErrDB, err)
}
defer rows.Close()
var msgs []HistoryMsg
for rows.Next() {
var m HistoryMsg
if err := rows.Scan(&m.Role, &m.Content); err != nil {
return nil, fmt.Errorf("%w: scan history: %w", ErrDB, err)
}
msgs = append(msgs, m)
}
return msgs, rows.Err()
}
// ClearHistory удаляет всю историю задачи (при /start).
func (s *Storage) ClearHistory(ctx context.Context, taskID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM task_history WHERE task_id = ?`, taskID)
if err != nil {
return fmt.Errorf("%w: clear history: %w", ErrDB, err)
}
return nil
}

View File

@@ -0,0 +1,70 @@
package storage
import (
"context"
"testing"
)
func TestHistoryCRUD(t *testing.T) {
s, ctx := setupTestDB(t)
task := &Task{ChatID: "tg://hist", TaskTag: "history"}
id, _ := s.CreateTask(ctx, task)
// append
if err := s.AppendHistory(ctx, id, "user", "привет"); err != nil {
t.Fatalf("AppendHistory: %v", err)
}
if err := s.AppendHistory(ctx, id, "assistant", "здравствуй"); err != nil {
t.Fatalf("AppendHistory: %v", err)
}
if err := s.AppendHistory(ctx, id, "user", "сделай задачу"); err != nil {
t.Fatalf("AppendHistory: %v", err)
}
h, err := s.GetHistory(ctx, id)
if err != nil {
t.Fatalf("GetHistory: %v", err)
}
if len(h) != 3 {
t.Fatalf("history len = %d, want 3", len(h))
}
if h[0].Role != "user" || h[0].Content != "привет" {
t.Fatalf("h[0] = %+v", h[0])
}
if h[2].Content != "сделай задачу" {
t.Fatalf("h[2] last = %q, want 'сделай задачу'", h[2].Content)
}
// clear
if err := s.ClearHistory(ctx, id); err != nil {
t.Fatalf("ClearHistory: %v", err)
}
h, _ = s.GetHistory(ctx, id)
if len(h) != 0 {
t.Fatalf("history after clear len = %d, want 0", len(h))
}
}
func TestHistoryIsolatedPerTask(t *testing.T) {
ctx := context.Background()
s, err := Open(ctx, ":memory:")
if err != nil {
t.Fatal(err)
}
defer s.Close()
id1, _ := s.CreateTask(ctx, &Task{ChatID: "a", TaskTag: "1"})
id2, _ := s.CreateTask(ctx, &Task{ChatID: "b", TaskTag: "2"})
_ = s.AppendHistory(ctx, id1, "user", "для задачи 1")
_ = s.AppendHistory(ctx, id2, "user", "для задачи 2")
h1, _ := s.GetHistory(ctx, id1)
h2, _ := s.GetHistory(ctx, id2)
if len(h1) != 1 || h1[0].Content != "для задачи 1" {
t.Fatalf("h1 wrong: %+v", h1)
}
if len(h2) != 1 || h2[0].Content != "для задачи 2" {
t.Fatalf("h2 wrong: %+v", h2)
}
}

View File

@@ -27,7 +27,7 @@ var AllStatuses = []Status{
var validTransitions = map[Status][]Status{ var validTransitions = map[Status][]Status{
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted}, StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted}, StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
StatusReady: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed}, StatusReady: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting}, // правка готового
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled}, StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
StatusSuccess: {StatusClosed}, StatusSuccess: {StatusClosed},
StatusFailed: {StatusReady, StatusClosed, StatusCancelled}, // retry StatusFailed: {StatusReady, StatusClosed, StatusCancelled}, // retry

View File

@@ -132,6 +132,16 @@ func (s *Storage) migrate(ctx context.Context) error {
CREATE INDEX IF NOT EXISTS idx_tasks_chat ON tasks(chat_id); CREATE INDEX IF NOT EXISTS idx_tasks_chat ON tasks(chat_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE TABLE IF NOT EXISTS task_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_history_task ON task_history(task_id);
CREATE TABLE IF NOT EXISTS traces ( CREATE TABLE IF NOT EXISTS traces (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,