From 815777da44731bd78b30bcb1ee5264a1b1ad6dd9 Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Thu, 23 Jul 2026 18:16:42 +0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(budget):=20safe-to-spend=20=E2=80=94?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B1=D0=BE=D0=B4=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BE=D0=BA=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=85=D0=BE=D0=B4=D0=B0=20+=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый read-only skill safe_to_spend (ADR-007): по приходу из сообщения и горизонту (по умолчанию 2 недели) детерминированно считает свободный остаток после обязательств и разбивку ожидаемых переменных трат по категориям — приход НЕ пишется в ledger. LLM только нарратив/советы, числа считаются в Go. - Store: GetPeriodSnapshot (произвольный период, chat-scoped), GetRegularMonthlyIncomeAvg (чистый якорь без разовых), planned_expense и envelope CRUD, миграции 00015/00016. - budget actions add_planned_expense, start_envelope; режим остатка по конверту (остаток производный из фактических трат, не хранится). - Доменные помощники budget: ResolveHorizon, ToTHB, классификация категорий (consumption / variable-daily / one-off income) — единый источник. - Вывод: вердикт-first (можно/нельзя отложить), явный курс, раскрытые плановые. --- internal/budget/categories.go | 42 +++ internal/budget/convert.go | 19 ++ internal/budget/envelope_test.go | 59 ++++ internal/budget/model.go | 62 ++-- internal/budget/period.go | 60 ++++ internal/budget/period_snapshot_test.go | 82 +++++ internal/budget/planned_expense_test.go | 45 +++ internal/budget/regular_income_test.go | 44 +++ internal/budget/store.go | 288 ++++++++++++++++++ .../00015_budget_planned_expense.sql | 25 ++ .../db/migrations/00016_budget_envelope.sql | 26 ++ internal/prompts/prompts.go | 2 +- internal/prompts/safetospend/narrate.tmpl | 13 + internal/skills/budget/envelope.go | 34 +++ internal/skills/budget/planned.go | 32 ++ internal/skills/budget/skill.go | 12 +- internal/skills/safetospend/compute.go | 131 ++++++++ internal/skills/safetospend/compute_test.go | 129 ++++++++ internal/skills/safetospend/config.go | 22 ++ internal/skills/safetospend/format.go | 121 ++++++++ internal/skills/safetospend/remaining.go | 77 +++++ internal/skills/safetospend/skill.go | 216 +++++++++++++ internal/skills/safetospend/skill_test.go | 101 ++++++ 23 files changed, 1617 insertions(+), 25 deletions(-) create mode 100644 internal/budget/categories.go create mode 100644 internal/budget/convert.go create mode 100644 internal/budget/envelope_test.go create mode 100644 internal/budget/period.go create mode 100644 internal/budget/period_snapshot_test.go create mode 100644 internal/budget/planned_expense_test.go create mode 100644 internal/budget/regular_income_test.go create mode 100644 internal/db/migrations/00015_budget_planned_expense.sql create mode 100644 internal/db/migrations/00016_budget_envelope.sql create mode 100644 internal/prompts/safetospend/narrate.tmpl create mode 100644 internal/skills/budget/envelope.go create mode 100644 internal/skills/budget/planned.go create mode 100644 internal/skills/safetospend/compute.go create mode 100644 internal/skills/safetospend/compute_test.go create mode 100644 internal/skills/safetospend/config.go create mode 100644 internal/skills/safetospend/format.go create mode 100644 internal/skills/safetospend/remaining.go create mode 100644 internal/skills/safetospend/skill.go create mode 100644 internal/skills/safetospend/skill_test.go diff --git a/internal/budget/categories.go b/internal/budget/categories.go new file mode 100644 index 0000000..332a2ed --- /dev/null +++ b/internal/budget/categories.go @@ -0,0 +1,42 @@ +package budget + +// Классификация категорий — единый доменный источник (OCP: правило расширяется +// добавлением в набор, а не правкой логики в скиллах). + +// nonConsumptionCategories — категории «движения денег», а не потребления: +// переводы, погашение кредитов/долгов. Их НЕ включают в прогноз «съедаемых» +// трат и в остаток конверта — иначе двойной учёт обязательств (ADR-007 §4). +var nonConsumptionCategories = map[string]bool{ + "Переводы": true, "переводы": true, + "Кредит": true, "кредит": true, + "Долг": true, "долг": true, +} + +// IsConsumptionCategory — true для потребительских категорий (еда, транспорт…), +// прогноз/факт которых имеет смысл вычитать из свободных денег. +func IsConsumptionCategory(category string) bool { + return !nonConsumptionCategories[category] +} + +// fixedExpenseCategories — предсказуемые ФИКСИРОВАННЫЕ траты (аренда, подписки, +// коммуналка). Пользователь их знает и обычно учитывает как recurring, поэтому +// в прогноз «переменных ежедневных трат, которые нельзя учесть» они НЕ входят +// (иначе прогноз раздувается арендой и уводит остаток в ложный минус). +var fixedExpenseCategories = map[string]bool{ + "Жильё": true, "жильё": true, "аренда": true, "Аренда": true, + "Подписки": true, "подписки": true, + "коммунальные услуги": true, "Коммунальные услуги": true, + "водоснабжение": true, +} + +// IsVariableDailyExpense — true для ПЕРЕМЕННЫХ ежедневных трат (еда, транспорт, +// кафе, развлечения…): потребительская И не фиксированная. Это то, что +// пользователь не может заранее учесть и где реально экономить. +func IsVariableDailyExpense(category string) bool { + return IsConsumptionCategory(category) && !fixedExpenseCategories[category] +} + +// OneOffIncomeCategories — категории разовых поступлений (при recurring_id IS +// NULL), исключаемых из якоря РЕГУЛЯРНОГО дохода (ADR-007 §8). Единый источник: +// используется как параметр SQL в GetRegularMonthlyIncomeAvg. +var OneOffIncomeCategories = []string{"Прочее", ""} diff --git a/internal/budget/convert.go b/internal/budget/convert.go new file mode 100644 index 0000000..40ee4d1 --- /dev/null +++ b/internal/budget/convert.go @@ -0,0 +1,19 @@ +package budget + +// ToTHB конвертирует amount из currency в THB через rates (currency → rate_to_rub): +// +// thb = amount * rates[currency] / rates["THB"] +// +// Единый источник конверсии для всех skill'ов (устраняет разбросанные инлайны). +// Возвращает ok=false, если нет курса THB или валюты amount. +func ToTHB(amount float64, currency string, rates map[string]float64) (float64, bool) { + thbRate, ok := rates["THB"] + if !ok || thbRate == 0 { + return 0, false + } + rate, ok := rates[currency] + if !ok || rate == 0 { + return 0, false + } + return amount * rate / thbRate, true +} diff --git a/internal/budget/envelope_test.go b/internal/budget/envelope_test.go new file mode 100644 index 0000000..e7537fc --- /dev/null +++ b/internal/budget/envelope_test.go @@ -0,0 +1,59 @@ +package budget + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestEnvelope_Integration проверяет CreateEnvelope/GetActiveEnvelope, правило +// «один активный на chat» и chat-scope изоляцию (ADR-007 T4). Требует WRITE — +// BOTCLIENT_DATABASE_URL_RW (owner-роль на реплике); иначе пропуск. +func TestEnvelope_Integration(t *testing.T) { + 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) + } + defer pool.Close() + s := NewStore(pool) + + const chatA, chatB = int64(-70001), int64(-70002) + cleanup := func() { + if _, err := pool.Exec(ctx, "DELETE FROM budget_envelope WHERE chat_id IN ($1,$2)", chatA, chatB); err != nil { + t.Logf("cleanup budget_envelope: %v", err) + } + } + cleanup() + defer cleanup() + + from := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + to := from.AddDate(0, 0, 14) + if _, err := s.CreateEnvelope(ctx, chatA, 127000, "RUB", from, to); err != nil { + t.Fatalf("create: %v", err) + } + + // Правило одного активного: второй create деактивирует первый. + if _, err := s.CreateEnvelope(ctx, chatA, 50000, "RUB", from, to); err != nil { + t.Fatalf("create 2: %v", err) + } + env, ok, err := s.GetActiveEnvelope(ctx, chatA) + if err != nil || !ok { + t.Fatalf("get active: ok=%v err=%v", ok, err) + } + if env.IncomeAmount != 50000 { + t.Errorf("активным должен быть последний конверт (50000), got %.0f", env.IncomeAmount) + } + + // chat-scope: у chatB конверта нет. + if _, ok, err := s.GetActiveEnvelope(ctx, chatB); err != nil || ok { + t.Errorf("chat-scope нарушен: у chatB найден конверт (ok=%v)", ok) + } +} diff --git a/internal/budget/model.go b/internal/budget/model.go index 1313b44..fcbc25f 100644 --- a/internal/budget/model.go +++ b/internal/budget/model.go @@ -42,7 +42,6 @@ const ( SortByCreatedAt TransactionSortField = "created_at" ) - // SortDirection — направление сортировки. type SortDirection string @@ -251,12 +250,12 @@ type Reminder struct { // CategoryForecast — прогноз трат по одной категории в одной валюте на следующий период. // Используется как граница интерфейса для будущего ForecastSkill (ADR-001). type CategoryForecast struct { - CategoryName string - Icon string - Currency string - ForecastAmount float64 // среднее за последние N месяцев (Этап 1) - TrendPct float64 // (last_month - prev_month) / prev_month * 100 - HasTrend bool // false если данных только за 1 месяц + CategoryName string + Icon string + Currency string + ForecastAmount float64 // среднее за последние N месяцев (Этап 1) + TrendPct float64 // (last_month - prev_month) / prev_month * 100 + HasTrend bool // false если данных только за 1 месяц } // MinTxForConfidence — минимум транзакций в текущем месяце для уверенного прогноза. @@ -282,6 +281,27 @@ type AdvisorSnapshot struct { LowData bool // TxCount < MinTxForConfidence (порог в пакете skills) } +// PlannedExpense — ручная разовая плановая трата (ADR-007 §6). +type PlannedExpense struct { + ID uuid.UUID + ChatID int64 + Amount float64 + Currency string + Description string +} + +// Envelope — сохранённый «живой конверт» (ADR-007 H3): событие прихода + горизонт. +// Остаток НЕ хранится — вычисляется из фактических транзакций за период. +type Envelope struct { + ID uuid.UUID + ChatID int64 + IncomeAmount float64 + IncomeCurrency string + PeriodStart time.Time + PeriodEnd time.Time + CreatedAt time.Time +} + // TopExpense — одна из топ-N самых дорогих расходных транзакций за период, // сконвертированная в THB. Используется AdvisorSkill action='analyze' для // передачи LLM детализированного среза трат. @@ -295,20 +315,20 @@ type TopExpense struct { // RecurringPayment — повторяющийся платёж, создающий транзакцию автоматически по расписанию. type RecurringPayment struct { - ID uuid.UUID - ChatID int64 - Name string - Type string // "expense" | "income" - Amount float64 - CategoryID *uuid.UUID - CategoryName string - Currency string // ISO 4217 - RecurrenceType string // "monthly" | "weekly" | "daily" - DayOfMonth *int // для monthly: день месяца (1–31) - NextDate time.Time // следующая дата срабатывания - Enabled bool - CreatedAt time.Time - UpdatedAt time.Time + ID uuid.UUID + ChatID int64 + Name string + Type string // "expense" | "income" + Amount float64 + CategoryID *uuid.UUID + CategoryName string + Currency string // ISO 4217 + RecurrenceType string // "monthly" | "weekly" | "daily" + DayOfMonth *int // для monthly: день месяца (1–31) + NextDate time.Time // следующая дата срабатывания + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time } // TransactionPatch — поля для частичного обновления транзакции. diff --git a/internal/budget/period.go b/internal/budget/period.go new file mode 100644 index 0000000..24af99e --- /dev/null +++ b/internal/budget/period.go @@ -0,0 +1,60 @@ +package budget + +import ( + "fmt" + "strings" + "time" +) + +// DefaultHorizonDays — горизонт расчёта по умолчанию, когда период не задан: +// интервал между приходами ≤ 2 недель (safe-to-spend / живой конверт). +const DefaultHorizonDays = 14 + +// Horizon — период расчёта плюс человекочитаемая метка. Единый источник границ +// для safe_to_spend и конверта (устраняет дублирование резолверов периода). +type Horizon struct { + Period + Label string +} + +// Days — число дней в периоде включительно (минимум 1). +func (h Horizon) Days() int { + d := int(h.To.Sub(h.From).Hours()/24) + 1 + if d < 1 { + d = 1 + } + return d +} + +// ResolveHorizon разрешает строку периода в границы и метку: +// - ”/'2weeks'/'2w' → [now, now+defaultDays] +// - 'month' → [now, конец текущего месяца] +// - 'YYYY-MM' → весь тот месяц +// +// defaultDays делает горизонт по умолчанию параметром, а не хардкодом. +func ResolveHorizon(period string, now time.Time, defaultDays int) Horizon { + switch strings.TrimSpace(strings.ToLower(period)) { + case "", "2weeks", "2w": + return Horizon{Period{From: now, To: now.AddDate(0, 0, defaultDays)}, humanDays(defaultDays)} + case "month": + end := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, -1) + return Horizon{Period{From: now, To: end}, "до конца месяца"} + } + if t, err := time.Parse("2006-01", period); err == nil { + start := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) + return Horizon{Period{From: start, To: start.AddDate(0, 1, -1)}, period} + } + return Horizon{Period{From: now, To: now.AddDate(0, 0, defaultDays)}, humanDays(defaultDays)} +} + +// humanDays форматирует горизонт по умолчанию человекочитаемо. +func humanDays(days int) string { + if days%7 != 0 { + return fmt.Sprintf("ближайшие %d дней", days) + } + weeks := days / 7 + if weeks == 1 { + return "ближайшую неделю" + } + return fmt.Sprintf("ближайшие %d недели", weeks) +} diff --git a/internal/budget/period_snapshot_test.go b/internal/budget/period_snapshot_test.go new file mode 100644 index 0000000..e723ebe --- /dev/null +++ b/internal/budget/period_snapshot_test.go @@ -0,0 +1,82 @@ +package budget + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestGetPeriodSnapshot_InvalidRange — чистый юнит без БД: to раньше from → +// ошибка ДО запроса (pool не трогается). +func TestGetPeriodSnapshot_InvalidRange(t *testing.T) { + s := &Store{} // pool не используется на этом пути + from := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + if _, err := s.GetPeriodSnapshot(context.Background(), 0, from, to, map[string]float64{"THB": 2.6}); err == nil { + t.Fatal("ожидалась ошибка при to < from") + } +} + +// TestGetPeriodSnapshot_Integration проверяет границы периода и chat-scope на +// реальной реплике. Пропускается, если BOTCLIENT_DATABASE_URL не задан. +// +// set -a; . ~/.simpleai-replica/botclient.env; set +a +// go test ./internal/budget/ -run TestGetPeriodSnapshot_Integration -v +func TestGetPeriodSnapshot_Integration(t *testing.T) { + url := os.Getenv("BOTCLIENT_DATABASE_URL") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL не задан — реплика не поднята") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + s := NewStore(pool) + rates := map[string]float64{"RUB": 1.0, "THB": 2.6, "USD": 90, "EUR": 100} + + const chatID = 420229961 // владелец (есть recurring) + day := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC) + next := day.AddDate(0, 0, 1) + + // Плотный день 2026-03-09: транзакции есть. + onDay, err := s.GetPeriodSnapshot(ctx, chatID, day, day, rates) + if err != nil { + t.Fatalf("snapshot on-day: %v", err) + } + if onDay.TxCount == 0 { + t.Fatalf("ожидались транзакции на 2026-03-09, TxCount=0") + } + + // Граница: тот же день НЕ попадает в период [след.день, след.день]. + nextDay, err := s.GetPeriodSnapshot(ctx, chatID, next, next, rates) + if err != nil { + t.Fatalf("snapshot next-day: %v", err) + } + if nextDay.TxCount >= onDay.TxCount { + t.Errorf("граница периода: TxCount след.дня (%d) не должен включать транзакции 03-09 (%d)", nextDay.TxCount, onDay.TxCount) + } + + // chat-scope: recurring фильтруется по chatID. У владельца recurring есть, + // у несуществующего chat — нет. + wide := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + far := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC) + owner, err := s.GetPeriodSnapshot(ctx, chatID, wide, far, rates) + if err != nil { + t.Fatalf("snapshot owner: %v", err) + } + stranger, err := s.GetPeriodSnapshot(ctx, 999999999, wide, far, rates) + if err != nil { + t.Fatalf("snapshot stranger: %v", err) + } + if owner.UpcomingRecurring == 0 { + t.Errorf("у владельца ожидались recurring-обязательства, получено 0") + } + if stranger.UpcomingRecurring != 0 { + t.Errorf("chat-scope нарушен: у чужого chatID recurring=%.2f, ожидался 0", stranger.UpcomingRecurring) + } +} diff --git a/internal/budget/planned_expense_test.go b/internal/budget/planned_expense_test.go new file mode 100644 index 0000000..e66baf3 --- /dev/null +++ b/internal/budget/planned_expense_test.go @@ -0,0 +1,45 @@ +package budget + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestPlannedExpensesTHB_Integration проверяет сумму и chat-scope изоляцию +// плановых трат на реплике (сид: 6 разовых для chat 420229961 = 71200₽). +// Пропускается без BOTCLIENT_DATABASE_URL. +func TestPlannedExpensesTHB_Integration(t *testing.T) { + url := os.Getenv("BOTCLIENT_DATABASE_URL") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL не задан — реплика не поднята") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + s := NewStore(pool) + rates := map[string]float64{"RUB": 1.0, "THB": 2.6} + + // Владелец: плановые есть (71200₽ / 2.6 в THB). + sumTHB, cnt, err := s.PlannedExpensesTHB(ctx, 420229961, rates) + if err != nil { + t.Fatalf("owner planned: %v", err) + } + if cnt == 0 || sumTHB <= 0 { + t.Fatalf("у владельца ожидались плановые траты, got sum=%.2f cnt=%d", sumTHB, cnt) + } + + // chat-scope: у чужого chatID плановых нет. + strangerTHB, strangerCnt, err := s.PlannedExpensesTHB(ctx, 999999999, rates) + if err != nil { + t.Fatalf("stranger planned: %v", err) + } + if strangerCnt != 0 || strangerTHB != 0 { + t.Errorf("chat-scope нарушен: у чужого chatID plannedTHB=%.2f cnt=%d", strangerTHB, strangerCnt) + } +} diff --git a/internal/budget/regular_income_test.go b/internal/budget/regular_income_test.go new file mode 100644 index 0000000..b2ca71f --- /dev/null +++ b/internal/budget/regular_income_test.go @@ -0,0 +1,44 @@ +package budget + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestGetRegularMonthlyIncomeAvg_Integration (ADR-007 §8, баг №3): чистый якорь +// дохода исключает разовые поступления и потому МЕНЬШЕ загрязнённого среднего. +// Пропускается без BOTCLIENT_DATABASE_URL. +func TestGetRegularMonthlyIncomeAvg_Integration(t *testing.T) { + url := os.Getenv("BOTCLIENT_DATABASE_URL") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL не задан — реплика не поднята") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + s := NewStore(pool) + rates := map[string]float64{"RUB": 1.0, "THB": 2.6, "USD": 90, "EUR": 100} + + polluted, err := s.GetMonthlyIncomeAvg(ctx, rates) + if err != nil { + t.Fatalf("polluted avg: %v", err) + } + regular, err := s.GetRegularMonthlyIncomeAvg(ctx, rates) + if err != nil { + t.Fatalf("regular avg: %v", err) + } + if regular <= 0 { + t.Fatalf("чистый якорь должен быть > 0, got %.2f", regular) + } + // Разовые поступления (Март «Прочее» 126000, Апрель без категории 152126) + // раздувают загрязнённое среднее → чистое строго меньше. + if !(regular < polluted) { + t.Errorf("чистый якорь (%.0f THB) должен быть меньше загрязнённого (%.0f THB)", regular, polluted) + } +} diff --git a/internal/budget/store.go b/internal/budget/store.go index 9342cdb..0a31f1f 100644 --- a/internal/budget/store.go +++ b/internal/budget/store.go @@ -2,6 +2,7 @@ package budget import ( "context" + "errors" "fmt" "log/slog" "sort" @@ -9,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -849,6 +851,197 @@ func (s *Store) GetMonthlyIncomeAvg(ctx context.Context, rates map[string]float6 return sum / float64(len(byMonth)), nil } +// GetRegularMonthlyIncomeAvg — как GetMonthlyIncomeAvg, но исключает РАЗОВЫЕ +// поступления (ADR-007 §8, фикс бага №3): income с recurring_id IS NULL И +// категорией из denylist (Прочее / без категории) в среднее не входит. Это +// снимает раздувание якоря дохода одноразовыми поступлениями +// (Март «Прочее» 126000, Апрель без категории 152126). GetMonthlyIncomeAvg +// оставлен без изменений — используется в affordability. +func (s *Store) GetRegularMonthlyIncomeAvg(ctx context.Context, rates map[string]float64) (float64, error) { + thbRate, ok := rates["THB"] + if !ok || thbRate == 0 { + return 0, fmt.Errorf("GetRegularMonthlyIncomeAvg: THB rate missing") + } + + rows, err := s.pool.Query(ctx, ` + SELECT t.currency, date_trunc('month', t.transaction_date) AS month, SUM(t.amount) AS total + FROM budget_transaction t + LEFT JOIN budget_category c ON c.id = t.category_id + WHERE t.type = 'income' + AND t.transaction_date < date_trunc('month', NOW()) + AND NOT (t.recurring_id IS NULL AND COALESCE(c.name, '') = ANY($1)) + GROUP BY t.currency, month + `, OneOffIncomeCategories) + if err != nil { + return 0, fmt.Errorf("GetRegularMonthlyIncomeAvg: %w", err) + } + defer rows.Close() + + byMonth := map[time.Time]float64{} + for rows.Next() { + var currency string + var month time.Time + var total float64 + if err := rows.Scan(¤cy, &month, &total); err != nil { + return 0, fmt.Errorf("GetRegularMonthlyIncomeAvg scan: %w", err) + } + rubRate, ok := rates[currency] + if !ok || rubRate == 0 { + continue + } + byMonth[month] += total * rubRate / thbRate + } + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("GetRegularMonthlyIncomeAvg rows: %w", err) + } + if len(byMonth) == 0 { + return 0, nil + } + var sum float64 + for _, v := range byMonth { + sum += v + } + return sum / float64(len(byMonth)), nil +} + +// AddPlannedExpense добавляет ручную разовую плановую трату (ADR-007 §6). +func (s *Store) AddPlannedExpense(ctx context.Context, chatID int64, amount float64, currency, description string) error { + if amount <= 0 { + return fmt.Errorf("AddPlannedExpense: amount must be > 0") + } + if currency == "" { + currency = "RUB" + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO budget_planned_expense (chat_id, amount, currency, description) + VALUES ($1, $2, $3, $4) + `, chatID, amount, currency, description) + if err != nil { + return fmt.Errorf("AddPlannedExpense: %w", err) + } + return nil +} + +// ListPlannedExpenses — незакрытые плановые траты chat'а (chat-scoped, ADR-004), +// отсортированные по убыванию суммы. Для детализации «на что заложено». +func (s *Store) ListPlannedExpenses(ctx context.Context, chatID int64) ([]PlannedExpense, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, chat_id, amount, currency, description + FROM budget_planned_expense + WHERE chat_id = $1 AND settled = false + ORDER BY amount DESC + `, chatID) + if err != nil { + return nil, fmt.Errorf("ListPlannedExpenses: %w", err) + } + defer rows.Close() + + var out []PlannedExpense + for rows.Next() { + var p PlannedExpense + if err := rows.Scan(&p.ID, &p.ChatID, &p.Amount, &p.Currency, &p.Description); err != nil { + return nil, fmt.Errorf("ListPlannedExpenses scan: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +// PlannedExpensesTHB — сумма НЕзакрытых плановых трат chat'а, в THB, и их число. +// chat-scoped (ADR-004). Валюта без курса — запись пропускается со slog.Warn. +func (s *Store) PlannedExpensesTHB(ctx context.Context, chatID int64, rates map[string]float64) (float64, int, error) { + thbRate, ok := rates["THB"] + if !ok || thbRate == 0 { + return 0, 0, fmt.Errorf("PlannedExpensesTHB: THB rate missing") + } + rows, err := s.pool.Query(ctx, ` + SELECT currency, SUM(amount)::float8, COUNT(*)::int + FROM budget_planned_expense + WHERE chat_id = $1 AND settled = false + GROUP BY currency + `, chatID) + if err != nil { + return 0, 0, fmt.Errorf("PlannedExpensesTHB: %w", err) + } + defer rows.Close() + + var totalTHB float64 + var count int + for rows.Next() { + var currency string + var sum float64 + var cnt int + if err := rows.Scan(¤cy, &sum, &cnt); err != nil { + return 0, 0, fmt.Errorf("PlannedExpensesTHB scan: %w", err) + } + rubRate, ok := rates[currency] + if !ok || rubRate == 0 { + slog.Warn("PlannedExpensesTHB: skipping, no rate", "currency", currency) + continue + } + totalTHB += sum * rubRate / thbRate + count += cnt + } + if err := rows.Err(); err != nil { + return 0, 0, fmt.Errorf("PlannedExpensesTHB rows: %w", err) + } + return totalTHB, count, nil +} + +// CreateEnvelope сохраняет новый активный конверт, деактивируя предыдущий +// активный для этого chat (не более одного активного). ADR-007 H3. +func (s *Store) CreateEnvelope(ctx context.Context, chatID int64, incomeAmount float64, currency string, from, to time.Time) (uuid.UUID, error) { + if incomeAmount <= 0 { + return uuid.Nil, fmt.Errorf("CreateEnvelope: income must be > 0") + } + if currency == "" { + currency = "RUB" + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return uuid.Nil, fmt.Errorf("CreateEnvelope begin: %w", err) + } + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + slog.Warn("CreateEnvelope rollback", "err", rbErr) + } + }() + + if _, err := tx.Exec(ctx, `UPDATE budget_envelope SET active = false WHERE chat_id = $1 AND active`, chatID); err != nil { + return uuid.Nil, fmt.Errorf("CreateEnvelope deactivate: %w", err) + } + var id uuid.UUID + err = tx.QueryRow(ctx, ` + INSERT INTO budget_envelope (chat_id, income_amount, income_currency, period_start, period_end) + VALUES ($1, $2, $3, $4, $5) RETURNING id + `, chatID, incomeAmount, currency, from, to).Scan(&id) + if err != nil { + return uuid.Nil, fmt.Errorf("CreateEnvelope insert: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return uuid.Nil, fmt.Errorf("CreateEnvelope commit: %w", err) + } + return id, nil +} + +// GetActiveEnvelope возвращает активный конверт chat'а (ok=false если нет). +func (s *Store) GetActiveEnvelope(ctx context.Context, chatID int64) (*Envelope, bool, error) { + var e Envelope + err := s.pool.QueryRow(ctx, ` + SELECT id, chat_id, income_amount, income_currency, period_start, period_end, created_at + FROM budget_envelope + WHERE chat_id = $1 AND active + LIMIT 1 + `, chatID).Scan(&e.ID, &e.ChatID, &e.IncomeAmount, &e.IncomeCurrency, &e.PeriodStart, &e.PeriodEnd, &e.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, fmt.Errorf("GetActiveEnvelope: %w", err) + } + return &e, true, nil +} + func (s *Store) getMonthlyExpenses(ctx context.Context, months int) ([]MonthlyCategoryExpense, error) { var fromClause string var args []any @@ -1072,6 +1265,67 @@ UNION ALL SELECT kind, subtype, currency, category, total, cnt FROM recurring_agg ` +// periodSnapshotQuery — аналог advisorSnapshotQuery, но период задаётся явными +// границами [$1::date, $2::date] (включительно с обеих сторон), а не date_trunc +// от месяца. Обязательства (debt.due_date, recurring.next_date) берутся с верхней +// границей <= period_end — та же семантика, что в месячном снапшоте (FreeCash), +// чтобы не расходиться с уже работающей формулой. $3 — chat_id для recurring. +const periodSnapshotQuery = ` +WITH params AS ( + SELECT $1::date AS period_start, $2::date AS period_end +), +tx_agg AS ( + SELECT + 'tx'::text AS kind, + t.type AS subtype, + t.currency AS currency, + COALESCE(c.name, 'Прочее') AS category, + SUM(t.amount)::float8 AS total, + COUNT(*)::int AS cnt + FROM budget_transaction t + LEFT JOIN budget_category c ON c.id = t.category_id + CROSS JOIN params p + WHERE t.transaction_date >= p.period_start + AND t.transaction_date <= p.period_end + GROUP BY t.type, t.currency, COALESCE(c.name, 'Прочее') +), +debt_agg AS ( + SELECT + 'debt'::text AS kind, + ''::text AS subtype, + 'RUB'::text AS currency, + ''::text AS category, + COALESCE(SUM(d.total_amount - d.paid_amount), 0)::float8 AS total, + COUNT(*)::int AS cnt + FROM budget_debt d + CROSS JOIN params p + WHERE d.status = 'active' + AND d.direction = 'owe' + AND d.due_date IS NOT NULL + AND d.due_date <= p.period_end +), +recurring_agg AS ( + SELECT + 'recurring'::text AS kind, + r.type AS subtype, + r.currency AS currency, + ''::text AS category, + SUM(r.amount)::float8 AS total, + COUNT(*)::int AS cnt + FROM budget_recurring r + CROSS JOIN params p + WHERE r.chat_id = $3 + AND r.enabled = true + AND r.next_date <= p.period_end + GROUP BY r.type, r.currency +) +SELECT kind, subtype, currency, category, total, cnt FROM tx_agg +UNION ALL +SELECT kind, subtype, currency, category, total, cnt FROM debt_agg WHERE cnt > 0 +UNION ALL +SELECT kind, subtype, currency, category, total, cnt FROM recurring_agg +` + // GetAdvisorSnapshot собирает финансовый снимок для AdvisorSkill одним SQL CTE. // Все суммы конвертируются в THB через rates (map[currency]rate_to_rub). // @@ -1103,6 +1357,40 @@ func (s *Store) GetAdvisorSnapshot(ctx context.Context, chatID int64, today time return aggregateAdvisorSnapshot(collected, rates) } +// GetPeriodSnapshot собирает финансовый снимок за ПРОИЗВОЛЬНЫЙ период +// [from, to] (включительно), а не за календарный месяц. Все суммы в THB. +// Переиспользует aggregateAdvisorSnapshot (период-агностична). +// +// - from, to: границы периода включительно (по transaction_date) +// - chatID: фильтр для budget_recurring; transaction и debt — глобальные +// - rates: map currency → rate_to_rub, должна содержать "THB" +// +// Обязательства (recurring.next_date, debt.due_date) учитываются с верхней +// границей <= to (та же семантика, что в месячном GetAdvisorSnapshot). +func (s *Store) GetPeriodSnapshot(ctx context.Context, chatID int64, from, to time.Time, rates map[string]float64) (*AdvisorSnapshot, error) { + if to.Before(from) { + return nil, fmt.Errorf("GetPeriodSnapshot: to (%s) раньше from (%s)", to.Format("2006-01-02"), from.Format("2006-01-02")) + } + rows, err := s.pool.Query(ctx, periodSnapshotQuery, from, to, chatID) + if err != nil { + return nil, fmt.Errorf("period snapshot query: %w", err) + } + defer rows.Close() + + var collected []advisorRow + for rows.Next() { + var r advisorRow + if err := rows.Scan(&r.Kind, &r.Subtype, &r.Currency, &r.Category, &r.Total, &r.Cnt); err != nil { + return nil, fmt.Errorf("scan period row: %w", err) + } + collected = append(collected, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("period rows: %w", err) + } + return aggregateAdvisorSnapshot(collected, rates) +} + // topExpenseQuery — топ-N самых дорогих расходов за месяц (определённый через // date_trunc от $1::date), глобально по всем chat_id (как budget_transaction). // Сортировка по amount DESC в исходной валюте — не идеально для multi-currency, diff --git a/internal/db/migrations/00015_budget_planned_expense.sql b/internal/db/migrations/00015_budget_planned_expense.sql new file mode 100644 index 0000000..f75db3e --- /dev/null +++ b/internal/db/migrations/00015_budget_planned_expense.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin +-- Ручные разовые плановые траты (ADR-007 §Действия задача 6): пользователь +-- заранее фиксирует «в этом периоде будет трата X на Y», чтобы safe_to_spend +-- вычитал их из свободных денег. chat-scoped (ADR-004 изоляция). +CREATE TABLE budget_planned_expense ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_id BIGINT NOT NULL, + amount NUMERIC(12,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'RUB', + description TEXT NOT NULL DEFAULT '', + due_date DATE, + settled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE INDEX idx_budget_planned_expense_chat + ON budget_planned_expense(chat_id) + WHERE settled = false; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS budget_planned_expense; +-- +goose StatementEnd diff --git a/internal/db/migrations/00016_budget_envelope.sql b/internal/db/migrations/00016_budget_envelope.sql new file mode 100644 index 0000000..8492561 --- /dev/null +++ b/internal/db/migrations/00016_budget_envelope.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- +goose StatementBegin +-- Живой конверт (ADR-007 H3): сохранённое событие прихода + горизонт. Остаток +-- НЕ хранится — вычисляется на лету из фактических транзакций за период +-- (структурная защита от двойного учёта). chat-scoped (ADR-004). +CREATE TABLE budget_envelope ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_id BIGINT NOT NULL, + income_amount NUMERIC(12,2) NOT NULL, + income_currency TEXT NOT NULL DEFAULT 'RUB', + period_start DATE NOT NULL, + period_end DATE NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- Не более одного активного конверта на chat. +CREATE UNIQUE INDEX idx_budget_envelope_active_chat + ON budget_envelope(chat_id) + WHERE active; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS budget_envelope; +-- +goose StatementEnd diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go index 159549c..ce9d02b 100644 --- a/internal/prompts/prompts.go +++ b/internal/prompts/prompts.go @@ -8,7 +8,7 @@ import ( "path/filepath" ) -//go:embed all:advisor +//go:embed all:advisor all:safetospend var fs embed.FS // Get returns the content of the named template (e.g. "advisor/advice.tmpl"). diff --git a/internal/prompts/safetospend/narrate.tmpl b/internal/prompts/safetospend/narrate.tmpl new file mode 100644 index 0000000..324d06f --- /dev/null +++ b/internal/prompts/safetospend/narrate.tmpl @@ -0,0 +1,13 @@ +Ты финансовый помощник. У пользователя есть свободные деньги на период, но +бытовые траты (продукты, транспорт, кафе и пр.) съедят часть. Ниже — разбивка +ожидаемых трат по категориям. Дай 2-3 КОНКРЕТНЫХ совета, ГДЕ и КАК урезать +именно эти категории, чтобы сэкономить (адресно к самым крупным статьям). + +НЕ придумывай и НЕ повторяй числа — суммы уже показаны пользователю. Только +текстовые советы, каждый с новой строки, без нумерации. + +Свободно после известных (справочно, ₽): %.0f +Ожидаемые бытовые траты за период (справочно, ₽): %.0f +Разбивка по категориям: %s + +Ответ — только строки-советы, ничего лишнего. diff --git a/internal/skills/budget/envelope.go b/internal/skills/budget/envelope.go new file mode 100644 index 0000000..c7aef1f --- /dev/null +++ b/internal/skills/budget/envelope.go @@ -0,0 +1,34 @@ +package budgetskill + +import ( + "context" + "fmt" + "time" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" +) + +// startEnvelope сохраняет «живой конверт» (ADR-007 H3): приход + горизонт. +// Остаток не хранится — считается позже из фактических трат (safe_to_spend). +// Границы горизонта — единый budget.ResolveHorizon (без дублирования резолвера). +func (s *BudgetSkill) startEnvelope(ctx context.Context, req budgetInput) (string, error) { + chatID, ok := ctx.Value(agent.ChatIDKey{}).(int64) + if !ok || chatID == 0 { + return "Не удалось определить чат — попробуй ещё раз.", nil + } + if req.Amount <= 0 { + return "", fmt.Errorf("amount must be positive") + } + currency := req.Currency + if currency == "" { + currency = "RUB" + } + + h := budget.ResolveHorizon(req.Period, time.Now(), budget.DefaultHorizonDays) + if _, err := s.store.CreateEnvelope(ctx, chatID, req.Amount, currency, h.From, h.To); err != nil { + return "", err + } + return fmt.Sprintf("🧧 Конверт создан: приход %.0f %s на горизонт %s.\nСпроси «сколько свободно осталось?» в любой момент — пересчитаю по фактическим тратам.", + req.Amount, currency, h.Label), nil +} diff --git a/internal/skills/budget/planned.go b/internal/skills/budget/planned.go new file mode 100644 index 0000000..1cb167e --- /dev/null +++ b/internal/skills/budget/planned.go @@ -0,0 +1,32 @@ +package budgetskill + +import ( + "context" + "fmt" + + "simpleAI/internal/agent" +) + +// addPlannedExpense записывает ручную разовую плановую трату (ADR-007 §6). +// chat-scoped. Используется safe_to_spend для точного свободного остатка. +func (s *BudgetSkill) addPlannedExpense(ctx context.Context, req budgetInput) (string, error) { + chatID, ok := ctx.Value(agent.ChatIDKey{}).(int64) + if !ok || chatID == 0 { + return "Не удалось определить чат — попробуй ещё раз.", nil + } + if req.Amount <= 0 { + return "", fmt.Errorf("amount must be positive") + } + currency := req.Currency + if currency == "" { + currency = "RUB" + } + if err := s.store.AddPlannedExpense(ctx, chatID, req.Amount, currency, req.Description); err != nil { + return "", err + } + suffix := "" + if req.Description != "" { + suffix = " на " + req.Description + } + return fmt.Sprintf("📝 Плановая трата записана: %.0f %s%s", req.Amount, currency, suffix), nil +} diff --git a/internal/skills/budget/skill.go b/internal/skills/budget/skill.go index 8aa00d2..bd94ec1 100644 --- a/internal/skills/budget/skill.go +++ b/internal/skills/budget/skill.go @@ -33,14 +33,16 @@ func (s *BudgetSkill) WithBuckets(cfg BucketConfig) (*BudgetSkill, error) { // Manifest возвращает описание скилла для registry и LLM. func (s *BudgetSkill) Manifest() plugin.Manifest { return plugin.Manifest{ - ID: "budget", - Name: "Budget Tracker", + ID: "budget", + Name: "Budget Tracker", Description: "Personal finance TRACKER: record COMPLETED expenses/income (past tense: 'купил', 'потратил', 'заплатил', 'получил'), get spending summaries, manage savings goals, debts, and recurring payments. " + "Use when user RECORDS a transaction or asks to SHOW data (summary, list, forecast, debt status). " + "action='summary' shows aggregate totals for a period. Pass transaction_type='income' when user asks ONLY about income ('покажи доходы за <период>', 'сколько я заработал'); pass transaction_type='expense' when user asks ONLY about expenses ('покажи траты / расходы за <период>'); omit transaction_type for a general overview ('итоги за <период>', 'сводка'). " + "action='list_transactions' shows individual transaction entries; pass transaction_type='income' or 'expense' when the user explicitly asks to LIST income or expense entries ('перечисли все доходы за апрель'). " + "Do NOT use for purchase advice / affordability questions / planning a future purchase ('планирую купить', 'хочу купить', 'стоит ли', 'можем ли позволить', 'хватит ли денег') — use the advisor skill for those. " + "Do NOT use for free-form spending analysis / anomalies / trends / savings advice ('проанализируй траты', 'найди аномалии', 'обзор трат', 'дай советы по экономии') — use advisor.analyze. " + + "action='add_planned_expense' records a FUTURE planned one-off expense the user wants to set aside ('запланируй трату X на Y', 'будет трата X', 'отложи X на Z', 'плановая трата X') — it is NOT a completed transaction. " + + "action='start_envelope' SAVES an arrived income to track its remaining balance over time ('запомни приход X', 'создай конверт на X', 'заведи конверт X на 2 недели', 'начни отслеживать приход X'). " + "Use budget.summary for plain numerical totals only.", Version: "1.0.0", InputSchema: &plugin.Schema{ @@ -51,7 +53,7 @@ func (s *BudgetSkill) Manifest() plugin.Manifest { "properties": map[string]any{ "action": map[string]any{ "type": "string", - "description": "Action to perform: add_expense, add_income, summary, list_transactions, edit_transaction, add_goal, update_goal, goal_status, add_debt, pay_debt, debt_status, set_reminder, get_reminder, add_recurring, list_recurring, disable_recurring, forecast", + "description": "Action to perform: add_expense, add_income, summary, list_transactions, edit_transaction, add_goal, update_goal, goal_status, add_debt, pay_debt, debt_status, set_reminder, get_reminder, add_recurring, list_recurring, disable_recurring, forecast, add_planned_expense, start_envelope", }, "amount": map[string]any{ "type": "number", @@ -251,6 +253,10 @@ func (s *BudgetSkill) Run(ctx context.Context, input string) (string, error) { return s.disableRecurring(ctx, req) case "forecast": return s.forecastAction(ctx, req) + case "add_planned_expense": + return s.addPlannedExpense(ctx, req) + case "start_envelope": + return s.startEnvelope(ctx, req) default: return "", fmt.Errorf("unknown action: %s", req.Action) } diff --git a/internal/skills/safetospend/compute.go b/internal/skills/safetospend/compute.go new file mode 100644 index 0000000..c28d4e6 --- /dev/null +++ b/internal/skills/safetospend/compute.go @@ -0,0 +1,131 @@ +// Package safetospend — read-only LLM-reasoning skill (ADR-002, ADR-007): +// «пришло X, сколько свободно на период?». Приход берётся ИЗ СООБЩЕНИЯ и НЕ +// пишется в ledger. Свободный остаток считается ДЕТЕРМИНИРОВАННО в Go +// (computeSafeToSpend), LLM только нарративит раскладку. +package safetospend + +import ( + "math" + "sort" + + "simpleAI/internal/budget" +) + +// CategorySpend — ожидаемая трата по одной потребительской категории за период. +type CategorySpend struct { + Category string + THB float64 +} + +// buildForecastBreakdown агрегирует прогноз по ПОТРЕБИТЕЛЬСКИМ категориям +// (движение денег исключено), пропорционально длине периода (days/30), и +// сортирует по убыванию. Чистая функция — отвечает на «на что уйдут деньги». +// Возвращает разбивку и итог. +func buildForecastBreakdown(fc []budget.CategoryForecast, rates map[string]float64, days int) ([]CategorySpend, float64) { + merged := map[string]float64{} + for _, f := range fc { + if !budget.IsVariableDailyExpense(f.CategoryName) { + continue // фикс (аренда/подписки) и движение денег — не «ежедневные» + } + thb, ok := budget.ToTHB(f.ForecastAmount, f.Currency, rates) + if !ok { + continue + } + merged[f.CategoryName] += thb * float64(days) / prorationBaseDays + } + items := make([]CategorySpend, 0, len(merged)) + var total float64 + for cat, thb := range merged { + items = append(items, CategorySpend{Category: cat, THB: thb}) + total += thb + } + sort.Slice(items, func(i, j int) bool { return items[i].THB > items[j].THB }) + return items, total +} + +// classifyIncome детерминированно (ADR-007 §7): «регулярный», если сумма близка +// (±regularIncomeTolerance) к известному регулярному доходу; иначе «разовый». +func classifyIncome(incomeTHB, recurringIncomeTHB float64) string { + if recurringIncomeTHB > 0 && math.Abs(incomeTHB-recurringIncomeTHB)/recurringIncomeTHB < regularIncomeTolerance { + return "регулярный" + } + return "разовый" +} + +// Result — детерминированный расчёт safe-to-spend, все суммы в THB. +// +// Формула (ADR-007 §4, одно слагаемое — один источник): +// +// FreeAfterObligations = Income − (Recurring + Debt) +// RealisticFree = FreeAfterObligations − ForecastSpend +// +// Уже потраченное за период НЕ вычитается из прихода (это прошлые деньги, не из +// нового прихода); ожидаемые будущие траты моделируются ForecastSpend. +type Result struct { + IncomeTHB float64 + RecurringTHB float64 + DebtTHB float64 + PlannedTHB float64 // ручные разовые плановые траты + ObligationsTHB float64 // Recurring + Debt + Planned (всё известное) + ForecastSpendTHB float64 // ожидаемые бытовые траты по статистике (справочно) + FreeAfterObligations float64 // Income − Obligations + RealisticFree float64 // FreeAfterObligations − ForecastSpend + Classification string // «регулярный» | «разовый» +} + +// computeSafeToSpend — чистая функция. Числа НЕ проходят через LLM. +// plannedTHB — ручные плановые траты; forecastSpendTHB — очищенный прогноз быта. +func computeSafeToSpend(incomeTHB float64, snap *budget.AdvisorSnapshot, plannedTHB, forecastSpendTHB float64) Result { + obligations := snap.UpcomingRecurring + snap.ActiveDebtDue + plannedTHB + freeAfter := incomeTHB - obligations + return Result{ + IncomeTHB: incomeTHB, + RecurringTHB: snap.UpcomingRecurring, + DebtTHB: snap.ActiveDebtDue, + PlannedTHB: plannedTHB, + ObligationsTHB: obligations, + ForecastSpendTHB: forecastSpendTHB, + FreeAfterObligations: freeAfter, + RealisticFree: freeAfter - forecastSpendTHB, + Classification: classifyIncome(incomeTHB, snap.UpcomingRecurringIncome), + } +} + +// RemainingResult — производный остаток живого конверта (ADR-007 H3/T5), THB. +// Остаток НЕ хранится: RemainingTHB считается из фактических данных, поэтому +// недвойного учёта — каждое слагаемое из одного источника (ADR §4): +// +// Remaining = Income − Recurring − Debt − Planned − ActualSpent +type RemainingResult struct { + IncomeTHB float64 + RecurringTHB float64 + DebtTHB float64 + PlannedTHB float64 + ActualSpentTHB float64 // фактически потрачено (дискреционное) с начала конверта + RemainingTHB float64 +} + +// computeRemaining — чистая функция (числа не через LLM). +func computeRemaining(incomeTHB float64, snap *budget.AdvisorSnapshot, plannedTHB, actualSpentTHB float64) RemainingResult { + rem := incomeTHB - snap.UpcomingRecurring - snap.ActiveDebtDue - plannedTHB - actualSpentTHB + return RemainingResult{ + IncomeTHB: incomeTHB, + RecurringTHB: snap.UpcomingRecurring, + DebtTHB: snap.ActiveDebtDue, + PlannedTHB: plannedTHB, + ActualSpentTHB: actualSpentTHB, + RemainingTHB: rem, + } +} + +// consumptionSpentTHB — сумма фактических трат по потребительским категориям +// (движение денег исключено, чтобы не задваивать обязательства; ADR-007 §4). +func consumptionSpentTHB(spentByCategory map[string]float64) float64 { + var total float64 + for cat, thb := range spentByCategory { + if budget.IsConsumptionCategory(cat) { + total += thb + } + } + return total +} diff --git a/internal/skills/safetospend/compute_test.go b/internal/skills/safetospend/compute_test.go new file mode 100644 index 0000000..c295ada --- /dev/null +++ b/internal/skills/safetospend/compute_test.go @@ -0,0 +1,129 @@ +package safetospend + +import ( + "math" + "testing" + + "simpleAI/internal/budget" +) + +func approx(a, b float64) bool { return math.Abs(a-b) < 0.01 } + +func TestComputeSafeToSpend(t *testing.T) { + snap := &budget.AdvisorSnapshot{ + UpcomingRecurring: 20000, + ActiveDebtDue: 5000, + } + // income 100000, recurring 20000, debt 5000, planned 10000, forecast 30000 + got := computeSafeToSpend(100000, snap, 10000, 30000) + + if !approx(got.ObligationsTHB, 35000) { + t.Errorf("Obligations: want 35000 (20000+5000+10000), got %.2f", got.ObligationsTHB) + } + if !approx(got.FreeAfterObligations, 65000) { + t.Errorf("FreeAfterObligations: want 65000 (100000-35000), got %.2f", got.FreeAfterObligations) + } + if !approx(got.RealisticFree, 35000) { + t.Errorf("RealisticFree: want 35000 (65000-30000), got %.2f", got.RealisticFree) + } +} + +// TestAcceptance_127k_to_27800 (ADR-007 задача 6, эталон bd l960): детерминир. +// проверка математики без LLM и БД. Приход 127000₽, известные обязательства = +// Сбер-кредит 28000 (recurring) + разовые плановые 71200 (кредитка 36400 + виза +// 15600 + электричество 6720 + пылесос 5200 + корм 3380 + прививки 3900) = +// 99200 → свободно 27800₽. Единицы: THB==₽ (курс 1:1) для прямого сравнения. +func TestAcceptance_127k_to_27800(t *testing.T) { + snap := &budget.AdvisorSnapshot{UpcomingRecurring: 28000, ActiveDebtDue: 0} + const planned = 36400 + 15600 + 6720 + 5200 + 3380 + 3900 // = 71200 + got := computeSafeToSpend(127000, snap, planned, 0) + + if !approx(got.ObligationsTHB, 99200) { + t.Errorf("известные обязательства: want 99200, got %.0f", got.ObligationsTHB) + } + if !approx(got.FreeAfterObligations, 27800) { + t.Errorf("свободно после известных: want 27800 (эталон), got %.0f", got.FreeAfterObligations) + } +} + +func TestBuildForecastBreakdown(t *testing.T) { + rates := map[string]float64{"RUB": 1.0, "THB": 1.0} // 1:1 для прямого сравнения + fc := []budget.CategoryForecast{ + {CategoryName: "Транспорт", Currency: "RUB", ForecastAmount: 6000}, + {CategoryName: "Еда", Currency: "RUB", ForecastAmount: 30000}, + {CategoryName: "Переводы", Currency: "RUB", ForecastAmount: 90000}, // движение денег → исключить + {CategoryName: "Жильё", Currency: "RUB", ForecastAmount: 50000}, // фикс → исключить из «ежедневных» + } + // days=30 (без пропорции): Переводы и Жильё исключены, сортировка desc. + items, total := buildForecastBreakdown(fc, rates, 30) + if !approx(total, 36000) { + t.Errorf("total: want 36000 (Переводы+Жильё исключены), got %.0f", total) + } + if len(items) != 2 || items[0].Category != "Еда" { + t.Errorf("ожидалась сортировка desc с Еда первой, got %+v", items) + } + // days=15 → пропорция вдвое. + if _, half := buildForecastBreakdown(fc, rates, 15); !approx(half, 18000) { + t.Errorf("пропорция 15 дней: want 18000, got %.0f", half) + } +} + +func TestComputeRemaining(t *testing.T) { + snap := &budget.AdvisorSnapshot{UpcomingRecurring: 10000, ActiveDebtDue: 5000} + // 100000 − 10000 − 5000 − 20000(planned) − 15000(spent) = 50000 + got := computeRemaining(100000, snap, 20000, 15000) + if !approx(got.RemainingTHB, 50000) { + t.Errorf("Remaining: want 50000, got %.0f", got.RemainingTHB) + } +} + +func TestDiscretionarySpentTHB(t *testing.T) { + m := map[string]float64{"Еда": 1000, "Переводы": 5000, "Транспорт": 500, "кредит": 9000} + // discretionary = Еда + Транспорт = 1500 (Переводы/кредит исключены) + if !approx(consumptionSpentTHB(m), 1500) { + t.Errorf("discretionarySpent: want 1500, got %.0f", consumptionSpentTHB(m)) + } +} + +func TestClassifyIncome(t *testing.T) { + // recurring income известен = 100000; приход 105000 (±5%) → регулярный. + if got := classifyIncome(105000, 100000); got != "регулярный" { + t.Errorf("105000 vs recurring 100000: want регулярный, got %s", got) + } + // приход 127000 при recurring 100000 (+27%) → разовый. + if got := classifyIncome(127000, 100000); got != "разовый" { + t.Errorf("127000 vs recurring 100000: want разовый, got %s", got) + } + // нет данных о recurring income → разовый. + if got := classifyIncome(50000, 0); got != "разовый" { + t.Errorf("no recurring: want разовый, got %s", got) + } +} + +func TestIsDiscretionary(t *testing.T) { + for _, c := range []string{"Переводы", "кредит", "долг"} { + if budget.IsConsumptionCategory(c) { + t.Errorf("%q должна быть non-discretionary", c) + } + } + for _, c := range []string{"Еда", "Транспорт", "Развлечения"} { + if !budget.IsConsumptionCategory(c) { + t.Errorf("%q должна быть discretionary", c) + } + } +} + +func TestToTHB(t *testing.T) { + rates := map[string]float64{"RUB": 1.0, "THB": 2.6} + // 2600 RUB / 2.6 = 1000 THB + got, ok := budget.ToTHB(2600, "RUB", rates) + if !ok || !approx(got, 1000) { + t.Errorf("budget.ToTHB(2600 RUB): want 1000, got %.2f ok=%v", got, ok) + } + if _, ok := budget.ToTHB(100, "XYZ", rates); ok { + t.Error("unknown currency должна давать ok=false") + } + if _, ok := budget.ToTHB(100, "RUB", map[string]float64{"RUB": 1}); ok { + t.Error("отсутствие THB-курса должно давать ok=false") + } +} diff --git a/internal/skills/safetospend/config.go b/internal/skills/safetospend/config.go new file mode 100644 index 0000000..cfa3b4d --- /dev/null +++ b/internal/skills/safetospend/config.go @@ -0,0 +1,22 @@ +package safetospend + +import "time" + +// Настроечные параметры skill'а — единый именованный блок (конвенция ADR-006: +// пороги/константы поведения не разбросаны по коду, а собраны и обоснованы). +// Горизонт по умолчанию берётся из budget.DefaultHorizonDays (общий домен). +const ( + // forecastMonths — окно истории для прогноза бытовых трат. + forecastMonths = 3 + // prorationBaseDays — прогноз даётся месячным; делим на 30 при пересчёте к + // длине периода (days/prorationBaseDays). + prorationBaseDays = 30.0 + // regularIncomeTolerance — |отклонение| прихода от регулярного дохода, при + // котором приход считается «регулярным» (±15%). + regularIncomeTolerance = 0.15 + // categoriesTopN — сколько категорий показывать в разбивке «на что уйдут», + // остальные сворачиваются в «прочее». + categoriesTopN = 5 + // defaultLLMTimeout — таймаут LLM-нарратива, независимый от Telegram-хендлера. + defaultLLMTimeout = 45 * time.Second +) diff --git a/internal/skills/safetospend/format.go b/internal/skills/safetospend/format.go new file mode 100644 index 0000000..cb490cb --- /dev/null +++ b/internal/skills/safetospend/format.go @@ -0,0 +1,121 @@ +package safetospend + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +// replyData — всё, что нужно для ответа. Числа детерминированы (из Result), +// planned/variable — разбивки, advice — нарратив LLM. +type replyData struct { + res Result + rubPerTHB float64 // rates["THB"] = ₽ за 1 ฿ (курс, показываем явно) + period string + planned []CategorySpend // плановые покупки по пунктам + variable []CategorySpend // повседневные траты по категориям + advice []string +} + +// formatReply: вердикт-заголовок сверху (по ИТОГУ, не по промежуточным суммам), +// затем прозрачная раскладка. Ложного зелёного на «свободно» нет. +func formatReply(d replyData) string { + r := d.res + rub := func(thb float64) float64 { return thb * d.rubPerTHB } + var b strings.Builder + + // 1) ВЕРДИКТ по финальному запасу (free − повседневные). + verdict := rub(r.RealisticFree) + if verdict >= 0 { + fmt.Fprintf(&b, "✅ Можно отложить ~%.0f ₽ за %s.\n", verdict, d.period) + } else { + fmt.Fprintf(&b, "❌ Отложить нельзя — не хватает ~%.0f ₽ за %s.\n", -verdict, d.period) + } + fmt.Fprintf(&b, "🗓 %s · курс %.1f ₽/฿\n\n", d.period, d.rubPerTHB) + + // 2) Раскладка. + fmt.Fprintf(&b, "💰 Приход: %.0f ₽\n", rub(r.IncomeTHB)) + fmt.Fprintf(&b, "➖ Обязательные платежи: %.0f ₽\n", rub(r.RecurringTHB+r.DebtTHB)) + if r.PlannedTHB > 0 { + fmt.Fprintf(&b, "➖ Запланированные покупки: %.0f ₽\n", rub(r.PlannedTHB)) + b.WriteString(formatItems(d.planned, d.rubPerTHB, len(d.planned))) + } + fmt.Fprintf(&b, "= Остаётся до повседневных трат: %.0f ₽\n", rub(r.FreeAfterObligations)) + + // 3) Повседневные (статистика) — «на что уйдёт». + if r.ForecastSpendTHB > 0 { + fmt.Fprintf(&b, "\n➖ Повседневные траты (по статистике, %s): %.0f ₽\n", d.period, rub(r.ForecastSpendTHB)) + b.WriteString(formatItems(d.variable, d.rubPerTHB, categoriesTopN)) + } + + // 4) Итог + связка с советами. + if verdict >= 0 { + fmt.Fprintf(&b, "\n⚖️ Итог: запас %.0f ₽ — можно отложить.\n", verdict) + } else { + fmt.Fprintf(&b, "\n⚖️ Итог: нехватка %.0f ₽. Чтобы выйти в ноль — срезать столько же:\n", -verdict) + } + for _, line := range d.advice { + fmt.Fprintf(&b, "• %s\n", line) + } + if r.PlannedTHB == 0 { + b.WriteString("\nℹ️ Разовые планы (кредитка, покупки) не заданы — добавь их, чтобы расчёт был точнее.") + } + return b.String() +} + +// formatItems печатает разбивку: до topN пунктов + свёртка остатка в «прочее». +// Категории нормализуются по регистру (единый вид). +func formatItems(items []CategorySpend, rubPerTHB float64, topN int) string { + if len(items) == 0 { + return "" + } + var b strings.Builder + var other float64 + for i, cs := range items { + if i >= topN { + other += cs.THB + continue + } + fmt.Fprintf(&b, " • %-16s %.0f ₽\n", normalizeLabel(cs.Category), cs.THB*rubPerTHB) + } + if other > 0 { + fmt.Fprintf(&b, " • %-16s %.0f ₽\n", "Остальные статьи", other*rubPerTHB) + } + return b.String() +} + +// normalizeLabel — единый регистр (Первая заглавная), чтобы «еда/Еда/ресторан» +// не выглядели как дубли из разных источников. +func normalizeLabel(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "прочее" + } + first, size := utf8.DecodeRuneInString(s) + return string(unicode.ToUpper(first)) + s[size:] +} + +// parseAdviceLines — нарратив LLM как простые строки (1 совет = 1 строка). +// Числа игнорируются: они уже в Result. Пусто/ошибка → nil (не фейлим ответ). +func parseAdviceLines(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var out []string + for _, ln := range strings.Split(raw, "\n") { + ln = strings.TrimSpace(strings.TrimLeft(ln, "-*•0123456789. ")) + if ln == "" { + continue + } + if len(ln) > 200 { + ln = ln[:200] + } + out = append(out, ln) + if len(out) >= 4 { + break + } + } + return out +} diff --git a/internal/skills/safetospend/remaining.go b/internal/skills/safetospend/remaining.go new file mode 100644 index 0000000..ac6334d --- /dev/null +++ b/internal/skills/safetospend/remaining.go @@ -0,0 +1,77 @@ +package safetospend + +import ( + "context" + "fmt" + "strings" + "time" + + "simpleAI/internal/budget" +) + +// runRemaining — режим «сколько свободно осталось» по активному конверту. +// Остаток вычисляется из фактических транзакций (ADR-007 §4, недвойной учёт), +// не хранится. +func (s *SafeToSpendSkill) runRemaining(ctx context.Context, chatID int64, rates map[string]float64) (string, error) { + env, ok, err := s.store.GetActiveEnvelope(ctx, chatID) + if err != nil { + s.logger.WarnContext(ctx, "safe_to_spend: get active envelope", "err", err, "chat_id", chatID) + return "Не удалось получить конверт — попробуй позже.", nil + } + if !ok { + return "Активного конверта нет. Скажи «пришло X, сколько свободно?» — посчитаю, или «запомни приход X», чтобы отслеживать остаток.", nil + } + + incomeTHB, ok := budget.ToTHB(env.IncomeAmount, env.IncomeCurrency, rates) + if !ok { + return fmt.Sprintf("Не знаю курс валюты конверта %s — обнови /rates.", env.IncomeCurrency), nil + } + + // Обязательства — за весь горизонт конверта. + snapObl, err := s.store.GetPeriodSnapshot(ctx, chatID, env.PeriodStart, env.PeriodEnd, rates) + if err != nil { + s.logger.ErrorContext(ctx, "safe_to_spend: remaining obligations snapshot", "err", err) + return "Временная ошибка — попробуй позже.", nil + } + + // Фактически потрачено — с начала конверта по сегодня (не позже конца). + spentTo := time.Now() + if spentTo.After(env.PeriodEnd) { + spentTo = env.PeriodEnd + } + snapSpent, err := s.store.GetPeriodSnapshot(ctx, chatID, env.PeriodStart, spentTo, rates) + if err != nil { + s.logger.ErrorContext(ctx, "safe_to_spend: remaining spent snapshot", "err", err) + return "Временная ошибка — попробуй позже.", nil + } + actualSpentTHB := consumptionSpentTHB(snapSpent.SpentByCategory) + + plannedTHB, _, err := s.store.PlannedExpensesTHB(ctx, chatID, rates) + if err != nil { + s.logger.WarnContext(ctx, "safe_to_spend: remaining planned (continuing with 0)", "err", err) + plannedTHB = 0 + } + + res := computeRemaining(incomeTHB, snapObl, plannedTHB, actualSpentTHB) + reply := formatRemaining(res, rates["THB"], env) + s.logger.InfoContext(ctx, "safe_to_spend.remaining", + "chat_id", chatID, "remaining_thb", res.RemainingTHB, "spent_thb", actualSpentTHB) + return reply, nil +} + +func formatRemaining(r RemainingResult, rubPerTHB float64, env *budget.Envelope) string { + rub := func(thb float64) float64 { return thb * rubPerTHB } + var b strings.Builder + + fmt.Fprintf(&b, "🧧 Конверт: приход ~%.0f ₽ (%s — %s)\n\n", + rub(r.IncomeTHB), env.PeriodStart.Format("02.01"), env.PeriodEnd.Format("02.01")) + fmt.Fprintf(&b, " 🔁 Регулярные: ~%.0f ₽\n", rub(r.RecurringTHB)) + fmt.Fprintf(&b, " 💳 Долги: ~%.0f ₽\n", rub(r.DebtTHB)) + if r.PlannedTHB > 0 { + fmt.Fprintf(&b, " 📝 Плановые: ~%.0f ₽\n", rub(r.PlannedTHB)) + } + fmt.Fprintf(&b, " 🛒 Уже потрачено: ~%.0f ₽\n", rub(r.ActualSpentTHB)) + b.WriteString(" ──────────────────────\n") + fmt.Fprintf(&b, " 💚 Свободно осталось: ~%.0f ₽\n", rub(r.RemainingTHB)) + return b.String() +} diff --git a/internal/skills/safetospend/skill.go b/internal/skills/safetospend/skill.go new file mode 100644 index 0000000..517b8a4 --- /dev/null +++ b/internal/skills/safetospend/skill.go @@ -0,0 +1,216 @@ +package safetospend + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" + "simpleAI/internal/core" + "simpleAI/internal/plugin" + "simpleAI/internal/prompts" +) + +// store — узкий read-only контракт (ADR-002: skill только читает). +type store interface { + GetExchangeRates(ctx context.Context) (map[string]float64, error) + GetPeriodSnapshot(ctx context.Context, chatID int64, from, to time.Time, rates map[string]float64) (*budget.AdvisorSnapshot, error) + GetForecastData(ctx context.Context, months int, rates map[string]float64) ([]budget.CategoryForecast, error) + PlannedExpensesTHB(ctx context.Context, chatID int64, rates map[string]float64) (float64, int, error) + ListPlannedExpenses(ctx context.Context, chatID int64) ([]budget.PlannedExpense, error) + GetActiveEnvelope(ctx context.Context, chatID int64) (*budget.Envelope, bool, error) +} + +// SafeToSpendSkill — read-only reasoning skill (ADR-007 фаза H1). +type SafeToSpendSkill struct { + store store + llm core.LLM + logger *slog.Logger + llmTimeout time.Duration +} + +func NewSafeToSpendSkill(s store, llm core.LLM, logger *slog.Logger) *SafeToSpendSkill { + if logger == nil { + logger = slog.Default() + } + return &SafeToSpendSkill{store: s, llm: llm, logger: logger, llmTimeout: defaultLLMTimeout} +} + +func (s *SafeToSpendSkill) WithLLMTimeout(d time.Duration) *SafeToSpendSkill { + if d > 0 { + s.llmTimeout = d + } + return s +} + +func (s *SafeToSpendSkill) Manifest() plugin.Manifest { + return plugin.Manifest{ + ID: "safe_to_spend", + Name: "Safe to Spend", + Version: "1.0.0", + Description: "Считает СКОЛЬКО СВОБОДНЫХ ДЕНЕГ останется из ПРИШЕДШЕГО дохода на период, " + + "после известных обязательств (регулярные платежи, долги) и прогноза трат. Приход берётся из вопроса и НЕ записывается. " + + "Триггеры RU: «пришло X, сколько свободно?», «получил X, сколько могу потратить?», «пришла зарплата/аванс X, сколько останется?», " + + "«на что распределить X?», «сколько свободных денег из X на этот месяц?». " + + "Триггеры EN: 'got X income, how much is free to spend?', 'received X, what's safe to spend?'. " + + "РЕЖИМ ОСТАТКА (БЕЗ суммы): показывает, сколько свободно осталось из ранее сохранённого конверта, пересчитывая по фактическим тратам. " + + "Триггеры RU: «сколько свободно осталось?», «сколько осталось из прихода?», «остаток по конверту», «сколько ещё могу потратить?», «сколько денег свободно сейчас?». " + + "НЕ используй для простой ЗАПИСИ дохода без вопроса о свободных деньгах («пришло X», «запиши доход X», «получил зарплату X» без вопроса) → budget.add_income. " + + "НЕ используй для СОХРАНЕНИЯ прихода («запомни приход X», «заведи конверт») → budget.start_envelope. " + + "НЕ используй для affordability конкретной покупки («хватит ли на телефон») → advisor.advice. " + + "НЕ используй для обзора трат без прихода → advisor.analyze / budget.summary.", + InputSchema: &plugin.Schema{ + Name: "SafeToSpendInput", + Version: "1.0.0", + JSON: map[string]any{ + "type": "object", + "properties": map[string]any{ + "amount": map[string]any{"type": "number", "description": "Сумма пришедшего дохода из сообщения. НЕ указывай в режиме остатка (вопрос без суммы)."}, + "currency": map[string]any{"type": "string", "description": "Валюта прихода: RUB (по умолчанию), THB, USD, EUR."}, + "period": map[string]any{"type": "string", "description": "Горизонт расчёта. По умолчанию (пусто) — ближайшие 2 недели (интервал между приходами). 'month' — до конца текущего месяца; 'YYYY-MM' — конкретный месяц."}, + "question": map[string]any{"type": "string", "description": "Исходный вопрос пользователя."}, + }, + "required": []string{}, + }, + }, + } +} + +type input struct { + Amount float64 `json:"amount"` + Currency string `json:"currency,omitempty"` + Period string `json:"period,omitempty"` + Question string `json:"question,omitempty"` +} + +func (s *SafeToSpendSkill) Run(ctx context.Context, raw string) (string, error) { + var in input + if err := json.Unmarshal([]byte(raw), &in); err != nil { + return "", fmt.Errorf("safe_to_spend: invalid input: %w", err) + } + currency := strings.ToUpper(strings.TrimSpace(in.Currency)) + if currency == "" { + currency = "RUB" + } + + chatID, ok := ctx.Value(agent.ChatIDKey{}).(int64) + if !ok { + s.logger.WarnContext(ctx, "safe_to_spend: chatID missing — chat-scoped данные будут пусты") + } + + rates, err := s.store.GetExchangeRates(ctx) + if err != nil || rates["THB"] == 0 { + s.logger.ErrorContext(ctx, "safe_to_spend: rates", "err", err) + return "Не могу посчитать — обнови курс валют командой /rates.", nil + } + + // Без суммы: режим «сколько осталось» по активному конверту (ADR-007 T5). + if in.Amount <= 0 { + return s.runRemaining(ctx, chatID, rates) + } + + incomeTHB, ok := budget.ToTHB(in.Amount, currency, rates) + if !ok { + return fmt.Sprintf("Не знаю курс валюты %s — обнови /rates.", currency), nil + } + + h := budget.ResolveHorizon(in.Period, time.Now(), budget.DefaultHorizonDays) + + snap, err := s.store.GetPeriodSnapshot(ctx, chatID, h.From, h.To, rates) + if err != nil { + s.logger.ErrorContext(ctx, "safe_to_spend: period snapshot", "err", err, "chat_id", chatID) + return "Временная ошибка при сборе финансового снимка — попробуй позже.", nil + } + + breakdown, forecastTHB := s.forecast(ctx, rates, h.Days()) + plannedItems, plannedTHB := s.plannedBreakdown(ctx, chatID, rates) + res := computeSafeToSpend(incomeTHB, snap, plannedTHB, forecastTHB) + + advice := s.narrate(ctx, res, rates["THB"], breakdown) + + reply := formatReply(replyData{ + res: res, + rubPerTHB: rates["THB"], + period: h.Label, + planned: plannedItems, + variable: breakdown, + advice: advice, + }) + s.logger.InfoContext(ctx, "safe_to_spend", + "chat_id", chatID, "amount", in.Amount, "currency", currency, + "free_after_obl_thb", res.FreeAfterObligations, "realistic_free_thb", res.RealisticFree) + return reply, nil +} + +// plannedBreakdown — ручные плановые траты по пунктам (описание→THB) и их итог. +// Отдельный источник от прогноза (budget_planned_expense vs история транзакций) — +// пересечения нет: одна и та же запись не попадает в оба блока (ADR-007 §4). +func (s *SafeToSpendSkill) plannedBreakdown(ctx context.Context, chatID int64, rates map[string]float64) ([]CategorySpend, float64) { + items, err := s.store.ListPlannedExpenses(ctx, chatID) + if err != nil { + s.logger.WarnContext(ctx, "safe_to_spend: planned list (continuing with 0)", "err", err) + return nil, 0 + } + out := make([]CategorySpend, 0, len(items)) + var total float64 + for _, p := range items { + thb, ok := budget.ToTHB(p.Amount, p.Currency, rates) + if !ok { + continue + } + out = append(out, CategorySpend{Category: p.Description, THB: thb}) + total += thb + } + return out, total +} + +// forecast — разбивка ожидаемых бытовых трат ЗА ПЕРИОД по категориям (на что +// уйдут деньги) и их итог. GetForecastData даёт месячный прогноз; проративим к +// длине периода. Non-consumption исключены. Best-effort: ошибка → пусто. +func (s *SafeToSpendSkill) forecast(ctx context.Context, rates map[string]float64, days int) ([]CategorySpend, float64) { + fc, err := s.store.GetForecastData(ctx, forecastMonths, rates) + if err != nil { + s.logger.WarnContext(ctx, "safe_to_spend: forecast (continuing with empty)", "err", err) + return nil, 0 + } + return buildForecastBreakdown(fc, rates, days) +} + +// narrate — LLM даёт АДРЕСНЫЕ советы по экономии на основе разбивки трат по +// категориям (числа не трогает). Best-effort → nil. +func (s *SafeToSpendSkill) narrate(ctx context.Context, res Result, rubPerTHB float64, breakdown []CategorySpend) []string { + prompt := fmt.Sprintf(prompts.Get("safetospend/narrate.tmpl"), + res.FreeAfterObligations*rubPerTHB, + res.ForecastSpendTHB*rubPerTHB, + breakdownText(breakdown, rubPerTHB)) + llmCtx, cancel := context.WithTimeout(context.Background(), s.llmTimeout) + defer cancel() + rawResp, err := s.llm.Ask(llmCtx, prompt) + if err != nil { + s.logger.WarnContext(ctx, "safe_to_spend: narrate llm (continuing without)", "err", err) + return nil + } + return parseAdviceLines(rawResp) +} + +// breakdownText — «категория: сумма₽» для промпта (топ-6), чтобы советы были +// адресными («в кафе уходит ~X — готовь дома»). +func breakdownText(breakdown []CategorySpend, rubPerTHB float64) string { + if len(breakdown) == 0 { + return "нет данных" + } + var parts []string + for i, cs := range breakdown { + if i >= 6 { + break + } + parts = append(parts, fmt.Sprintf("%s ~%.0f₽", cs.Category, cs.THB*rubPerTHB)) + } + return strings.Join(parts, ", ") +} + +// Период и горизонт вынесены в budget.ResolveHorizon (единый источник). diff --git a/internal/skills/safetospend/skill_test.go b/internal/skills/safetospend/skill_test.go new file mode 100644 index 0000000..0033e22 --- /dev/null +++ b/internal/skills/safetospend/skill_test.go @@ -0,0 +1,101 @@ +package safetospend + +import ( + "context" + "regexp" + "testing" + "time" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" +) + +type fakeStore struct{} + +func (fakeStore) GetExchangeRates(context.Context) (map[string]float64, error) { + return map[string]float64{"RUB": 1.0, "THB": 2.6}, nil +} +func (fakeStore) GetPeriodSnapshot(context.Context, int64, time.Time, time.Time, map[string]float64) (*budget.AdvisorSnapshot, error) { + // recurring 20000 RUB → /2.6 в THB (snapshot хранит THB). Здесь сразу THB. + return &budget.AdvisorSnapshot{ + UpcomingRecurring: 20000 / 2.6, + ActiveDebtDue: 0, + SpentByCategory: map[string]float64{"Еда": 100}, + }, nil +} +func (fakeStore) GetForecastData(context.Context, int, map[string]float64) ([]budget.CategoryForecast, error) { + return []budget.CategoryForecast{{CategoryName: "Еда", Currency: "RUB", ForecastAmount: 30000}}, nil +} +func (fakeStore) PlannedExpensesTHB(context.Context, int64, map[string]float64) (float64, int, error) { + return 0, 0, nil +} +func (fakeStore) ListPlannedExpenses(context.Context, int64) ([]budget.PlannedExpense, error) { + return nil, nil +} +func (fakeStore) GetActiveEnvelope(context.Context, int64) (*budget.Envelope, bool, error) { + return nil, false, nil +} + +// fakeLLM возвращает заданную строку (для проверки, что числа от неё не зависят). +type fakeLLM struct{ resp string } + +func (f fakeLLM) Ask(context.Context, string) (string, error) { return f.resp, nil } +func (f fakeLLM) AskWithSystem(context.Context, string, string) (string, error) { return f.resp, nil } + +// numericLines извлекает строки с числовыми суммами (₽) — детерминированный вывод. +func numericLines(s string) []string { + re := regexp.MustCompile(`(?m)^.*[-\d]+ ₽.*$`) + return re.FindAllString(s, -1) +} + +// TestNumbersIndependentOfLLM (ADR-007 §3): мутация ответа LLM НЕ меняет числа. +func TestNumbersIndependentOfLLM(t *testing.T) { + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, int64(1)) + payload := `{"amount":127000,"currency":"RUB","question":"сколько свободно?"}` + + s1 := NewSafeToSpendSkill(fakeStore{}, fakeLLM{resp: "Совет A"}, nil) + out1, err := s1.Run(ctx, payload) + if err != nil { + t.Fatal(err) + } + // Другой ответ LLM с фейковыми числами. + s2 := NewSafeToSpendSkill(fakeStore{}, fakeLLM{resp: "Потратьте 999999 на ерунду"}, nil) + out2, err := s2.Run(ctx, payload) + if err != nil { + t.Fatal(err) + } + + n1, n2 := numericLines(out1), numericLines(out2) + if len(n1) == 0 { + t.Fatal("нет числовых строк в ответе") + } + if len(n1) != len(n2) { + t.Fatalf("число числовых строк изменилось от LLM: %d vs %d", len(n1), len(n2)) + } + for i := range n1 { + if n1[i] != n2[i] { + t.Errorf("числовая строка зависит от LLM:\n A: %q\n B: %q", n1[i], n2[i]) + } + } + + // И проверим само число: приход 127000 − recurring 20000 = 107000. + if !regexpContains(out1, `Остаётся до повседневных трат: 107000 ₽`) { + t.Errorf("ожидалось 'Остаётся до повседневных трат: 107000 ₽', got:\n%s", out1) + } +} + +// Без суммы и без активного конверта — приглашение назвать приход/завести конверт. +func TestNoAmountNoEnvelope(t *testing.T) { + s := NewSafeToSpendSkill(fakeStore{}, fakeLLM{}, nil) + out, err := s.Run(context.Background(), `{"amount":0}`) + if err != nil { + t.Fatal(err) + } + if !regexpContains(out, `Активного конверта нет`) { + t.Errorf("ожидалось сообщение об отсутствии конверта, got: %q", out) + } +} + +func regexpContains(s, pat string) bool { + return regexp.MustCompile(pat).MatchString(s) +} From d7dbe2be83700b35385c9e3325c8fb1624fd73b7 Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Thu, 23 Jul 2026 18:17:19 +0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(tooling):=20botclient=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=D0=B9=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?-=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20=D0=BA=20=D1=8F=D0=B4?= =?UTF-8?q?=D1=80=D1=83=20=D0=B1=D0=BE=D1=82=D0=B0=20+=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/appwiring: переиспользуемая сборка registry+Service (вынесено из cmd/app). - cmd/botclient + internal/botclient: read-only клиент к agent.Service в обход Telegram; write-safety на уровне БД-роли (--allow-writes + отдельный URL). - scripts/refresh-replica.sh + launchd: почасовой graceful рефреш локальной read-only реплики боевой БД (прод не мутируется, только pg_dump). - evals: golden-кейсы роутинга safe_to_spend/add_planned_expense/start_envelope, clarity-бенчмарк подачи. --- MakeFile | 4 + cmd/app/main.go | 34 +----- cmd/botclient/main.go | 92 ++++++++++++++ .../com.simpleai.replica-refresh.plist | 43 +++++++ evals/clarity/safe_to_spend.md | 44 +++++++ evals/cmd/routing/main.go | 18 +-- evals/golden_set.jsonl | 6 + internal/appwiring/appwiring.go | 75 ++++++++++++ internal/appwiring/appwiring_test.go | 44 +++++++ internal/botclient/botclient.go | 48 ++++++++ .../botclient/botclient_integration_test.go | 47 ++++++++ internal/botclient/botclient_test.go | 56 +++++++++ scripts/refresh-replica.sh | 114 ++++++++++++++++++ 13 files changed, 586 insertions(+), 39 deletions(-) create mode 100644 cmd/botclient/main.go create mode 100644 deploy/launchd/com.simpleai.replica-refresh.plist create mode 100644 evals/clarity/safe_to_spend.md create mode 100644 internal/appwiring/appwiring.go create mode 100644 internal/appwiring/appwiring_test.go create mode 100644 internal/botclient/botclient.go create mode 100644 internal/botclient/botclient_integration_test.go create mode 100644 internal/botclient/botclient_test.go create mode 100755 scripts/refresh-replica.sh diff --git a/MakeFile b/MakeFile index ad126d1..938aa07 100644 --- a/MakeFile +++ b/MakeFile @@ -42,3 +42,7 @@ run-mcp: lint: golangci-lint run + +# Обновить локальную read-only реплику боевой БД (прод не мутируется, только pg_dump). +refresh-replica: + bash scripts/refresh-replica.sh diff --git a/cmd/app/main.go b/cmd/app/main.go index adfc805..6a1ffe6 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -22,7 +22,7 @@ import ( "simpleAI/config" llmfactory "simpleAI/internal/adapters/llm" telegramadapter "simpleAI/internal/adapters/telegram" - "simpleAI/internal/agent" + "simpleAI/internal/appwiring" "simpleAI/internal/budget" "simpleAI/internal/core" "simpleAI/internal/db" @@ -31,10 +31,7 @@ import ( "simpleAI/internal/notify" "simpleAI/internal/observability" "simpleAI/internal/plugin" - "simpleAI/internal/rag" "simpleAI/internal/rates" - "simpleAI/internal/skills" - advisorskill "simpleAI/internal/skills/advisor" budgetskill "simpleAI/internal/skills/budget" "simpleAI/internal/telegram" "simpleAI/internal/tools" @@ -65,7 +62,7 @@ func main() { } // Единый реестр skills для всех компонентов. - registry, budgetSkill := buildRegistry(llmClient, logger, pool, cfg.LLM.AdvisorLLMTimeout) + registry, budgetSkill := appwiring.BuildRegistry(llmClient, logger, pool, cfg.LLM.AdvisorLLMTimeout) // Контекст с graceful shutdown. ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -177,7 +174,7 @@ func runTelegram(ctx context.Context, cfg config.Config, logger *slog.Logger, ll logger.Warn("failed to set telegram commands", "err", err) } - agentService := agent.NewServiceWithRegistry(llmClient, registry).WithTracer(tracer).WithObservability(obsTracer).WithLogger(logger).WithLLMModel(cfg.LLM.ChatModel) + agentService := appwiring.BuildAgentService(llmClient, registry, tracer, obsTracer, logger, cfg.LLM.ChatModel) router := telegram.NewRouter() router.Use(telegram.DeduplicateUpdates(1000)) @@ -282,31 +279,6 @@ func runWorker(ctx context.Context, cfg config.Config, logger *slog.Logger, pool } } -func buildRegistry(llmClient core.LLMClient, logger *slog.Logger, pool *pgxpool.Pool, advisorLLMTimeout time.Duration) (*plugin.Registry, *budgetskill.BudgetSkill) { - registry := plugin.NewRegistry() - - retriever := rag.NewRetriever(pool) - ragSkill := skills.NewRAGSearchSkill(retriever, llmClient) - if err := registry.Register(ragSkill); err != nil { - logger.Error("failed to register rag_search skill", "err", err) - } - - budgetStore := budget.NewStore(pool) - bs := budgetskill.NewBudgetSkill(budgetStore) - if err := registry.Register(bs); err != nil { - logger.Error("failed to register budget skill", "err", err) - } - - advisorSkill := advisorskill.NewAdvisorSkill(budgetStore, llmClient, logger). - WithLLMTimeout(advisorLLMTimeout) - if err := registry.Register(advisorSkill); err != nil { - logger.Error("failed to register advisor skill", "err", err) - } - - logger.Info("registry ready", "skills", len(registry.List())) - return registry, bs -} - func buildHelpText(commands []telegramadapter.Command) string { var b strings.Builder b.WriteString("Что умею:\n") diff --git a/cmd/botclient/main.go b/cmd/botclient/main.go new file mode 100644 index 0000000..08e61f6 --- /dev/null +++ b/cmd/botclient/main.go @@ -0,0 +1,92 @@ +// Command botclient — прямой тест-клиент к ядру бота (agent.Service), +// в обход Telegram-транспорта. Шлёт текст в AskWithMeta и печатает ответ. +// +// По умолчанию read-only: подключается к реплике ролью botclient_ro (запись +// отклоняется БД). Запись — только с --allow-writes и BOTCLIENT_DATABASE_URL_RW. +// +// Пример: +// +// set -a; . ~/.simpleai-replica/botclient.env; set +a +// go run ./cmd/botclient "пришло 127000₽, сколько свободных осталось?" +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "simpleAI/config" + llmfactory "simpleAI/internal/adapters/llm" + "simpleAI/internal/appwiring" + "simpleAI/internal/botclient" + "simpleAI/internal/tools" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + var ( + allowWrites bool + chatID int64 + ) + flag.BoolVar(&allowWrites, "allow-writes", false, "разрешить мутации БД (по умолчанию read-only реплика)") + flag.Int64Var(&chatID, "chat-id", 420229961, "telegram chat_id для chat-scoped skills") + flag.Parse() + + prompt := strings.TrimSpace(strings.Join(flag.Args(), " ")) + if prompt == "" { + return fmt.Errorf("укажи текст запроса: botclient [флаги] \"<текст>\"") + } + + dbURL, err := botclient.ResolveDatabaseURL(botclient.Options{ + AllowWrites: allowWrites, + ReadOnlyURL: os.Getenv("BOTCLIENT_DATABASE_URL"), + WriteURL: os.Getenv("BOTCLIENT_DATABASE_URL_RW"), + }) + if err != nil { + return err + } + + cfg, err := config.LoadConfig() + if err != nil { + return fmt.Errorf("config: %w", err) + } + logger := tools.NewLogger() + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dbURL) + if err != nil { + return fmt.Errorf("db pool: %w", err) + } + defer pool.Close() + + llmClient, err := llmfactory.NewClient(cfg, logger) + if err != nil { + return fmt.Errorf("llm: %w", err) + } + + registry, _ := appwiring.BuildRegistry(llmClient, logger, pool, cfg.LLM.AdvisorLLMTimeout) + svc := appwiring.BuildAgentService(llmClient, registry, nil, nil, logger, cfg.LLM.ChatModel) + + mode := "read-only" + if allowWrites { + mode = "READ-WRITE" + } + logger.Info("botclient", "mode", mode, "chat_id", chatID) + + answer, err := botclient.Ask(ctx, svc, prompt, chatID) + if err != nil { + return fmt.Errorf("ask: %w", err) + } + fmt.Println(answer) + return nil +} diff --git a/deploy/launchd/com.simpleai.replica-refresh.plist b/deploy/launchd/com.simpleai.replica-refresh.plist new file mode 100644 index 0000000..66f8f7f --- /dev/null +++ b/deploy/launchd/com.simpleai.replica-refresh.plist @@ -0,0 +1,43 @@ + + + + + Label + com.simpleai.replica-refresh + + ProgramArguments + + /bin/bash + /Users/tarasmalinovskij/dev/simpleAI/scripts/refresh-replica.sh + + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + + StartInterval + 3600 + RunAtLoad + + + StandardOutPath + /Users/tarasmalinovskij/.simpleai-replica/refresh.log + StandardErrorPath + /Users/tarasmalinovskij/.simpleai-replica/refresh.err.log + + diff --git a/evals/clarity/safe_to_spend.md b/evals/clarity/safe_to_spend.md new file mode 100644 index 0000000..3b15f50 --- /dev/null +++ b/evals/clarity/safe_to_spend.md @@ -0,0 +1,44 @@ +# Clarity benchmark — safe_to_spend + +Регулярный замер **ясности** вывода (не роутинга). Прогонять периодически: +запустить свежий агент БЕЗ контекста проекта, дать ему промпт ниже + текущий +вывод бота, собрать замечания. Цель — ловить деградацию подачи (двойные смыслы, +непрозрачные статьи, перевёрнутые приоритеты, валютная слепота). + +## Как прогнать + +1. Получить свежий вывод: + ``` + set -a; . ~/.simpleai-replica/botclient.env; . ~/.simpleai-replica/llm.env; set +a + go run ./cmd/botclient "пришло 127000 рублей, сколько свободно и на что уйдут деньги за 2 недели?" + ``` +2. Запустить холодного агента (general-purpose, без контекста) с промптом ниже, + подставив вывод. +3. Сверить замечания с известными (см. «Закрытые претензии»). Новые = регресс. + +## Промпт ревьюера (полная ситуация) + +> Ситуация пользователя: он получает зарплату в рублях, но живёт в Таиланде и +> тратит в батах. Когда приходит зарплата, он хочет решить: сколько уйдёт на +> обязательные и повседневные траты и может ли он что-то ОТЛОЖИТЬ (накопить) или +> всё уйдёт. Оцени вывод как инструмент для ЭТОГО решения: +> 1. Отвечает ли вывод на «сколько свободно и могу ли отложить»? +> 2. Чего критически не хватает? +> 3. Что лишнее или сбивает с толку? +> 4. Как переработать, чтобы решение принималось за 5 секунд? +> Не пиши код. Вот вывод: + +## Закрытые претензии (ожидаемо НЕ должны всплывать) + +- Двойное «свободно» (промежуточное vs итог) — вердикт вынесен наверх, ложный + зелёный снят. +- «−X ₽ запаса» оксюморон — теперь «нехватка X ₽». +- Плановые как чёрный ящик — раскрыты по пунктам. +- Дубли категорий по регистру — нормализованы; свёртка названа «Остальные статьи». +- Курс бат↔рубль скрыт — показан явно (`курс N ₽/฿`). +- Приоритет мелочи над крупным — вердикт и рычаг (дефицит) сверху. + +## Открытые (осознанно НЕ делаем пока) + +- Валютный разрез (сумма к обмену в батах, разные карманы ₽/฿) — отложено, + пока достаточно рублей + явный курс. diff --git a/evals/cmd/routing/main.go b/evals/cmd/routing/main.go index da47ffb..8f531e4 100644 --- a/evals/cmd/routing/main.go +++ b/evals/cmd/routing/main.go @@ -32,23 +32,24 @@ import ( "simpleAI/internal/plugin" advisorskill "simpleAI/internal/skills/advisor" budgetskill "simpleAI/internal/skills/budget" + safetospendskill "simpleAI/internal/skills/safetospend" "simpleAI/evals" ) const ( - caseTimeout = 60 * time.Second - promptHashLen = 12 + caseTimeout = 60 * time.Second + promptHashLen = 12 ) func main() { var ( - inputPath = flag.String("input", "evals/golden_set.jsonl", "путь к golden_set.jsonl") - outDir = flag.String("out", "evals/runs", "каталог куда писать run-файлы") - tagFilter = flag.String("tag", "", "фильтр по тегу (пусто = без фильтра)") - limit = flag.Int("limit", 0, "smoke: ограничить N кейсами (0 = все)") - prevPath = flag.String("prev", "", "путь к предыдущему run для diff (пусто = без diff)") - dryRun = flag.Bool("dry-run", false, "не вызывать LLM, проверить только загрузку и схему") + inputPath = flag.String("input", "evals/golden_set.jsonl", "путь к golden_set.jsonl") + outDir = flag.String("out", "evals/runs", "каталог куда писать run-файлы") + tagFilter = flag.String("tag", "", "фильтр по тегу (пусто = без фильтра)") + limit = flag.Int("limit", 0, "smoke: ограничить N кейсами (0 = все)") + prevPath = flag.String("prev", "", "путь к предыдущему run для diff (пусто = без diff)") + dryRun = flag.Bool("dry-run", false, "не вызывать LLM, проверить только загрузку и схему") ) flag.Parse() @@ -126,6 +127,7 @@ func buildManifests() []plugin.Manifest { return []plugin.Manifest{ budgetskill.NewBudgetSkill(nil).Manifest(), advisorskill.NewAdvisorSkill(nil, nil, nil).Manifest(), + safetospendskill.NewSafeToSpendSkill(nil, nil, nil).Manifest(), } } diff --git a/evals/golden_set.jsonl b/evals/golden_set.jsonl index 7b75e9a..f3fdeb3 100644 --- a/evals/golden_set.jsonl +++ b/evals/golden_set.jsonl @@ -36,3 +36,9 @@ {"id":"r034","input":"покажи доходы предыдущего месяца","expected":{"skill":"budget","action":"summary"},"tags":["summary","prev_month","income","ru","regression"]} {"id":"r035","input":"перечисли все доходы за апрель","expected":{"skill":"budget","action":"list_transactions"},"tags":["list_transactions","income","ru"]} {"id":"r036","input":"сколько я заработал в марте","expected":{"skill":"budget","action":"summary"},"tags":["summary","income","ru"]} +{"id":"r037","input":"пришло 127000, сколько свободных денег осталось на этот месяц?","expected":{"skill":"safe_to_spend","action":""},"tags":["safe_to_spend","income_arrived","free","ru","regression"],"notes":"ADR-007 бага №1 — раньше уходило в budget.add_income (запись прихода вместо ответа)"} +{"id":"r038","input":"получил премию 40000, на что её лучше распределить?","expected":{"skill":"safe_to_spend","action":""},"tags":["safe_to_spend","allocate","ru"]} +{"id":"r039","input":"запиши доход зарплата 130000","expected":{"skill":"budget","action":"add_income"},"tags":["add_income","record","ru","regression"],"notes":"чистая запись без вопроса о свободных деньгах остаётся add_income"} +{"id":"r040","input":"запланируй трату 15600 на визу","expected":{"skill":"budget","action":"add_planned_expense"},"tags":["add_planned_expense","planned","ru"]} +{"id":"r041","input":"запомни приход 127000 на 2 недели","expected":{"skill":"budget","action":"start_envelope"},"tags":["start_envelope","envelope","ru"]} +{"id":"r042","input":"сколько свободных денег осталось?","expected":{"skill":"safe_to_spend","action":""},"tags":["remaining","envelope","ru"]} diff --git a/internal/appwiring/appwiring.go b/internal/appwiring/appwiring.go new file mode 100644 index 0000000..27ef5fe --- /dev/null +++ b/internal/appwiring/appwiring.go @@ -0,0 +1,75 @@ +// Package appwiring собирает переиспользуемую обвязку приложения — единый +// реестр skills и agent.Service — чтобы её могли разделять разные точки входа +// (cmd/app, cmd/botclient) без дублирования конструирования. +// +// Логика перенесена из cmd/app без смены поведения (задача simpleAI-fm3g). +package appwiring + +import ( + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" + "simpleAI/internal/core" + "simpleAI/internal/observability" + "simpleAI/internal/plugin" + "simpleAI/internal/rag" + "simpleAI/internal/skills" + advisorskill "simpleAI/internal/skills/advisor" + budgetskill "simpleAI/internal/skills/budget" + safetospendskill "simpleAI/internal/skills/safetospend" + "simpleAI/internal/trace" +) + +// BuildRegistry собирает единый реестр skills (rag_search, budget, advisor) +// поверх пула БД. Возвращает реестр и BudgetSkill (нужен отдельно для +// Telegram callback-хендлера). +func BuildRegistry(llmClient core.LLMClient, logger *slog.Logger, pool *pgxpool.Pool, advisorLLMTimeout time.Duration) (*plugin.Registry, *budgetskill.BudgetSkill) { + if logger == nil { + logger = slog.Default() + } + registry := plugin.NewRegistry() + + retriever := rag.NewRetriever(pool) + ragSkill := skills.NewRAGSearchSkill(retriever, llmClient) + if err := registry.Register(ragSkill); err != nil { + logger.Error("failed to register rag_search skill", "err", err) + } + + budgetStore := budget.NewStore(pool) + bs := budgetskill.NewBudgetSkill(budgetStore) + if err := registry.Register(bs); err != nil { + logger.Error("failed to register budget skill", "err", err) + } + + advisorSkill := advisorskill.NewAdvisorSkill(budgetStore, llmClient, logger). + WithLLMTimeout(advisorLLMTimeout) + if err := registry.Register(advisorSkill); err != nil { + logger.Error("failed to register advisor skill", "err", err) + } + + safeToSpendSkill := safetospendskill.NewSafeToSpendSkill(budgetStore, llmClient, logger). + WithLLMTimeout(advisorLLMTimeout) + if err := registry.Register(safeToSpendSkill); err != nil { + logger.Error("failed to register safe_to_spend skill", "err", err) + } + + logger.Info("registry ready", "skills", len(registry.List())) + return registry, bs +} + +// BuildAgentService собирает agent.Service с реестром и опциональными +// трейсерами. Перенесено из cmd/app runTelegram без смены поведения. +func BuildAgentService(llmClient core.LLM, registry *plugin.Registry, tracer *trace.Store, obsTracer *observability.Tracer, logger *slog.Logger, chatModel string) *agent.Service { + if logger == nil { + logger = slog.Default() + } + return agent.NewServiceWithRegistry(llmClient, registry). + WithTracer(tracer). + WithObservability(obsTracer). + WithLogger(logger). + WithLLMModel(chatModel) +} diff --git a/internal/appwiring/appwiring_test.go b/internal/appwiring/appwiring_test.go new file mode 100644 index 0000000..e33b443 --- /dev/null +++ b/internal/appwiring/appwiring_test.go @@ -0,0 +1,44 @@ +package appwiring + +import ( + "context" + "testing" + "time" + + "simpleAI/internal/core" +) + +// fakeLLM реализует core.LLMClient без сети — для проверки сборки обвязки. +type fakeLLM struct{} + +func (fakeLLM) Ask(context.Context, string) (string, error) { return "", nil } +func (fakeLLM) AskWithSystem(context.Context, string, string) (string, error) { return "", nil } +func (fakeLLM) Embed(context.Context, []string) ([][]float32, error) { return nil, nil } + +var _ core.LLMClient = fakeLLM{} + +func TestBuildRegistry_RegistersExpectedSkills(t *testing.T) { + // pool=nil безопасен: конструкторы store/retriever только сохраняют пул, + // не обращаясь к БД при сборке. + reg, bs := BuildRegistry(fakeLLM{}, nil, nil, 30*time.Second) + if bs == nil { + t.Fatal("BuildRegistry вернул nil BudgetSkill") + } + got := make(map[string]bool) + for _, m := range reg.List() { + got[m.ID] = true + } + for _, want := range []string{"rag_search", "budget", "advisor", "safe_to_spend"} { + if !got[want] { + t.Errorf("реестр не содержит skill %q (есть: %v)", want, got) + } + } +} + +func TestBuildAgentService_NotNil(t *testing.T) { + reg, _ := BuildRegistry(fakeLLM{}, nil, nil, 30*time.Second) + svc := BuildAgentService(fakeLLM{}, reg, nil, nil, nil, "test-model") + if svc == nil { + t.Fatal("BuildAgentService вернул nil") + } +} diff --git a/internal/botclient/botclient.go b/internal/botclient/botclient.go new file mode 100644 index 0000000..e2c644c --- /dev/null +++ b/internal/botclient/botclient.go @@ -0,0 +1,48 @@ +// Package botclient — прямой доступ к ядру бота (agent.Service) в обход +// Telegram-транспорта. Инструмент для ручного/тестового прогона фич на реплике. +// +// Инвариант безопасности: по умолчанию read-only — используется connection +// string реплики с ролью botclient_ro, запись отклоняется на уровне БД. +// Запись возможна только явным --allow-writes + отдельным write-URL. +package botclient + +import ( + "context" + "errors" + + "simpleAI/internal/agent" +) + +// Options управляет выбором подключения к БД (guard read-only). +type Options struct { + AllowWrites bool + ReadOnlyURL string // BOTCLIENT_DATABASE_URL (роль botclient_ro) + WriteURL string // BOTCLIENT_DATABASE_URL_RW (write-роль), нужен только при AllowWrites +} + +// ErrNoReadOnlyURL и ErrNoWriteURL — явные ошибки конфигурации подключения. +var ( + ErrNoReadOnlyURL = errors.New("botclient: не задан BOTCLIENT_DATABASE_URL (read-only реплика)") + ErrNoWriteURL = errors.New("botclient: --allow-writes требует BOTCLIENT_DATABASE_URL_RW") +) + +// ResolveDatabaseURL выбирает connection string, форсируя read-only по умолчанию. +// Это НЕ косметика: default всегда возвращает read-only URL, а запись требует +// и явного флага, и отдельного write-URL — иначе ошибка, а не тихий фолбэк. +func ResolveDatabaseURL(o Options) (string, error) { + if !o.AllowWrites { + if o.ReadOnlyURL == "" { + return "", ErrNoReadOnlyURL + } + return o.ReadOnlyURL, nil + } + if o.WriteURL == "" { + return "", ErrNoWriteURL + } + return o.WriteURL, nil +} + +// Ask прогоняет один запрос через собранный agent.Service с привязкой chatID. +func Ask(ctx context.Context, svc *agent.Service, prompt string, chatID int64) (string, error) { + return svc.AskWithMeta(ctx, prompt, &chatID) +} diff --git a/internal/botclient/botclient_integration_test.go b/internal/botclient/botclient_integration_test.go new file mode 100644 index 0000000..32a7131 --- /dev/null +++ b/internal/botclient/botclient_integration_test.go @@ -0,0 +1,47 @@ +package botclient + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestReadOnlyPoolRejectsWrite доказывает enforcement на уровне БД: пул, +// собранный из BOTCLIENT_DATABASE_URL (роль botclient_ro), физически не может +// писать. Запускается только когда задан BOTCLIENT_DATABASE_URL (локальная +// реплика поднята); иначе пропускается, чтобы не ломать обычный CI. +// +// Прогон: +// +// set -a; . ~/.simpleai-replica/botclient.env; set +a +// go test ./internal/botclient/ -run TestReadOnlyPoolRejectsWrite -v +// +// Мутация: если default-режим начнёт отдавать write-роль, тест краснеет. +func TestReadOnlyPoolRejectsWrite(t *testing.T) { + url := os.Getenv("BOTCLIENT_DATABASE_URL") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL не задан — реплика не поднята, пропускаю integration-проверку") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect read-only pool: %v", err) + } + defer pool.Close() + + // SELECT должен работать. + var n int + if err := pool.QueryRow(ctx, "select count(*) from budget_transaction").Scan(&n); err != nil { + t.Fatalf("read-only SELECT должен работать, got: %v", err) + } + + // Любая запись — отклонена ролью. + if _, err := pool.Exec(ctx, "create table botclient_probe(x int)"); err == nil { + t.Fatal("ожидалось permission denied на read-only пуле, но запись прошла") + } else if !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("ожидалось 'permission denied', got: %v", err) + } +} diff --git a/internal/botclient/botclient_test.go b/internal/botclient/botclient_test.go new file mode 100644 index 0000000..b9989d9 --- /dev/null +++ b/internal/botclient/botclient_test.go @@ -0,0 +1,56 @@ +package botclient + +import ( + "errors" + "testing" +) + +func TestResolveDatabaseURL(t *testing.T) { + const ro = "postgres://botclient_ro:pw@localhost:55432/simpleai" + const rw = "postgres://simpleai:pw@localhost:55432/simpleai" + + tests := []struct { + name string + opt Options + want string + wantErr error + }{ + { + name: "default read-only returns RO url", + opt: Options{AllowWrites: false, ReadOnlyURL: ro, WriteURL: rw}, + want: ro, + }, + { + name: "read-only without RO url errors", + opt: Options{AllowWrites: false, ReadOnlyURL: "", WriteURL: rw}, + wantErr: ErrNoReadOnlyURL, + }, + { + name: "allow-writes returns RW url", + opt: Options{AllowWrites: true, ReadOnlyURL: ro, WriteURL: rw}, + want: rw, + }, + { + name: "allow-writes without RW url errors (not silent RO fallback)", + opt: Options{AllowWrites: true, ReadOnlyURL: ro, WriteURL: ""}, + wantErr: ErrNoWriteURL, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveDatabaseURL(tt.opt) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("want err %v, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != tt.want { + t.Errorf("want %q, got %q", tt.want, got) + } + }) + } +} diff --git a/scripts/refresh-replica.sh b/scripts/refresh-replica.sh new file mode 100755 index 0000000..4c93ba4 --- /dev/null +++ b/scripts/refresh-replica.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# refresh-replica.sh — обновить локальную read-only реплику боевой БД. +# +# Прод НЕ мутируется: только pg_dump (read-only) внутри прод-контейнера через SSH. +# Локально: restore в docker-контейнер simpleai-postgres + пересоздание read-only +# роли botclient_ro (её SELECT-гранты слетают при --clean, поэтому выдаём заново). +# +# GRACEFUL: любой недоступный ресурс (docker спит, комп/сеть/прод недоступны, +# postgres не поднят) — НЕ фатально: пишем warn в лог с таймстампом и выходим 0, +# оставляя предыдущую реплику нетронутой. launchd не спамит и не копит ошибки. +# +# Ручной запуск: make -f MakeFile refresh-replica (или bash scripts/refresh-replica.sh) +# Почасовой: launchd — deploy/launchd/com.simpleai.replica-refresh.plist +# +set -uo pipefail + +# --- config --- +SSH_HOST="${REPLICA_SSH_HOST:-simpleai}" +PROD_CONTAINER="${REPLICA_PROD_CONTAINER:-simpleai-postgres}" +LOCAL_CONTAINER="${REPLICA_LOCAL_CONTAINER:-simpleai-postgres}" +DB="${POSTGRES_DB:-simpleai}" +DB_USER="${POSTGRES_USER:-simpleai}" +DUMP_DIR="${REPLICA_DUMP_DIR:-$HOME/.simpleai-replica}" +ENV_FILE="$DUMP_DIR/botclient.env" +LOG_FILE="${REPLICA_LOG_FILE:-$DUMP_DIR/refresh.log}" +DOCKER="${DOCKER_BIN:-docker}" +SSH="${SSH_BIN:-ssh}" +KEEP_DUMPS="${REPLICA_KEEP_DUMPS:-7}" +SSH_TIMEOUT="${REPLICA_SSH_TIMEOUT:-15}" + +mkdir -p "$DUMP_DIR" + +log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "$2" | tee -a "$LOG_FILE" >&2; } +info() { log INFO "$1"; } +warn() { log WARN "$1"; } +# soft-fail: не фатально — пишем warn и выходим 0 (реплика остаётся прежней). +skip() { warn "SKIP: $1 → реплика не обновлена, выхожу без ошибки"; exit 0; } + +info "=== refresh-replica start ===" + +# --- 0. preflight: docker daemon --- +if ! "$DOCKER" info >/dev/null 2>&1; then + skip "docker daemon недоступен (Docker спит/не запущен)" +fi +# локальный контейнер поднят? +if ! "$DOCKER" exec "$LOCAL_CONTAINER" pg_isready -U "$DB_USER" >/dev/null 2>&1; then + skip "локальный контейнер $LOCAL_CONTAINER не готов (postgres не поднят)" +fi +# прод достижим по ssh? +if ! "$SSH" -o BatchMode=yes -o ConnectTimeout="$SSH_TIMEOUT" "$SSH_HOST" 'true' >/dev/null 2>&1; then + skip "прод $SSH_HOST недоступен по SSH (комп/сеть/сервер offline)" +fi + +# --- read-only роль: пароль стабилен между рефрешами --- +RO_PASS="" +if [[ -f "$ENV_FILE" ]]; then + RO_PASS="$(sed -n 's#.*botclient_ro:\([^@]*\)@.*#\1#p' "$ENV_FILE")" +fi +if [[ -z "$RO_PASS" ]]; then + RO_PASS="$(openssl rand -hex 16)" + info "botclient.env отсутствует — сгенерирован новый пароль read-only роли" +fi + +# --- 1. dump прода (read-only) --- +DUMP="$DUMP_DIR/prod-$(date +%Y%m%d-%H%M%S).dump" +info "dump прода через ssh $SSH_HOST (read-only)..." +if ! "$SSH" -o BatchMode=yes -o ConnectTimeout="$SSH_TIMEOUT" "$SSH_HOST" \ + "docker exec $PROD_CONTAINER pg_dump -U $DB_USER -Fc $DB" > "$DUMP" 2>>"$LOG_FILE"; then + rm -f "$DUMP"; skip "pg_dump на проде упал (контейнер прода не запущен?)" +fi +if [[ ! -s "$DUMP" ]]; then + rm -f "$DUMP"; skip "получен пустой дамп" +fi +info "дамп: $(du -h "$DUMP" | cut -f1)" + +# --- 2. restore в локальный контейнер --- +info "restore в локальный $LOCAL_CONTAINER..." +if ! "$DOCKER" exec -i "$LOCAL_CONTAINER" pg_restore -U "$DB_USER" -d "$DB" \ + --clean --if-exists --no-owner --no-privileges < "$DUMP" 2>>"$LOG_FILE"; then + warn "pg_restore вернул ненулевой код (возможны безобидные NOTICE) — проверяю данные ниже" +fi + +# --- 3. read-only роль + гранты (идемпотентно) --- +if ! "$DOCKER" exec -i "$LOCAL_CONTAINER" psql -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1 >/dev/null 2>>"$LOG_FILE" </dev/null | head -1 | sed 's/.*://')" +HOST_PORT="${HOST_PORT:-55432}" +printf 'BOTCLIENT_DATABASE_URL=postgres://botclient_ro:%s@localhost:%s/%s\n' \ + "$RO_PASS" "$HOST_PORT" "$DB" > "$ENV_FILE" +chmod 600 "$ENV_FILE" + +# --- 5. prune старых дампов --- +ls -t "$DUMP_DIR"/prod-*.dump 2>/dev/null | tail -n +"$((KEEP_DUMPS+1))" | xargs -r rm -f + +TX="$("$DOCKER" exec "$LOCAL_CONTAINER" psql -U "$DB_USER" -d "$DB" -tAc 'select count(*) from budget_transaction' 2>/dev/null | tr -d '[:space:]')" +info "OK: budget_transaction=${TX:-?} строк, порт=$HOST_PORT, conn → $ENV_FILE" +info "=== refresh-replica done ===" From b301104820a1b973d2f1efa3a4db691a8fea83dc Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Thu, 23 Jul 2026 18:17:24 +0700 Subject: [PATCH 3/3] =?UTF-8?q?chore(beads):=20sync=20=E2=80=94=20=D1=8D?= =?UTF-8?q?=D0=BF=D0=B8=D0=BA=20safe-to-spend=20(ADR-007)=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 601c4fa..ea96cdb 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -5,6 +5,7 @@ {"id":"simpleAI-0ny","title":"fix(budget): убрать 'Баланс месяца' из ответа add_income/add_expense","description":"Блок 'Баланс месяца: ... (доходы ~..., расходы ~...)' в ответе на add_income/add_expense вводит в заблуждение: это просто факт.доход − факт.расход за месяц, без учёта recurring/долгов/обязательств. Пользователь принимает число за 'свободно к тратам'. Решение: удалить блок, оставить только строку об успешной записи. Файл internal/skills/budget_skill.go (formatTransaction, addTransaction). Параметры summary/rates у formatTransaction убрать, GetSummary/GetExchangeRates в addTransaction убрать.","status":"closed","priority":2,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-08T18:23:37.399813+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-08T18:33:11.260318+07:00","closed_at":"2026-05-08T18:33:11.260318+07:00","close_reason":"Удалён блок 'Баланс месяца' из ответа add_income/add_expense. formatTransaction упрощён (без summary/rates). GetSummary/GetExchangeRates вызовы убраны из addTransaction. Файл: internal/skills/budget_skill.go. Тесты: go test ./internal/skills/... — PASS. Build: PASS. Lint: PASS. Коммит 1938f7b запушен в origin/main."} {"id":"simpleAI-0rm","title":"[Бюджет] Фаза 5: Тесты и документация","description":"Родитель: simpleAI-f94\nЗависит от: simpleAI-igw\n\n1. Unit-тесты store: TestAddTransaction, TestGetSummary, TestGoalProgress, TestDebtPayment\n2. Unit-тесты BudgetSkill.Run: парсинг actions, edge cases\n3. Обновить README.md: секция Budget Tracker, примеры команд\n4. Обновить docs/db_schema.md и docs/db_schema.sql\n5. Обновить TECHNICAL_README.md: описание internal/budget/ и budget_skill","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-03-07T15:03:34.255435+07:00","created_by":"Claude (AI)","updated_at":"2026-05-04T11:30:56.181936+07:00","closed_at":"2026-05-04T11:30:56.181936+07:00","close_reason":"Отменено: тесты признаны нецелесообразными на данном этапе"} {"id":"simpleAI-0tb","title":"task/simpleAI-agent-fix: Fix agent orchestrator — правильный tool calling и защита от prompt injection","description":"Ветка: task/simpleAI-agent-fix\nФайлы: internal/agent/service.go, internal/rag/prompt.go\nПроблемы:\n1. Инструкции по тулзам уходят в user message вместо system — LLM путается\n2. BuildPrompt вставляет контент документов RAW — уязвимость prompt injection (пример: Chinese SQL в ответе бота)\nИсправления:\n- agent.Service использует AskWithSystem для tool calling инструкций\n- rag.BuildPrompt оборачивает контент в XML-теги \u003cdocument\u003e для изоляции\n- Улучшить извлечение JSON из ответа LLM (поддержать markdown code blocks)\n- Улучшить buildToolsPrompt: четкий system prompt\nAcceptance: бот не выполняет инструкции из контента документов; tool calling работает корректно","status":"closed","priority":2,"issue_type":"task","assignee":"Taras Malinovskii","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T15:27:30.51858+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T15:31:04.332773+07:00","closed_at":"2026-02-15T15:31:04.332773+07:00","close_reason":"Closed"} +{"id":"simpleAI-0vqt","title":"infra: cmd/botclient — прямой тест-клиент к ядру бота (read-only default)","description":"Тонкий CLI поверх internal/appwiring: шлёт текст в agent.Service.AskWithMeta, печатает ответ. Транспорт-независимый доступ к мозгу бота (Telegram/MCP — обёртки). Основание: @architect YES. AC: (1) cmd/botclient '\u003cтекст\u003e' собирает боевой Service, connection string из env (не коммитить), печатает ответ; (2) дефолт read-only, мутации только с --allow-writes; (3) ENFORCEMENT доказан тестом: read-only режим отклоняет мутацию (add_expense) на уровне механизма (предпочт. отдельная read-only PG-роль в conn string; альтернатива — context-guard), тест краснеет при снятии guard; (4) TDD: wiring+флаги+smoke через mockLLM, краснеет при return nil в отправке; (5) числовые ассерты — не сюда, в budget-юниты; (6) README: запуск, env conn string, семантика --allow-writes. Переиспользовать mockLLM/mockSkill из internal/agent/service_test.go, не плодить новый harness.","notes":"DONE. cmd/botclient + internal/botclient. Read-only дефолт через BOTCLIENT_DATABASE_URL (роль botclient_ro); --allow-writes требует BOTCLIENT_DATABASE_URL_RW иначе ошибка. Тесты: ResolveDatabaseURL (4 кейса, мутация guard→красный), integration TestReadOnlyPoolRejectsWrite (реальная реплика: SELECT ok, CREATE TABLE→permission denied; skip если env нет). go build+весь go test зелёные, gofmt чисто. Живой прогон deepseek на реплике подтвердил enforcement (add_income отклонён БД) и вскрыл баги (см. close reason).","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T11:23:49.779121+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T11:55:10.447812+07:00","closed_at":"2026-07-23T11:55:10.447812+07:00","close_reason":"botclient построен по TDD (unit+integration+mutation), read-only enforcement доказан на живой реплике. Эмпирический прогон 127k выполнен, баги задокументированы.","dependencies":[{"issue_id":"simpleAI-0vqt","depends_on_id":"simpleAI-fm3g","type":"blocks","created_at":"2026-07-23T11:23:49.900865+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-0y0","title":"[Forecast] forecast action в BudgetSkill","description":"internal/skills/budget_skill.go:\n- добавить 'forecast' в manifest (параметр months int, опционально)\n- метод forecastAction(ctx, req) → вызывает store.GetForecastData() → форматирует ответ\n- Блок 🔮 с категориями, стрелками тренда (\u003c ±3% → стабильно), итогом по валютам","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-04T20:19:52.548222+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-04T20:22:24.542854+07:00","closed_at":"2026-04-04T20:22:24.542854+07:00","close_reason":"forecast action в BudgetSkill: manifest, switch case, forecastAction(), formatForecastBlock(), trendLabel(). Компилируется.","dependencies":[{"issue_id":"simpleAI-0y0","depends_on_id":"simpleAI-7ab","type":"blocks","created_at":"2026-04-04T20:19:57.063093+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-0ya","title":"observability: запустить Langfuse UI в docker-compose","description":"## Цель\nПоднять Langfuse self-hosted UI локально через docker-compose чтобы трейсы из simpleAI агента можно было смотреть в UI.\n\n## Контекст\nЗадача simpleAI-8h8 (инструментация SDK) предполагает работающий Langfuse instance на http://localhost:3001 с заданными ключами. Сейчас сервис не задеплоен — без него инструментация некуда писать.\n\n## Scope\n- Добавить сервис(ы) langfuse в docker-compose.yml (или отдельный compose-файл deployments/langfuse/)\n- Зависимости: postgres (можно использовать существующий или отдельную инстанс), clickhouse, redis (по требованиям актуальной версии Langfuse v3)\n- Прокинуть env: NEXTAUTH_SECRET, SALT, ENCRYPTION_KEY, DATABASE_URL и т.д.\n- UI должен быть доступен на http://localhost:3001\n\n## Acceptance criteria\n- [ ] docker compose up -d поднимает Langfuse + зависимости без ошибок\n- [ ] UI открывается на http://localhost:3001, проект rag-mm создан (или инструкция как создать вручную)\n- [ ] API-ключи pk-lf-rag-mm-dr-local / sk-lf-... работают (проверить простым curl trace ingest)\n- [ ] README/CONTEXT — короткая инструкция запуск/стоп\n- [ ] .env.example обновлён (если нужны новые переменные)\n\n## Зависимости\nБлокирует simpleAI-8h8 (инструментация). После закрытия — 8h8 можно разблокировать.","notes":"Реализовано: deploy/langfuse/{docker-compose.yml,.env.example,README.md} + блок Langfuse в корневом .env.example.\n\nСтек: langfuse-web(3001) + worker + postgres(5433) + clickhouse + redis + minio(9190/9191). Все sensitive порты на 127.0.0.1.\n\nКонфликты портов: 5432→5433 (simpleai-postgres), 9090→9190 (prometheus).\n\nVerification:\n- docker compose --env-file .env up -d → все 6 контейнеров healthy\n- curl http://localhost:3001/api/public/health → {\"status\":\"OK\",\"version\":\"3.172.1\"}\n- curl -u pk-lf-rag-mm-dr-local:sk-lf-... /api/public/projects → проект rag-mm найден, org simpleAI\n\nЗаметка: LANGFUSE_INIT_USER_EMAIL должен валидироваться как email (admin@local.dev, не admin@local) — поправлено в .env, но в .env.example осталось 'admin@local' → надо обновить пример.\n\nPending: /review, commit, push.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T19:51:10.092326+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-05T08:59:34.657558+07:00","closed_at":"2026-05-05T08:59:34.657558+07:00","close_reason":"docker-compose Langfuse v3 поднят, UI на :3001 healthy, проект rag-mm + ключи seed-нуты, README + .env.example. Commit 8071be6."} {"id":"simpleAI-11s","title":"docs(observability): инструкция как пользоваться Langfuse","description":"Расширить deploy/langfuse/README.md разделом: как запустить стек, как смотреть трейсы, как агент туда пишет, как отключить.","status":"closed","priority":3,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-05T15:04:53.992652+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-05T15:06:03.706869+07:00","closed_at":"2026-05-05T15:06:03.706869+07:00","close_reason":"docs added: deploy/langfuse/README.md + раздел Observability в README.md"} @@ -30,11 +31,13 @@ {"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."} {"id":"simpleAI-399","title":"routing: разделить triggers list_transactions vs summary в budget Manifest","description":"Кейс r017 'покажи траты за неделю' уходит в budget.summary вместо budget.list_transactions (baseline 2026-05-06, accuracy_list_transactions=0.67).\n\nПричина: в budget Manifest (internal/skills/budget_skill.go) описание action не разделяет 'покажи список' vs 'итог' — LLM путает.\n\nЧто сделать:\n- Уточнить description action'ов summary и list_transactions в Manifest\n- Добавить явные триггеры: list_transactions = 'покажи/список/что покупал', summary = 'сколько/итого/сумма'\n- Перепрогнать baseline, проверить r017 PASS\n\nDoD:\n- r017 PASS\n- accuracy не упала на других кейсах (проверка via -prev evals/runs/baseline-2026-05-06.jsonl)\n- лимит на регрессию: regressed=[]\n\nИсточник: simpleAI-lpy BASELINE.md","status":"open","priority":3,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-06T09:29:54.555901+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-06T09:29:54.555901+07:00"} +{"id":"simpleAI-3k6n","title":"[STS-5] Производный остаток + проверка недвойного учёта","description":"'Сколько свободно осталось' = период-агрегация фактических транзакций поверх конверта. ADR §4. AC: остаток==агрегация транзакций за период; тест недвойного учёта против FreeCash; мутация LLM не меняет числа. Epic simpleAI-k5j4.","notes":"DONE. Режим остатка в safe_to_spend (remaining.go): без суммы → runRemaining читает активный конверт, считает Remaining = Income − Recurring − Debt − Planned − ActualSpent (фактические траты за [start,now], discretionary). Остаток НЕ хранится (ADR §4 недвойной учёт): каждое слагаемое из одного источника; discretionarySpentTHB исключает Переводы/кредиты чтобы не задваивать обязательства. Манифест: amount опционален + триггеры режима остатка; negative-prompt vs start_envelope/add_income. Тесты: computeRemaining (unit+мутация вычитания spent), discretionarySpentTHB. Живой e2e: 'сколько свободных осталось?' → Свободно осталось 22358₽ (потрачено 1942 по факту). Routing r042 PASS.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.817163+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T16:16:45.542914+07:00","closed_at":"2026-07-23T16:16:45.542914+07:00","close_reason":"Производный остаток из фактических транзакций (не хранится), недвойной учёт (discretionary фильтр). Тесты+мутация+живой e2e.","dependencies":[{"issue_id":"simpleAI-3k6n","depends_on_id":"simpleAI-tsg4","type":"blocks","created_at":"2026-07-23T12:15:20.363321+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-3k6n","depends_on_id":"simpleAI-adoo","type":"blocks","created_at":"2026-07-23T12:15:20.725452+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-3lf","title":"feat(budget): action compare — сравнение двух месяцев","description":"Новый action compare в BudgetSkill.\n\nВходные параметры: period (базовый, по умолчанию текущий месяц), compare_to (предыдущий месяц по умолчанию).\n\nВывод:\n 📊 Март vs Февраль\n Расходы: 8200 ฿ vs 6100 ฿ (+34%)\n Топ рост: еда +40%, транспорт +20%\n Топ снижение: развлечения -15%\n\nРеализация:\n- GetSummary вызывается дважды для двух периодов\n- diff по категориям считается в форматере\n- Добавить action compare в Manifest и switch в Run\n\nЗатрагиваемые файлы:\n- internal/skills/budget_skill.go (новый метод compare + formatCompare)","status":"open","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T19:13:38.200727+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T19:13:38.200727+07:00"} {"id":"simpleAI-3ol","title":"epic/python-migration: [Фаза 4] Python: базовая структура aiogram 3","description":"Родитель: simpleAI-0ma\nФаза: 4 — после ADR (фаза 3)\n\nСоздать python/ директорию (или отдельный репо):\n- pyproject.toml / requirements.txt: aiogram\u003e=3.0, asyncpg, pydantic, openai\n- python/bot/main.py: Application setup, router, graceful shutdown\n- python/bot/middleware/: allowed_chats, rate_limit, deduplication\n- python/bot/handlers/: start, help, default (agent)\n- python/bot/config.py: pydantic Settings из .env\n\nAcceptance:\n- Бот стартует, отвечает на /start и /help\n- AllowedChats middleware блокирует посторонних\n- Тесты middleware зелёные (pytest-asyncio)\n- Поведение совпадает с фаза-1d тестами (те же сценарии, теперь на Python)","status":"open","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T11:30:25.900103+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T11:30:25.900103+07:00"} {"id":"simpleAI-48n","title":"[Recurring] Skill actions: add/list/disable_recurring","description":"internal/skills/budget_skill.go — добавить в manifest actions: add_recurring, list_recurring, disable_recurring. Параметры add_recurring: name, amount, category, currency, day_of_month. list_recurring возвращает все (enabled+disabled). disable_recurring по id или name.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-04T19:41:18.111024+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-04T19:45:50.012299+07:00","closed_at":"2026-04-04T19:45:50.012299+07:00","close_reason":"budget_skill.go: добавлены actions add_recurring, list_recurring, disable_recurring. Новые поля в budgetInput: recurring_id, day_of_month. Параметры в manifest. Компилируется.","dependencies":[{"issue_id":"simpleAI-48n","depends_on_id":"simpleAI-7au","type":"blocks","created_at":"2026-04-04T19:41:22.312763+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-491","title":"Telegram меню и ускорение Ollama","description":"Добавить меню команд бота и ускорить Ollama: выбрать быструю модель по умолчанию и fallback на более быструю при таймауте.","notes":"Старт: добавляю меню команд Telegram и ускорение Ollama (быстрая модель по умолчанию + fallback при таймауте).\nДобавлено меню команд Telegram через адаптер; Ollama: дефолтная быстрая модель qwen2.5:7b-instruct-q4_K_M, fallback на OLLAMA_FALLBACK_MODEL при таймауте/404. Документация обновлена.","status":"closed","priority":0,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T14:43:52.518755+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T14:46:34.58807+07:00","closed_at":"2026-02-08T14:46:34.58807+07:00","close_reason":"Добавлено меню команд Telegram и ускорен Ollama (быстрая модель + fallback)"} {"id":"simpleAI-4e9","title":"Плагины/навыки и контракты","description":"Определить интерфейс навыков, реестр плагинов, контракты инструментов и эталонный плагин.","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:09.925761+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T15:11:26.359461+07:00","closed_at":"2026-02-15T15:11:26.359461+07:00","close_reason":"Closed"} +{"id":"simpleAI-4ekk","title":"[STS-6] planned_expense (ручные плановые траты) + eval против эталона 127k→27800","description":"Сущность planned_expense (разовые плановые траты, chat-scoped). Eval-сьют ADR-003 против эталона l960: приход 127000 − известные(36400+28000+15600+6720+5200+3380+3900=99200) = 27800 свободных + предупреждение про продукты/транспорт/массажи из статистики. Детерминир., не через LLM-числа. Epic simpleAI-k5j4.","notes":"\nДОБАВЛЕНО: (a) budget action add_planned_expense (planned.go) + manifest-триггеры ('запланируй трату X на Y') + golden r040; юзер сам вносит планы. (b) 2-НЕДЕЛЬНЫЙ ГОРИЗОНТ по умолчанию (интервал между приходами ≤2нед): resolvePeriod default = [now, now+14d] с меткой 'ближайшие 2 недели'; 'month'/'YYYY-MM' явно. Прогноз трат пропорц. ужат к длине периода (days/30): месячные 108605→~54302 за 2 недели. Живой прогон: горизонт 2 недели, свободно после известных 24300₽, прогноз быта 54302₽.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.975313+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T16:04:35.691004+07:00","closed_at":"2026-07-23T16:04:35.691004+07:00","close_reason":"planned_expense + add_planned_expense action + 2-недельный горизонт по умолчанию + пропорц. прогноз. Acceptance 127k→27800 (детерминир. PASS; живой прогон). Полный сьют зелёный.","dependencies":[{"issue_id":"simpleAI-4ekk","depends_on_id":"simpleAI-3k6n","type":"blocks","created_at":"2026-07-23T12:15:20.854276+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-4fc","title":"EPIC: Refactor по образцу assister/rag_mm — структура и удобство","description":"## Цель\nПривести архитектуру simpleAI к чистому layered-дизайну: skills — бизнес-логика, bot-адаптер — форматирование, prompts — отдельно.\n\n## Ключевые архитектурные инсайты (2026-05-08)\n1. **Skills отвечают за логику И форматирование** — нарушение SRP. Нельзя тестировать логику без форматирования.\n2. **Telegram-строки живут в domain-слое** (skills/budget_format.go) — должны быть в bot-адаптере.\n3. **Skills возвращают string** — не типизировано, не тестируемо. Должны возвращать структуры.\n4. **Промпты захардкожены константами** в .go-файлах вместо отдельных файлов.\n\n## Целевая архитектура\n```\ninternal/skills/\n budget/\n handler.go ← Run() + Manifest() — тонкий роутер\n logic.go ← бизнес-логика → возвращает типизированные структуры\n models.go ← BudgetResult, SummaryResult, TransactionResult, ...\n advisor/\n handler.go\n logic.go\n models.go\n prompts.go ← prompt templates (или go:embed из prompts/)\n\ninternal/bot/\n budget_format.go ← BudgetResult → Telegram Markdown\n advisor_format.go ← AdvisorResult → Telegram Markdown\n\ninternal/agent/\n router.go ← parseToolCalls + buildSystemPrompt\n loop.go ← итерации агента\n service.go ← только координация\n```\n\n## Подзадачи (поэтапно)\nA. **prompts/ директория** — вынести промпты в yaml/txt + go:embed\nB. **Split skills/budget/** — разложить budget_skill.go по файлам в папке budget/\nC. **Split skills/advisor/** — разложить advisor_skill.go по файлам в папке advisor/\nD. **Типизированные возвращаемые значения** — skills возвращают structs вместо string\nE. **Форматирование → bot-адаптер** — перенести budget_format.go и advisor format в internal/bot/\nF. **Выделить router/loop из agent/service.go**\nG. **ADR-003: prompt-as-file convention**\nH. **ADR-004: skill folder structure**\n\n## Порядок выполнения\nB → C → D → E (форматирование только после типизации) → A → F → G,H\n\n## Definition of Done\n- Подзадачи B-F закрыты с /review = PASS\n- go test ./... + golangci-lint = green\n- Все smoke-кейсы (advisor + budget) без регрессий\n- Skills не содержат ни одного Telegram-специфичного символа (*_`)","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-05-05T08:32:03.774801+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-09T08:19:41.18762+07:00","closed_at":"2026-05-09T08:19:41.18762+07:00","close_reason":"Все 6 подзадач (B-F + router/loop) закрыты. Архитектура приведена к layered-дизайну: bot/format/, prompts/, agent/router.go+loop.go. go test ./... green.","dependencies":[{"issue_id":"simpleAI-4fc","depends_on_id":"simpleAI-5mi","type":"blocks","created_at":"2026-05-05T08:33:18.773653+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-4fc","depends_on_id":"simpleAI-ckb","type":"blocks","created_at":"2026-05-05T08:33:18.916967+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-4fc","depends_on_id":"simpleAI-2vw","type":"blocks","created_at":"2026-05-05T08:33:19.063037+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-4fc","depends_on_id":"simpleAI-sqz","type":"blocks","created_at":"2026-05-08T20:32:38.823794+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-4fc","depends_on_id":"simpleAI-njq","type":"blocks","created_at":"2026-05-08T20:32:38.946708+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-4fc","depends_on_id":"simpleAI-4u6s","type":"blocks","created_at":"2026-05-08T20:32:39.06471+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-4l2","title":"refactor: split budget_skill.go into domain files","description":"budget_skill.go is 1384 lines with 7 domains mixed together. Split into: budget_transactions.go, budget_goals.go, budget_debts.go, budget_recurring.go, budget_forecast.go, budget_format.go. Main file keeps only BudgetSkill struct, Manifest, Run, budgetInput.","status":"closed","priority":3,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-08T20:18:32.818342+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-08T20:22:24.058678+07:00","closed_at":"2026-05-08T20:22:24.058678+07:00","close_reason":"split budget_skill.go (1384 lines) into 6 domain files: budget_transactions.go, budget_goals.go, budget_debts.go, budget_recurring.go, budget_forecast.go, budget_format.go. budget_skill.go now 289 lines (struct+Manifest+Run+summary). go build + go test pass."} {"id":"simpleAI-4u6s","title":"refactor: Telegram-форматирование → internal/bot/ (подзадача E)","description":"## Цель\nУбрать Telegram-специфичный код из skills/ в bot-адаптер. Подзадача E эпика simpleAI-4fc.\n\n## Мотивация\n- skills/ — domain layer, не должен знать про Telegram Markdown\n- Завтра появится второй адаптер (Web/Slack) — skills трогать не придётся\n- Invariant: ни одного символа * _ ` в skills/\n\n## Что сделать\n1. Создать internal/bot/format/ (или internal/bot/skillformat/)\n2. Перенести budget/format.go и advisor/format.go туда\n3. handler.go в skills вызывает logic.go → struct, handler.go же вызывает bot/format → string\n ИЛИ: handler.go возвращает struct, bot вызывает format сам — зависит от plugin.Skill interface\n4. Уточнить plugin.Skill interface: Run() string или Run() (SkillResult, error)?\n\n## Зависит от\n- simpleAI-njq (типизированные структуры) — форматирование переносить имеет смысл только после этого\n\n## DoD\n- internal/skills/ не содержит Telegram Markdown (escapeTelegramMarkdown, *bold*, _italic_)\n- internal/bot/ рендерит результаты\n- go test ./... green, /review PASS","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-08T20:32:32.110594+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-09T08:10:14.443146+07:00","closed_at":"2026-05-09T08:10:14.443146+07:00","close_reason":"Создан internal/bot/format/ (telegram.go, advisor.go). escapeTelegramMarkdown, formatAdvisorReply, formatAnalyzeReply перенесены туда. renderRecurringList очищен от * markdown. advisor/advice.go и analyze.go используют botformat.*. Тесты обновлены. go test ./... green.","dependencies":[{"issue_id":"simpleAI-4u6s","depends_on_id":"simpleAI-njq","type":"blocks","created_at":"2026-05-08T20:32:35.707567+07:00","created_by":"Taras Malinovskii"}]} @@ -76,6 +79,7 @@ {"id":"simpleAI-9og","title":"epic/python-migration: [Фаза 1c] Unit-тесты ReminderWorker + notify.Telegram","description":"Родитель: simpleAI-0ma\nФаза: 1 (тесты)\nЗависит от: simpleAI-l7c (параллельно)\n\nФайлы: internal/notify/reminder_worker_test.go (новый)\n\nЧто тестировать:\n- ReminderWorker.check: нет активных reminders → ничего не отправляется\n- check: reminder enabled, время совпадает → SendToChatID вызывается\n- check: reminder enabled, время не совпадает → не отправляется\n- check: reminder disabled → не отправляется\n- check: невалидный timezone → fallback на UTC\n- Параллельно: добавить NoopSender / InMemorySender в notify (связано с #31)\n\nAC: go test ./internal/notify/... — все сценарии green.\nПобочный эффект: появляется notify.InMemorySender для будущих тестов.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T11:19:18.944962+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-19T09:11:01.728346+07:00","closed_at":"2026-04-19T09:11:01.728346+07:00","close_reason":"Создан reminder_worker_test.go: 6 тестов — нет reminders, время совпадает/не совпадает, disabled, невалидный timezone (UTC fallback), несколько напоминаний. InMemorySender добавлен для будущих тестов. go test ./internal/notify/... PASS."} {"id":"simpleAI-9wq","title":"advisor: усилить routing — русские триггеры + negative в budget manifest","description":"Smoke на dev: запрос «Планирую купить байк остальное смотри сам» роутится в budget.add_expense (amount=0 → ошибка amount must be positive), а должен в advisor.\n\nПричины:\n1. budget.Manifest.Description слишком широкая (talks about spending, money, budget) и захватывает affordability-вопросы\n2. advisor.Manifest на английском без русских триггеров (планирую купить / хочу купить / стоит ли)\n\nFix:\n- Усилить advisor description: добавить русские триггеры\n- Усилить budget description: добавить negative phrase про advisor (Do NOT use for purchase advice / affordability — use advisor)","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T20:08:08.473777+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-05T08:18:35.256457+07:00","closed_at":"2026-05-05T08:18:35.256457+07:00","close_reason":"Усилены manifest descriptions: advisor получил русские триггеры (планирую/хочу/стоит ли/хватит ли) и явное past-tense-разделение, budget получил negative phrase про advisor + указание past tense ('купил'/'потратил'). go build/test/lint — green. Перепроверить smoke на dev после рестарта бота."} {"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-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."} @@ -110,11 +114,13 @@ {"id":"simpleAI-et2","title":"epic/python-migration: [Фаза 1b] Unit-тесты agent loop — tool calling","description":"Родитель: simpleAI-0ma\nФаза: 1 (тесты)\nЗависит от: simpleAI-l7c (запускать параллельно)\n\nФайлы: internal/agent/service_test.go (расширить существующий)\n\nЧто тестировать:\n- parseToolCalls: JSON-объект, JSON-массив, markdown-блок, невалидный JSON\n- AskWithMeta: chatID инжектится в context (проверить через skill-mock)\n- Агентный loop: финальный ответ без tool calls\n- Агентный loop: один tool call → результат передаётся обратно LLM\n- Агентный loop: несколько tool calls за итерацию\n- Превышение maxIterations → fallback\n\nПодход: мокать core.LLM и plugin.Registry.\n\nAC: go test ./internal/agent/... — все сценарии покрыты.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T11:19:12.481739+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-19T09:09:40.191788+07:00","closed_at":"2026-04-19T09:09:40.191788+07:00","close_reason":"Тесты уже существовали в service_test.go: parseToolCalls, agent loop, maxIterations. go test ./internal/agent/... PASS."} {"id":"simpleAI-f0n","title":"task/simpleAI-agent-fix: Fix /help и передача registry в telegram.Context","description":"Ветка: task/simpleAI-agent-fix\nФайлы: internal/telegram/context.go, internal/telegram/handlers.go, cmd/telegram/main.go\nПроблемы:\n1. /help хардкод, не знает о зарегистрированных skills\n2. Registry недоступен в хендлерах\nИсправления:\n- Добавить Registry *plugin.Registry в telegram.Context\n- HandleHelp показывает динамический список skills из registry\n- cmd/telegram/main.go передаёт registry в Context\nAcceptance: /help показывает актуальный список skills и описания","status":"closed","priority":3,"issue_type":"task","assignee":"Taras Malinovskii","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T15:27:30.629483+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T15:32:13.180202+07:00","closed_at":"2026-02-15T15:32:13.180202+07:00","close_reason":"Closed"} {"id":"simpleAI-f18h","title":"refactor(budget): строго-типизированный TransactionFilter","description":"Заменить TransactionFilter на строго-типизированную версию: typed конст для Direction/Sort/Currency, value-объекты AmountRange/DateRange/Pagination, Validate() метод. Обновить ListTransactions и все call sites.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-09T09:53:17.658112+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-09T09:54:51.948311+07:00","closed_at":"2026-05-09T09:54:51.948311+07:00","close_reason":"Реализован строго-типизированный TransactionFilter: typed конст Currency/Direction/SortField/SortDirection, value-объекты DateRange/AmountRange/Pagination, Validate() с полной проверкой инвариантов. Обновлён ListTransactions (динамический query builder). Call site в transactions.go мигрирован. go build \u0026\u0026 go vet — чисто."} +{"id":"simpleAI-f3b4","title":"feat(safe_to_spend): вердикт-first + чистка подачи + гарантия непересечения planned/forecast","description":"Правки по холодному ревью 3 агентов (#2 корректность + #3 подача). #3: вердикт-заголовок по итогу вверху (не промежуточные суммы), убрать ложный зелёный на 'свободно', 'нехватка' вместо '-запаса', раскрыть плановые по пунктам, нормализовать регистр категорий, явно показать курс ₽/฿ при пересчёте. #2: гарантировать непересечение planned vs forecast (disjoint источники) + тест + пометка. Валюта пока RUB, курс 2.6. Клэрити-бенчмарк агента-3 сохранить в evals для регулярного замера. Эпик k5j4.","status":"closed","priority":1,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T17:44:19.914749+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T18:14:34.022555+07:00","closed_at":"2026-07-23T18:14:34.022555+07:00","close_reason":"Вердикт-first подача + плановые раскрыты + чистка категорий + явный курс + непересечение planned/forecast + клэрити-бенчмарк. Код-ревью: golangci 0 issues (починены 6), весь go test ALL GREEN."} {"id":"simpleAI-f3l","title":"Реестр плагинов и DI","description":"Эпик: Плагины/навыки и контракты. Реестр плагинов и DI-инициализация в internal/agent.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:38.760449+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T12:47:07.082193+07:00","closed_at":"2026-02-08T12:47:07.082193+07:00","close_reason":"Closed","comments":[{"id":58,"issue_id":"simpleAI-f3l","author":"Taras Malinovskii","text":"WIP: добавлен plugin.Registry (register/get/list) и опциональная DI интеграция в Agent.","created_at":"2026-02-08T05:46:23Z"},{"id":59,"issue_id":"simpleAI-f3l","author":"Taras Malinovskii","text":"Готово: Registry для skills + DI hook в Agent.","created_at":"2026-02-08T05:47:16Z"}]} {"id":"simpleAI-f94","title":"Бюджет-трекер: персональный учёт финансов","description":"Эпик: Интеграция полноценного бюджет-трекера в simpleAI.\n\nФункционал:\n1. Отслеживание расходов и доходов по категориям\n2. Баланс доходов/расходов за период\n3. Планирование накоплений и финансовых целей\n4. Контроль долгов и кредитов\n\nРеализация: новый Skill (plugin.Skill) `budget`, работающий через Telegram и MCP.\nУровень детализации: средний — основные статьи расходов.\n\nФазы:\n- Фаза 1: Схема БД и миграции\n- Фаза 2: Модели и store (CRUD)\n- Фаза 3: BudgetSkill — реализация plugin.Skill\n- Фаза 4: Регистрация и интеграция в cmd/app\n- Фаза 5: Тесты и документация","status":"open","priority":1,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-03-07T15:03:34.255435+07:00","created_by":"Claude (AI)","updated_at":"2026-03-07T15:03:34.255435+07:00"} {"id":"simpleAI-fbl","title":"RAG: минимальная схема данных","description":"Эпик: RAG по тратам и чекам (V1). Зафиксировать схему receipt/receipt_item/store/category и связи.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:56.068343+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T12:19:03.732654+07:00","closed_at":"2026-02-08T12:19:03.732654+07:00","close_reason":"Closed","comments":[{"id":37,"issue_id":"simpleAI-fbl","author":"Taras Malinovskii","text":"WIP: добавлен store в схему (migration 00004), receipt.store_id, обновлены ingest input/store_name и db_schema docs.","created_at":"2026-02-08T05:18:16Z"},{"id":38,"issue_id":"simpleAI-fbl","author":"Taras Malinovskii","text":"Готово: добавлен store (таблица + receipt.store_id), обновлены ingest и docs/schema + пример payload.","created_at":"2026-02-08T05:19:10Z"}]} {"id":"simpleAI-fkn","title":"Интеграция бота с агентом","description":"Эпик: Telegram-бот как фасад системы. Единый интерфейс запрос/ответ, формат контекста и проброс метаданных.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:28:46.792751+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T11:42:53.680368+07:00","closed_at":"2026-02-08T11:42:53.680368+07:00","close_reason":"Closed","comments":[{"id":14,"issue_id":"simpleAI-fkn","author":"Taras Malinovskii","text":"WIP: интеграция с агентом через internal/agent/service.go и cmd/telegram/main.go (LLM -\u003e ответ в чат).","created_at":"2026-02-08T04:36:22Z"},{"id":16,"issue_id":"simpleAI-fkn","author":"Taras Malinovskii","text":"WIP: cmd/telegram/main.go поднимает бота, использует agent service, poll timeout, ограничения по чатам.","created_at":"2026-02-08T04:37:57Z"},{"id":20,"issue_id":"simpleAI-fkn","author":"Taras Malinovskii","text":"Готово: интеграция бота с агентом через internal/agent/service и cmd/telegram.","created_at":"2026-02-08T04:42:53Z"},{"id":12,"issue_id":"simpleAI-fkn","author":"Taras Malinovskii","text":"WIP: стартую интеграцию бота с агентом: контракт запроса/ответа, проброс контекста, формат ответов.","created_at":"2026-02-08T04:29:57Z"}]} {"id":"simpleAI-fkq","title":"feat(summary): expense-focused monthly report with RUB equivalent","description":"Переделать formatSummary в budget_skill.go под новый формат отчёта.\\n\\nЦель: пользователи хотят понять КУДА уходят деньги, а не сколько в кармане.\\n\\nНовый формат:\\n- Заголовок: период\\n- Итого потрачено в RUB-эквиваленте\\n- Категории отсортированы по убыванию суммы, с %\\n- Оригинальные суммы в валюте в скобках для THB/USD\\n- Без блока дохода и баланса\\n- Сравнение с предыдущим месяцем (итого + топ-изменения по категориям)\\n\\nКурсы конвертации: захардкодить в skill (THB=2.5, USD=82) — MVP.\\n\\nФайлы: internal/skills/budget_skill.go — summary(), formatSummary()","status":"closed","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T13:41:33.999056+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T13:48:08.633424+07:00","closed_at":"2026-04-01T13:48:08.633424+07:00","close_reason":"formatSummary переписан: расходы в RUB-экв, категории по убыванию с %, сравнение с прошлым месяцем. Файл: internal/skills/budget_skill.go"} +{"id":"simpleAI-fm3g","title":"infra: вынести wiring Service+registry в internal/appwiring","description":"Экстракция buildRegistry (cmd/app/main.go:285) и сборки agent.Service (client+registry+WithTracer/WithObservability/WithLogger/WithLLMModel) в переиспользуемый пакет internal/appwiring. cmd/app зовёт пакет. Без смены поведения. Основание: @architect YES (сессия 2026-07-23). AC: (1) wiring в internal/appwiring, cmd/app использует; (2) поведение TG-бота не изменилось, go build ./... зелёный, тесты проходят; (3) мутация: сломать сборку registry → cmd/app падает явно.","notes":"DONE. Вынес BuildRegistry + BuildAgentService в internal/appwiring (без смены поведения, добавлены inert nil-logger guard'ы). cmd/app/main.go зовёт пакет, локальный buildRegistry удалён, импорты agent/rag/skills/advisorskill убраны. Тесты: internal/appwiring/appwiring_test.go (fakeLLM реализует core.LLMClient, pool=nil безопасен — конструкторы lazy). go build ./... OK, весь go test ./... зелёный. Мутация: убрал регистрацию budget → тест краснеет ('не содержит skill budget'), восстановил → ok. gofmt чисто.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T11:23:49.612993+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T11:47:05.810572+07:00","closed_at":"2026-07-23T11:47:05.810572+07:00","close_reason":"Wiring вынесен в internal/appwiring, cmd/app использует пакет, поведение не изменилось (build+все тесты зелёные), тест с мутация-проверкой."} {"id":"simpleAI-g9w","title":"LLM: тесты надежности","description":"Эпик: Надежность LLM-клиента. Тесты для таймаутов, ретраев и пустых ответов.","status":"closed","priority":3,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:33.414332+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-04T11:30:56.18271+07:00","closed_at":"2026-05-04T11:30:56.18271+07:00","close_reason":"Отменено: тесты признаны нецелесообразными на данном этапе"} {"id":"simpleAI-gcj","title":"fix(budget): forecast агрегирует по месяцу в THB до усреднения","description":"Прогноз Жильё показывает ~30k ฿ при реальных 18k ฿/мес.\n\nКорень: GetForecastData (store.go:678) считает avg per (категория, валюта) раздельно. Один и тот же платёж за жильё, оплаченный частично в RUB и THB в одном месяце, попадает в два бакета. После усреднения и merge в formatForecastBlock суммы складываются → двойной счёт.\n\nПример: март RUB 50000 (≈18k ฿) + март THB 241; апрель THB 18000.\nСейчас: avg(RUB)=50000→18k ฿ + avg(THB)=(241+18000)/2=9120 ฿ → 27120 ฿\nДолжно: per-month THB: март=18241, апрель=18000 → avg≈18120 ฿\n\nFix:\n1. Store: SQL возвращает raw monthly rows (cat, icon, currency, month, total)\n2. Pure helper aggregateForecast(rows, rates) []CategoryForecast:\n - конвертит каждую строку в THB\n - группирует по (категория, месяц) суммируя\n - avg по месяцам\n - тренд от двух последних месяцев в THB\n3. GetForecastData(ctx, months, rates) — rates приходят от caller\n4. CategoryForecast.Currency = THB, ForecastAmount в THB\n5. Обновить computeForecastRemaining (advisor) — теперь без двойной конвертации\n\nAcceptance:\n- unit-тесты на aggregateForecast: cross-currency same-month → single avg\n- Жильё пример из бага → ~18k ฿ а не 30k\n\nRefs: пользователь репорт 2026-05-07 'почему расход на жилье такой ведь это статично 18000к'","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-07T16:38:13.368367+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-07T16:41:56.29445+07:00","closed_at":"2026-05-07T16:41:56.29445+07:00","close_reason":"Per-month THB агрегация. Жильё пример: 27k → 18k ฿. 6 unit-тестов. PR #58 merged."} {"id":"simpleAI-gfp","title":"feature/budget-other-comment-and-reminders: Fix — сломан UX прочее + get_reminder error + /help","description":"GitHub issues: #33, #34\nВетка: feature/budget-other-comment-and-reminders\n\nBug A (критичный): addTransaction с category=прочее + пустой description → hard-block с просьбой описания.\nАгент stateless — следующее сообщение пользователя теряет контекст, LLM вызывает RAG вместо budget.\nФикс: убрать hard-block. Сохранять транзакцию, в success-ответе добавить мягкое напоминание.\nInstruction в manifest усилить: LLM должен извлекать description из текста сообщения до вызова.\n\nBug B (get_reminder): возвращает error при chat_id unavailable → LLM форматирует как 'Ошибка доступа к данным'.\nФикс: вместо error возвращать friendly-строку.\nТакже добавить guard: если таблица budget_reminder не существует — graceful fallback.\n\n#34: /help не содержит информацию о напоминаниях. Добавить пример.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T11:35:19.424534+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T11:36:23.54204+07:00","closed_at":"2026-04-01T11:36:23.54204+07:00","close_reason":"removed hard-block for прочее category, getReminder/setReminder now return friendly messages instead of errors, /help updated with reminders section"} @@ -137,17 +143,22 @@ {"id":"simpleAI-jt5.2","title":"LLM adapters: OpenAI и Ollama","description":"Вынести реализацию OpenAI в adapters/llm/openai. Добавить адаптер Ollama (локальный) с теми же интерфейсами. Конфиг выбора провайдера.","notes":"Старт: выношу OpenAI реализацию в adapters/llm/openai, добавляю adapters/llm/ollama и конфиг выбора провайдера.\nРеализация: добавлены adapters/llm (factory + OpenAI/Ollama клиенты), вынесены интерфейсы в core, обновлены entrypoints на фабрику, добавлены LLM_PROVIDER/OLLAMA_* в конфиг и .env.example. pkg/llm удален.","status":"closed","priority":0,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T14:09:40.226808+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T14:21:40.512956+07:00","closed_at":"2026-02-08T14:21:40.512956+07:00","close_reason":"OpenAI/Ollama адаптеры добавлены, конфиг LLM_PROVIDER и wiring обновлены","dependencies":[{"issue_id":"simpleAI-jt5.2","depends_on_id":"simpleAI-jt5","type":"parent-child","created_at":"2026-02-08T14:09:40.227543+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-jt5.3","title":"Telegram adapter и слой маршрутизации","description":"Выделить интерфейс Bot/Updates и реализовать telegram adapter в adapters/telegram. Core не должен зависеть от tgbotapi.","notes":"Старт: переношу Telegram реализацию в adapters/telegram, core не должен зависеть от tgbotapi. Обновлю wiring и router для работы через интерфейсы.\nTelegram вынесен в adapters/telegram (tgbotapi), internal/telegram переведен на core.Bot/core.Update и AttachmentFetcher, cmd/telegram wiring обновлен. Документы README/TECHNICAL_README обновлены.","status":"closed","priority":0,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T14:09:43.805009+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T14:29:47.040921+07:00","closed_at":"2026-02-08T14:29:47.040921+07:00","close_reason":"Telegram adapter вынесен, core/telegram не зависит от tgbotapi","dependencies":[{"issue_id":"simpleAI-jt5.3","depends_on_id":"simpleAI-jt5","type":"parent-child","created_at":"2026-02-08T14:09:43.805747+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-jt5.4","title":"Сборка и wiring","description":"Обновить main/worker/telegram wiring для использования интерфейсов и adapters. Обновить README/TECHNICAL_README.","status":"closed","priority":0,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T14:09:47.541493+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T14:31:27.841739+07:00","closed_at":"2026-02-08T14:31:27.841739+07:00","close_reason":"Wiring и README/TECHNICAL_README обновлены при внедрении адаптеров","dependencies":[{"issue_id":"simpleAI-jt5.4","depends_on_id":"simpleAI-jt5","type":"parent-child","created_at":"2026-02-08T14:09:47.542174+07:00","created_by":"Taras Malinovskii"}]} +{"id":"simpleAI-k5j4","title":"epic: Safe-to-Spend / живой конверт (ADR-007)","description":"Приход(сумма+период)→детерминир. свободный остаток после обязательств + LLM-нарратив раскладки; stateful конверт (H3), фаза1=H1 stateless. Основание ADR-007, @architect YES 2026-07-23. Эталон: bd l960 (127k→27800). Фиксит 4 бага (роутинг-мутация, привязка к приходу, якорь дохода, отсутствие примитива).","status":"closed","priority":1,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.028788+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T16:16:45.669036+07:00","closed_at":"2026-07-23T16:16:45.669036+07:00","close_reason":"Safe-to-Spend эпик завершён: ADR-007 + T1-T6. 4 бага закрыты, acceptance 127k→27800 (детерминир.+живой), 2-нед. горизонт, stateful конверт с производным остатком. Прод не тронут (реплика)."} {"id":"simpleAI-kcx","title":"Наблюдаемость Telegram-бота","description":"Эпик: Telegram-бот как фасад системы. Логи, метрики, трассировка запросов.","status":"closed","priority":3,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:28:47.178725+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T11:49:46.128609+07:00","closed_at":"2026-02-08T11:49:46.128609+07:00","close_reason":"Closed","comments":[{"id":24,"issue_id":"simpleAI-kcx","author":"Taras Malinovskii","text":"WIP: добавлены логи апдейтов и длительности, request_id, rate-limit события.","created_at":"2026-02-08T04:44:55Z"},{"id":25,"issue_id":"simpleAI-kcx","author":"Taras Malinovskii","text":"Осталось: метрики/трейсинг при необходимости (сейчас только structured logs).","created_at":"2026-02-08T04:45:28Z"},{"id":30,"issue_id":"simpleAI-kcx","author":"Taras Malinovskii","text":"Готово (v1): structured logs + duration + request_id. Метрики/трейсинг можно вынести в отдельный follow-up.","created_at":"2026-02-08T04:49:53Z"}]} +{"id":"simpleAI-km3w","title":"[STS-3] Детерминир. классификация прихода + чистка якоря дохода (бага №3)","description":"Классификация разовый/регулярный детерминированно (совпадение с recurring income), LLM лишь предлагает. Чистый income-baseline: исключить разовые (recurring_id IS NULL + denylist Прочее/без категории). ADR-007 §7,§8. AC: GetMonthlyIncomeAvg НЕ трогать по месту — новый чистый вариант; тест границы denylist; ошибка классификации не искажает арифметику периода. Epic simpleAI-k5j4.","notes":"DONE. (1) Детерминир. классификация прихода classifyIncome (±15% от recurring income → регулярный/разовый), показывается в reply. (2) Чистка прогноза: nonDiscretionaryCategories denylist (Переводы/кредит/долг) исключены из forecastSpendTHB — прогноз 137105→108605₽ (Переводы ~19000/мес = погашение кредитки, не расход; ADR §4 двойной учёт). (3) Бага №3: Store.GetRegularMonthlyIncomeAvg — чистый якорь исключает разовые (recurring_id NULL AND cat in Прочее/''); GetMonthlyIncomeAvg НЕ тронут (ADR §8). Загрязнённое 193117₽, чистое строго меньше (тест). Тесты: classifyIncome/isDiscretionary unit, чистый якорь integration; мутации denylist+income-clean ловятся. Прогноз всё ещё высок (аренда) → эталон 27800 требует planned_expense (T6).","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.489675+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T12:37:42.523221+07:00","closed_at":"2026-07-23T12:37:42.523221+07:00","close_reason":"Классификация прихода + чистка прогноза (Переводы/кредиты) + чистый income-якорь (баг №3). Тесты+мутации зелёные.","dependencies":[{"issue_id":"simpleAI-km3w","depends_on_id":"simpleAI-tsg4","type":"blocks","created_at":"2026-07-23T12:15:20.243434+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-ksnx","title":"fix(advisor): детали показывают ForecastRemaining вместо YesFund","description":"В FormatAdvisorReply строки 'Прогноз остатка' и 'После покупки' используют ForecastRemaining/AfterPurchase (без обязательств), а шапка показывает YesFundNow/YesFundAfterPurchase (с обязательствами). Два разных 'После покупки' в одном ответе: 36433 и 54184. Фикс: в деталях заменить ForecastRemaining на YesFundNow, AfterPurchase на YesFundAfterPurchase.","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-11T11:13:38.255301+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-11T11:19:34.650078+07:00","closed_at":"2026-05-11T11:19:34.650078+07:00","close_reason":"fixed: format/advisor.go lines 74-76 — Прогноз остатка и После покупки теперь используют YesFundNow/YesFundAfterPurchase вместо ForecastRemaining/AfterPurchase. Задеплоено на сервер."} {"id":"simpleAI-l7c","title":"epic/python-migration: [Фаза 1a] Unit-тесты budget skill — все actions","description":"Родитель: simpleAI-0ma\nФаза: 1 (тесты — первая)\n\nФайлы: internal/skills/budget_skill_test.go (новый)\n\nЧто тестировать:\n- add_expense/add_income: корректное сохранение\n- add_expense с category=прочее + пустой description → возврат prompt (без сохранения)\n- add_expense с category=прочее + описание → сохраняется\n- summary: форматирование по валютам\n- list_transactions: фильтры, пагинация\n- edit_transaction: patch полей\n- goal/debt lifecycle\n- set_reminder / get_reminder: chat_id из context\n\nПодход: мокать budget.Store через интерфейс или использовать testify/mock.\nStore сейчас — конкретный тип, нужно сначала вынести интерфейс (или использовать in-memory fake).\n\nAC: go test ./internal/skills/... покрывает все happy-path + граничные случаи для category=прочее.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T11:19:04.725004+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-04T11:30:29.693186+07:00","closed_at":"2026-05-04T11:30:29.693186+07:00","close_reason":"Отменено: unit-тесты budget skill признаны нецелесообразными, не блокируют миграцию Go→Python"} +{"id":"simpleAI-l960","title":"spike: дизайн Safe-to-Spend / живой конверт + ADR-007","description":"Ресёрч и проектирование нового флоу: приход(сумма+период) → детерминир. свободный остаток после обязательств + LLM-раскладка. Решения владельца, 3 цикла критики, вердикт архитектора (NO до ADR-007, модель H3, фаза1=H1). Артефакты: ADR-007, декомпозиция 6 задач, эмпир. проверка на боевых данных. Контекст в CONTEXT.md (сессия 2026-07-23). НЕ эпик — эпик заводить после ADR-007 и повторного @architect YES.","notes":"ЭМПИРИКА 2026-07-23 (через botclient на реплике, deepseek) — 4 БАГА подтверждены:\n1. Роутинг: 'пришло 127000, сколько свободно?' → budget.add_income (ПИШЕТ доход в ledger вместо ответа). На реплике отклонено read-only; в ПРОДЕ засорит данные при каждом вопросе.\n2. 'Свободно' отдаётся как Фонд Да=-76014₽, НЕ учитывает пришедшие 127k (add_income упал → summary без прихода). Ответа на вопрос нет.\n3. Якорь дохода загрязнён разовыми: income по месяцам — Март(Зарплата 114093 + Прочее 126000 разовое), Апр(Зарплата 133110 + без_кат 152126), Май 133110, Июнь 114000. Настоящий оклад ~123k, наивное среднее ~193k (+57%). GetMonthlyIncomeAvg завышает.\n4. Нет примитива safe-to-spend (приход из сообщения + период → детерминир. свободный остаток). Июльского дохода в базе нет → бот говорит 'доходов нет'.\n\nACCEPTANCE-ЭТАЛОН (юзер посчитал руками, цель фичи):\nПриход 127000₽. Известные траты периода: 36400 кредитка/беспроц.период (разовая) + 28000 Сбер потреб кредит (регуляр) + 15600 виза тайская (разовая) + 6720 электричество + 5200 пылесос (разовая) + 3380 корм Балто + 3900 прививки Балто = 99200. Свободно после известных = 127000-99200 = 27800₽. НЕ учтены: продукты/массажи/транспорт (из статистики). Профит фичи: показать 27800 свободных И предупредить что stat-траты (продукты/транспорт/массажи по истории) съедят часть → сколько реально останется. Валюта: приход в RUB, часть трат в THB — конверсия обязательна.\n\nNEXT: ADR-007 (H3, недвойной учёт, граница CRUD/reasoning ADR-002, мультипериод, chat_id) → @architect YES → эпик 6 задач → H1 stateless safe-to-spend как первая рабочая фича, гонять против этого эталона через botclient.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T11:10:49.915342+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T12:15:39.647238+07:00","closed_at":"2026-07-23T12:15:39.647238+07:00","close_reason":"ADR-007 написан (Accepted), @architect YES, эпик k5j4 + 6 подзадач заведены. Эталон 127k и 4 бага задокументированы для эпика."} {"id":"simpleAI-lpy","title":"eval: baseline run + первый отчёт","description":"## Цель\nПрогнать harness на текущем коде, зафиксировать baseline. Подзадача EPIC simpleAI-wr7.\n\n## Действия\n1. Запустить `evals/cmd/routing` на golden_set из подзадачи C\n2. Сохранить результат в `evals/runs/\u003ctimestamp\u003e_baseline.jsonl`\n3. Создать `evals/runs/BASELINE.md` с:\n - дата, коммит, accuracy\n - таблица per-tag accuracy\n - список failed_ids с пометкой 'expected fail / unexpected fail'\n4. Если accuracy \u003c 80% — открыть отдельный bug на улучшение routing-промпта\n5. Если есть фейл на 'r001' (хочу купить блендер) — открыть регрессию simpleAI-q49\n\n## DoD\n- BASELINE.md создан\n- Baseline run в runs/\n- Если accuracy \u003c 80%, заведена follow-up задача\n\n## Зависимости\n- Блокируется B (harness) и C (golden_set)","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-05T08:32:49.883645+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-06T09:30:25.489728+07:00","closed_at":"2026-05-06T09:30:25.489728+07:00","close_reason":"Baseline run выполнен: 24 кейса, accuracy 0.83 (20/24), порог 80% пройден. Файлы: evals/runs/baseline-2026-05-06.jsonl + BASELINE.md. r001 PASS (q49 не воспроизводится). 4 fail: 3 из-за low-quality LLM (qwen Ollama-fallback т.к. нет DEEPSEEK_API_KEY локально), 1 routing-неточность (r017) → simpleAI-399. ADR-003 выровнен под фактический формат .jsonl. /review = PASS.","dependencies":[{"issue_id":"simpleAI-lpy","depends_on_id":"simpleAI-85c","type":"blocks","created_at":"2026-05-05T08:33:18.303978+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-lpy","depends_on_id":"simpleAI-578","type":"blocks","created_at":"2026-05-05T08:33:18.448541+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-luw","title":"Ingestion: валидация и дедупликация","description":"Эпик: Ingestion pipeline. Валидация входа и дедупликация чеков.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:11.072817+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T12:28:23.563378+07:00","closed_at":"2026-02-08T12:28:23.563378+07:00","close_reason":"Closed","comments":[{"id":43,"issue_id":"simpleAI-luw","author":"Taras Malinovskii","text":"WIP: добавлена валидация неотрицательных сумм/количеств и дедуп по source+source_ref (возврат существующего receipt_id).","created_at":"2026-02-08T05:27:43Z"},{"id":44,"issue_id":"simpleAI-luw","author":"Taras Malinovskii","text":"Готово: валидация неотрицательных сумм/количеств и дедуп по source+source_ref (возврат существующего receipt_id).","created_at":"2026-02-08T05:28:30Z"}]} {"id":"simpleAI-lvb","title":"task/simpleAI-finance-ui: adapters/telegram/adapter.go — реализовать SendWithKeyboard, EditMessageText, AnswerCallback и callback в toCoreUpdate","description":"Текущая ветка: task/simpleAI-finance-ui\nЭпик: simpleAI-nzc\nЗатрагиваемые файлы: internal/adapters/telegram/adapter.go\nУже сделано: изменения внесены в этой сессии\nЗависит от: simpleAI-88s (core/bot.go должен быть готов)\nЗадача:\n1. В toCoreUpdate добавить обработку update.CallbackQuery → заполнить IsCallback=true, CallbackID=cq.ID, CallbackData=cq.Data, ChatID из cq.Message.Chat.ID\n2. Реализовать SendWithKeyboard — tgbotapi.NewMessage + buildTgKeyboard\n3. Реализовать EditMessageText — tgbotapi.NewEditMessageText + ReplyMarkup\n4. Реализовать AnswerCallback — tgbotapi.NewCallback + bot.Request\n5. Вспомогательный buildTgKeyboard(kb core.InlineKeyboard) tgbotapi.InlineKeyboardMarkup\nAcceptance: go build ./internal/adapters/telegram/... без ошибок, Adapter реализует весь интерфейс Bot","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T18:53:06.630347+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T18:56:23.490127+07:00","closed_at":"2026-02-15T18:56:23.490127+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-lvb","depends_on_id":"simpleAI-nzc","type":"parent-child","created_at":"2026-02-15T18:55:57.409955+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-lxye","title":"Digest: строка неделя-к-неделе (WoW)","description":"Epic simpleAI-btol.\n\nЭТА ЗАДАЧА: строка '📈 Неделя: -12% к прошлой' — сумма THB этих 7д vs прошлых 7д.\n\nAC:\n- [ ] prevWeekTotal==0 → строка молча опускается (нет делёжа на 0).\n- [ ] Знак и стрелка ↑/↓ корректны (рост и падение трат оба валидны).\n- [ ] Переиспользован helper WoW из format.go:168 (summaryTotalRUB), не дублировать.\n- [ ] Окна недель якорятся UTC-midnight как 'вчера'.\n- [ ] Строка опциональна — падение не ломает дайджест (инвариант).\n\nФайлы: internal/skills/budget/digest.go (+ digest_test.go). Зависит от Task 1.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-02T22:21:19.224591+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-02T22:31:46.662019+07:00","closed_at":"2026-07-02T22:31:46.662019+07:00","close_reason":"Строка WoW: последние 7 дней (кончая вчера) vs предыдущие 7. weekOverWeekLine (2 запроса, деградация молча при ошибке). wowLine pure: защита от делёжа на 0 (prevWeek\u003c=0→''), ↑ рост/↓ падение трат, ~0→'≈ как на прошлой'. Окна якорятся dayPeriod (UTC-midnight). Переиспользован summaryTotalRUB через summaryTotalTHB. Тесты: pure div0/up/down/equal + полный 3-строчный дайджест. build+test+vet+lint 0. /review PASS.","dependencies":[{"issue_id":"simpleAI-lxye","depends_on_id":"simpleAI-gkyb","type":"blocks","created_at":"2026-07-02T22:21:45.045601+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-m1x","title":"RAG: генерация эмбеддингов","description":"Эпик: RAG по тратам и чекам (V1). Генерация эмбеддингов и запись в rag_document.embedding.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:05.583957+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T12:26:24.624975+07:00","closed_at":"2026-02-08T12:26:24.624975+07:00","close_reason":"Closed","comments":[{"id":41,"issue_id":"simpleAI-m1x","author":"Taras Malinovskii","text":"WIP: добавлен cmd/embeddings, rag store для pending docs, контент-хеш и обновление embedding_updated_at.","created_at":"2026-02-08T05:24:32Z"},{"id":42,"issue_id":"simpleAI-m1x","author":"Taras Malinovskii","text":"Готово: cmd/embeddings для генерации эмбеддингов, rag store для pending docs, обновление embedding_updated_at/content_hash.","created_at":"2026-02-08T05:26:29Z"}]} +{"id":"simpleAI-mjkt","title":"refactor: safe-to-spend — поддерживаемость/SOLID (обобщить skill+flow, вынести хардкод в конфиг)","description":"Ревью-итерации по коду эпика Safe-to-Spend. Фокус: DRY (дубли period-резолвера и конверсии валют), конфиг вместо хардкода (горизонт 2нед, denylist категорий, толеранс классификации, окно прогноза, база пропорции), SOLID (OCP для классификации категорий, SRP, DIP). Без смены поведения — тесты остаются зелёными.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T16:28:07.942796+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T16:42:38.960299+07:00","closed_at":"2026-07-23T16:42:38.960299+07:00","close_reason":"3 итерации рефактора на поддерживаемость/SOLID: (1) единый budget.ResolveHorizon (убран дубль resolvePeriod/envelopePeriod); (2) единая budget.ToTHB (убран свой toTHB); (3) конфиг-блок safetospend/config.go (магические 14/3/30/0.15 названы) + централизация классификации категорий в budget/categories.go (единый источник для forecast-фильтра И SQL-якоря через ANY(). Поведение не изменилось (весь go test ./... зелёный incl integration; живой acceptance 127k→27300). Мутации на инварианты проходят."} {"id":"simpleAI-mpy","title":"Логирование этапов pipeline","description":"Эпик: Структурированное логирование и ошибки. Логировать ingest/RAG этапы.","status":"closed","priority":3,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:52.513747+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T15:18:19.444022+07:00","closed_at":"2026-02-08T15:18:19.444022+07:00","close_reason":"Closed"} {"id":"simpleAI-nih","title":"fix(budget): summary показывает доходы вместе с расходами","description":"Bug: запрос 'покажи доходы за прошлый месяц' роутится в budget.summary, который хардкодом показывает только расходы. Слово 'доходы' игнорируется.\n\n## Файлы\n\n- internal/skills/budget_skill.go:\n - formatSummary (L720): добавить блок '💰 Доходы' когда TotalIncome \u003e 0\n - заголовок изменить с '\u003cperiod\u003e — расходы' на '\u003cperiod\u003e' (общая сводка)\n - helper summaryIncomeRUB (по аналогии с summaryTotalRUB)\n\n## Поведение после фикса\n\nЗапрос 'доходы за март' / 'покажи траты за март' → одинаковая сводка с обоими блоками:\n 💰 Доходы: ~XXX ₽ (если есть income в периоде)\n 💸 Всего потрачено: ~XXX ₽\n ...\n\n## DoD\n\n- [ ] formatSummary показывает income блок когда TotalIncome \u003e 0\n- [ ] go build/vet/test PASS\n- [ ] golden_set: новый кейс 'покажи доходы за март' → budget.summary\n- [ ] eval \u003e= baseline 0.83","status":"closed","priority":2,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-07T08:54:54.008995+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-07T08:57:49.866873+07:00","closed_at":"2026-05-07T08:57:49.866873+07:00","close_reason":"formatSummary показывает блок 💰 Доходы и 📊 Баланс когда TotalIncome \u003e 0; helper summaryIncomeRUB; заголовок изменён на 'сводка' (вместо 'расходы'); 3 unit-теста (with_income, no_income, negative_balance); golden_set +2 income кейсов; eval 0.94."} {"id":"simpleAI-njq","title":"refactor: skills возвращают типизированные структуры вместо string (подзадача D)","description":"## Цель\nУбрать string как возвращаемый тип из всех skill-методов. Подзадача D эпика simpleAI-4fc.\n\n## Мотивация\n- Логику нельзя тестировать без парсинга строки\n- Форматирование и логика неразделимы\n- Нельзя добавить второй адаптер (не-Telegram) без копипасты\n\n## Что сделать\n1. Определить result-структуры:\n - budget/models.go: TransactionResult, SummaryResult, ListResult, GoalResult, DebtResult, ...\n - advisor/models.go: AdviceResult, AnalyzeResult\n2. Методы logic.go возвращают эти структуры\n3. handler.go (Run) вызывает logic + format → string для обратной совместимости с plugin.Skill interface\n (или расширить интерфейс — обсудить при старте задачи)\n\n## Зависит от\n- simpleAI-ckb (budget в подпапке)\n- новая задача C (advisor в подпапке)\n\n## DoD\n- Все бизнес-методы возвращают структуры\n- Форматирование вызывается только из handler.go\n- go test ./... green, /review PASS","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-08T20:32:22.40852+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-08T20:54:27.023857+07:00","closed_at":"2026-05-08T20:54:27.023857+07:00","close_reason":"Логика отделена от форматирования в budget/: listTransactions, goalStatus, debtStatus, listRecurring теперь возвращают данные, render-функции (renderTransactionList, renderGoalList, renderDebtList, renderRecurringList) в format.go. Advisor уже был разделён после предыдущего рефактора. go test green.","dependencies":[{"issue_id":"simpleAI-njq","depends_on_id":"simpleAI-ckb","type":"blocks","created_at":"2026-05-08T20:32:35.4653+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-njq","depends_on_id":"simpleAI-sqz","type":"blocks","created_at":"2026-05-08T20:32:35.588282+07:00","created_by":"Taras Malinovskii"}]} +{"id":"simpleAI-nz5b","title":"[STS-2] H1 SafeToSpendSkill (read-only plugin) + фикс роутинга","description":"Новый read-only plugin internal/skills/safetospend (ADR-002 profile). Приход из СООБЩЕНИЯ, НЕ пишется (фикс бага №1). Детерминир. свободный остаток (LLM только нарратив, бага №2). AC: golden_set 'пришло X сколько свободно'→safe_to_spend НЕ budget.add_income (мутация: убрать negative-prompt→краснеет); тест 'мутация LLM-ответа не меняет числа' (ADR §3); negative-prompt vs advisor.advice/budget.summary. Epic simpleAI-k5j4.","notes":"DONE (фаза H1). Новый read-only plugin internal/skills/safetospend (ADR-002/007): Manifest safe_to_spend с триггерами+negative-prompt; приход из СООБЩЕНИЯ, НЕ пишется. Ядро computeSafeToSpend чистое (числа в Go), LLM только нарратив (best-effort). prompt-as-file safetospend/narrate.tmpl (ADR-005, embed обновлён). Зарегистрирован в appwiring. Тесты: compute (unit+мутация формулы), TestNumbersIndependentOfLLM (ADR §3, 2 разных LLM-ответа→одинаковые числа), amount required. Golden_set r037/r038 (→safe_to_spend) r039 (→add_income) — routing eval PASS на deepseek. Botclient на реплике: 'пришло 127000' → safe_to_spend (НЕ add_income), Приход 127000, свободно после обязательств 98500. БАГИ #1(роутинг-мутация)/#2(привязка к приходу)/#4(нет примитива) ЗАКРЫТЫ. Осталось для T3: прогноз трат раздут (137105, Переводы/кредиты в расходах) → 'реально останется' в минус; возможен двойной учёт recurring в прогнозе.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.335309+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T12:31:51.466045+07:00","closed_at":"2026-07-23T12:31:51.466045+07:00","close_reason":"H1 SafeToSpendSkill: routing fix (eval+botclient PASS), детерминир. остаток (unit+мутация), LLM-независимость чисел. Баги #1/#2/#4 закрыты.","dependencies":[{"issue_id":"simpleAI-nz5b","depends_on_id":"simpleAI-tsg4","type":"blocks","created_at":"2026-07-23T12:15:20.117606+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-nzc","title":"task/simpleAI-finance-ui: [ЭПИК] Finance UI — раздел финансов в Telegram-боте","description":"Текущая ветка: task/simpleAI-finance-ui\nЦель: Добавить в Telegram-бот раздел 'Финансы' с возможностью сохранять покупки (итерация 1 — текст, итерация 2 — фото), просматривать список и видеть аналитику по периодам.\nТекущий бот: только LLM-ответы и сохранение raw JSON. Нет inline-кнопок, callback-запросов, conversation state, чтения из БД.\nAcceptance: /finance открывает меню, можно добавить покупку текстом, посмотреть список, аналитику по периодам.\nЗатрагиваемые модули: migrations/, internal/core, internal/adapters/telegram, internal/state (новый), internal/finance (новый), internal/llm (новый), internal/telegram, internal/telegram/finance (новый), cmd/app/main.go","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T18:52:26.812491+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T19:03:41.421366+07:00","closed_at":"2026-02-15T19:03:41.421366+07:00","close_reason":"Closed"} {"id":"simpleAI-nzc.1","title":"task/simpleAI-finance-ui: fix receipt_parser.go — LLM возвращает название категории вместо UUID","description":"Текущая ветка: task/simpleAI-finance-ui\nЭпик: уже закрыт simpleAI-nzc, это hotfix\nПроблема: ошибка 'invalid category_id \"Развлечения\": invalid UUID length: 22'\nLLM возвращает в поле category_id строку с названием категории, а не UUID.\nФайл: internal/llm/receipt_parser.go\nФикс: усилить системный промпт — добавить явный пример JSON-объекта item с UUID,\nдобавить постобработку в ParseReceiptFromText: если category_id не является UUID,\nпопытаться сопоставить название с известными категориями и заменить на UUID,\nиначе — использовать UUID категории 'Другое' (88888888-8888-8888-8888-888888888888)\nAcceptance: при тексте 'кино 500р' LLM возвращает category_id=66666666-..., ошибки нет","status":"closed","priority":1,"issue_type":"task","assignee":"Taras Malinovskii","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T19:24:13.703497+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T19:25:06.220208+07:00","closed_at":"2026-02-15T19:25:06.220208+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-nzc.1","depends_on_id":"simpleAI-nzc","type":"parent-child","created_at":"2026-02-15T19:24:13.704978+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-nzc.2","title":"task/simpleAI-finance-ui: fix receipt_parser — currency захардкожен как RUB, должен определяться из текста","description":"Текущая ветка: task/simpleAI-finance-ui\nПроблема: в systemPrompt написано 'currency: RUB' — LLM всегда возвращает рубли даже если пользователь пишет о батах, долларах и т.д.\nФайл: internal/llm/receipt_parser.go\nФикс:\n1. В systemPrompt убрать hardcode RUB, заменить на: 'определи валюту из контекста (RUB/THB/USD/EUR/etc), по умолчанию RUB'\n2. Убрать 'RUB' из примера JSON, заменить на динамическое определение\n3. В format.go/FormatReceiptPreview — валюта уже берётся из input.Currency, менять не надо\nAcceptance: пользователь пишет '500 батт за такси' → preview показывает 500 THB, не RUB","status":"closed","priority":1,"issue_type":"task","assignee":"Taras Malinovskii","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T19:28:37.629554+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T19:29:17.135528+07:00","closed_at":"2026-02-15T19:29:17.135528+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-nzc.2","depends_on_id":"simpleAI-nzc","type":"parent-child","created_at":"2026-02-15T19:28:37.63094+07:00","created_by":"Taras Malinovskii"}]} @@ -174,6 +185,7 @@ {"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-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). Границы и изоляция доказаны на реплике."} {"id":"simpleAI-tvn","title":"fix(budget): summary уважает type=income/expense + чёткий manifest","description":"Баг: запрос 'Покажи доходы предыдущего месяца' роутится нестабильно (то summary, то list_transactions; type иногда теряется). Когда LLM выбирает summary без type — рендерится сводка где блок 'Доходы' скрыт условием incomeRUB\u003e0 (budget_skill.go:747), и юзер видит только расходы вместо явного ответа 'доходов в апреле нет'.\n\nFix:\n1. summary() в internal/skills/budget_skill.go:300 учитывает req.Type:\n - type=income → только блок доходов; если 0 — 'Доходов за \u003cпериод\u003e нет'\n - type=expense → только блок расходов\n - пусто → текущее поведение (оба)\n2. Manifest (budget_skill.go:36): уточнить что 'покажи доходы за \u003cпериод\u003e' = summary с type=income (стабильное правило)\n\nAcceptance:\n- Юзер: 'покажи доходы за апрель' → видит блок доходов (или явное 'нет доходов')\n- Юзер: 'покажи расходы за апрель' → видит только расходы\n- Юзер: 'итоги/сводка за апрель' → оба блока\n- Тесты на formatSummary с filter\n\nRefs логи: 2026-05-07 08:40 input='{action:summary, period:2026-04}'","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-07T15:50:35.937783+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-07T15:54:18.497905+07:00","closed_at":"2026-05-07T15:54:18.497905+07:00","close_reason":"summary фильтр по type, явное 'нет доходов', manifest уточнён. PR #57 merged."} {"id":"simpleAI-v4a","title":"feat: Telegram-меню команд /budget /recurring /forecast /reminders","description":"Добавить 4 отдельные команды в Telegram-меню бота.\n\nПроблема: сейчас меню показывает только /start и /help. Пользователь не видит возможности бота через кнопку /.\n\nЧто сделать:\n1. cmd/telegram/main.go — добавить 4 команды в SetCommands: /budget, /recurring, /forecast, /reminders\n2. internal/telegram/handlers.go — добавить HandleBudget, HandleRecurring, HandleForecast, HandleReminders (каждый возвращает соответствующий MsgHelp* из constants)\n3. cmd/telegram/main.go — зарегистрировать через router.HandleCommand\n\nAC: go build ./cmd/telegram/... проходит. В Telegram при наборе / видны все 6 команд. Каждая возвращает правильный текст.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T11:36:43.62995+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-04T11:38:17.269088+07:00","closed_at":"2026-05-04T11:38:17.269088+07:00","close_reason":"Реализовано: handlers.go +4 хендлера, main.go +4 команды в SetCommands и router. go build чист."} {"id":"simpleAI-vdm","title":"task/simpleAI-finance-ui: Создать internal/finance/models.go и store.go — read-only слой для чтения чеков","description":"Текущая ветка: task/simpleAI-finance-ui\nЭпик: simpleAI-nzc\nЗатрагиваемые файлы: internal/finance/models.go (новый), internal/finance/store.go (новый)\nЗависит от: simpleAI-68w (нужна колонка chat_id)\nЗадача — models.go:\n type ReceiptSummary struct{ ID uuid.UUID; StoreName string; PurchaseTS time.Time; Total float64; Currency string; ItemCount int }\n type CategorySpend struct{ Category string; Total float64; Count int }\n type ReceiptDetail struct{ ReceiptSummary; Items []ItemLine }\n type ItemLine struct{ Name string; Quantity, UnitPrice, Amount float64; Category string }\nЗадача — store.go:\n type Store struct{ pool *pgxpool.Pool }\n func NewStore(pool *pgxpool.Pool) *Store\n func ListReceipts(ctx, chatID int64, limit, offset int) ([]ReceiptSummary, error)\n SQL: SELECT r.id, COALESCE(st.name,''), COALESCE(r.purchase_ts,NOW()), COALESCE(r.total_amount,0), COALESCE(r.currency,'RUB'), COUNT(ri.id)\n FROM receipt r LEFT JOIN store st ON st.id=r.store_id LEFT JOIN receipt_item ri ON ri.receipt_id=r.id\n WHERE r.chat_id= GROUP BY r.id,st.name ORDER BY r.purchase_ts DESC NULLS LAST LIMIT OFFSET \n func SpendByCategory(ctx, chatID int64, from, to time.Time) ([]CategorySpend, error)\n SQL: SELECT COALESCE(c.name,'Другое'), SUM(ri.amount), COUNT(ri.id)\n FROM receipt r JOIN receipt_item ri ON ri.receipt_id=r.id LEFT JOIN category c ON c.id=ri.category_id\n WHERE r.chat_id= AND r.purchase_ts\u003e= AND r.purchase_ts\u003c GROUP BY c.name ORDER BY SUM(ri.amount) DESC\n func GetReceipt(ctx, id uuid.UUID) (*ReceiptDetail, error)\n SQL: два запроса — receipt JOIN store + receipt_item JOIN category\nAcceptance: go build ./internal/finance/... без ошибок","status":"closed","priority":2,"issue_type":"task","assignee":"Taras Malinovskii","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T18:53:34.344968+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T18:58:04.667258+07:00","closed_at":"2026-02-15T18:58:04.667258+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-vdm","depends_on_id":"simpleAI-nzc","type":"parent-child","created_at":"2026-02-15T18:55:57.684539+07:00","created_by":"Taras Malinovskii"}]} @@ -185,11 +197,13 @@ {"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-yarz","title":"infra: локальная read-only реплика прод-БД + ночной рефреш","description":"Цель: работать с копией боевых данных, прод не трогать (только pg_dump read-only через SSH host 'simpleai'). Прод-БД = docker simpleai-postgres (pgvector/pgvector:pg16) на VPS. План: (1) dump через ssh simpleai 'docker exec simpleai-postgres pg_dump -Fc'; (2) локальный pgvector pg16 через docker-compose.yml; (3) pg_restore в локальную БД; (4) read-only PG-роль для botclient (enforcement из @architect); (5) refresh-скрипт + Makefile target + launchd ночной. Данные меняются раз/день. Дампы вне репо (~/.simpleai-replica), в git не коммитить.","notes":"UPDATE: рефреш переведён на почасовой + graceful + логирование. scripts/refresh-replica.sh: preflight-проверки (docker daemon / локальный контейнер pg_isready / ssh-доступ к проду) → при любой недоступности skip() пишет WARN в ~/.simpleai-replica/refresh.log и exit 0 (не фатально, launchd не спамит). Таймстампы в логе. launchd: StartInterval 3600 + RunAtLoad (было ночное StartCalendarInterval). Проверено: normal run OK (tx 268→271, прод живой), graceful skip при недоступном проде exit 0 + WARN, plutil OK, агент загружен.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T11:30:46.047814+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T12:10:03.042059+07:00","closed_at":"2026-07-23T12:10:03.042059+07:00","close_reason":"Реплика: почасовой graceful рефреш с логом. Проверено (normal + skip-пути)."} {"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"}]} {"id":"simpleAI-ys2","title":"Почта: pipeline дайджеста","description":"Эпик: Агент почты (hourly). Чтение, нормализация, LLM-категоризация, дайджест и отправка.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:35.238457+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T11:04:39.293871+07:00","closed_at":"2026-02-08T11:04:39.293871+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-ys2","depends_on_id":"simpleAI-bna","type":"blocks","created_at":"2026-02-08T10:23:42.006368+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-ys2","depends_on_id":"simpleAI-zru","type":"blocks","created_at":"2026-02-08T10:23:45.596067+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-ys2","depends_on_id":"simpleAI-sa5","type":"blocks","created_at":"2026-02-08T10:23:47.85888+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-ys2","depends_on_id":"simpleAI-95t","type":"blocks","created_at":"2026-02-08T10:23:50.538067+07:00","created_by":"Taras Malinovskii"}],"comments":[{"id":7,"issue_id":"simpleAI-ys2","author":"Taras Malinovskii","text":"WIP: добавлен mail store (upsert аккаунтов, checkpoint, run, insert messages) и Telegram notifier. Оркестрация воркера и LLM-суммаризация еще не сделаны.","created_at":"2026-02-08T03:32:15Z"},{"id":8,"issue_id":"simpleAI-ys2","author":"Taras Malinovskii","text":"WIP: добавлен cmd/worker с циклом, mail runner (fetch -\u003e LLM digest -\u003e store -\u003e Telegram), digest builder с JSON-ответом, сохранение host/port/use_tls в mail_account, миграция обновлена.","created_at":"2026-02-08T03:38:39Z"},{"id":9,"issue_id":"simpleAI-ys2","author":"Taras Malinovskii","text":"Готово: воркер cmd/worker, runner (fetch-\u003eLLM digest-\u003estore-\u003eTelegram), digest builder, обновлен mail_account (host/port/use_tls), обновлен .env.example.","created_at":"2026-02-08T04:04:42Z"}]} {"id":"simpleAI-yvq","title":"advisor: Run logic + LLM call","description":"Phase 3 из spec/advisor/plan.yaml.\n\nРеализация Run(): rates → snapshot → forecast → prompt → LLM → JSON parse → Markdown.\n\nФайлы:\n- internal/skills/advisor_skill.go — Run, prompt template (const), formatAdvisorReply helper\n\nАлгоритм Run:\n1. Парсинг payload (question/amount/currency).\n2. store.GetExchangeRates(ctx). Fail fast если нет THB.\n3. Если amount + currency != THB → конверсия в THB; в выводе обе суммы.\n4. store.GetAdvisorSnapshot(ctx, chatID, time.Now(), rates).\n5. store.GetForecastData(ctx, 0); посчитать ForecastRemaining в THB; положить в snapshot.\n6. Render prompt → llm.Complete(ctx, prompt).\n7. JSON parse: {verdict: Да|Нет|Условно, numbers: {free_cash_thb, forecast_remaining_thb, obligations_thb}, explanation, recommendation?}.\n8. Format Markdown (русский). Округление THB до 0 decimals.\n9. slog: skill=advisor, chat_id, question_len(\u003c=200), verdict, free_cash_thb, duration_ms.\n\nКонстанты:\n- MinTxForConfidence = 5\n\nAcceptance criteria:\n- Конверсия суммы из вопроса (RUB/USD → THB), обе суммы в выводе при не-THB\n- Округление THB до 0 decimals\n- slog с указанными полями\n- Friendly fallback при невалидном JSON / SQL-ошибке (см. AC-E3..E5)\n- low_data передаётся в prompt при TxCount \u003c 5\n\nЗависит от: snapshot layer + skill scaffold.","notes":"Phase 3: Run+LLM+format в advisor_skill.go. Алгоритм: parse JSON → GetExchangeRates (fail если нет THB) → chatID из ctx → конверсия суммы вопроса в THB → GetAdvisorSnapshot → GetForecastData + computeForecastRemaining → buildAdvisorPrompt → llm.Ask → parseAdvisorLLMResponse (поддержка fenced JSON, валидация verdict ∈ Да/Нет/Условно) → formatAdvisorReply (Markdown, обе суммы при non-THB). Friendly fallback на каждой стадии. slog: skill=advisor, chat_id, question(200), verdict, free_cash_thb, duration_ms. go build/vet — green.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T12:35:28.30482+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-04T19:55:04.195774+07:00","closed_at":"2026-05-04T19:55:04.195774+07:00","close_reason":"Phase 3 done: Run+LLM+format. Тесты в rvs.","dependencies":[{"issue_id":"simpleAI-yvq","depends_on_id":"simpleAI-blg","type":"blocks","created_at":"2026-05-04T12:35:42.305481+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-yvq","depends_on_id":"simpleAI-cyb","type":"blocks","created_at":"2026-05-04T12:35:42.422652+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-yza","title":"EPIC: Observability UI — Langfuse self-hosted + инструментация","description":"## Цель\nПоднять UI где видно ВСЕ вызовы LLM и ответы (system prompt, user input, tool calls, tool results, финальный ответ) с фильтрацией по chat_id/session_id. Сейчас трейсы идут в Postgres (internal/trace/store.go), но просмотра нет.\n\n## Скоуп эпика\n1. Langfuse self-hosted в docker-compose (simpleAI-0ya, существует, P2)\n2. Инструментация agent.Service агентного цикла Langfuse SDK (simpleAI-8h8, существует, blocked by 0ya)\n3. Маппинг текущей модели trace.Entry → Langfuse traces/spans/generations (новая подзадача нужна?)\n\n## Готовый референс\n**На work-хосте уже работает Langfuse self-hosted:**\n- Путь: `/home/malinovskiy.t/GolandProjects/assister/langfuse/docker-compose.yml`\n- UI: http://localhost:3001\n- Стек: postgres + clickhouse + redis + minio + langfuse-web + langfuse-worker (Langfuse v3)\n- Просто скопировать compose-файл и адаптировать env\n\n## Текущее состояние трейсинга\n- internal/trace/store.go — Append(Entry) в Postgres\n- Entry: SessionID, ChatID, UserInput, Iteration, Skill, SkillInput, SkillResult, LLMResponse, IsFinal\n- agent/service.go вызывает appendTrace на каждой итерации loop'а\n- Один user input → N итераций → каждая итерация может содержать M tool calls\n- Маппинг в Langfuse: один trace = sessionID; spans = итерации; generations = AskWithSystem вызовы; events = skill calls\n\n## Definition of Done\n- Все 3 подзадачи закрыты с /review = PASS\n- Smoke: написать боту в dev → открыть Langfuse → увидеть полный trace с system prompt, user message, выбранным skill, входом/выходом skill, финальным reply\n\n## Зависимости\n- Блокирует визуальную отладку routing-багов (типа simpleAI-q49 — без UI смотрели trace через grep по логам)","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-05-05T08:31:26.071259+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-06T09:33:38.255741+07:00","closed_at":"2026-05-06T09:33:38.255741+07:00","close_reason":"Все 3 подзадачи закрыты с /review=PASS (0ya self-hosted compose, 8h8 инструментация, 11s docs). Manual smoke на dev (написать боту → проверить trace в Langfuse) отложен пользователем — будет тестить дальше.","dependencies":[{"issue_id":"simpleAI-yza","depends_on_id":"simpleAI-0ya","type":"blocks","created_at":"2026-05-05T08:32:07.470175+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-yza","depends_on_id":"simpleAI-8h8","type":"blocks","created_at":"2026-05-05T08:32:07.615467+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-z1u","title":"fix(agent): устранить дублирование транзакций в БД","description":"Баг: бот дважды записывает одну транзакцию с интервалом ~6 сек. Причина: в agentic loop (internal/agent/service.go:69-72) follow-up промпт содержит исходный запрос пользователя — LLM перечитывает его и вызывает инструмент повторно.\n\nДва уровня защиты:\n1. Fix follow-up prompt: явно указать что инструмент уже выполнен и нужен только текстовый ответ, без новых tool calls\n2. Dedup в store (internal/budget/store.go): перед INSERT проверять наличие записи с теми же (type, amount, category_id, description, date) созданной в последние 30 сек, возвращать существующую\n\nЗатрагиваемые файлы:\n- internal/agent/service.go (fix промпта)\n- internal/budget/store.go (dedup check в AddTransaction)","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T18:36:02.080094+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T18:46:06.77473+07:00","closed_at":"2026-03-09T18:46:06.77473+07:00","close_reason":"Implemented and committed on fix/simpleAI-z1u-cfl-dedup-tracing"} +{"id":"simpleAI-z3go","title":"feat(safe_to_spend): разбивка прогноза быта по категориям + советы по экономии","description":"Профит владельца: он знает ~27к свободных, но НЕ знает ежедневные траты. Бот должен ответить 'сколько надо на горизонт (2 нед) и НА ЧТО уйдёт' — детерминир. разбивка прогноза по потребительским категориям (сортировка desc, топ-N + прочее), чтобы видеть где урезать. LLM даёт адресные советы по экономии по разбивке (числа не трогает). Основание ADR-007 (H1 расширение). Эпик k5j4.","status":"closed","priority":1,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T17:01:16.199674+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T17:14:36.367457+07:00","closed_at":"2026-07-23T17:14:36.367457+07:00","close_reason":"Разбивка прогноза по категориям: buildForecastBreakdown (чистая, топ-N+прочее, пропорция к горизонту) → ответ 'на что уйдут деньги'. Прогноз = ПЕРЕМЕННЫЕ ежедневные траты (budget.IsVariableDailyExpense: consumption минус фикс аренда/подписки/коммуналка) — честно про то, что нельзя учесть. LLM даёт адресные советы по экономии по разбивке. Живой прогон: переменные 30842₽ за 2нед (Еда/рестораны/транспорт…), свободно 24300 → -6542 → советы. Тесты+мутации (consumption-фильтр, фикс-исключение) зелёные, полный сьют ALL GREEN."} {"id":"simpleAI-zb6","title":"feat: поиск транзакций по конкретному дню (#42)","description":"Расширить list_transactions в budget_skill.go: если передано поле date — строить однодневный Period и фильтровать по нему. Поле date уже существует в схеме и в budgetInput, но не используется в listTransactions. Изменения только в internal/skills/budget_skill.go (~15 строк).","status":"closed","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-04-04T17:47:28.521483+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-04T17:49:16.002194+07:00","closed_at":"2026-04-04T17:49:16.002194+07:00","close_reason":"Реализовано в budget_skill.go: date в list_transactions строит однодневный Period. Manifest обновлён. Компилируется, review PASS."} {"id":"simpleAI-zru","title":"Почта: миграции и модели","description":"Эпик: Агент почты (hourly). Таблицы mail_account/mail_message/mail_checkpoint/mail_run и модели.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:26.748628+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T10:23:32.823126+07:00","closed_at":"2026-02-08T10:23:32.823126+07:00","close_reason":"Closed","comments":[{"id":10,"issue_id":"simpleAI-zru","author":"Taras Malinovskii","text":"Готово: миграции mail_* в migrations/00003_mail_tables.sql и модели в internal/mail/model.go.","created_at":"2026-02-08T03:23:38Z"}]} {"id":"simpleAI-zz5","title":"fix(agent): LLM не знает текущую дату — передаёт неверный год в period","description":"Баг: пользователь говорит 'февраль' — бот возвращает февраль 2025 вместо 2026.\n\nПричина: buildToolsSystemPrompt в internal/agent/service.go не содержит текущей даты. LLM генерирует period YYYY-MM используя данные обучения, угадывает неверный год.\n\nФикс: добавить time.Now().Format в начало buildToolsSystemPrompt:\n 'Сегодня: 09.03.2026. При указании месяца без года — использовать текущий год.'\n\nЗатрагиваемые файлы:\n- internal/agent/service.go (buildToolsSystemPrompt)","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T19:15:09.607916+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T19:16:55.854738+07:00","closed_at":"2026-03-09T19:16:55.854738+07:00","close_reason":"Added time.Now() date to buildToolsSystemPrompt in service.go"}