internal/core: ядро-машина состояний (ProcessTurn поверх storage) + история задач в БД
All checks were successful
build-test / build (push) Successful in 1m11s
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:
430
internal/core/core.go
Normal file
430
internal/core/core.go
Normal 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
228
internal/core/core_test.go
Normal 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
49
internal/core/decider.go
Normal 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
23
internal/core/errors.go
Normal file
@@ -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")
|
||||
)
|
||||
55
internal/storage/history.go
Normal file
55
internal/storage/history.go
Normal 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
|
||||
}
|
||||
70
internal/storage/history_test.go
Normal file
70
internal/storage/history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user