diff --git a/internal/core/core.go b/internal/core/core.go new file mode 100644 index 0000000..9d96833 --- /dev/null +++ b/internal/core/core.go @@ -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:]) +} \ No newline at end of file diff --git a/internal/core/core_test.go b/internal/core/core_test.go new file mode 100644 index 0000000..7136014 --- /dev/null +++ b/internal/core/core_test.go @@ -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") + } +} \ No newline at end of file diff --git a/internal/core/decider.go b/internal/core/decider.go new file mode 100644 index 0000000..9be1ef7 --- /dev/null +++ b/internal/core/decider.go @@ -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 +} \ No newline at end of file diff --git a/internal/core/errors.go b/internal/core/errors.go new file mode 100644 index 0000000..65c93f2 --- /dev/null +++ b/internal/core/errors.go @@ -0,0 +1,23 @@ +package core + +import ( + "errors" +) + +// Классы ошибок: D1–D5 +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") +) \ No newline at end of file diff --git a/internal/storage/history.go b/internal/storage/history.go new file mode 100644 index 0000000..e7e60ff --- /dev/null +++ b/internal/storage/history.go @@ -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 +} \ No newline at end of file diff --git a/internal/storage/history_test.go b/internal/storage/history_test.go new file mode 100644 index 0000000..0d5ec6a --- /dev/null +++ b/internal/storage/history_test.go @@ -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) + } +} \ No newline at end of file diff --git a/internal/storage/models.go b/internal/storage/models.go index 2db18e2..1cf4c72 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -27,7 +27,7 @@ var AllStatuses = []Status{ var validTransitions = map[Status][]Status{ StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted}, StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted}, - StatusReady: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed}, + StatusReady: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting}, // правка готового StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled}, StatusSuccess: {StatusClosed}, StatusFailed: {StatusReady, StatusClosed, StatusCancelled}, // retry diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 6272c10..3196944 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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_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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,