diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ea96cdb..7dbab09 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -27,6 +27,7 @@ {"id":"simpleAI-2sl","title":"RAG: хранение сырого текста и артефактов","description":"Эпик: RAG по тратам и чекам (V1). Определить где хранить raw_text и артефакты.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:58.711758+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T13:08:50.257714+07:00","closed_at":"2026-02-08T13:08:50.257714+07:00","close_reason":"Closed","comments":[{"id":69,"issue_id":"simpleAI-2sl","author":"Taras Malinovskii","text":"WIP: добавлена таблица receipt_artifact и обновлены docs по хранению raw_text/артефактов.","created_at":"2026-02-08T06:08:08Z"},{"id":70,"issue_id":"simpleAI-2sl","author":"Taras Malinovskii","text":"Готово: таблица receipt_artifact, docs/README/TECHNICAL_README описывают хранение raw_text и артефактов.","created_at":"2026-02-08T06:09:00Z"}]} {"id":"simpleAI-2t2","title":"Докстринги пакетов: улучшение описаний","description":"Заменить заглушки docstring-комментариев на подробные описания пакетов по примеру config (назначение, зоны, принципы, точки входа).","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T13:36:39.449191+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T15:10:06.675337+07:00","closed_at":"2026-02-08T15:10:06.675337+07:00","close_reason":"Closed"} {"id":"simpleAI-2vw","title":"refactor: выделить router/loop из agent/service.go","description":"## Цель\nservice.go (287 строк) смешивает три ответственности: построение system prompt с tools, парсинг tool calls из ответа LLM, агентный loop с накоплением результатов. Разделить по образцу assister/rag_mm/pipelines/. Подзадача EPIC simpleAI-4fc.\n\n## Предложение\n```\ninternal/agent/\n├── service.go # Service.Ask / AskWithMeta — координация\n├── router.go # buildToolsSystemPrompt + parseToolCalls + unmarshalCalls\n├── loop.go # agentic loop с итерациями + accumulatedResults\n└── core.go # уже есть\n```\n\n## DoD\n- Файлы разделены без изменения публичного API Service\n- Все существующие тесты проходят без изменений\n- Lint green\n- Eval-suite не регрессит\n\n## Зависимости\n- ПОСЛЕ Eval Suite epic","status":"closed","priority":3,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-05T08:33:12.793795+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-09T08:14:58.087259+07:00","closed_at":"2026-05-09T08:14:58.087259+07:00","close_reason":"service.go (335 строк) разбит на: service.go (координация, 101л), router.go (buildToolsSystemPrompt+parseToolCalls+unmarshalCalls, 118л), loop.go (runAgentLoop+runSkill+appendTrace, 139л). Публичный API не изменён. go test ./... green.","dependencies":[{"issue_id":"simpleAI-2vw","depends_on_id":"simpleAI-wr7","type":"blocks","created_at":"2026-05-05T08:33:19.492044+07:00","created_by":"Taras Malinovskii"}]} +{"id":"simpleAI-302i","title":"конверты 2/3: знак валюты у каждой денежной строки через единую точку печати","description":"[TASK 2/3] Печатать знак валюты у каждой денежной строки через единую точку\n\n## DoD\n- `go test ./internal/skills/...` — зелёно.\n- Тест-регексп по собранному ответу safe_to_spend и budget: число, не сопровождённое `฿` или `₽`, роняет тест.\n- Ни один `Fprintf` с деньгами не печатает знак валюты сам — только через `Display.Fmt`.\n- Мутация: убрать знак из `Display.Fmt` → тест знака краснеет.\n\n## Для человека\nВ выдаче бота у сумм не видно валюты, и понять, баты это или рубли, нельзя. Местами рядом с батовым числом стоит знак рубля.\nРядом с каждой цифрой встанет знак — один символ, ฿ или ₽.\nЗависимостей нет, можно брать сразу.\n\n## Для агента\n`internal/skills/safetospend/display.go:66` — `Display.Fmt`, единственная легальная точка печати денег. Уже группирует разряды и округляет.\n\nМимо неё печатают:\n- `internal/skills/safetospend/format.go` — `%.0f ₽` хардкодом, строки 35, 37, 42, 43, 45, 48, 52, 58, 60, 410, 413. Знак рубля прибит к числу, которое местами уже в батах.\n- `internal/skills/budget/limits.go:66` — `%.0f %s`, валюта приходит строкой.\n\nСвести всё к `Display.Fmt`. Где `Display` в области видимости нет — протащить, а не заводить второй форматтер: разъехавшиеся «код валюты отдельно, курс отдельно» и дают рублёвый знак на батовом числе.\n\nСтроки курса (`format.go:137`, `:512`, «Курс X ₽/฿») — не деньги, а сам курс; их не трогать, но исключить из регекспа явно, а не случайно.\n\nБлокирует: [TASK 3/3] Задавать курс словами — курс печатается через ту же точку.\n","notes":"Закрыта. Все суммы через safetospend.Display; добавлены Display.Signed (для округлённых чисел моноблока) и FmtAmount (сумма в своей валюте без перевода). Колонка моноблока 8→10 знаков. Файлы: safetospend/{display.go,format.go,remaining.go}, budget/limits.go. Тест currency_sign_test.go — регексп по собранному ответу. Мутации: снять знак в Signed и в formatItems — обе краснят. Коммит ffe196f.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-08-24T19:57:34.533707+07:00","created_by":"Taras Malinovskii","updated_at":"2026-08-24T20:57:58.742022+07:00","closed_at":"2026-08-24T20:57:58.742022+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-302i","depends_on_id":"simpleAI-rrn8","type":"parent-child","created_at":"2026-08-24T19:57:34.535134+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-30u","title":"Архитектура Telegram-бота","description":"Эпик: Telegram-бот как фасад системы. Модульная архитектура, расширяемые обработчики и маршрутизация команд/событий.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:28:46.665604+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T11:42:53.530014+07:00","closed_at":"2026-02-08T11:42:53.530014+07:00","close_reason":"Closed","comments":[{"id":13,"issue_id":"simpleAI-30u","author":"Taras Malinovskii","text":"WIP: добавлены роутер/контекст/миддлвари для Telegram, модульные handlers (start/help/default).","created_at":"2026-02-08T04:36:22Z"},{"id":15,"issue_id":"simpleAI-30u","author":"Taras Malinovskii","text":"WIP: добавлен worker pool для обработчиков (TELEGRAM_WORKERS), логирование апдейтов, базовый роутер/миддлвари.","created_at":"2026-02-08T04:37:57Z"},{"id":19,"issue_id":"simpleAI-30u","author":"Taras Malinovskii","text":"Готово: базовая архитектура бота (router/middleware/context), логирование апдейтов, worker pool.","created_at":"2026-02-08T04:42:53Z"},{"id":11,"issue_id":"simpleAI-30u","author":"Taras Malinovskii","text":"WIP: стартую архитектуру Telegram-бота: модульные хендлеры, маршрутизация, контекст/сессии.","created_at":"2026-02-08T04:29:52Z"}]} {"id":"simpleAI-36n","title":"fix(budget): категория 'прочее' вместо пользовательской при add_expense","description":"Баг: пользователь передаёт 'уход / косметика', бот записывает как 'прочее'.\n\nПричина: manifest BudgetSkill описывает категорию как закрытый список (еда, транспорт, ..., прочее). LLM видит список, не находит совпадения и выбирает 'прочее' самостоятельно, не передавая оригинальное название в скилл.\n\nХотя в addTransaction есть фолбэк AddCategory (создаст любую категорию) — LLM до него не доходит.\n\nДва фикса:\n1. internal/skills/budget_skill.go — расширить описание поля category в manifest: указать, что если пользователь назвал категорию явно — передавать её как есть, система создаст если не найдёт\n2. migrations/00009_budget_category_beauty.sql — добавить 'Красота' (expense) как стандартную категорию\n\nЗатрагиваемые файлы:\n- internal/skills/budget_skill.go (manifest description)\n- migrations/00009_budget_category_beauty.sql (новый файл)","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T19:00:09.982952+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T19:01:55.153142+07:00","closed_at":"2026-03-09T19:01:55.153142+07:00","close_reason":"Fixed manifest + added Красота category migration. Branch fix/simpleAI-36n-category-passthrough"} {"id":"simpleAI-36sq","title":"epic: иерархический budget summary с drill-down кнопками","description":"Трёхуровневый UI для бюджетного отчёта. L1: income/balance/avg → кнопки. L2: корзины (BucketConfig). L3: детали корзины. Config-driven: один []Bucket, default bucket для fallback, ровно один default (validation). Additive: старый формат остаётся.","status":"closed","priority":1,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-05-11T12:47:41.629584+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-11T12:55:06.779175+07:00","closed_at":"2026-05-11T12:55:06.779175+07:00","close_reason":"Эпик завершён. 4 слоя: core/adapter, router/context, buckets+format, callback wire."} @@ -81,6 +82,7 @@ {"id":"simpleAI-a7w","title":"epic/python-migration: [Фаза 5] Python: agent loop + LLM skills","description":"Родитель: simpleAI-0ma\nФаза: 5 — после фазы 4\n\nПортировать agent loop на Python:\n- python/bot/agent/service.py: async AskWithMeta, tool calling loop\n- python/bot/agent/parser.py: parseToolCalls (JSON, markdown)\n- python/bot/skills/base.py: Skill protocol/ABC\n- python/bot/skills/budget.py: все BudgetSkill actions (включая FSM для прочее)\n- python/bot/skills/rag.py: RAGSearchSkill\n- asyncpg для DB, openai SDK для LLM\n\nКлючевое преимущество:\n- Категория прочее: FSM ConversationHandler, без LLM round-trip\n- set_reminder/get_reminder: chat_id берётся из aiogram Message.chat.id напрямую\n\nAC:\n- Все budget actions работают через Python бота\n- Тесты agent loop (аналог фаза-1b) зелёные на pytest","status":"open","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T11:30:34.419821+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T11:30:34.419821+07:00"} {"id":"simpleAI-adoo","title":"[STS-4] H3 persistence: budget_envelope + CRUD + chat_id-изоляция","description":"Миграция budget_envelope (income_event+период+план-раскладка, БЕЗ хранимого decrement-баланса), chat-scoped, feature-flag (ADR-004). CRUD в BudgetSkill/Store (не в reasoning-skill, ADR §5). AC: изоляция chat A≠B тест-инвариант; нет хранимого остатка. Epic simpleAI-k5j4.","notes":"DONE. Миграция 00016_budget_envelope (income_event + период + active, БЕЗ хранимого остатка — H3). Store: CreateEnvelope (tx, деактивирует прошлый — уник. индекс на один активный/chat), GetActiveEnvelope. Budget action start_envelope (envelope.go) + manifest + golden r041. Integration тест: один активный + chat-scope изоляция (chatA≠chatB). Живой e2e: 'запомни приход 127000 на 2 недели' → конверт создан.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.65329+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T16:16:45.289267+07:00","closed_at":"2026-07-23T16:16:45.289267+07:00","close_reason":"budget_envelope persistence: миграция + Store CRUD (один активный/chat) + start_envelope action + chat-scope integration. Живой e2e.","dependencies":[{"issue_id":"simpleAI-adoo","depends_on_id":"simpleAI-nz5b","type":"blocks","created_at":"2026-07-23T12:15:20.481736+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-adoo","depends_on_id":"simpleAI-km3w","type":"blocks","created_at":"2026-07-23T12:15:20.600365+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-afs","title":"feat(rates): live exchange rates from open.er-api.com with DB cache","description":"Заменить хардкод курсов валют в budget_skill.go на актуальные данные.\\n\\nАрхитектура:\\n1. Миграция: internal/db/migrations/00010_exchange_rates.sql\\n - exchange_rate(currency TEXT PK, rate_to_rub FLOAT, updated_at TIMESTAMPTZ)\\n2. internal/budget/store.go:\\n - GetExchangeRates(ctx) (map[string]float64, error)\\n - SaveExchangeRate(ctx, currency string, rate float64) error\\n3. internal/rates/worker.go:\\n - Фоновый job, раз в день GET https://open.er-api.com/v6/latest/RUB\\n - rate_to_rub = 1 / response.rates[currency]\\n - Сохраняет THB, USD, EUR в БД\\n4. internal/skills/budget_skill.go:\\n - toRUB загружает курсы из store при старте skill (или инжектируется)\\n - Fallback на hardcoded rubRates если store вернул ошибку\\n5. cmd/app/main.go: запуск RatesWorker\\n\\nAC:\\n- Курсы обновляются раз в день без рестарта бота\\n- При недоступном API используются предыдущие курсы из БД\\n- При пустой БД используется хардкод-fallback","status":"closed","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T13:48:23.531075+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T13:52:57.807002+07:00","closed_at":"2026-04-01T13:52:57.807002+07:00","close_reason":"Реализован rates.Worker (internal/rates/worker.go): раз в сутки фетчит THB/USD/EUR из open.er-api.com, сохраняет в exchange_rate. budget_skill использует DB-курсы с fallback на хардкод. Миграция 00010."} +{"id":"simpleAI-agz4","title":"конверты 1/3: считать только обязательства, попадающие в период конверта","description":"[TASK 1/3] Считать в конверте только обязательства, попадающие в его период\n\n## DoD\n- `go test ./internal/skills/safetospend/...` — зелёно.\n- Конверт 24.08–06.09 при `next_date` аренды 10.09 не содержит share «аренда» и «подписка Клауд личная»; на гибкие конверты уходит на 18 671 ฿ больше.\n- Граница: due = period_end входит, due = period_end + 1 день не входит. Оба случая — отдельными тестами.\n- Отсечённый платёж напечатан строкой «впереди» с суммой и датой — тест проверяет наличие в собранном ответе.\n- Мутация: `return nil` первой строкой в `fixedShares` → тесты краснеют.\n\n## Для человека\nАренда платится 10 сентября, но деньги на неё бот запирает уже сейчас — свободных на текущие две недели меньше почти на девятнадцать тысяч бат. Оператору цифра кажется враньём: столько он реально может потратить.\nТеперь в период войдёт только то, что платится внутри его рамок. Аренда и подписка уедут в следующий период, а в ответе останутся отдельной строкой «впереди» — чтобы про них не забыть и не потратить всё.\nЗависимостей нет, можно брать сразу.\n\n## Для агента\n`internal/skills/safetospend/allocate.go:123` — `fixedShares`. Окно `windowEnd := from.AddDate(0, 0, fixedFundingDays)`, `fixedFundingDays = 31` в `config.go:56`. Заменить на границу периода конверта (`budget.Horizon.To`, включительно).\n\nКомментарий-обоснование над функцией сейчас обосновывает ровно обратное — sinking fund, «отложить надо сейчас». Переписать под новое решение оператора, иначе следующий агент вернёт 31 день обратно.\n\nОтсечённые платежи не выбрасывать молча: собрать в отдельный список и напечатать. Канал уже есть — функция возвращает `warnings` вторым значением.\n\nВсе даты — `DATE`, границы включительные (см. решение по закрытию конверта в родительском интенте). `fixedFundingDays` после правки может остаться без пользователей — тогда удалить, а не оставлять мёртвую константу.\n\nРешение оператора (interview 2026-08-24): окно = период конверта. Отвергнуто: sinking fund целиком; пропорциональное резервирование части суммы.\n\nБлокирует: ничего. Родитель — интент эпика конвертов, итерация 2.\n","notes":"Ритм прихода — дважды в месяц (оператор, 24.08.2026). Период конверта совпадает с ритмом: в каждом периоде ровно один приход, поэтому обязательство следующего периода финансируется приходом того периода. Дополнительной страховки «покрыт ли платёж» не закладываем — бот всё равно не знает ритма, приход не пишется в ledger (ADR-002).\nЗакрыта. Окно финансирования fixed-shares = период конверта (было 31 день). Отсечённые платежи уходят в EnvelopePlan.Upcoming и печатаются блоком «Впереди». Файлы: internal/skills/safetospend/{allocate.go,config.go,format.go}, internal/skills/budget/{envelope.go,limits.go}. Тесты: fixed_window_test.go, обновлён envelope_invariant_test.go. Мутация return nil в fixedShares краснит все три теста. Коммит 3d886c7.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-08-24T19:57:34.384959+07:00","created_by":"Taras Malinovskii","updated_at":"2026-08-24T20:52:49.731015+07:00","closed_at":"2026-08-24T20:52:49.731015+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-agz4","depends_on_id":"simpleAI-rrn8","type":"parent-child","created_at":"2026-08-24T19:57:34.387316+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-at6","title":"fix(advisor): таблица budget_recurring отсутствует — миграция в мёртвой папке","description":"Bug: AdvisorSkill отдаёт 'Временная ошибка при сборе финансового снимка — попробуй позже.' на сообщения вида 'Планирую купить байк, что скажешь?'.\n\nКорень: GetAdvisorSnapshot (internal/budget/store.go:870) делает CTE с FROM budget_recurring r. Таблица создаётся миграцией 00011_budget_recurring.sql, но файл лежит только в МЁРТВОЙ /migrations/ (не git ls-tree HEAD internal/db/migrations/00011_budget_recurring.sql — пусто). Активная папка — internal/db/migrations/ (go:embed в internal/db/migrate.go:15). Таблицы в БД нет → SQL error → fallback сообщение.\n\nАналогичный баг фиксили коммитом b4b85c5 (budget_reminder). Регрессия повторилась.\n\nFix:\n- Скопировать migrations/00011_budget_recurring.sql в internal/db/migrations/00011_budget_recurring.sql (готово, файл untracked).\n- Прогнать goose миграцию на dev — таблица budget_recurring создаётся.\n- Воспроизвести 'Планирую купить байк' → ожидаемый ответ от advisor (verdict + recommendation), не fallback.\n\nDoD:\n- internal/db/migrations/00011_budget_recurring.sql закоммичен.\n- go build ./... зелёный.\n- go test ./internal/budget/... ./internal/skills/... зелёный.\n- Воспроизведение бага на стенде → ответ advisor, не fallback.\n- /review → PASS.","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-06T18:58:15.43661+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-06T18:59:17.60277+07:00","closed_at":"2026-05-06T18:59:17.60277+07:00","close_reason":"Скопирована миграция 00011_budget_recurring.sql в активную internal/db/migrations/. go build + go test ok. /review PASS. Untracked файл готов к коммиту. Ручная верификация на dev — зона деплоя."} {"id":"simpleAI-axp","title":"fix(agent): batch multiple actions into array tool call","description":"Когда пользователь присылает несколько действий в одном сообщении (несколько блоков Действие:), LLM генерирует одиночный tool call вместо массива. Второе и последующие действия игнорируются. Нужно добавить явную инструкцию в buildToolsSystemPrompt чтобы LLM всегда использовала JSON-массив при нескольких действиях.","status":"closed","priority":2,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T20:14:07.619197+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T20:14:58.052942+07:00","closed_at":"2026-03-09T20:14:58.052942+07:00","close_reason":"Добавлена инструкция в buildToolsSystemPrompt: батчевать все действия одним массивом. Файл: internal/agent/service.go:242. Тесты OK."} {"id":"simpleAI-b1r","title":"task/simpleAI-finance-ui: telegram/context.go — добавить FinanceStore, IngestStore, StateManager, LLMClient и хелперы","description":"Текущая ветка: task/simpleAI-finance-ui\nЭпик: simpleAI-nzc\nЗатрагиваемые файлы: internal/telegram/context.go\nЗависит от: simpleAI-vet (state), simpleAI-vdm (finance), simpleAI-2gp (ingest)\nЗадача: Расширить struct Context:\n FinanceStore *finance.Store — для чтения чеков\n IngestStore *ingest.Store — для записи чеков\n StateManager *state.Manager — для conversation state\n LLMClient core.LLMClient — для парсинга через LLM\nДобавить хелпер-методы:\n SendWithKeyboard(text string, kb core.InlineKeyboard) error — type-assert Bot к интерфейсу с SendWithKeyboard, fallback на Reply\n EditMessage(messageID int, text string, kb *core.InlineKeyboard) error — type-assert Bot к editor\n AnswerCallback(notification string) error — type-assert Bot к callbackAnswerer, только если IsCallback==true\nВажно: использовать локальные interface{} для type-assert чтобы не создавать circular imports\nAcceptance: go build ./internal/telegram/... без ошибок","status":"closed","priority":2,"issue_type":"task","assignee":"Taras Malinovskii","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T18:54:37.353419+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T18:59:48.069312+07:00","closed_at":"2026-02-15T18:59:48.069312+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-b1r","depends_on_id":"simpleAI-nzc","type":"parent-child","created_at":"2026-02-15T18:55:57.964245+07:00","created_by":"Taras Malinovskii"}]} @@ -178,11 +180,13 @@ {"id":"simpleAI-qqra","title":"budget: BucketConfig + GroupByBuckets + format L1/L2/L3","description":"buckets.go: Bucket{ID,Name,Icon,Categories,Default bool}, BucketConfig []Bucket, ровно один Default (validation). GroupByBuckets(cats, config). formatSummaryL1 (текст+кнопки), formatBuckets (L2), formatBucketDetail (L3).","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-11T12:47:56.152816+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-11T12:50:49.149698+07:00","closed_at":"2026-05-11T12:50:49.149698+07:00","close_reason":"buckets.go: Bucket, BucketConfig, Validate (ровно 1 default), GroupByBuckets. format.go: formatSummaryL1, formatBucketsL2, formatBucketDetailL3. DefaultBuckets конфиг.","dependencies":[{"issue_id":"simpleAI-qqra","depends_on_id":"simpleAI-iorx","type":"blocks","created_at":"2026-05-11T12:48:00.286236+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-qqra","depends_on_id":"simpleAI-36sq","type":"blocks","created_at":"2026-05-11T12:48:00.883808+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-rcm","title":"Автоматические бэкапы БД: скрипт + крон","description":"Нужен скрипт бэкапа PostgreSQL + крон для автозапуска.\n\nТребования:\n- pg_dump через docker exec simpleai-postgres\n- Сжатие gzip\n- Локальное хранение на сервере (последние 7 дней, ротация)\n- Загрузка как GitHub release asset в приватный репо\n- Крон: раз в день (например в 3:00)\n- Уведомление в Telegram при ошибке\n\nОкружение сервера: Ubuntu, /home/deploy/, docker compose, gh CLI нужно проверить","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-04T18:31:54.775588+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-04T18:42:55.776315+07:00","closed_at":"2026-04-04T18:42:55.776315+07:00","close_reason":"Скрипт /home/deploy/backup.sh: pg_dump через docker exec → gzip → ротация 7 дней → GitHub release asset. gh CLI установлен в /home/deploy/bin/gh. Крон: 0 3 * * *. Тест прошёл успешно (40K)."} {"id":"simpleAI-ri4","title":"fix(help): обновить MsgTelegramHelp — ссылки на /budget вместо /help budget","description":"MsgTelegramHelp в messages.go показывает устаревшие команды вида '/help budget', но зарегистрированные команды бота — /budget, /recurring, /forecast, /reminders. Нужно обновить текст чтобы отражал реальные команды.","status":"closed","priority":2,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T11:48:24.745841+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-04T11:48:42.676498+07:00","closed_at":"2026-05-04T11:48:42.676498+07:00","close_reason":"Обновлён MsgTelegramHelp: /help budget → /budget, /help recurring → /recurring, /help forecast → /forecast, /help reminders → /reminders. Файл: internal/constants/messages.go:30. Билд чистый."} +{"id":"simpleAI-rrn8","title":"epic: конверты, итерация 2 — периметр обязательств, курс словами, знак валюты","description":"**Декомпозиция — 8 задач.** Граф: 1 → 2 и 3 (параллельно); 2,3 → 4; 4 → 6 → 5; 6 → 7; 2,3 → 8.\nЗадачи и эпик — в `.beads/issues.jsonl` репозитория simpleAI.\n\n---\n\n# Итерация 2 — правки по живой выдаче (24.08.2026)\n\nПервая итерация закрыта: PR #62 `feature/envelope-shares` слит в main, деплой прошёл. Оператор посмотрел живой конверт (24.08–06.09, приход 128 000 ₽) и дал три правки.\n\n## DoD — итерация 2\n- `go build ./... \u0026\u0026 go test ./...` — зелёно.\n- Обязательства: конверт 24.08–06.09 при `next_date` аренды 10.09 НЕ содержит share «аренда» и «подписка Клауд личная»; на гибкие конверты уходит на 18 671 ฿ больше. Тест на границе: due = period_end входит, due = period_end + 1 день не входит.\n- Впереди-строка: ответ бота содержит предупреждение о платежах вне периода с суммой и датой.\n- Знак валюты: ни одна денежная строка в выдаче safe_to_spend и budget не печатается без знака ฿ или ₽. Тест — регексп по собранному ответу: число, не сопровождённое знаком, роняет тест.\n- Курс словами: «курс 2,7» → конверты пересчитаны по 2,7; «курс авто» → вернулся курс из API. Ручной курс переживает рестарт бота и суточный тик `rates.Worker`.\n- Мутация: `return nil` первой строкой в `fixedShares` → тесты обязательств краснеют; снятие знака валюты в `Display.Fmt` → тест знака краснеет.\n\n## Для человека — итерация 2\nОператор увидел три вещи, мешающие доверять цифрам.\nОбязательства следующего периода заперты в текущем: аренда платится 10 сентября, а деньги отложены уже сейчас — свободных на две недели меньше почти на девятнадцать тысяч бат. Решено: в период входит только то, что платится внутри его рамок, остальное отдельной строкой «впереди», чтобы не забыть.\nКурс бата берётся из межбанка и расходится с обменником, повлиять на него было нельзя. Теперь курс можно назначить словами и вернуть автоматический.\nУ сумм не видно валюты — рядом с каждой цифрой встанет знак.\nЗависит от: эпик конвертов, итерация 1 — закрыта и в проде.\n\n## Для агента — итерация 2\n\n**Решения оператора (interview 2026-08-24):**\n- **Периметр обязательств.** Окно финансирования fixed-share = период конверта, НЕ 31 день. Отвергнуто: sinking fund целиком (текущее поведение — оператор видит заниженный свободный остаток) и пропорциональное резервирование (усложняет без пользы, оператор выбрал явный вариант).\n- **Курс.** Ручной override словами + «курс авто» для возврата. Отвергнуто: только принудительное обновление автокурса — не решает расхождение межбанка с обменником.\n- **Валюта.** Знак у каждой денежной строки, единая точка печати.\n\n**Точки в коде:**\n- `internal/skills/safetospend/allocate.go:123` — `fixedShares`; окно `windowEnd := from.AddDate(0, 0, fixedFundingDays)`. `fixedFundingDays = 31` в `config.go:56`. Заменить на границу периода из `budget.Horizon`. Комментарий-обоснование sinking fund над функцией переписать под новое решение — иначе следующий агент вернёт 31 день обратно.\n- Отсечённые платежи не выбрасывать молча: вернуть их отдельным списком и напечатать строкой «впереди». Сейчас функция уже возвращает `warnings` — тот же канал.\n- `internal/skills/safetospend/display.go:66` — `Display.Fmt`, единственная легальная точка печати денег. `internal/skills/safetospend/format.go` печатает `%.0f ₽` хардкодом минимум в 12 местах (строки 35, 37, 42, 43, 45, 48, 52, 58, 60, 410, 413) — это и есть рубль на батовом числе. `internal/skills/budget/limits.go:66` печатает `%.0f %s`.\n- Курс: `internal/rates/worker.go` — суточный фетч `open.er-api.com`, `internal/budget/store.go:1611` `GetExchangeRates`, `:1631` `SaveExchangeRate`, таблица `exchange_rate(currency, rate_to_rub, updated_at)`, миграция `00010`. Ручной override должен пережить тик воркера — значит отдельная колонка/таблица, а не перезапись `rate_to_rub`.\n- Роутинг новых фраз — `internal/agent/router.go`, правила пронумерованы; golden-сценарии — `evals/golden_set.jsonl`.\n\n**Состояние реплики на момент interview:** конверт `b1c32dd1`, 24.08–06.09, 128 000 ₽, 12 shares. Recurring: Кредит Сбербанк 28 500 ₽ (27.08), Ежемесячный 3 000 ₽ (01.09), аренда 18 000 ฿ (10.09), подписка Клауд 1 700 ₽ (10.09). Курс THB 2,5351 ₽ от 24.08 12:12 МСК.\n\n**Декомпозиция итерации 2 — 3 задачи.** Граф: 1 независима; 2 → 3.\n","notes":"Все три задачи закрыты, ветка feature/envelope-iter2, коммиты 3d886c7, ffe196f, 30aab88.","status":"closed","priority":1,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-08-24T19:57:25.607867+07:00","created_by":"Taras Malinovskii","updated_at":"2026-08-24T21:07:41.548583+07:00","closed_at":"2026-08-24T21:07:41.548583+07:00","close_reason":"Closed"} {"id":"simpleAI-rvs","title":"advisor: tests + smoke + ship","description":"Phase 4 + 5 из spec/advisor/plan.yaml.\n\nПолное тестовое покрытие + manual smoke + /review + PR.\n\nТесты в internal/skills/advisor_skill_test.go:\n- Manifest content test: positive trigger phrases ('purchase', 'afford', 'prioritization') + negative phrase ('Do NOT ... budget skill')\n- Prompt rendering test (table-driven, snapshot fixture × question, low_data вариант)\n- LLM JSON parsing test (valid / invalid / missing fields / wrong verdict)\n- Run() integration test с моками store + llm: happy / low_data / RUB question / no THB rate / unknown currency\n- Format helper unit-test\n\nSmoke на dev:\n- 'можем купить велосипед за 25000?' → happy path\n- 'можем купить за 50000 руб?' → RUB конверсия в выводе\n- 'купил молоко за 50' → НЕ роутится в advisor (роутится в budget.add_expense)\n- low_data сценарий → verdict='Условно' + объяснение\n\nAcceptance criteria:\n- go test ./... зелёный\n- AC traceability: каждый AC из spec/advisor/acceptance_criteria.md (кроме A1/A2/I1) покрыт тестом\n- A1/A2 — manual verification (manifest content test покрывает manifest sanity)\n- I1 (latency p95) — design goal, не CI\n- /review = PASS\n- PR открыт в main, conventional commits: feat(advisor): financial advisor skill\n\nЗависит от всех предыдущих task.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T12:35:37.047668+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-04T20:03:11.21492+07:00","closed_at":"2026-05-04T20:03:11.21492+07:00","close_reason":"Phase 4-5: rapid property tests (budget + skills) + golangci-lint = 0 issues. /review WARNINGS закрыты (doc-comment, UTF-8 truncate, MD escape, chatID warn, testdata gitignore). Manual smoke на dev — отдельно.","dependencies":[{"issue_id":"simpleAI-rvs","depends_on_id":"simpleAI-yvq","type":"blocks","created_at":"2026-05-04T12:35:42.540146+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-s1wj","title":"feat(budget): прогнозный Фонд Да + Обязательства с учётом recurring","description":"Архитектура: repository возвращает facts, skill интерпретирует state.\n\nЧасти:\n1. Миграция 00012: ALTER TABLE budget_transaction ADD COLUMN recurring_id UUID NULL; отдельно FK; composite index (chat_id, recurring_id, created_at). ON DELETE SET NULL. Backfill не нужен.\n2. Transaction struct: RecurringID *uuid.UUID. ListTransactions: добавить в SELECT. CreateRecurringTransaction: добавить в INSERT.\n3. Skill: ListTransactions(period) → executedRecurring set. ListRecurring(chatID) → filter enabled + next_date в месяце + not in set. Прогноз = факт + pending. UI: две строки если прогноз != факт. Buckets: факт + план по категориям recurring.","status":"closed","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-05-12T08:16:48.314447+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-23T09:25:36.235505+07:00","closed_at":"2026-05-23T09:25:36.235505+07:00","close_reason":"Реализовано в fe5a752: migration 00014, RecurringID в Transaction, GetExecutedRecurringIDs, pendingRecurring set-diff, прогнозный UI с тремя состояниями, buckets toLower матчинг"} {"id":"simpleAI-sa5","title":"Почта: Gmail API провайдер","description":"Эпик: Агент почты (hourly). Интеграция Gmail API (OAuth).","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:29.903075+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T10:23:32.931601+07:00","closed_at":"2026-02-08T10:23:32.931601+07:00","close_reason":"Closed","comments":[{"id":5,"issue_id":"simpleAI-sa5","author":"Taras Malinovskii","text":"Готово: Gmail провайдер в internal/mail/gmail.go и интерфейс провайдера internal/mail/provider.go.","created_at":"2026-02-08T03:23:38Z"}]} {"id":"simpleAI-spu","title":"fix(budget): routing+filter для income-запросов","description":"Запрос 'покажи доходы предыдущего месяца' роутится в list_transactions без фильтра по type → возвращаются все транзакции (доминируют expense).\n\n## Состав фикса (вариант C: routing + filter)\n\n### A. Routing\n- budget manifest summary description: явно claim запросы со словами 'доходы / заработал / итоги / income / earned'\n- list_transactions description: defer в income-overview случаях → summary\n- summary action description: упомянуть что показывает оба (после prev-fix)\n\n### B. Filter\n- budgetInput.Type (income | expense) — optional\n- listTransactions: если Type указан — добавить в TransactionFilter\n- TransactionFilter.Type — поле в budget package\n- ListTransactions store-метод применяет фильтр к WHERE\n\n## Файлы\n\n- internal/skills/budget_skill.go (manifest + listTransactions handler + budgetInput)\n- internal/budget/store.go (TransactionFilter применение)\n- internal/budget/model.go (TransactionFilter.Type)\n- evals/golden_set.jsonl\n\n## DoD\n\n- [ ] manifest claims income queries → summary\n- [ ] list_transactions поддерживает type=income filter\n- [ ] go build/vet/test PASS\n- [ ] golden_set: 'покажи доходы за месяц' → summary; 'перечисли все доходы за апрель' → list_transactions type=income\n- [ ] eval \u003e= 0.83","status":"closed","priority":2,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-07T09:02:52.697267+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-07T09:06:12.072851+07:00","closed_at":"2026-05-07T09:06:12.072851+07:00","close_reason":"manifest: summary claims income queries, list_transactions поддерживает transaction_type=income/expense filter; handler передаёт req.TransactionType в TransactionFilter; golden_set +3 кейса (r034-r036, все pass); eval 0.92."} {"id":"simpleAI-sqz","title":"refactor: разбить advisor_skill.go → skills/advisor/ (подзадача C)","description":"## Цель\nПереместить advisor_skill.go в internal/skills/advisor/ (отдельный пакет). Подзадача C эпика simpleAI-4fc.\n\n## Текущее состояние\nadvisor_skill.go = 632 строки, всё в одном файле: struct, manifest, Run(), два action (advice/analyze), все хелперы, форматирование.\n\n## Что сделать\n1. Создать internal/skills/advisor/\n2. Разбить на файлы в пакете `advisorskill`:\n - skill.go — AdvisorSkill struct, NewAdvisorSkill, Manifest(), Run()\n - advice.go — runAdvice, buildAdvisorPrompt, computeForecastRemaining, parseAdvisorLLMResponse, formatAdvisorReply, advisorLLMResponse, advisorPromptTemplate\n - analyze.go — runAnalyze, buildAnalyzePrompt, parseAnalyzePeriod, parseAnalyzeLLMResponse, formatAnalyzeReply, analyzeLLMResponse, analyzePromptTemplate\n - format.go — escapeTelegramMarkdown, formatSpentByCategory, formatTopExpenses, truncateRunes, trimList (shared между advice/analyze)\n3. Извлечь stripMarkdownFences() — дублируется в parseAdvisorLLMResponse и parseAnalyzeLLMResponse\n4. Обновить импорты в cmd/app/main.go, evals/\n\n## Не делать здесь\n- Менять сигнатуры (string returns) — задача D\n- Переносить format.go в bot/ — задача E\n\n## DoD\n- internal/skills/advisor/ создан, package advisorskill\n- go test ./... green\n- lint green\n- /review PASS","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-08T20:32:16.644344+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-08T20:46:37.205981+07:00","closed_at":"2026-05-08T20:46:37.205981+07:00","close_reason":"Перенесён в internal/skills/advisor/ (package advisorskill). Файлы: skill.go, advice.go, analyze.go, format.go. Извлечён stripMarkdownFences и unmarshalJSON в format.go. Тесты переехали в advisor_test.go. go test green.","dependencies":[{"issue_id":"simpleAI-sqz","depends_on_id":"simpleAI-ckb","type":"blocks","created_at":"2026-05-08T20:32:35.33667+07:00","created_by":"Taras Malinovskii"}]} +{"id":"simpleAI-su6l","title":"конверты 3/3: задавать курс бата словами и возвращать автокурс","description":"[TASK 3/3] Задавать курс бата словами и возвращать автоматический\n\n## DoD\n- `go test ./internal/budget/... ./internal/rates/... ./internal/skills/...` — зелёно.\n- «курс 2,7» → бот подтверждает и пересчитывает конверты по 2,7; «курс авто» → возвращает курс из API.\n- Ручной курс переживает суточный тик `rates.Worker` и рестарт бота — тест: сохранить override, прогнать `fetchAndSave`, прочитать курс, он прежний.\n- Golden-сценарии роутинга «курс 2,7» и «курс авто» в `evals/golden_set.jsonl` проходят.\n- Мутация: игнорировать override при чтении курсов → тест переживания тика краснеет.\n\n## Для человека\nКурс бата бот берёт из межбанка раз в сутки, а в обменнике курс другой — цифры расходятся, и повлиять на это было нельзя.\nТеперь курс можно назначить словами и так же словами вернуть автоматический.\nЗависит от: [TASK 2/3] Печатать знак валюты у каждой денежной строки через единую точку\n\n## Для агента\nСейчас: `internal/rates/worker.go` раз в сутки тянет `open.er-api.com` и пишет в `exchange_rate(currency, rate_to_rub, updated_at)` через `store.go:1631` `SaveExchangeRate`. Чтение — `store.go:1611` `GetExchangeRates`.\n\nOverride обязан пережить тик воркера — значит отдельная колонка (`manual_rate_to_rub NUMERIC NULL`) или отдельная таблица, а не перезапись `rate_to_rub`. Воркер продолжает писать автокурс; чтение отдаёт override, когда он есть. «курс авто» = обнулить override, не трогая автокурс.\n\nНовая миграция goose с down-миграцией.\n\nРоутинг: `internal/agent/router.go`, правила пронумерованы — добавить правило про курс. Развести с уже существующим `display_currency` («покажи конверты в рублях» — это про показ, а не про курс) и с суммами («курс 2,7» — не трата на 2,7).\n\nПечать курса — через ту же точку, что и деньги (см. [TASK 2/3]); в подтверждении показать, ручной курс или автоматический, иначе оператор не отличит.\n\nРешение оператора (interview 2026-08-24): ручной override словами + «курс авто». Отвергнуто: только принудительное обновление автокурса — расхождение межбанка с обменником оно не снимает.\n\nЗависит от задачи 2/3. Блокирует: ничего.\n","notes":"Закрыта. Ручной курс в exchange_rate.manual_rate_to_rub (миграция 00020, отдельная колонка — переживает тик воркера). GetExchangeRates выбирает через COALESCE. Действия set_rate/clear_rate/rate_status в internal/skills/budget/rate.go, правило 7 в router.go, golden r053-r056. Тесты: internal/budget/manual_rate_test.go (переживание тика, идемпотентность, отказ на неположительном). Мутации: снять COALESCE и затирать manual в SaveExchangeRate — обе краснят. Живой прогон на реплике: курс 2,7 → clear → 2,5351. Коммит 30aab88.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-08-24T19:57:34.683582+07:00","created_by":"Taras Malinovskii","updated_at":"2026-08-24T21:07:41.312028+07:00","closed_at":"2026-08-24T21:07:41.312028+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-su6l","depends_on_id":"simpleAI-rrn8","type":"parent-child","created_at":"2026-08-24T19:57:34.685221+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-su6l","depends_on_id":"simpleAI-302i","type":"blocks","created_at":"2026-08-24T19:57:34.803697+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-syzl","title":"fix: RUB hardcode as base currency in GetExchangeRates","description":"GetExchangeRates не возвращает RUB (его нет в exchange_rate таблице). aggregateAdvisorSnapshot тихо скипает все RUB-транзакции → FreeCash считается неверно (доход в RUB игнорируется). Фикс: после загрузки rates из БД — принудительно ставить rates[RUB]=1.0 если отсутствует.","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-09T09:20:41.289282+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-09T09:22:17.176516+07:00","closed_at":"2026-05-09T09:22:17.176516+07:00","close_reason":"RUB hardcoded as base currency (1.0) in GetExchangeRates. Фикс: internal/budget/store.go:824. Тесты прошли, запушено."} {"id":"simpleAI-t2d","title":"Константы для строк и API ошибок","description":"Вынести сырые строки и API ошибки в централизованные константы, упорядочить доступ и переиспользование.","notes":"Старт: создан пакет internal/constants (messages/errors) + начальная миграция LLM ошибок на константы; README/TECHNICAL_README обновлены. Полная миграция строк/ошибок остается в simpleAI-73b.\nСделано: internal/constants/errors.go + messages.go, LLM ошибки переведены на константы; README/TECHNICAL_README обновлены. Коммит 9ae949f.","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T12:10:29.377853+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T18:35:52.487918+07:00","closed_at":"2026-03-09T18:35:52.487918+07:00","close_reason":"Stale: work completed in Feb 2026, constants package created and used, closing retroactively"} {"id":"simpleAI-tsg4","title":"[STS-1] Store: мультипериодная агрегация (произвольный период, chat-scoped)","description":"Новый метод Store: доходы/расходы/обязательства за произвольный период (from/to), chat-scoped, в THB. Не ломать MTD-путь GetAdvisorSnapshot. ADR-007 §6. AC: юнит-тесты границ периода (from inclusive, to inclusive/exclusive явно); chat-scope изоляция; конверсия thb=amount*rates[cur]/rates[THB]; мутация-проверка. Epic simpleAI-k5j4.","notes":"DONE. internal/budget/store.go: GetPeriodSnapshot(ctx,chatID,from,to,rates) + periodSnapshotQuery (явные границы [from,to] включ., обязательства \u003c= to как в месячном снапшоте). Переиспользует aggregateAdvisorSnapshot. Тесты period_snapshot_test.go: unit invalid-range (без DB) + integration против реплики (границы дня 2026-03-09 + chat-scope изоляция). Мутация: (r.chat_id=$3 OR TRUE) → чужой chatID получает recurring=30769 → тест краснеет; восстановил → ok. Build+весь go test зелёные, gofmt чисто.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.184473+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T12:19:20.663137+07:00","closed_at":"2026-07-23T12:19:20.663137+07:00","close_reason":"Мультипериодная агрегация GetPeriodSnapshot + тесты (unit+integration+мутация chat-scope). Границы и изоляция доказаны на реплике."} diff --git a/evals/golden_set.jsonl b/evals/golden_set.jsonl index 741db91..8cf01a0 100644 --- a/evals/golden_set.jsonl +++ b/evals/golden_set.jsonl @@ -55,3 +55,7 @@ {"id":"r050","input":"пришло 127000 рублей, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","income_arrived","acceptance","ru"],"notes":"simpleAI-faeq.10 — эталонный кейс приёмки: 127к делятся на ВСЕ расходы периода, включая регулярные платежи отдельными строками; повтор той же фразы обязан давать тот же результат (carried_in не растёт)"} {"id":"r051","input":"пришло 10 рублей, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","small_income","ru"],"notes":"simpleAI-faeq.11 §5 — малый приход не ломает раскладку: дневной лимит считается от гибких конвертов, приход в формуле не участвует"} {"id":"r052","input":"сколько можно тратить в день","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","daily_limit","read_only","ru"],"notes":"simpleAI-faeq.11 §5 — дневной лимит = гибкие конверты / оставшиеся дни, пересчитывается после каждой траты; read-only"} +{"id":"r053","input":"курс 2,7","expected":{"skill":"budget","action":"set_rate"},"tags":["exchange_rate","manual_rate","ru"],"notes":"simpleAI-su6l — ручной курс словами. Число = рубли за один бат, НЕ трата и НЕ лимит конверта: в фразе нет ни прошедшего времени, ни категории"} +{"id":"r054","input":"курс авто","expected":{"skill":"budget","action":"clear_rate"},"tags":["exchange_rate","manual_rate","ru"],"notes":"simpleAI-su6l — возврат к автокурсу из open.er-api.com; ручной override снимается, автокурс всё это время обновлялся воркером"} +{"id":"r055","input":"какой сейчас курс","expected":{"skill":"budget","action":"rate_status"},"tags":["exchange_rate","read_only","ru"],"notes":"simpleAI-su6l — ответ обязан назвать, ручной курс или автоматический: иначе оператор не отличит подействовавшую команду от проигнорированной"} +{"id":"r056","input":"покажи конверты в рублях","expected":{"skill":"safe_to_spend","action":""},"tags":["exchange_rate","display_currency","regression","ru"],"notes":"контраст к r053: это ПОКАЗ (display_currency=RUB), курс пересчёта не трогается. Разграничение уже ломалось на паре currency/display_currency"} diff --git a/internal/adapters/telegram/adapter_test.go b/internal/adapters/telegram/adapter_test.go index ce3add1..b6aec68 100644 --- a/internal/adapters/telegram/adapter_test.go +++ b/internal/adapters/telegram/adapter_test.go @@ -56,7 +56,9 @@ func newFakeTelegram(t *testing.T) *fakeTelegram { func writeJSON(w http.ResponseWriter, code int, body string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) - _, _ = w.Write([]byte(body)) + if _, err := w.Write([]byte(body)); err != nil { + panic(err) // тестовый сервер: писать некуда — тест уже недействителен + } } func (f *fakeTelegram) sendCalls() []map[string]string { diff --git a/internal/agent/router.go b/internal/agent/router.go index 78279da..7eeccc0 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -37,7 +37,10 @@ func buildToolsSystemPrompt(manifests []plugin.Manifest) string { sb.WriteString("5. Вопрос про ОСТАТОК по категории или по конвертам («сколько осталось на еду», «сколько в конвертах», «сколько осталось в конверте на транспорт») — skill=safe_to_spend, сумму НЕ передавать. Это не budget.summary: спрашивают не сколько потрачено, а сколько ещё можно потратить.\n") sb.WriteString("6. ВАЛЮТА КОНВЕРТОВ. По умолчанию конверты показываются В БАТАХ (THB) — поле display_currency НЕ заполняй. Если оператор попросил рубли («покажи конверты в рублях», «разложи и покажи в рублях», «сколько это в рублях») — передай display_currency=\"RUB\"; если явно попросил баты («в батах») — display_currency=\"THB\". Поле display_currency — только про ПОКАЗ; сумму из сообщения оно не меняет.\n") sb.WriteString(" Валюта САМОЙ СУММЫ идёт в currency: «пришло 127000₽» → currency=\"RUB\"; «на еду хватит 5000 бат» → currency=\"THB\"; «на еду хватит 15000 рублей» → currency=\"RUB\". Не путай currency с display_currency: первая описывает названную сумму, вторая — валюту ответа.\n") - sb.WriteString("7. Если в сообщении нет суммы и нет глагола в прошедшем времени — это НЕ add_expense.\n") + sb.WriteString("7. КУРС БАТА. «курс 2,7», «ставь курс 2.65», «курс бата 2,7» — skill=budget, action=set_rate, поле amount=<курс>. Это НЕ трата и НЕ лимит: число — сколько РУБЛЕЙ за один БАТ, деньги никуда не двигались. «курс авто», «верни автоматический курс», «бери курс сам» — action=clear_rate. «какой сейчас курс», «по какому курсу считаешь» — action=rate_status.\n") + sb.WriteString(" Разграничение с правилом 6: «покажи конверты в рублях» — это ПОКАЗ, display_currency=RUB, курс не трогаем. Правило 7 меняет сам курс пересчёта.\n") + sb.WriteString(" Разграничение с правилом 3: «на еду хватит 15000» — лимит конверта (названа КАТЕГОРИЯ); «курс 2,7» — категории нет, есть слово «курс».\n") + sb.WriteString("8. Если в сообщении нет суммы и нет глагола в прошедшем времени — это НЕ add_expense.\n") sb.WriteString("\nДоступные инструменты:\n") for _, m := range manifests { fmt.Fprintf(&sb, "- %s: %s\n", m.ID, m.Description) diff --git a/internal/agent/routing_rules_test.go b/internal/agent/routing_rules_test.go index 490372d..a14b9b5 100644 --- a/internal/agent/routing_rules_test.go +++ b/internal/agent/routing_rules_test.go @@ -24,10 +24,7 @@ func TestBuildToolsSystemPrompt_ShareLimitRuleSeparatedFromExpense(t *testing.T) {ID: "safe_to_spend", Description: "safe to spend"}, }) - rules := prompt[:strings.Index(prompt, "Доступные инструменты")] - if rules == "" { - t.Fatal("в промпте нет блока правил до списка инструментов") - } + rules := rulesBlock(t, prompt) mustContain := map[string]string{ "set_share_limit": "нет правила про правку лимита конверта", @@ -50,3 +47,58 @@ func TestBuildToolsSystemPrompt_ShareLimitRuleSeparatedFromExpense(t *testing.T) t.Errorf("правило про лимит должно идти после правила про add_expense (expense=%d, limit=%d)", expenseIdx, limitIdx) } } + +// Правило про курс живёт в том же блоке ROUTING RULES и разграничено с двумя +// соседями, на которых оно естественно налипает (simpleAI-su6l): +// - «покажи конверты в рублях» — это ПОКАЗ (display_currency), курс не трогаем; +// - «на еду хватит 15000» — лимит конверта, там названа КАТЕГОРИЯ. +// +// Как и тест выше, доказывает только присутствие правила в промпте. Что модель +// ему следует — гоняют golden-кейсы r053–r056 на боевой модели. +func TestBuildToolsSystemPrompt_RateRuleSeparatedFromDisplayAndLimit(t *testing.T) { + prompt := buildToolsSystemPrompt([]plugin.Manifest{ + {ID: "budget", Description: "budget tracker"}, + {ID: "safe_to_spend", Description: "safe to spend"}, + }) + rules := rulesBlock(t, prompt) + + mustContain := map[string]string{ + "set_rate": "нет правила про ручной курс", + "clear_rate": "нет правила про возврат автокурса", + "rate_status": "нет правила про «какой сейчас курс»", + "курс авто": "нет фразы-триггера возврата к автокурсу", + "РУБЛЕЙ за один БАТ": "не сказано, что число — курс, а не сумма денег", + } + for marker, why := range mustContain { + if !strings.Contains(rules, marker) { + t.Errorf("%s: в ROUTING RULES нет %q", why, marker) + } + } + + // Правило про курс обязано стоять ПОСЛЕ правил про display_currency и про + // лимит: разграничение читается как уточнение к ним, а не наоборот. + rateAt := strings.Index(rules, "КУРС БАТА") + displayAt := strings.Index(rules, "ВАЛЮТА КОНВЕРТОВ") + limitAt := strings.Index(rules, "Правка ЛИМИТА конверта") + if rateAt < 0 || displayAt < 0 || limitAt < 0 { + t.Fatalf("не найдены заголовки правил: курс=%d показ=%d лимит=%d", rateAt, displayAt, limitAt) + } + if rateAt < displayAt || rateAt < limitAt { + t.Errorf("правило про курс стоит раньше правил, от которых его отделяют (курс=%d показ=%d лимит=%d)", + rateAt, displayAt, limitAt) + } +} + +// rulesBlock вырезает блок ROUTING RULES — всё до списка инструментов. +// +// Отдельной функцией, а не срезом по strings.Index на месте: Index возвращает +// −1, когда маркера нет, и prompt[:-1] уронил бы тест паникой вместо внятного +// «в промпте нет блока правил». +func rulesBlock(t *testing.T, prompt string) string { + t.Helper() + at := strings.Index(prompt, "Доступные инструменты") + if at <= 0 { + t.Fatal("в промпте нет блока правил до списка инструментов") + } + return prompt[:at] +} diff --git a/internal/bot/format/telegram_html.go b/internal/bot/format/telegram_html.go index f2bb1cd..10cd8a3 100644 --- a/internal/bot/format/telegram_html.go +++ b/internal/bot/format/telegram_html.go @@ -65,9 +65,9 @@ func parseItems(src string) []item { inPre := make([]bool, len(lines)) skip := make([]bool, len(lines)) for i := 0; i+1 < len(fences); i += 2 { - open, close := fences[i], fences[i+1] - skip[open], skip[close] = true, true - for j := open + 1; j < close; j++ { + start, end := fences[i], fences[i+1] + skip[start], skip[end] = true, true + for j := start + 1; j < end; j++ { inPre[j] = true } } diff --git a/internal/budget/manual_rate_test.go b/internal/budget/manual_rate_test.go new file mode 100644 index 0000000..4732ee8 --- /dev/null +++ b/internal/budget/manual_rate_test.go @@ -0,0 +1,145 @@ +package budget + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func rateTestStore(t *testing.T) (*Store, context.Context) { + t.Helper() + url := os.Getenv("BOTCLIENT_DATABASE_URL_RW") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + return &Store{pool: pool}, ctx +} + +// Ручной курс обязан пережить суточный тик воркера (simpleAI-su6l). +// +// Это главный гейт задачи: положи ручной курс в ту же колонку, что пишет +// воркер, — и он молча исчезнет в ближайшие 24 часа. Оператор увидит, что +// команда «сработала», а через сутки конверты снова посчитаются по межбанку. +// +// Мутация: убрать COALESCE из GetExchangeRates либо начать писать manual в +// SaveExchangeRate — тест краснеет. +func TestManualRate_SurvivesWorkerTick(t *testing.T) { + s, ctx := rateTestStore(t) + const cur = "THB" + + before, existed, err := s.GetRateSource(ctx, cur) + if err != nil { + t.Fatalf("read rate source: %v", err) + } + t.Cleanup(func() { + // Вернуть реплику в исходное состояние: тест ходит в живую базу. + // Ошибки восстановления не глушим — незамеченный сбой оставит реплику с + // чужим курсом, и следующий тест упадёт непонятно почему. + restore := func(err error) { + if err != nil { + t.Errorf("не удалось вернуть реплику в исходное состояние: %v", err) + } + } + if existed && before.Manual { + restore(s.SetManualRate(ctx, cur, before.RateToRUB)) + return + } + restore(s.ClearManualRate(ctx, cur)) + if existed { + restore(s.SaveExchangeRate(ctx, cur, before.Auto)) + } + }) + + if err := s.SetManualRate(ctx, cur, 2.7); err != nil { + t.Fatalf("set manual rate: %v", err) + } + + rates, err := s.GetExchangeRates(ctx) + if err != nil { + t.Fatalf("get rates: %v", err) + } + if got := rates[cur]; got != 2.7 { + t.Fatalf("действующий курс %v, ожидался ручной 2,7", got) + } + + // Тик воркера: тот же вызов, что делает rates.Worker раз в сутки. + if err := s.SaveExchangeRate(ctx, cur, 2.5351); err != nil { + t.Fatalf("worker tick: %v", err) + } + + rates, err = s.GetExchangeRates(ctx) + if err != nil { + t.Fatalf("get rates after tick: %v", err) + } + if got := rates[cur]; got != 2.7 { + t.Errorf("после тика воркера курс %v — ручной курс затёрт автоматическим", got) + } + + src, ok, err := s.GetRateSource(ctx, cur) + if err != nil || !ok { + t.Fatalf("read rate source after tick: err=%v ok=%v", err, ok) + } + if !src.Manual { + t.Error("курс перестал считаться ручным") + } + if src.Auto != 2.5351 { + t.Errorf("автокурс %v — воркер обязан продолжать его обновлять и под override'ом", src.Auto) + } + + // «курс авто» возвращает то, что за это время принёс воркер. + if err := s.ClearManualRate(ctx, cur); err != nil { + t.Fatalf("clear manual rate: %v", err) + } + rates, err = s.GetExchangeRates(ctx) + if err != nil { + t.Fatalf("get rates after clear: %v", err) + } + if got := rates[cur]; got != 2.5351 { + t.Errorf("после «курс авто» действует %v, ожидался автокурс 2,5351", got) + } +} + +// Повторный «курс авто» — не ошибка: оператору важно состояние «ручного курса +// нет», а не факт удаления строки. +func TestManualRate_ClearIsIdempotent(t *testing.T) { + s, ctx := rateTestStore(t) + const cur = "THB" + + before, existed, err := s.GetRateSource(ctx, cur) + if err != nil { + t.Fatalf("read rate source: %v", err) + } + t.Cleanup(func() { + if existed && before.Manual { + if err := s.SetManualRate(ctx, cur, before.RateToRUB); err != nil { + t.Errorf("не удалось вернуть ручной курс: %v", err) + } + } + }) + + if err := s.ClearManualRate(ctx, cur); err != nil { + t.Fatalf("first clear: %v", err) + } + if err := s.ClearManualRate(ctx, cur); err != nil { + t.Errorf("повторный «курс авто» вернул ошибку: %v", err) + } +} + +// Курс — величина строго положительная: нулевой делит на ноль в ToTHB, +// отрицательный печатает отрицательные конверты. +func TestManualRate_RejectsNonPositive(t *testing.T) { + s, ctx := rateTestStore(t) + for _, bad := range []float64{0, -2.7} { + if err := s.SetManualRate(ctx, "THB", bad); err == nil { + t.Errorf("курс %v принят, ожидалась ошибка", bad) + } + } +} diff --git a/internal/budget/store.go b/internal/budget/store.go index 4ad1efe..265795d 100644 --- a/internal/budget/store.go +++ b/internal/budget/store.go @@ -1609,8 +1609,13 @@ func AggregateForecast(rows []MonthlyCategoryExpense, rates map[string]float64) // --- Курсы валют --- // GetExchangeRates возвращает все курсы из БД как map[currency]rate_to_rub. +// +// Ручной курс перекрывает автоматический (simpleAI-su6l): COALESCE выбирает его +// прямо в запросе, чтобы «какой курс действует» имело ровно один ответ и +// вызывающему не приходилось помнить про override. func (s *Store) GetExchangeRates(ctx context.Context) (map[string]float64, error) { - rows, err := s.pool.Query(ctx, `SELECT currency, rate_to_rub FROM exchange_rate`) + rows, err := s.pool.Query(ctx, + `SELECT currency, COALESCE(manual_rate_to_rub, rate_to_rub) FROM exchange_rate`) if err != nil { return nil, fmt.Errorf("get exchange rates: %w", err) } @@ -1628,7 +1633,94 @@ func (s *Store) GetExchangeRates(ctx context.Context) (map[string]float64, error return rates, rows.Err() } -// SaveExchangeRate сохраняет или обновляет курс валюты. +// RateSource — откуда взялся действующий курс валюты. +type RateSource struct { + Currency string + RateToRUB float64 // действующий курс: ручной, если задан, иначе автоматический + Auto float64 // последний курс из API — остаётся жить и под override'ом + Manual bool // курс задан руками + UpdatedAt time.Time // когда действующий курс появился +} + +// GetRateSource отдаёт действующий курс валюты вместе с его происхождением. +// Нужен подтверждению «курс 2,7»: не показав, ручной курс или автоматический, +// мы оставляем оператора гадать, подействовала его команда или нет. +func (s *Store) GetRateSource(ctx context.Context, currency string) (RateSource, bool, error) { + var ( + out = RateSource{Currency: currency} + manual *float64 + mAt *time.Time + aAt time.Time + ) + err := s.pool.QueryRow(ctx, ` + SELECT rate_to_rub, updated_at, manual_rate_to_rub, manual_set_at + FROM exchange_rate WHERE currency = $1 + `, currency).Scan(&out.Auto, &aAt, &manual, &mAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, false, nil + } + if err != nil { + return out, false, fmt.Errorf("get rate source: %w", err) + } + out.RateToRUB, out.UpdatedAt = out.Auto, aAt + if manual != nil { + out.RateToRUB, out.Manual = *manual, true + if mAt != nil { + out.UpdatedAt = *mAt + } + } + return out, true, nil +} + +// SetManualRate задаёт ручной курс валюты. Автоматический не трогается — он +// продолжает обновляться воркером и ждёт «курс авто». +func (s *Store) SetManualRate(ctx context.Context, currency string, rateToRUB float64) error { + if rateToRUB <= 0 { + return fmt.Errorf("set manual rate: курс должен быть больше нуля, получено %v", rateToRUB) + } + tag, err := s.pool.Exec(ctx, ` + UPDATE exchange_rate + SET manual_rate_to_rub = $2, manual_set_at = NOW() + WHERE currency = $1 + `, currency, rateToRUB) + if err != nil { + return fmt.Errorf("set manual rate: %w", err) + } + if tag.RowsAffected() == 0 { + // Валюты нет в таблице — воркер её ещё не приносил. Заводим строку с + // ручным курсом в обеих колонках: без rate_to_rub (NOT NULL по схеме) + // вставка не пройдёт, а «курс авто» до первого тика воркера честно + // вернёт ровно то же число. + _, err = s.pool.Exec(ctx, ` + INSERT INTO exchange_rate (currency, rate_to_rub, updated_at, manual_rate_to_rub, manual_set_at) + VALUES ($1, $2, NOW(), $2, NOW()) + ON CONFLICT (currency) DO UPDATE + SET manual_rate_to_rub = EXCLUDED.manual_rate_to_rub, manual_set_at = NOW() + `, currency, rateToRUB) + if err != nil { + return fmt.Errorf("set manual rate (insert): %w", err) + } + } + return nil +} + +// ClearManualRate снимает ручной курс: действовать снова начинает автоматический. +// Идемпотентна — «курс авто» без заданного override'а не ошибка. +func (s *Store) ClearManualRate(ctx context.Context, currency string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE exchange_rate + SET manual_rate_to_rub = NULL, manual_set_at = NULL + WHERE currency = $1 + `, currency) + if err != nil { + return fmt.Errorf("clear manual rate: %w", err) + } + return nil +} + +// SaveExchangeRate сохраняет или обновляет АВТОМАТИЧЕСКИЙ курс валюты. +// Колонку manual_rate_to_rub не трогает: ручной курс обязан пережить суточный +// тик воркера, иначе его затрёт в ближайшие 24 часа (simpleAI-su6l). func (s *Store) SaveExchangeRate(ctx context.Context, currency string, rateToRUB float64) error { _, err := s.pool.Exec(ctx, ` INSERT INTO exchange_rate (currency, rate_to_rub, updated_at) diff --git a/internal/db/migrations/00020_exchange_rate_manual.sql b/internal/db/migrations/00020_exchange_rate_manual.sql new file mode 100644 index 0000000..69b6b5c --- /dev/null +++ b/internal/db/migrations/00020_exchange_rate_manual.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- +goose StatementBegin +-- Ручной курс валюты (simpleAI-su6l). +-- +-- Автокурс тянется раз в сутки из open.er-api.com — это межбанк. Оператор +-- живёт в Тайланде и меняет наличными, где курс другой; повлиять на цифру он +-- не мог никак, а все конверты считаются именно по ней. +-- +-- ОТДЕЛЬНАЯ колонка, а не перезапись rate_to_rub: воркер продолжает писать +-- автокурс каждые сутки, и ручное значение, положенное в ту же колонку, он +-- затёр бы в ближайший тик. Чтение отдаёт manual_rate_to_rub, когда он есть. +-- «курс авто» = обнулить эту колонку, не трогая автокурс. +ALTER TABLE exchange_rate + ADD COLUMN IF NOT EXISTS manual_rate_to_rub DOUBLE PRECISION; + +ALTER TABLE exchange_rate + ADD COLUMN IF NOT EXISTS manual_set_at TIMESTAMPTZ; + +-- Курс — величина строго положительная. Нулевой курс делит на ноль в ToTHB, +-- отрицательный печатает отрицательные конверты. +ALTER TABLE exchange_rate + DROP CONSTRAINT IF EXISTS exchange_rate_manual_positive; + +ALTER TABLE exchange_rate + ADD CONSTRAINT exchange_rate_manual_positive + CHECK (manual_rate_to_rub IS NULL OR manual_rate_to_rub > 0); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE exchange_rate DROP CONSTRAINT IF EXISTS exchange_rate_manual_positive; +ALTER TABLE exchange_rate DROP COLUMN IF EXISTS manual_set_at; +ALTER TABLE exchange_rate DROP COLUMN IF EXISTS manual_rate_to_rub; +-- +goose StatementEnd diff --git a/internal/notify/envelope_command.go b/internal/notify/envelope_command.go index 35a22bf..2bbe914 100644 --- a/internal/notify/envelope_command.go +++ b/internal/notify/envelope_command.go @@ -38,7 +38,7 @@ type EnvelopeReminderStore interface { // ни одной фразы оператора). var ( // «в 7:30», «в 7.30», «в 9» — время после предлога, чтобы не подобрать сумму из соседней фразы. - envelopeTimeRe = regexp.MustCompile(`(?:^|[^\p{L}])в\s+(\d{1,2})(?:[:.](\d{2}))?(?:[^\d]|$)`) + envelopeTimeRe = regexp.MustCompile(`(?:^|[^\p{L}])в\s+(\d{1,2})(?:[:.](\d{2}))?(?:\D|$)`) negationRe = regexp.MustCompile(`(?:^|[^\p{L}])(не|выключи|отключи|перестань|прекрати|хватит)(?:[^\p{L}]|$)`) positiveRe = regexp.MustCompile(`(?:^|[^\p{L}])(присылай|шли|высылай|включи|напоминай|показывай|отправляй)(?:[^\p{L}]|$)`) ) @@ -70,14 +70,14 @@ func ParseEnvelopeCommand(text string) (EnvelopeCommand, bool) { if err != nil || h > 23 { return cmd, true } - min := 0 + minute := 0 if m[2] != "" { - min, err = strconv.Atoi(m[2]) - if err != nil || min > 59 { + minute, err = strconv.Atoi(m[2]) + if err != nil || minute > 59 { return cmd, true } } - cmd.HasTime, cmd.Hour, cmd.Minute = true, h, min + cmd.HasTime, cmd.Hour, cmd.Minute = true, h, minute } return cmd, true default: diff --git a/internal/notify/telegram_test.go b/internal/notify/telegram_test.go index 763221c..f1739a8 100644 --- a/internal/notify/telegram_test.go +++ b/internal/notify/telegram_test.go @@ -38,7 +38,9 @@ func newFakeAPI(t *testing.T) *fakeAPI { return } w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) + if _, err := w.Write([]byte(`{"ok":true}`)); err != nil { + t.Errorf("тестовый сервер не смог ответить: %v", err) + } })) t.Cleanup(f.srv.Close) return f @@ -80,7 +82,10 @@ func TestSendToChatID_UsesHTML(t *testing.T) { if calls[0]["parse_mode"] != "HTML" { t.Errorf("пуш ушёл без parse_mode=HTML: %#v", calls[0]) } - text, _ := calls[0]["text"].(string) + text, ok := calls[0]["text"].(string) + if !ok { + t.Fatalf("в запросе нет текстового поля text: %#v", calls[0]) + } if !strings.Contains(text, "
Аренда 15.09 12 000\nЕда 8 000") { t.Errorf("моноблок пуша не стал pre:\n%s", text) } @@ -101,7 +106,10 @@ func TestSendToChatID_EscapesEvilStrings(t *testing.T) { if err := tg.SendToChatID(context.Background(), 7, "Кафе <Мама & Папа> — 15.09"); err != nil { t.Fatalf("send: %v", err) } - text, _ := f.sent()[0]["text"].(string) + text, ok := f.sent()[0]["text"].(string) + if !ok { + t.Fatalf("в запросе нет текстового поля text: %#v", f.sent()[0]) + } if !strings.Contains(text, "<Мама & Папа>") { t.Fatalf("не экранировано: %q", text) } @@ -121,7 +129,10 @@ func TestSendToChatID_SplitsLongPush(t *testing.T) { t.Fatalf("длинный пуш ушёл %d куском(ами)", len(calls)) } for i, c := range calls { - text, _ := c["text"].(string) + text, ok := c["text"].(string) + if !ok { + t.Fatalf("в куске %d нет текстового поля text: %#v", i, c) + } if n := len([]rune(text)); n > 4096 { t.Errorf("кусок %d длиной %d — Telegram вернёт 400", i, n) } @@ -143,7 +154,10 @@ func TestSendToChatID_FallsBackToPlainOn400(t *testing.T) { if _, has := last["parse_mode"]; has { t.Errorf("фоллбэк ушёл с parse_mode: %#v", last) } - text, _ := last["text"].(string) + text, ok := last["text"].(string) + if !ok { + t.Fatalf("в запросе нет текстового поля text: %#v", last) + } if !strings.Contains(text, "Аренда") { t.Errorf("фоллбэк потерял содержимое: %q", text) } diff --git a/internal/skills/budget/envelope.go b/internal/skills/budget/envelope.go index 7103dcb..7300841 100644 --- a/internal/skills/budget/envelope.go +++ b/internal/skills/budget/envelope.go @@ -99,6 +99,7 @@ func (s *BudgetSkill) startEnvelope(ctx context.Context, req budgetInput) (strin History: history, Recurring: recurring, From: h.From, + To: h.To, }) s.attachCategoryIDs(ctx, plan.Shares) diff --git a/internal/skills/budget/envelope_test.go b/internal/skills/budget/envelope_test.go index 110e2e6..ec50307 100644 --- a/internal/skills/budget/envelope_test.go +++ b/internal/skills/budget/envelope_test.go @@ -107,8 +107,7 @@ func TestStartEnvelope_WritesSharesAndReply(t *testing.T) { var hasFallback, hasSavings bool for _, sh := range shares { sum += sh.Allocated - switch strings.ToLower(sh.Name) { - case budget.FallbackShareName: + if strings.EqualFold(sh.Name, budget.FallbackShareName) { hasFallback = true } if sh.Kind == budget.ShareKindSave { @@ -134,11 +133,20 @@ func TestStartEnvelope_WritesSharesAndReply(t *testing.T) { t.Errorf("в ответе нет строки по доле %q:\n%s", sh.Name, reply) } } - for _, want := range []string{"Приход", "Обязательства", "К раскладке", "Свободно", "Вне конвертов"} { + // Строки утверждённого оператором формата (simpleAI-faeq.11). Прежние + // «Приход / Обязательства / К раскладке / Вне конвертов» он отверг: сводная + // строка обязательств прятала и сумму, и повод, а «вне конвертов» показывала + // 0 ฿ при аренде 18 000, потому что считала ФАКТ прошедших трат. + for _, want := range []string{"Пришло", "Курс", "Куда уйдут", "На день"} { if !strings.Contains(reply, want) { t.Errorf("в ответе нет обязательной строки %q:\n%s", want, reply) } } + // Знак валюты у каждой суммы (simpleAI-302i): без него колонку нельзя + // прочитать — баты там или рубли. + if !strings.Contains(reply, "฿") { + t.Errorf("в ответе нет знака валюты:\n%s", reply) + } // Повторный приход деактивирует прошлый конверт, а не плодит второй активный. if _, err := skill.Run(ctx, `{"action":"start_envelope","amount":50000,"currency":"RUB"}`); err != nil { diff --git a/internal/skills/budget/limits.go b/internal/skills/budget/limits.go index cab7bd8..1ffd55d 100644 --- a/internal/skills/budget/limits.go +++ b/internal/skills/budget/limits.go @@ -63,8 +63,10 @@ func (s *BudgetSkill) setShareLimit(ctx context.Context, req budgetInput) (strin slog.Default().InfoContext(ctx, "set_share_limit", "chat_id", chatID, "share", name, "amount", req.Amount, "currency", currency) - head := fmt.Sprintf("📌 Лимит на «%s» — %.0f %s. Запомнил: применю и к следующим приходам, пока не скажешь «убери лимит на %s».", - name, req.Amount, currency, name) + // Знак валюты, а не её код: оператор назвал сумму в рублях или батах, и + // «15000 RUB» посреди русской фразы читается хуже, чем «15 000 ₽». + head := fmt.Sprintf("📌 Лимит на «%s» — %s. Запомнил: применю и к следующим приходам, пока не скажешь «убери лимит на %s».", + name, safetospend.FmtAmount(req.Amount, currency), name) return head + s.replanTail(ctx, chatID, req), nil } @@ -182,6 +184,7 @@ func (s *BudgetSkill) replanActiveEnvelope(ctx context.Context, chatID int64, re History: history, Recurring: recurring, From: h.From, + To: h.To, }) s.attachCategoryIDs(ctx, plan.Shares) plan.Shares, err = s.keepCarriedIn(ctx, chatID, env.ID, plan.Shares) diff --git a/internal/skills/budget/rate.go b/internal/skills/budget/rate.go new file mode 100644 index 0000000..c80fd55 --- /dev/null +++ b/internal/skills/budget/rate.go @@ -0,0 +1,98 @@ +package budgetskill + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "simpleAI/internal/skills/safetospend" +) + +// rateCurrency — валюта, курс которой правится словами. +// +// Одна и только одна: rates хранит «₽ за 1 единицу», рубль в этой шкале равен +// единице по определению, а доллар и евро в конвертах не участвуют. Оператор +// живёт в батах и спрашивает про бат — «курс 2,7» без названия валюты значит +// именно его. +const rateCurrency = "THB" + +// maxPlausibleRate — потолок правдоподобия для курса ₽ за 1 ฿. Бат к рублю +// исторически держится в единицах (2–4). Значение выше почти наверняка сказано +// без запятой («курс 27» вместо «2,7»), и принять его молча значит раздуть все +// конверты в десять раз убедительно выглядящими числами. +const maxPlausibleRate = 10.0 + +// setRate задаёт ручной курс бата (simpleAI-su6l). +// +// Зачем это вообще: автокурс приходит из open.er-api.com — межбанк. Оператор +// меняет наличными в Тайланде, где курс другой, и все конверты считались по +// цифре, на которую он не мог повлиять никак, даже рестартом бота. +// +// Ручной курс живёт в отдельной колонке и переживает суточный тик воркера — +// иначе его затёрло бы в ближайшие 24 часа. +func (s *BudgetSkill) setRate(ctx context.Context, req budgetInput) (string, error) { + rate := req.Amount + if rate <= 0 { + return "Не понял курс. Скажи «курс 2,7» — сколько рублей за один бат.", nil + } + // Курс бата к рублю живёт в единицах, а не в десятках: «курс 27» — это почти + // наверняка «2,7», сказанное без запятой. Молча принять такое значение + // нельзя: конверты раздуются в десять раз и будут выглядеть правдоподобно. + if rate > maxPlausibleRate { + return fmt.Sprintf("Курс %s ₽/฿ — это в разы больше обычного (около 2,5). "+ + "Если правда столько, скажи ещё раз с десятыми: «курс %s».", + safetospend.FmtRate(rate), safetospend.FmtRate(rate/10)), nil + } + + if err := s.store.SetManualRate(ctx, rateCurrency, rate); err != nil { + slog.Default().ErrorContext(ctx, "set_rate", "err", err, "rate", rate) + return "Не удалось сохранить курс — попробуй ещё раз.", nil + } + slog.Default().InfoContext(ctx, "set_rate", "rate", rate, "currency", rateCurrency) + + return fmt.Sprintf("💱 Курс: %s ₽/฿ (вручную). Считаю по нему, пока не скажешь «курс авто».", + safetospend.FmtRate(rate)), nil +} + +// clearRate возвращает автоматический курс. Идемпотентно: «курс авто» без +// заданного ручного — не ошибка, а подтверждение того же состояния. +func (s *BudgetSkill) clearRate(ctx context.Context) (string, error) { + if err := s.store.ClearManualRate(ctx, rateCurrency); err != nil { + slog.Default().ErrorContext(ctx, "clear_rate", "err", err) + return "Не удалось вернуть автоматический курс — попробуй ещё раз.", nil + } + src, ok, err := s.store.GetRateSource(ctx, rateCurrency) + if err != nil || !ok { + slog.Default().WarnContext(ctx, "clear_rate: read back", "err", err, "found", ok) + return "💱 Вернул автоматический курс.", nil + } + return fmt.Sprintf("💱 Вернул автоматический курс %s ₽/฿ (обновлён %s).", + safetospend.FmtRate(src.RateToRUB), src.UpdatedAt.Local().Format("02.01 15:04")), nil +} + +// rateStatus — «какой сейчас курс». Отдельным ответом показывает, ручной он или +// автоматический: без этого оператор не отличит подействовавшую команду от +// проигнорированной. +func (s *BudgetSkill) rateStatus(ctx context.Context) (string, error) { + src, ok, err := s.store.GetRateSource(ctx, rateCurrency) + if err != nil { + slog.Default().ErrorContext(ctx, "rate_status", "err", err) + return "Не удалось получить курс — попробуй позже.", nil + } + if !ok { + return "Курса бата пока нет — он подтянется автоматически в ближайшие сутки.", nil + } + var b strings.Builder + if src.Manual { + fmt.Fprintf(&b, "💱 Курс: %s ₽/฿ (вручную, задан %s).", + safetospend.FmtRate(src.RateToRUB), src.UpdatedAt.Local().Format("02.01 15:04")) + fmt.Fprintf(&b, "\nАвтоматический сейчас %s ₽/฿ — скажи «курс авто», чтобы вернуться к нему.", + safetospend.FmtRate(src.Auto)) + return b.String(), nil + } + fmt.Fprintf(&b, "💱 Курс: %s ₽/฿ (автоматически, обновлён %s).", + safetospend.FmtRate(src.RateToRUB), src.UpdatedAt.Local().Format("02.01 15:04")) + b.WriteString("\nЕсли в обменнике другой — скажи «курс 2,7», буду считать по нему.") + return b.String(), nil +} diff --git a/internal/skills/budget/skill.go b/internal/skills/budget/skill.go index 1b9eb31..5f9e49f 100644 --- a/internal/skills/budget/skill.go +++ b/internal/skills/budget/skill.go @@ -60,6 +60,10 @@ func (s *BudgetSkill) Manifest() plugin.Manifest { "action='set_share_limit' CORRECTS the LIMIT of a category envelope by hand ('на еду хватит 15000', 'на транспорт закладывай 5000', 'лимит на развлечения 3000', 'ставь на еду 15000') — pass name=<категория>, amount, currency. " + "It is NOT a transaction: nothing was spent, the user is fixing the PLAN. The correction is remembered and applied to every following income until removed. " + "action='clear_share_limit' REMOVES that manual limit ('убери лимит на еду', 'сними лимит с транспорта', 'считай лимит на еду сам') — the limit goes back to being computed from spending history; pass name=<категория>. " + + "action='set_rate' SETS the ฿→₽ EXCHANGE RATE by hand ('курс 2,7', 'ставь курс 2.65', 'курс бата 2,7') — pass amount=