diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index bffb9f2..506f155 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -15,6 +15,7 @@ {"id":"simpleAI-23h","title":"epic/python-migration: [Фаза 3] ADR: контракт Go↔Python","description":"Родитель: simpleAI-0ma\nФаза: 3 — после фазы 2\nGitHub issue: #25\n\nПринять архитектурное решение и задокументировать в docs/adr/:\nВарианты:\nA. HTTP REST API: Go экспонирует /skills/* endpoint, Python вызывает\nB. Переезд целиком: Python сразу берёт всё на себя, Go отключается\nC. Shared DB: оба читают одну БД, Python постепенно берёт функции\n\nРекомендация для обсуждения: вариант B (чистый переезд).\nПричины:\n- Проект небольшой (один процесс), parallel run ненужно усложняет\n- Все данные в Postgres — Python подключается к той же БД\n- Миграции остаются в Go (goose) или переносятся в alembic\n\nВыход задачи:\n- docs/adr/NNNN-python-migration-strategy.md принят\n- Следующие фазы декомпозированы исходя из ADR","status":"open","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T11:29:49.956032+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T11:29:49.956032+07:00"} {"id":"simpleAI-24o","title":"RAG: retrieval и шаблоны промпта","description":"Эпик: RAG по тратам и чекам (V1). Retrieval по запросу с фильтрами + шаблоны промпта.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:08.2595+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T12:34:04.468178+07:00","closed_at":"2026-02-08T12:34:04.468178+07:00","close_reason":"Closed","comments":[{"id":47,"issue_id":"simpleAI-24o","author":"Taras Malinovskii","text":"WIP: добавлен retriever (vector search + фильтры) и BuildPrompt, cmd/rag-query для проверки.","created_at":"2026-02-08T05:32:11Z"},{"id":48,"issue_id":"simpleAI-24o","author":"Taras Malinovskii","text":"Готово: vector search + фильтры (receipt/category/time), BuildPrompt и cmd/rag-query для проверки.","created_at":"2026-02-08T05:34:15Z"}]} {"id":"simpleAI-25x","title":"fix(agent): return skill output verbatim, don't let LLM reformat","description":"LLM получает форматированный вывод скилла (formatSummary) и переписывает его в свободный текст, теряя форматирование. Фикс: изменить финальный промпт в service.go чтобы LLM возвращала результат инструмента без изменений.","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T13:57:58.931403+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T13:58:56.765486+07:00","closed_at":"2026-04-01T13:58:56.765486+07:00","close_reason":"Изменён финальный промпт в service.go: 'Сформулируй' → 'Верни дословно'. Файл: internal/agent/service.go:127"} +{"id":"simpleAI-27a5","title":"feat(budget): DigestSource — дайджест трат за вчера","description":"Дайджест трат за вчера в утреннем напоминании (эпик simpleAI-4vy, GH #22).\n\nАРХИТЕКТУРНЫЙ КОНТЕКСТ (verified @architect):\n- budget_transaction/budget_debt — ГЛОБАЛЬНЫЕ, без chat_id (store.go:996-998, 1089). Бюджет = single-household ledger by design.\n- ListTransactions: f.ChatID обязателен в Validate(), но в SQL WHERE НЕ участвует (forward-compat guard, не фильтр).\n- GetSummary(ctx, Period) глобальна намеренно — переиспользуем как есть, store НЕ меняем.\n- chatID в цикле напоминаний = получатель + таймзона окна «вчера», НЕ фильтр данных.\n\nВАЛЮТА ДАЙДЖЕСТА: БАТЫ (THB), не RUB. Внимание: вся остальная app агрегирует в RUB-экв (rubRates база RUB, THB=2.5 → 1 THB=2.5 RUB; format.go:14-19). Конверсия в THB: total_RUB / rates[\"THB\"]. Реюз toRUB() для сведения к RUB, затем делёж на курс THB. НЕ хардкодить 2.5 — брать из rates (GetExchangeRates с фолбэком на rubRates, как в callback.go:33-39).\n\nЭТА ЗАДАЧА: интерфейс notify.DigestSource.YesterdayDigest(ctx, chatID int64, loc *time.Location) (string, error). Реализация-адаптер (слой budget/skills, НЕ notify): day-Period «вчера» в loc → GetSummary → однострочный дайджест в THB-экв. notify остаётся развязан (как ReminderStore/ReminderSender).\n\nACCEPTANCE:\n- [ ] Интерфейс в notify; возвращает готовый текст (пусто = нечего показывать). notify не импортирует rates/budget-форматтеры — рендер в адаптере.\n- [ ] Адаптер строит day-Period «вчера» в переданном loc, зовёт существующий GetSummary (без chatID-фильтра — инвариант store.go:996).\n- [ ] Мультивалюта сведена к THB-экв: toRUB → /rates[THB]. Курс из GetExchangeRates с фолбэком rubRates, не хардкод.\n- [ ] Пустой вчерашний день → \"\" (не «потрачено 0»).\n- [ ] Формат строки: «💸 Вчера потрачено: ~N ฿» (символ бата ฿).\n- [ ] Юнит-тесты: непустой день, пустой день (\"\"), мультивалюта (THB-экв верный), граница tz (день в loc, не UTC).\n- [ ] go build ./... и go test ./... зелёные.\n\nФайлы: internal/skills/budget/format.go (toRUB:21, summaryTotalRUB:28, formatPeriodName:385), internal/skills/budget/callback.go (rates fetch:33), internal/budget/store.go (GetSummary:255), internal/budget/model.go (Summary/Period:128).","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-06-29T08:12:13.190599+07:00","created_by":"Taras Malinovskii","updated_at":"2026-06-29T17:47:21.462498+07:00","closed_at":"2026-06-29T17:47:21.462498+07:00","close_reason":"DigestSource дайджест трат за вчера (THB-экв). Файлы: internal/skills/budget/digest.go (DigestProvider.YesterdayDigest: day-Period вчера в loc → GetSummary глобально → summaryTotalTHB), digest_test.go (5 сценариев: non-empty/empty/multicurrency/tz-boundary/summary-error), internal/notify/reminder_worker.go (интерфейс DigestSource). Тесты: go test ./... зелёные, vet+golangci-lint 0 issues. /review PASS."} {"id":"simpleAI-28q","title":"feature/budget-other-comment-and-reminders: Ежедневные напоминания о покупках (миграция, store, skill, worker)","description":"Родитель: simpleAI-4vy\nВетка: feature/budget-other-comment-and-reminders\nФайлы: migrations/00010_budget_reminder.sql, internal/budget/model.go, internal/budget/store.go, internal/skills/budget_skill.go, internal/notify/reminder_worker.go, internal/agent/service.go, cmd/app/main.go\n\nGitHub issue #22.\n\n1. Миграция: таблица budget_reminder (chat_id, enabled, notify_hour, notify_minute, timezone)\n2. Model + Store: GetReminder, SetReminder, ListActiveReminders\n3. Skill: действия set_reminder, get_reminder (chat_id из context)\n4. Agent: inject chatID в context в AskWithMeta\n5. ReminderWorker: горутина, раз в минуту проверяет кому слать\n6. main.go: запустить worker\n\nAC: пользователь говорит 'включи напоминания в 21:00' — каждый день в 21:00 приходит уведомление.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T10:52:49.882599+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T11:07:53.851721+07:00","closed_at":"2026-04-01T11:07:53.851721+07:00","close_reason":"implemented: reminder migration, store methods, skill actions, ReminderWorker, wired in main.go"} {"id":"simpleAI-28v","title":"feat(budget): action top_expenses — топ-N самых дорогих трат","description":"Новый action top_expenses в BudgetSkill.\n\nВходные параметры: period (текущий месяц по умолчанию), limit (10 по умолчанию).\n\nВывод:\n 🔝 Топ трат за март:\n 1. 3500 ฿ — Прочее (полотенца Bamboo...) | 13.12\n 2. 324 ฿ — Еда (7-Eleven) | 18.01\n ...\n\nРеализация:\n- Новый метод store.TopTransactions(ctx, period, limit) — SELECT с ORDER BY amount DESC\n- Добавить action top_expenses в Manifest и switch в Run\n\nЗатрагиваемые файлы:\n- internal/budget/store.go (новый метод TopTransactions)\n- internal/skills/budget_skill.go (новый метод + formatTopExpenses)","status":"open","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T19:13:38.451598+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T19:13:38.451598+07:00"} {"id":"simpleAI-2eg","title":"fix(llm): fallback to Gemini on DeepSeek 503/transient errors","description":"DeepSeek периодически отдаёт 503 service_unavailable. Сейчас primary с ретраями (SDK auto-retry + outer LLM_RETRY_COUNT=2) выжирает parent-ctx 60s, после чего compositeClient.AskWithSystem видит ctx.Err()!=nil и пропускает Gemini. Бот висит 60s и возвращает deadline exceeded на каждое сообщение.\n\nЛоги подтверждают (2026-05-08 09:08-09:10): два запроса 'Получение зарплаты 133110р' и 'Добавь доход зарплата 133110' — оба duration_ms ~61000, error 'context deadline exceeded', skill_called не залогировано.\n\nAcceptance:\n- На 503/service_unavailable от primary — мгновенный fallback, без ретраев\n- На 429/прочие 5xx — поведение не регрессит\n- На сетевой timeout — ретрай или fallback, конфигурируемо\n- compositeClient пробует fallback пока parent-ctx жив, даже если primary отвалился по своему reqCtx\n- LLM_RETRY_COUNT по умолчанию 0 (был 2)\n- Unit-тесты: mock primary возвращает 503 → fallback вызван за \u003c2s\n- Лог: 'primary unavailable, fallback to \u003cprovider\u003e' с err code\n\nФайлы:\n- internal/adapters/llm/openai/client.go — option.WithMaxRetries(0), классификация 503\n- internal/adapters/llm/factory.go — compositeClient.AskWithSystem/Ask логика\n- config/config.go — default LLM_RETRY_COUNT=0\n\nDoD:\n- go test ./internal/adapters/llm/... → PASS\n- /review → PASS\n- Локально smoke: фейковый primary 503 → ответ от fallback \u003c 3s","notes":"Сделано:\n- openai/client.go: option.WithMaxRetries(0) на NewClient + NewFallbackClient (отключён auto-retry SDK на 503/429/5xx). Добавлен IsTransientUpstream(err) — классификатор по openai.Error.StatusCode (429, 503, 5xx). В askWithCustomSystem outer-loop делает break на transient — caller сразу получит управление.\n- factory.go: compositeClient.shouldFallback(ctx, err) централизует решение. На transient upstream идём на fallback пока parent-ctx жив. Добавлен logFallback с флагом transient_upstream для grep.\n- config.go: LLM_RETRY_COUNT default 0 (был 2). На сервере .env прибит к 2 — нужно либо удалить строку, либо выставить 0.\n\nТесты: go test ./internal/adapters/llm/... → PASS, новый TestComposite_503Primary_FastFallback ловит 503 от httptest и переключается на fb за \u003c2s. Полный go test ./... → PASS.\n\nSmoke на проде после деплоя: должны увидеть лог 'primary llm failed, switching to fallback' + 'transient_upstream=true' и ответ от Gemini пока DeepSeek в downtime.","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-08T16:40:33.177943+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-08T18:01:45.243915+07:00","closed_at":"2026-05-08T18:01:45.243915+07:00","close_reason":"fix(llm) fast-fallback Gemini on 503; merged to main commit a78ce8e; deploys via GH Actions"} @@ -83,6 +84,7 @@ {"id":"simpleAI-bna","title":"Почта: конфиг аккаунтов и Telegram","description":"Эпик: Агент почты (hourly). Конфиг аккаунтов, Telegram токен и chat_id.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:23.330521+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T10:23:32.71345+07:00","closed_at":"2026-02-08T10:23:32.71345+07:00","close_reason":"Closed","comments":[{"id":2,"issue_id":"simpleAI-bna","author":"Taras Malinovskii","text":"Готово: добавлен mail-конфиг (MAIL_ACCOUNTS_JSON + TELEGRAM_BOT_TOKEN/CHAT_ID) в config/config.go.","created_at":"2026-02-08T03:23:38Z"}]} {"id":"simpleAI-bqh","title":"Надежность LLM-клиента","description":"Усилить клиент LLM: таймауты, ретраи, обработка пустых ответов и тесты надежности.","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:02.341269+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T12:42:38.620972+07:00","closed_at":"2026-02-08T12:42:38.620972+07:00","close_reason":"Closed","comments":[{"id":55,"issue_id":"simpleAI-bqh","author":"Taras Malinovskii","text":"Готово: таймауты, ретраи и обработка пустых ответов в LLM клиенте.","created_at":"2026-02-08T05:42:44Z"}]} {"id":"simpleAI-bv3","title":"feat(budget): action patterns — паттерны трат по дням недели","description":"Новый action patterns в BudgetSkill.\n\nВходные параметры: period (текущий месяц по умолчанию).\n\nВывод:\n 📈 Паттерны трат за март:\n Самый дорогой день: суббота (avg 850 ฿)\n Самый дешёвый день: вторник (avg 120 ฿)\n Активнее всего тратишь: выходные (65% бюджета)\n\nРеализация:\n- Новый метод store.SpendByWeekday(ctx, period) — SELECT EXTRACT(DOW ...), SUM, COUNT GROUP BY DOW\n- Форматирование в skill\n\nЗатрагиваемые файлы:\n- internal/budget/store.go (новый метод SpendByWeekday)\n- internal/skills/budget_skill.go (новый метод patterns + formatPatterns)","status":"open","priority":3,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T19:13:50.158662+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T19:13:50.158662+07:00"} +{"id":"simpleAI-c2ud","title":"feat(budget): проводка дайджеста в ReminderWorker (non-fatal)","description":"Обогащение утреннего напоминания дайджестом за вчера (эпик simpleAI-4vy, GH #22). Зависит от simpleAI-27a5 (DigestSource).\n\nКОНТЕКСТ: ReminderWorker (internal/notify/reminder_worker.go) тикает раз в минуту, на NotifyHour:NotifyMinute per chat шлёт фикс-текст через ReminderStore+ReminderSender. В check() уже грузится loc из reminder.Timezone.\n\nЭТА ЗАДАЧА: добавить DigestSource в воркер, дописать дайджест к тексту.\n\nИНВАРИАНТ (жёстко): доставка напоминания НЕ зависит от дайджеста. Ошибка/пустой дайджест никогда не блокирует send.\n\nACCEPTANCE:\n- [ ] ReminderWorker принимает DigestSource через конструктор (как store/sender), точка сборки воркера (DI) обновлена.\n- [ ] В check() используется уже загруженный loc; текст = напоминание + (если дайджест непуст) \\n + дайджест.\n- [ ] Ошибка DigestSource → logger.Warn, отправляется напоминание БЕЗ дайджеста (send не блокируется).\n- [ ] Пустой дайджест → только напоминание, без висящего переноса строки.\n- [ ] Тест: error-degradation (мок-источник с ошибкой → напоминание всё равно ушло).\n- [ ] go test ./internal/notify/... зелёные.\n\nФайлы: internal/notify/reminder_worker.go (check:48, NewReminderWorker:29).","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-06-29T08:12:26.554853+07:00","created_by":"Taras Malinovskii","updated_at":"2026-06-29T18:14:40.705234+07:00","closed_at":"2026-06-29T18:14:40.705234+07:00","close_reason":"Проводка DigestSource в ReminderWorker (non-fatal). reminder_worker.go: DI digest в конструктор + аппенд дайджеста в check() (ошибка→Warn+продолжить, пусто→без \\n\\n, nil→только напоминание). main.go: сборка NewDigestProvider. reminder_worker_test.go: 6 сценариев (error-degradation ключевой, non-empty/empty/nil/outside-window). go test ./... зелёные, vet+golangci-lint 0 issues. /review PASS.","dependencies":[{"issue_id":"simpleAI-c2ud","depends_on_id":"simpleAI-27a5","type":"blocks","created_at":"2026-06-29T08:12:31.105434+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-c6k","title":"Telegram бот: настройка и запуск","description":"Основной фокус: запуск и проверка Telegram-бота как фасада системы агентов. Подготовить конфиг, инструкции запуска и обеспечить готовность к пользовательскому тестированию.","notes":"Приоритет P0 по запросу пользователя. Включить подготовку .env, проверку webhook/polling, сценарии теста, минимальную стабильность для пользовательского прогона.\nБерем в работу. Цель: подготовить запуск бота (polling), проверить конфиг, описать шаги для теста со стороны пользователя.\nГотово: добавлены инструкции запуска и чеклист теста (README/TECHNICAL_README), задача simpleAI-c6k.1 закрыта и в main. Пользователь может протестировать polling-бота через cmd/telegram/main.go.","status":"closed","priority":0,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T13:58:18.79907+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T15:45:23.033616+07:00","closed_at":"2026-02-15T15:45:23.033616+07:00","close_reason":"Closed"} {"id":"simpleAI-c6k.1","title":"Telegram бот: инструкция запуска и тест","description":"Подготовить понятные шаги запуска бота (polling) и чеклист для пользовательского тестирования.","notes":"Старт: готовлю инструкции запуска (polling) и чеклист теста для пользователя.\nДобавлены README/TECHNICAL_README: секция запуска Telegram бота (polling) + чеклист теста.","status":"closed","priority":0,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T14:00:38.518321+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T14:02:15.395353+07:00","closed_at":"2026-02-08T14:02:15.395353+07:00","close_reason":"README/TECHNICAL README добавлены: запуск Telegram бота и чеклист теста","dependencies":[{"issue_id":"simpleAI-c6k.1","depends_on_id":"simpleAI-c6k","type":"parent-child","created_at":"2026-02-08T14:00:38.519149+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-cfl","title":"feat(trace): логирование agentic loop в таблицу agent_trace","description":"Хотим видеть историю диалогов бота: что спросил пользователь, какие инструменты вызвал агент, что ответил.\n\nАрхитектурное решение (вариант C — Postgres):\n\nНовая таблица agent_trace:\n- id UUID PK\n- session_id UUID (генерируется на каждый user message)\n- chat_id BIGINT (Telegram chat, nullable)\n- user_input TEXT\n- iteration INT\n- skill TEXT (nullable — null для финального ответа)\n- skill_input JSONB (nullable)\n- skill_result TEXT (nullable)\n- llm_response TEXT\n- is_final BOOLEAN DEFAULT false\n- created_at TIMESTAMPTZ DEFAULT now()\n\nИзменения:\n1. migrations/00008_agent_traces.sql — создать таблицу + индексы (chat_id, session_id, created_at)\n2. internal/trace/store.go — TraceStore с методом Append(ctx, TraceEntry)\n3. internal/trace/model.go — тип TraceEntry\n4. internal/agent/service.go — принимать *trace.TraceStore опционально (nil = без логирования), логировать каждую итерацию и финальный ответ\n5. cmd/app/main.go — создать TraceStore, передать в NewServiceWithRegistry\n\nAcceptance:\n- После каждого сообщения пользователя в agent_trace появляются строки\n- SELECT * FROM agent_trace WHERE chat_id=X ORDER BY created_at DESC позволяет видеть историю\n- При TraceStore=nil поведение не меняется (нет паники, нет логов)","status":"closed","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T18:37:43.704032+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T18:46:06.776241+07:00","closed_at":"2026-03-09T18:46:06.776241+07:00","close_reason":"Implemented and committed on fix/simpleAI-z1u-cfl-dedup-tracing"} @@ -160,7 +162,7 @@ {"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-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":"in_progress","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-12T08:26:36.814039+07:00"} +{"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"}]} @@ -174,7 +176,7 @@ {"id":"simpleAI-vn8","title":"CI: Open-source readiness","description":"Улучшение CI/CD с фокусом на open-source: стабильные версии, кэширование, проверки и отчеты.","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T12:08:06.652291+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T12:12:20.46019+07:00","closed_at":"2026-02-08T12:12:20.46019+07:00","close_reason":"Closed","comments":[{"id":36,"issue_id":"simpleAI-vn8","author":"Taras Malinovskii","text":"Готово: go-version-file, cache, go-test workflow, golangci-lint timeout.","created_at":"2026-02-08T05:12:27Z"}]} {"id":"simpleAI-wr7","title":"EPIC: Eval Suite — golden-set routing/advisor + harness + baseline","description":"## Цель\nRegression suite чтобы изменения промптов давали измеримый сигнал «лучше/хуже», а не «пальцем в небо». Триггер — баг simpleAI-q49 («хочу купить блендер» → budget.add_expense вместо advisor): фикс закоммичен, но без бенча мы не знаем сколько таких кейсов осталось и не сломаем ли мы что-то ещё через неделю.\n\n## Что меряем (по приоритету)\n1. **Routing accuracy** (P0 для эпика): {user_input → expected_skill, expected_action}. Прогон через agent.Service.Ask, парсинг tool calls, сравнение. Метрики: accuracy total, per-skill breakdown, regressed/improved cases vs baseline.\n2. **Advisor verdict accuracy** (P1): {snapshot+question → expected_verdict (Да/Нет/Условно)}. Mock store, реальный LLM call.\n3. **Budget action parsing** (P2): {input → expected action+params}. По мере обнаружения багов.\n\n## Готовый референс\n**На work-хосте /home/malinovskiy.t/GolandProjects/assister/evals/:**\n- `golden_set.jsonl` — 13 retrieval кейсов\n- `run_eval.py` — harness внутри docker-контейнера бота\n- `metrics.py` — hit@k, recall@k, summary с failed_ids\n- `runs/\u003ctimestamp\u003e_\u003chash\u003e.jsonl` — результаты прогонов\n- README — мотивация, архитектура, инфраструктура (см. evals/README.md в assister)\n\nСкопировать паттерн (jsonl golden + harness + metrics + timestamped runs).\n\n## Архитектура (предложение)\n```\nevals/\n├── README.md\n├── golden_set.jsonl # routing кейсы: {id, input, expected_skill, expected_action, tags}\n├── advisor_set.jsonl # advisor кейсы (отдельно — нужен mock snapshot)\n├── run_routing.go # CLI: bd-style. Прогоняет golden через agent.Service с моком LLM (через configurable client)\n├── run_advisor.go # отдельный harness\n├── metrics.go # accuracy, per-skill, diff prev/current\n└── runs/\u003ctimestamp\u003e.jsonl\n```\n\nRun прогоняем через РЕАЛЬНЫЙ LLM (не мок), потому что мы валидируем именно поведение модели.\n\n## Подзадачи\nA. Структура каталога + ADR с конвенцией eval-кейсов\nB. Routing harness + metrics\nC. Первый golden_set: ≥20 кейсов routing (включая «хочу купить блендер», past-tense, summary, forecast, edge cases)\nD. Baseline run + фиксация результата\nE. (опционально) Advisor harness + golden\n\n## Definition of Done эпика\n- Подзадачи A-D закрыты с /review = PASS\n- `go run ./evals/cmd/routing` (или Makefile target) выводит accuracy + список фейлов\n- README.md в evals/ описывает: как добавить кейс, как прогнать, как читать diff\n- Baseline зафиксирован в runs/\n\n## Контекст багов которые должны ловиться\n- simpleAI-q49: «хочу купить блендер» → advisor (не budget.add_expense)\n- Past-tense «купил кофе 200» → budget.add_expense\n- «сколько потратил» → budget.summary\n- «прогноз на конец месяца» → budget.forecast","status":"closed","priority":1,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-05-05T08:31:44.415311+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-06T09:33:24.614655+07:00","closed_at":"2026-05-06T09:33:24.614655+07:00","close_reason":"DoD выполнен: подзадачи A-D закрыты (cts/85c/578/lpy), evals/README.md описывает процесс, baseline-2026-05-06.jsonl + BASELINE.md в runs/, go run ./evals/cmd/routing работает (accuracy 0.83 на 24 кейсах). Optional E (advisor harness) — отдельный эпик при необходимости. Разблокирует refactor-эпики 5mi/ckb/2vw.","dependencies":[{"issue_id":"simpleAI-wr7","depends_on_id":"simpleAI-cts","type":"blocks","created_at":"2026-05-05T08:33:17.446407+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-wr7","depends_on_id":"simpleAI-85c","type":"blocks","created_at":"2026-05-05T08:33:17.588776+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-wr7","depends_on_id":"simpleAI-578","type":"blocks","created_at":"2026-05-05T08:33:17.732944+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-wr7","depends_on_id":"simpleAI-lpy","type":"blocks","created_at":"2026-05-05T08:33:17.875186+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-ws8","title":"Financial Advisor Skill (LLM-reasoning)","description":"LLM-reasoning skill для советов о покупках, приоритезации, проверки affordability.\n\nПотребляет budget snapshot (transactions/debt/recurring) + forecast через budget.Store, один LLM-call со структурированным JSON-ответом, формат Markdown в Telegram. Auto-routing через manifest, без явной команды.\n\nСпека: spec/advisor/{proposal,requirements,acceptance_criteria,constraints,verification,plan.yaml}.md\n\nАрхитектура:\n- ADR-001: Forecast Architecture (потребляется через GetForecastData)\n- ADR-002: LLM-reasoning skills as separate plugins (обосновывает выделение AdvisorSkill отдельным plugin'ом)\n\nScope: см. spec/advisor/proposal.md.\nOut of scope: RAG (#43 отдельная итерация), команда /advice, conversation history, multi-tenant per-chat бюджет.\n\nDefinition of Done:\n- 4 task закрыты с /review = PASS\n- ADR-002 в статусе Accepted (готово)\n- Manual smoke на dev: 4 сценария (happy/low_data/RUB-сумма/нет курса)","notes":"Спека готова: spec/advisor/. ADR-002 (Accepted, локально в exclude). Tasks: blg → cyb → yvq → rvs. Старт: simpleAI-blg (snapshot layer).","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T12:34:57.397403+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-06T09:33:38.138639+07:00","closed_at":"2026-05-06T09:33:38.138639+07:00","close_reason":"Все 4 подзадачи закрыты с /review=PASS (blg/cyb/yvq/rvs). ADR-002 Accepted. Manual smoke на dev отложен пользователем (закрытие по запросу — будет тестить дальше).","dependencies":[{"issue_id":"simpleAI-ws8","depends_on_id":"simpleAI-blg","type":"blocks","created_at":"2026-05-04T12:36:03.101112+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-ws8","depends_on_id":"simpleAI-cyb","type":"blocks","created_at":"2026-05-04T12:36:03.217305+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-ws8","depends_on_id":"simpleAI-yvq","type":"blocks","created_at":"2026-05-04T12:36:03.339487+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-ws8","depends_on_id":"simpleAI-rvs","type":"blocks","created_at":"2026-05-04T12:36:03.459177+07:00","created_by":"Taras Malinovskii"}]} -{"id":"simpleAI-wse4","title":"refactor(budget): расширить TransactionFilter — multi-category, amount range, currencies, pagination, sort","description":"Рефактор TransactionFilter: Period→From/To, CategoryID→Categories[]uuid, добавить Currencies[]string, MinAmount/MaxAmount *float64, Offset int, SortBy/SortDir string. Обновить ListTransactions в store.go и caller в skills/budget/transactions.go.","status":"in_progress","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-09T09:48:51.19524+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-09T09:48:53.144923+07:00"} +{"id":"simpleAI-wse4","title":"refactor(budget): расширить TransactionFilter — multi-category, amount range, currencies, pagination, sort","description":"Рефактор TransactionFilter: Period→From/To, CategoryID→Categories[]uuid, добавить Currencies[]string, MinAmount/MaxAmount *float64, Offset int, SortBy/SortDir string. Обновить ListTransactions в store.go и caller в skills/budget/transactions.go.","status":"open","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-09T09:48:51.19524+07:00","created_by":"Taras Malinovskii","updated_at":"2026-06-29T17:41:45.741119+07:00"} {"id":"simpleAI-xes","title":"Telegram-бот как фасад системы","description":"Основной транспорт и окно общения с агентами. Требуется масштабируемый и расширяемый бот с модульной архитектурой, поддержкой разных каналов ввода и очередей.","status":"closed","priority":1,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:27:59.330639+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T11:49:59.462994+07:00","closed_at":"2026-02-08T11:49:59.462994+07:00","close_reason":"Closed","comments":[{"id":23,"issue_id":"simpleAI-xes","author":"Taras Malinovskii","text":"Progress: архитектура и интеграция бота закрыты (simpleAI-30u, simpleAI-fkn). В работе: входные каналы, масштабирование, наблюдаемость.","created_at":"2026-02-08T04:43:29Z"},{"id":31,"issue_id":"simpleAI-xes","author":"Taras Malinovskii","text":"Готово: базовый Telegram-бот (архитектура, интеграция с агентом, входные каналы, масштабирование и наблюдаемость v1).","created_at":"2026-02-08T04:49:59Z"}]} {"id":"simpleAI-xl0","title":"Агент почты (hourly)","description":"Сбор почты по аккаунтам, LLM-категоризация, дайджест и отправка в Telegram.","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:21.222899+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T11:28:33.758076+07:00","closed_at":"2026-02-08T11:27:52.997351+07:00","comments":[{"id":6,"issue_id":"simpleAI-xl0","author":"Taras Malinovskii","text":"Mail epic: bna/zru/sa5/95t/ys2/oae выполнены, ветка epic/mail-agent запушена. Готово к следующему эпику (Telegram фасад).","created_at":"2026-02-08T04:09:44Z"}]} {"id":"simpleAI-ynf","title":"Ingestion: нормализация позиций","description":"Эпик: Ingestion pipeline. Нормализация позиций, итогов и валюты.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:15.063224+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T12:30:23.05966+07:00","closed_at":"2026-02-08T12:30:23.05966+07:00","close_reason":"Closed","comments":[{"id":45,"issue_id":"simpleAI-ynf","author":"Taras Malinovskii","text":"WIP: добавлена нормализация позиций (amount/unit_price/quantity вычисляются, если один из параметров отсутствует).","created_at":"2026-02-08T05:29:40Z"},{"id":46,"issue_id":"simpleAI-ynf","author":"Taras Malinovskii","text":"Готово: нормализация позиций (вычисление amount/unit_price/quantity при наличии двух параметров).","created_at":"2026-02-08T05:30:29Z"}]} diff --git a/cmd/app/main.go b/cmd/app/main.go index dabeaf8..adfc805 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -118,7 +118,8 @@ func main() { defer wg.Done() budgetStore := budget.NewStore(pool) tg := notify.NewTelegram(cfg.Telegram.Token, "") - worker := notify.NewReminderWorker(budgetStore, tg, logger) + digest := budgetskill.NewDigestProvider(budgetStore) + worker := notify.NewReminderWorker(budgetStore, tg, digest, logger) logger.Info("reminder worker started") worker.Run(ctx) }() diff --git a/internal/notify/reminder_worker.go b/internal/notify/reminder_worker.go index 09d13e9..50d290c 100644 --- a/internal/notify/reminder_worker.go +++ b/internal/notify/reminder_worker.go @@ -18,16 +18,25 @@ type ReminderSender interface { SendToChatID(ctx context.Context, chatID int64, text string) error } +// DigestSource строит дайджест трат за вчера для напоминания. +// Реализуется в budget/skills (DigestProvider); notify знает только контракт. +// Пустая строка = показывать нечего. Проводка в воркер — задача simpleAI-c2ud. +type DigestSource interface { + YesterdayDigest(ctx context.Context, chatID int64, loc *time.Location) (string, error) +} + // ReminderWorker каждую минуту проверяет, кому нужно отправить напоминание. type ReminderWorker struct { store ReminderStore sender ReminderSender + digest DigestSource logger *slog.Logger } // NewReminderWorker создаёт воркер напоминаний. -func NewReminderWorker(store ReminderStore, sender ReminderSender, logger *slog.Logger) *ReminderWorker { - return &ReminderWorker{store: store, sender: sender, logger: logger} +// digest опционален (nil допустим) — без него шлётся только напоминание. +func NewReminderWorker(store ReminderStore, sender ReminderSender, digest DigestSource, logger *slog.Logger) *ReminderWorker { + return &ReminderWorker{store: store, sender: sender, digest: digest, logger: logger} } // Run запускает цикл опроса. Блокируется до отмены ctx. @@ -60,6 +69,15 @@ func (w *ReminderWorker) check(ctx context.Context, now time.Time) { local := now.In(loc) if local.Hour() == r.NotifyHour && local.Minute() == r.NotifyMinute { text := "👋 Привет! Не забудь внести сегодняшние покупки и траты." + // Дайджест за вчера — non-fatal: ошибка/пусто не блокирует напоминание. + if w.digest != nil { + d, err := w.digest.YesterdayDigest(ctx, r.ChatID, loc) + if err != nil { + w.logger.Warn("reminder worker: digest failed", "chat_id", r.ChatID, "err", err) + } else if d != "" { + text += "\n\n" + d + } + } if err := w.sender.SendToChatID(ctx, r.ChatID, text); err != nil { w.logger.Error("reminder worker: send failed", "chat_id", r.ChatID, "err", err) } diff --git a/internal/notify/reminder_worker_test.go b/internal/notify/reminder_worker_test.go new file mode 100644 index 0000000..5cf0655 --- /dev/null +++ b/internal/notify/reminder_worker_test.go @@ -0,0 +1,116 @@ +package notify + +import ( + "context" + "errors" + "io" + "log/slog" + "strings" + "testing" + "time" + + "simpleAI/internal/budget" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +type stubReminderStore struct { + reminders []budget.Reminder +} + +func (s *stubReminderStore) ListActiveReminders(_ context.Context) ([]budget.Reminder, error) { + return s.reminders, nil +} + +type captureSender struct { + sent []string +} + +func (c *captureSender) SendToChatID(_ context.Context, _ int64, text string) error { + c.sent = append(c.sent, text) + return nil +} + +type stubDigest struct { + out string + err error +} + +func (s stubDigest) YesterdayDigest(_ context.Context, _ int64, _ *time.Location) (string, error) { + return s.out, s.err +} + +// now, попадающее в окно напоминания (09:00 UTC). +func windowNow() time.Time { return time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC) } + +func workerWith(digest DigestSource) (*ReminderWorker, *captureSender) { + store := &stubReminderStore{reminders: []budget.Reminder{ + {ChatID: 1, Enabled: true, NotifyHour: 9, NotifyMinute: 0, Timezone: "UTC"}, + }} + sender := &captureSender{} + return NewReminderWorker(store, sender, digest, discardLogger()), sender +} + +// Ошибка дайджеста не блокирует доставку: напоминание уходит без дайджеста. +func TestCheck_DigestError_StillSendsReminder(t *testing.T) { + w, sender := workerWith(stubDigest{err: errors.New("boom")}) + w.check(context.Background(), windowNow()) + + if len(sender.sent) != 1 { + t.Fatalf("want 1 message sent, got %d", len(sender.sent)) + } + if !strings.Contains(sender.sent[0], "Не забудь") { + t.Fatalf("reminder text missing: %q", sender.sent[0]) + } + if strings.Contains(sender.sent[0], "Вчера потрачено") { + t.Fatalf("digest must be absent on error: %q", sender.sent[0]) + } +} + +// Непустой дайджест добавляется к напоминанию. +func TestCheck_DigestNonEmpty_Appended(t *testing.T) { + w, sender := workerWith(stubDigest{out: "💸 Вчера потрачено: ~1000 ฿"}) + w.check(context.Background(), windowNow()) + + if len(sender.sent) != 1 { + t.Fatalf("want 1 message, got %d", len(sender.sent)) + } + if !strings.Contains(sender.sent[0], "Не забудь") || !strings.Contains(sender.sent[0], "1000 ฿") { + t.Fatalf("want reminder + digest, got %q", sender.sent[0]) + } +} + +// Пустой дайджест → только напоминание, без висящего переноса строки. +func TestCheck_DigestEmpty_NoTrailingNewline(t *testing.T) { + w, sender := workerWith(stubDigest{out: ""}) + w.check(context.Background(), windowNow()) + + if len(sender.sent) != 1 { + t.Fatalf("want 1 message, got %d", len(sender.sent)) + } + if strings.HasSuffix(sender.sent[0], "\n") { + t.Fatalf("empty digest must not append newline: %q", sender.sent[0]) + } +} + +// nil digest (источник не сконфигурирован) → только напоминание. +func TestCheck_NilDigest_SendsReminder(t *testing.T) { + w, sender := workerWith(nil) + w.check(context.Background(), windowNow()) + + if len(sender.sent) != 1 { + t.Fatalf("want 1 message, got %d", len(sender.sent)) + } +} + +// Вне окна напоминания ничего не отправляется. +func TestCheck_OutsideWindow_NoSend(t *testing.T) { + w, sender := workerWith(stubDigest{out: "x"}) + w.check(context.Background(), time.Date(2026, 6, 29, 10, 30, 0, 0, time.UTC)) + + if len(sender.sent) != 0 { + t.Fatalf("want no messages outside window, got %d", len(sender.sent)) + } +} diff --git a/internal/skills/budget/digest.go b/internal/skills/budget/digest.go new file mode 100644 index 0000000..41cf4a2 --- /dev/null +++ b/internal/skills/budget/digest.go @@ -0,0 +1,91 @@ +package budgetskill + +import ( + "context" + "fmt" + "time" + + "simpleAI/internal/budget" +) + +// nowFunc — источник текущего времени, подменяется в тестах для проверки +// границы таймзоны. +var nowFunc = time.Now + +// digestStore — узкий контракт стора для дайджеста (тестируемость). +// *budget.Store его удовлетворяет. +type digestStore interface { + GetSummary(ctx context.Context, p budget.Period) (*budget.Summary, error) + GetExchangeRates(ctx context.Context) (map[string]float64, error) +} + +// DigestProvider строит текстовый дайджест трат за вчера для напоминаний. +// Реализует notify.DigestSource структурно (метод YesterdayDigest), что держит +// пакет notify развязанным: рендер и конверсия валют живут здесь (budget/skills), +// notify знает только сигнатуру интерфейса. +type DigestProvider struct { + store digestStore +} + +// NewDigestProvider создаёт провайдер дайджеста. +func NewDigestProvider(store digestStore) *DigestProvider { + return &DigestProvider{store: store} +} + +// YesterdayDigest возвращает однострочный дайджест трат за вчера в THB-экв. +// +// chatID НЕ участвует в выборке данных: бюджет — глобальный single-household +// ledger (budget_transaction без chat_id, store.go GetSummary). chatID и loc +// определяют только календарный день «вчера» в таймзоне получателя. +// +// Пустой день (нет трат) → "" (вызывающий не показывает блок). +func (d *DigestProvider) YesterdayDigest(ctx context.Context, chatID int64, loc *time.Location) (string, error) { + if loc == nil { + loc = time.UTC + } + _ = chatID // global ledger: chatID только для адресации, не фильтр данных + + // Календарный день «вчера» берём в loc, диапазон якорим UTC-midnight — + // transaction_date это DATE, важна только дата (консистентно с query.go). + y := nowFunc().In(loc).AddDate(0, 0, -1) + p := budget.Period{ + From: time.Date(y.Year(), y.Month(), y.Day(), 0, 0, 0, 0, time.UTC), + To: time.Date(y.Year(), y.Month(), y.Day(), 23, 59, 59, 0, time.UTC), + } + + sum, err := d.store.GetSummary(ctx, p) + if err != nil { + return "", fmt.Errorf("digest: get summary: %w", err) + } + + totalTHB := summaryTotalTHB(sum, d.rates(ctx)) + if totalTHB <= 0 { + return "", nil + } + return fmt.Sprintf("💸 Вчера потрачено: ~%.0f ฿", totalTHB), nil +} + +// rates возвращает курсы из стора с фолбэком на rubRates (как в callback.go). +func (d *DigestProvider) rates(ctx context.Context) map[string]float64 { + rates, err := d.store.GetExchangeRates(ctx) + if err != nil || len(rates) == 0 { + return rubRates + } + for k, v := range rubRates { + if _, ok := rates[k]; !ok { + rates[k] = v + } + } + return rates +} + +// summaryTotalTHB сводит расходы сводки к THB-экв. +// Все валюты → RUB (summaryTotalRUB), затем делёж на курс THB→RUB. +// Курс THB берётся из rates с фолбэком на rubRates; защита от нуля. +func summaryTotalTHB(s *budget.Summary, rates map[string]float64) float64 { + thbRate := rates["THB"] + if thbRate <= 0 { + thbRate = rubRates["THB"] + } + return summaryTotalRUB(s, rates) / thbRate +} diff --git a/internal/skills/budget/digest_test.go b/internal/skills/budget/digest_test.go new file mode 100644 index 0000000..5fc4b75 --- /dev/null +++ b/internal/skills/budget/digest_test.go @@ -0,0 +1,126 @@ +package budgetskill + +import ( + "context" + "strings" + "testing" + "time" + + "simpleAI/internal/budget" +) + +// mockDigestStore — фейк digestStore: фиксированная сводка + захват Period. +type mockDigestStore struct { + summary *budget.Summary + rates map[string]float64 + gotPeriod budget.Period + summaryErr error +} + +func (m *mockDigestStore) GetSummary(_ context.Context, p budget.Period) (*budget.Summary, error) { + m.gotPeriod = p + if m.summaryErr != nil { + return nil, m.summaryErr + } + return m.summary, nil +} + +func (m *mockDigestStore) GetExchangeRates(_ context.Context) (map[string]float64, error) { + return m.rates, nil +} + +func expenseSummary(groups ...budget.CurrencyGroup) *budget.Summary { + return &budget.Summary{Currencies: groups} +} + +// withFixedNow подменяет nowFunc на время для проверки и восстанавливает. +func withFixedNow(t *testing.T, now time.Time) { + t.Helper() + prev := nowFunc + nowFunc = func() time.Time { return now } + t.Cleanup(func() { nowFunc = prev }) +} + +// Непустой день в RUB → строка с символом ฿ и THB-эквивалентом (RUB/2.5). +func TestYesterdayDigest_NonEmpty(t *testing.T) { + store := &mockDigestStore{ + summary: expenseSummary(budget.CurrencyGroup{Currency: "RUB", TotalExpense: 2500}), + rates: map[string]float64{"RUB": 1, "THB": 2.5}, + } + out, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, time.UTC) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + // 2500 RUB / 2.5 = 1000 ฿ + if !strings.Contains(out, "฿") || !strings.Contains(out, "1000") { + t.Fatalf("want THB total 1000 ฿, got %q", out) + } +} + +// Пустой день (нет трат) → "". +func TestYesterdayDigest_Empty(t *testing.T) { + store := &mockDigestStore{ + summary: expenseSummary(), + rates: map[string]float64{"RUB": 1, "THB": 2.5}, + } + out, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, time.UTC) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if out != "" { + t.Fatalf("empty day must yield \"\", got %q", out) + } +} + +// Мультивалюта сводится к THB-экв: RUB напрямую, THB как есть, USD через RUB. +func TestYesterdayDigest_MultiCurrency(t *testing.T) { + store := &mockDigestStore{ + summary: expenseSummary( + budget.CurrencyGroup{Currency: "RUB", TotalExpense: 250}, // /2.5 = 100 ฿ + budget.CurrencyGroup{Currency: "THB", TotalExpense: 300}, // = 300 ฿ + budget.CurrencyGroup{Currency: "USD", TotalExpense: 10}, // *82/2.5 = 328 ฿ + ), + rates: map[string]float64{"RUB": 1, "THB": 2.5, "USD": 82}, + } + out, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, time.UTC) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + // 100 + 300 + 328 = 728 ฿ + if !strings.Contains(out, "728") { + t.Fatalf("want 728 ฿, got %q", out) + } +} + +// Граница таймзоны: «вчера» считается в loc, не в UTC. +func TestYesterdayDigest_TimezoneBoundary(t *testing.T) { + // 18:00 UTC 2026-06-29 == 01:00 2026-06-30 в Asia/Bangkok (+7). + withFixedNow(t, time.Date(2026, 6, 29, 18, 0, 0, 0, time.UTC)) + bangkok, err := time.LoadLocation("Asia/Bangkok") + if err != nil { + t.Fatalf("load tz: %v", err) + } + store := &mockDigestStore{ + summary: expenseSummary(budget.CurrencyGroup{Currency: "THB", TotalExpense: 100}), + rates: map[string]float64{"RUB": 1, "THB": 2.5}, + } + if _, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, bangkok); err != nil { + t.Fatalf("unexpected err: %v", err) + } + // В Бангкоке сейчас 30 июня → вчера = 29 июня. В UTC было бы 28 июня. + if got := store.gotPeriod.From.Day(); got != 29 { + t.Fatalf("yesterday in loc must be day 29, got day %d (%s)", got, store.gotPeriod.From) + } +} + +// Пустая строка при ошибке GetSummary (вызывающий деградирует non-fatal). +func TestYesterdayDigest_SummaryError(t *testing.T) { + store := &mockDigestStore{summaryErr: context.DeadlineExceeded, rates: map[string]float64{"THB": 2.5}} + out, err := NewDigestProvider(store).YesterdayDigest(context.Background(), 1, time.UTC) + if err == nil { + t.Fatal("want error from GetSummary") + } + if out != "" { + t.Fatalf("error must yield empty string, got %q", out) + } +}