refactor: чистка мёртвого кода, лимит ходов D3, HTML-экранирование и UTF-8 обрезка в Telegram
Some checks failed
CI / test (pull_request) Failing after 34s
CI / build-and-package (amd64, linux) (pull_request) Successful in 34s
CI / build-and-package (amd64, windows) (pull_request) Successful in 36s

This commit is contained in:
ki.sagidullin
2026-08-18 23:13:04 +05:00
parent 7eb3a0292c
commit 1459670ce9
13 changed files with 138 additions and 190 deletions

View File

@@ -66,7 +66,6 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
if task.Status == storage.StatusApproved {
return Result{
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -106,7 +105,6 @@ func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Re
default:
return Result{
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
Action: "send",
TaskID: taskID,
}, nil
}
@@ -128,7 +126,6 @@ func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
}
return Result{
Reply: greeting,
Action: "greeting",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -146,7 +143,6 @@ func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
}
return Result{
Reply: "🚫 Отменил.",
Action: "drop",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -161,7 +157,6 @@ func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
if task.Status == storage.StatusReady {
return Result{
Reply: "Напишите «создавай» — или правьте текст.",
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -191,8 +186,7 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/retry 5`.",
Action: "send",
Reply: "Укажите номер задачи: `/retry 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -204,7 +198,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
if storage.IsTerminal(task.Status) {
return Result{
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -218,7 +211,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
}
return Result{
Reply: "Задача перезапущена. Опишите, что меняем:",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -229,8 +221,7 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/status 5`.",
Action: "send",
Reply: "Укажите номер задачи: `/status 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -246,7 +237,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
}
return Result{
Reply: reply,
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -257,8 +247,7 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/continue 5`.",
Action: "send",
Reply: "Укажите номер задачи: `/continue 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -268,7 +257,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
return Result{
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
". Резюм сессии — пока не реализован.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -277,8 +265,7 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
// notFoundReply формирует reply для ненайденной задачи.
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
return Result{
Reply: "Задача #" + itoa(id) + " не найдена.",
Action: "send",
Reply: "Задача #" + itoa(id) + " не найдена.",
}, nil
}
@@ -291,7 +278,6 @@ func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, e
}
return Result{
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
Action: "created:" + itoa(task.ID),
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -333,6 +319,23 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
if err != nil {
return Result{}, err
}
if !force && c.MaxTurns > 0 {
userTurns := 0
for _, h := range history {
if h.Role == "user" {
userTurns++
}
}
if userTurns > c.MaxTurns {
return Result{
Reply: "Превышен лимит ходов сбора (" + itoa(int64(c.MaxTurns)) + "). Используйте /skip чтобы сформулировать черновик, или /start для новой задачи.",
TaskID: task.ID,
Status: task.Status,
}, nil
}
}
msgs := make([]Message, 0, len(history))
for _, h := range history {
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
@@ -353,7 +356,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
if reply == "" {
reply = "Недостаточно данных. Начните заново (/start)."
}
return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
case "propose", "ready":
// применяем черновик (для ready — текущий, без изменений)
@@ -365,7 +368,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
return Result{}, err
}
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
task.Status = storage.StatusReady
if err := c.Store.UpdateTask(ctx, task); err != nil {
@@ -373,7 +376,6 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
}
return Result{
Reply: formatSummary(*task),
Action: "summary",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -385,7 +387,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
return Result{}, err
}
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
}

View File

@@ -2,6 +2,7 @@ package core
import (
"context"
"strings"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
@@ -44,13 +45,11 @@ func TestStartCreatesCollecting(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/start")
_, 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)
@@ -61,13 +60,11 @@ func TestCancelSetsCancelled(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/cancel")
_, 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)
@@ -84,13 +81,11 @@ func TestSingleTurnPropose(t *testing.T) {
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
_, 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)
@@ -114,9 +109,7 @@ func TestAskReturnsQuestions(t *testing.T) {
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)
}
@@ -133,13 +126,11 @@ func TestAbortReturnsDrop(t *testing.T) {
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "привет")
_, 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)
@@ -153,13 +144,11 @@ func TestConsentInReady(t *testing.T) {
id := mkTask(t, store, ctx, "u1")
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
res, err := c.ProcessTurn(ctx, id, "создавай")
_, 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)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusApproved {
t.Fatalf("status после создавай = %s, want approved", task.Status)
@@ -176,13 +165,11 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
_, _ = c.ProcessTurn(ctx, id, "сделай X")
// в ready пишем правку, не согласие
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
_, 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)
}
@@ -194,25 +181,59 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
func TestRetryNotFound(t *testing.T) {
c, ctx, _ := setupCore(t, nil)
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
_, 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")
_, 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 TestMaxTurnsBlocksExcessCollection(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: "ask", ChatReply: "Ещё вопрос"}, nil
})
c.MaxTurns = 2
id := mkTask(t, store, ctx, "u1")
_, err := c.ProcessTurn(ctx, id, "первый факт")
if err != nil {
t.Fatalf("1-й ход: %v", err)
}
_, err = c.ProcessTurn(ctx, id, "второй факт")
if err != nil {
t.Fatalf("2-й ход: %v", err)
}
res, err := c.ProcessTurn(ctx, id, "третий факт")
if err != nil {
t.Fatalf("3-й ход: %v", err)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
if !strings.Contains(res.Reply, "лимит") {
t.Fatalf("reply = %q, want упоминание лимита", res.Reply)
}
// /skip — принудительный вызов, лимит не мешает
_, err = c.ProcessTurn(ctx, id, "/skip")
if err != nil {
t.Fatalf("/skip: %v", err)
}
if calls != 3 {
t.Fatalf("decide calls after /skip = %d, want 3", calls)
}
}
@@ -229,4 +250,4 @@ func TestParseTaskID(t *testing.T) {
if _, ok := parseTaskID("abc"); ok {
t.Fatal("parseTaskID(abc) should be invalid")
}
}
}

View File

@@ -15,10 +15,10 @@ type Message struct {
// Decision — вердикт аналитика.
type Decision struct {
Phase string // "ask" | "propose" | "ready" | "abort"
Draft storage.Task // обновлённые поля черновика
Questions []string // вопросы для phase=ask
ChatReply string // ответ пользователю
Phase string // "ask" | "propose" | "ready" | "abort"
Draft storage.Task // обновлённые поля черновика
Questions []string // вопросы для phase=ask
ChatReply string // ответ пользователю
}
// Decider — интерфейс для вызова аналитика (opencode).
@@ -29,7 +29,6 @@ type Decider interface {
// Result — результат одного хода.
type Result struct {
Reply string
Action string // send | summary | created:N | abort | drop | greeting
TaskID int64
Status storage.Status
}
@@ -46,4 +45,4 @@ func isConsent(text string) bool {
}
}
return false
}
}