From 3d886c79c861aa7980542ff4a726a23a1f95cf3f Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Mon, 24 Aug 2026 20:52:14 +0700 Subject: [PATCH 1/6] =?UTF-8?q?fix(budget):=20=D0=B2=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=82=20=D0=B2=D1=85=D0=BE=D0=B4=D1=8F=D1=82?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BE=D0=B1=D1=8F?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D1=81=D1=82=D0=B2=D0=B0=20?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Окно финансирования регулярных платежей было месяцем вперёд (sinking fund): аренда 10.09 при периоде до 06.09 резервировалась уже сейчас. На эталоне оператора это запирало 18 671 из 128 000 под платежи, до которых придёт свой приход — свободный остаток читался как заниженный. Приход дважды в месяц, период конверта совпадает с ритмом прихода, поэтому платёж следующего периода финансируется приходом того периода. Окно теперь равно периоду конверта, обе границы включительно. Отсечённые платежи не исчезают: уходят в EnvelopePlan.Upcoming и печатаются блоком «Впереди, из следующего прихода» — иначе аренда будет съедена как свободные деньги. simpleAI-agz4 --- internal/skills/budget/envelope.go | 1 + internal/skills/budget/limits.go | 1 + internal/skills/safetospend/allocate.go | 75 ++++++--- internal/skills/safetospend/config.go | 11 +- .../safetospend/envelope_invariant_test.go | 26 ++- .../skills/safetospend/fixed_window_test.go | 155 ++++++++++++++++++ internal/skills/safetospend/format.go | 25 +++ 7 files changed, 258 insertions(+), 36 deletions(-) create mode 100644 internal/skills/safetospend/fixed_window_test.go diff --git a/internal/skills/budget/envelope.go b/internal/skills/budget/envelope.go index 7103dcb..7300841 100644 --- a/internal/skills/budget/envelope.go +++ b/internal/skills/budget/envelope.go @@ -99,6 +99,7 @@ func (s *BudgetSkill) startEnvelope(ctx context.Context, req budgetInput) (strin History: history, Recurring: recurring, From: h.From, + To: h.To, }) s.attachCategoryIDs(ctx, plan.Shares) diff --git a/internal/skills/budget/limits.go b/internal/skills/budget/limits.go index cab7bd8..5c745de 100644 --- a/internal/skills/budget/limits.go +++ b/internal/skills/budget/limits.go @@ -182,6 +182,7 @@ func (s *BudgetSkill) replanActiveEnvelope(ctx context.Context, chatID int64, re History: history, Recurring: recurring, From: h.From, + To: h.To, }) s.attachCategoryIDs(ctx, plan.Shares) plan.Shares, err = s.keepCarriedIn(ctx, chatID, env.ID, plan.Shares) diff --git a/internal/skills/safetospend/allocate.go b/internal/skills/safetospend/allocate.go index e117826..b721fdf 100644 --- a/internal/skills/safetospend/allocate.go +++ b/internal/skills/safetospend/allocate.go @@ -53,9 +53,10 @@ type EnvelopePlanInput struct { // пофамильные конверты — одни и те же деньги, и складывать их значит // вычесть обязательства дважды. Recurring []budget.RecurringPayment - // From — начало периода конверта; от него отсчитывается окно - // финансирования регулярных платежей. - From time.Time + // From, To — границы периода конверта, ОБЕ включительно. Регулярный платёж + // финансируется этим приходом тогда и только тогда, когда его дата лежит + // внутри [From, To]; всё, что дальше, — забота следующего конверта. + From, To time.Time } // EnvelopePlan — результат раскладки: детерминированные числа и доли. @@ -64,6 +65,10 @@ type EnvelopePlan struct { Result Result Shares []budget.EnvelopeShare Warnings []string + // Upcoming — регулярные платежи, попадающие уже в СЛЕДУЮЩИЙ период. Деньги + // на них этим приходом не откладываются, но пропасть из виду они не имеют + // права: оператор, не увидев аренду, потратит её на еду. + Upcoming []budget.EnvelopeShare } // PlanEnvelope — единственная точка входа раскладки для внешних пакетов. @@ -76,12 +81,13 @@ func PlanEnvelope(in EnvelopePlanInput) EnvelopePlan { if snap == nil { snap = &budget.AdvisorSnapshot{} } - fixed, warnings := fixedShares(in.Recurring, in.Rates, in.From) + fixed, upcoming, warnings := fixedShares(in.Recurring, in.Rates, in.From, in.To) // Обязательства снимаются с прихода РОВНО ОДИН раз — суммой пофамильных // fixed-конвертов. Snapshot.UpcomingRecurring здесь подменяется ею, а не - // складывается: это одни и те же платежи, посчитанные с разными границами - // (снимок режет период конверта, окно финансирования — месяц вперёд). + // складывается: это одни и те же платежи. Границы у них теперь совпадают + // (обе — период конверта), но подмена всё равно обязательна: снимок режет + // ещё и долги, а fixed-доли собраны только из recurring. obligations := *snap obligations.UpcomingRecurring = sumAllocatedShares(fixed) @@ -98,10 +104,11 @@ func PlanEnvelope(in EnvelopePlanInput) EnvelopePlan { for i := range shares { shares[i].Position = i } - return EnvelopePlan{Result: res, Shares: shares, Warnings: warnings} + return EnvelopePlan{Result: res, Shares: shares, Warnings: warnings, Upcoming: upcoming} } -// fixedShares превращает регулярные платежи в видимые конверты (simpleAI-faeq.11). +// fixedShares превращает регулярные платежи в видимые конверты (simpleAI-faeq.11) +// и отдельно возвращает те, что придутся уже на следующий период. // // Почему платежи стали конвертами, а не остались скрытым вычетом: оператор // отверг деление трат на «обязательные» и «на жизнь» — «есть мне тоже надо, или @@ -110,9 +117,18 @@ func PlanEnvelope(in EnvelopePlanInput) EnvelopePlan { // первыми, а не вычет до раскладки. Скрытый вычет ещё и ломает сходимость: в // ответе бота приход не сходился визуально, 12 332 ฿ исчезали без строки. // -// Окно финансирования — месяц вперёд от начала периода, а НЕ период конверта. -// Аренда платится 10.09, период кончается 06.09, но отложить деньги надо сейчас: -// иначе приход между 06.09 и 10.09 придётся на пустой карман (sinking fund). +// Окно финансирования — РОВНО период конверта [from, to], обе границы +// включительно. Раньше здесь стоял месяц вперёд (sinking fund: «аренда 10.09 +// при периоде до 06.09 всё равно должна быть отложена сейчас»), и это отменено +// оператором 24.08.2026: приход у него ДВАЖДЫ в месяц, период конверта совпадает +// с ритмом прихода, и платёж следующего периода профинансируется приходом того +// периода. Месячное окно при этом запирало 18 671 ฿ из 128 000 под платежи, +// до которых ещё придут деньги, и оператор читал свободный остаток как заниженный. +// Не возвращать 31 день обратно, не переспросив: на другом ритме прихода +// (раз в месяц) верным будет ровно прежнее поведение. +// +// Отсечённые платежи молча не пропадают — они уходят вторым результатом и +// печатаются строкой «впереди»: не увидев аренду, оператор потратит её на еду. // // Категорий у fixed-доли нет: её факт — сам recurring-платёж, а транзакции с // recurring_id в факт долей не попадают (ADR-008 §5). Дай мы ей категорию, @@ -120,18 +136,22 @@ func PlanEnvelope(in EnvelopePlanInput) EnvelopePlan { // // Порядок — по убыванию суммы: колонка чисел читается сверху вниз, и крупное // обязательство должно быть первым (ADR-008 §11 — числа считает Go, не LLM). -func fixedShares(rec []budget.RecurringPayment, rates map[string]float64, from time.Time) ([]budget.EnvelopeShare, []string) { +func fixedShares(rec []budget.RecurringPayment, rates map[string]float64, from, to time.Time) (fixed, upcoming []budget.EnvelopeShare, warnings []string) { if len(rec) == 0 { - return nil, nil + return nil, nil, nil } - windowEnd := from.AddDate(0, 0, fixedFundingDays) - var warnings []string - out := make([]budget.EnvelopeShare, 0, len(rec)) + // Обе границы включительно: платёж день в день с концом периода — ещё этот + // период. Полночь следующих суток берётся именно для этого. + periodStart := dayStart(from) + periodEnd := dayStart(to).AddDate(0, 0, 1) + lookaheadEnd := periodEnd.AddDate(0, 0, upcomingLookaheadDays) + + fixed = make([]budget.EnvelopeShare, 0, len(rec)) for _, r := range rec { if !r.Enabled || r.Type != "expense" { continue } - if r.NextDate.Before(dayStart(from)) || !r.NextDate.Before(windowEnd) { + if r.NextDate.Before(periodStart) || !r.NextDate.Before(lookaheadEnd) { continue } thb, ok := budget.ToTHB(r.Amount, r.Currency, rates) @@ -142,21 +162,36 @@ func fixedShares(rec []budget.RecurringPayment, rates map[string]float64, from t continue } due := r.NextDate - out = append(out, budget.EnvelopeShare{ + share := budget.EnvelopeShare{ Name: r.Name, Kind: budget.ShareKindFixed, Allocated: roundKopecks(thb), Source: budget.ShareSourceAuto, DueDate: &due, - }) + } + if r.NextDate.Before(periodEnd) { + fixed = append(fixed, share) + continue + } + upcoming = append(upcoming, share) } + sortSharesByAmount(fixed) + // «Впереди» сортируется по ДАТЕ, а не по сумме: это список ближайших + // платежей, и первым читается тот, что наступит раньше. + sort.SliceStable(upcoming, func(i, j int) bool { + return upcoming[i].DueDate.Before(*upcoming[j].DueDate) + }) + return fixed, upcoming, warnings +} + +// sortSharesByAmount — по убыванию суммы, при равенстве по имени (устойчиво). +func sortSharesByAmount(out []budget.EnvelopeShare) { sort.SliceStable(out, func(i, j int) bool { if out[i].Allocated != out[j].Allocated { return out[i].Allocated > out[j].Allocated } return out[i].Name < out[j].Name }) - return out, warnings } // dayStart — начало суток: next_date хранится DATE (полночь UTC), а from diff --git a/internal/skills/safetospend/config.go b/internal/skills/safetospend/config.go index 82d28a0..63abf8f 100644 --- a/internal/skills/safetospend/config.go +++ b/internal/skills/safetospend/config.go @@ -47,13 +47,10 @@ const ( // предупреждении. Остальные сворачиваются в «и ещё N»: живой прогон дал // одиннадцать таких категорий, и одиннадцать строк шума прятали сам ответ. lowDataNamesShown = 3 - // fixedFundingDays — окно финансирования регулярных платежей: платёж - // финансируется этим приходом, если его дата попадает в месяц вперёд от - // начала периода. Не период конверта: аренда 10.09 при периоде до 06.09 всё - // равно должна быть отложена сейчас, иначе платёж встретит пустой карман - // (sinking fund). Месяц — потому что все регулярные платежи месячные, и - // более широкое окно заложило бы один платёж дважды. - fixedFundingDays = 31 + // upcomingLookaheadDays — насколько далеко за конец периода заглядывать в + // строке «впереди». Платёж дальше этого окна оператору сегодня не нужен: он + // придётся не на следующий приход, а на один из последующих. + upcomingLookaheadDays = 31 // kopecksInUnit — округление сумм долей до копейки: раскладка показывается // человеку, а не хранит доли рубля. kopecksInUnit = 100.0 diff --git a/internal/skills/safetospend/envelope_invariant_test.go b/internal/skills/safetospend/envelope_invariant_test.go index 03d2b60..6dc4c4e 100644 --- a/internal/skills/safetospend/envelope_invariant_test.go +++ b/internal/skills/safetospend/envelope_invariant_test.go @@ -184,15 +184,19 @@ func TestPlanEnvelope_InvariantHolds(t *testing.T) { } // Регулярные платежи становятся видимыми конвертами, а не скрытым вычетом -// (simpleAI-faeq.11 §1–§4): каждый — своей строкой с датой, включая платёж ЗА -// границей периода, а итог сходится с приходом до бата. +// (simpleAI-faeq.11 §1–§4): каждый — своей строкой с датой, а итог сходится с +// приходом до бата. Платёж ЗА границей периода в конверт не попадает +// (simpleAI-agz4) — он уходит в Upcoming и проверяется отдельным тестом. func TestPlanEnvelope_RecurringBecomeVisibleShares(t *testing.T) { from := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC) rec := []budget.RecurringPayment{ {Name: "аренда", Type: "expense", Amount: 18000, Currency: "THB", Enabled: true, - NextDate: time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)}, // ЗА границей периода + NextDate: time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)}, // ЗА границей периода — в Upcoming {Name: "Кредит потребительский Сбербанк", Type: "expense", Amount: 28500, Currency: "RUB", Enabled: true, NextDate: time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC)}, + {Name: "уборка", Type: "expense", Amount: 2500, Currency: "THB", Enabled: true, + NextDate: time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC)}, // день в день с концом — ВНУТРИ {Name: "кредитная карта", Type: "expense", Amount: 12500, Currency: "RUB", Enabled: false, NextDate: time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC)}, // выключен — не платёж {Name: "зарплата", Type: "income", Amount: 100000, Currency: "RUB", Enabled: true, @@ -213,19 +217,23 @@ func TestPlanEnvelope_RecurringBecomeVisibleShares(t *testing.T) { History: map[string]int{"еда": 3}, Recurring: rec, From: from, + To: to, }) fixed := FixedShares(plan.Shares) if len(fixed) != 2 { - t.Fatalf("видимых платежей %d, ожидалось 2 (аренда и кредит): %+v", len(fixed), fixed) + t.Fatalf("видимых платежей %d, ожидалось 2 (кредит и уборка): %+v", len(fixed), fixed) } - if fixed[0].Name != "аренда" || !eq(fixed[0].Allocated, 18000) { - t.Errorf("первым платежом ожидалась аренда 18000: %+v", fixed[0]) + if fixed[0].Name != "Кредит потребительский Сбербанк" || !eq(fixed[0].Allocated, roundKopecks(28500/3.1)) { + t.Errorf("первым платежом ожидался кредит: %+v", fixed[0]) } - if fixed[0].DueDate == nil || fixed[0].DueDate.Format("02.01") != "10.09" { - t.Errorf("платёж за границей периода потерял дату: %+v", fixed[0]) + if fixed[1].Name != "уборка" { + t.Errorf("платёж день в день с концом периода обязан войти в конверт: %+v", fixed) } - if !eq(plan.Result.RecurringTHB, 18000+roundKopecks(28500/3.1)) { + if fixed[0].DueDate == nil || fixed[0].DueDate.Format("02.01") != "27.08" { + t.Errorf("платёж потерял дату: %+v", fixed[0]) + } + if !eq(plan.Result.RecurringTHB, 2500+roundKopecks(28500/3.1)) { t.Errorf("обязательства = %.2f — сводная сумма снимка не подменена суммой конвертов", plan.Result.RecurringTHB) } diff --git a/internal/skills/safetospend/fixed_window_test.go b/internal/skills/safetospend/fixed_window_test.go new file mode 100644 index 0000000..3da9e92 --- /dev/null +++ b/internal/skills/safetospend/fixed_window_test.go @@ -0,0 +1,155 @@ +package safetospend + +import ( + "strings" + "testing" + "time" + + "simpleAI/internal/budget" +) + +// Окно финансирования регулярных платежей = период конверта, обе границы +// включительно (simpleAI-agz4). Проверяется ровно граница: платёж день в день +// с концом периода — этот конверт, следующий день — уже следующий. +// +// Тест дословный к решению оператора 24.08.2026: приход дважды в месяц, период +// совпадает с ритмом прихода, поэтому платёж следующего периода финансируется +// приходом того периода. Прежнее поведение (месяц вперёд, sinking fund) +// запирало деньги под платежи, до которых ещё придут свои. +func TestFixedShares_WindowIsEnvelopePeriod(t *testing.T) { + from := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC) + rates := map[string]float64{"RUB": 1, "THB": 2.5351} + + day := func(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + } + + cases := []struct { + name string + next time.Time + wantIn bool + wantUpco bool + }{ + {"за день до конца периода", day(2026, 9, 5), true, false}, + {"день в день с концом периода", day(2026, 9, 6), true, false}, + {"на следующий день после конца", day(2026, 9, 7), false, true}, + {"первый день периода", from, true, false}, + {"за день до начала — уже просрочен", day(2026, 8, 23), false, false}, + {"дальше окна обзора", day(2026, 12, 1), false, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := []budget.RecurringPayment{ + {Name: "аренда", Type: "expense", Amount: 18000, Currency: "THB", Enabled: true, NextDate: c.next}, + } + fixed, upcoming, _ := fixedShares(rec, rates, from, to) + if got := len(fixed) == 1; got != c.wantIn { + t.Errorf("в конверте=%v, ожидалось %v (платёж %s, период %s–%s)", + got, c.wantIn, c.next.Format("02.01"), from.Format("02.01"), to.Format("02.01")) + } + if got := len(upcoming) == 1; got != c.wantUpco { + t.Errorf("в «впереди»=%v, ожидалось %v (платёж %s)", got, c.wantUpco, c.next.Format("02.01")) + } + }) + } +} + +// Эталон оператора (реплика 24.08.2026): конверт 24.08–06.09, четыре регулярных +// платежа. Аренда 10.09 и подписка 10.09 уходят из конверта в «впереди», и на +// гибкие конверты высвобождается ровно их сумма. +func TestPlanEnvelope_UpcomingFreesFlexible(t *testing.T) { + from := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC) + rates := map[string]float64{"RUB": 1, "THB": 2.5351} + + rec := []budget.RecurringPayment{ + {Name: "аренда", Type: "expense", Amount: 18000, Currency: "THB", Enabled: true, + NextDate: time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)}, + {Name: "подписка Клауд личная", Type: "expense", Amount: 1700, Currency: "RUB", Enabled: true, + NextDate: time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)}, + {Name: "Кредит потребительский Сбербанк", Type: "expense", Amount: 28500, Currency: "RUB", Enabled: true, + NextDate: time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC)}, + {Name: "Ежемесячный платеж 3000р", Type: "expense", Amount: 3000, Currency: "RUB", Enabled: true, + NextDate: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)}, + } + in := EnvelopePlanInput{ + IncomeTHB: 50490.91, // 128 000 ₽ по курсу 2,5351 + Snapshot: &budget.AdvisorSnapshot{}, + Forecast: []budget.CategoryForecast{{CategoryName: "Еда", Currency: "THB", ForecastAmount: 10769}}, + Rates: rates, + Days: 14, + History: map[string]int{"еда": 3}, + Recurring: rec, + From: from, + To: to, + } + + plan := PlanEnvelope(in) + + fixed := FixedShares(plan.Shares) + if len(fixed) != 2 { + t.Fatalf("в конверте %d платежей, ожидалось 2 (кредит и ежемесячный): %+v", len(fixed), fixed) + } + for _, sh := range fixed { + if sh.Name == "аренда" || sh.Name == "подписка Клауд личная" { + t.Errorf("платёж 10.09 не имеет права быть в конверте до 06.09: %+v", sh) + } + } + if len(plan.Upcoming) != 2 { + t.Fatalf("в «впереди» %d платежей, ожидалось 2: %+v", len(plan.Upcoming), plan.Upcoming) + } + if plan.Upcoming[0].Name != "аренда" || !eq(plan.Upcoming[0].Allocated, 18000) { + t.Errorf("первой в «впереди» ожидалась аренда 18000 ฿: %+v", plan.Upcoming[0]) + } + + // Ровно та сумма, которую оператор недосчитывался в свободных деньгах. + freed := 18000 + roundKopecks(1700/2.5351) + prev := PlanEnvelope(withMonthWindow(in)) + if got := plan.Result.FreeAfterObligations - prev.Result.FreeAfterObligations; !eqTol(got, freed, 0.02) { + t.Errorf("высвободилось %.2f ฿, ожидалось %.2f ฿ (аренда + подписка)", got, freed) + } +} + +// withMonthWindow воспроизводит ПРЕЖНЕЕ поведение (окно — месяц вперёд), чтобы +// измерить разницу тем же кодом, а не константой из головы. +func withMonthWindow(in EnvelopePlanInput) EnvelopePlanInput { + out := in + out.To = in.From.AddDate(0, 0, 30) + return out +} + +// Отсечённый платёж обязан быть НАПЕЧАТАН: не увидев аренду нигде, оператор +// потратит её на еду. Мутация: убрать блок «впереди» — тест краснеет. +func TestFormatEnvelopePlan_PrintsUpcoming(t *testing.T) { + due := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC) + out := FormatEnvelopePlan(EnvelopeReply{ + Plan: EnvelopePlan{ + Shares: []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5000}, + }, + Upcoming: []budget.EnvelopeShare{ + {Name: "аренда", Kind: budget.ShareKindFixed, Allocated: 18000, DueDate: &due}, + }, + }, + RubPerTHB: 2.5351, + Display: NewDisplay("THB", 2.5351), + From: time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC), + }) + + if !strings.Contains(out, "Впереди") { + t.Fatalf("нет блока «Впереди»:\n%s", out) + } + for _, want := range []string{normalizeLabel("аренда"), "18 000 ฿", "10.09"} { + if !strings.Contains(out, want) { + t.Errorf("в блоке «Впереди» нет %q:\n%s", want, out) + } + } +} + +func eqTol(a, b, tol float64) bool { + d := a - b + return d < tol && d > -tol +} diff --git a/internal/skills/safetospend/format.go b/internal/skills/safetospend/format.go index e86ee62..1e9d611 100644 --- a/internal/skills/safetospend/format.go +++ b/internal/skills/safetospend/format.go @@ -156,12 +156,37 @@ func FormatEnvelopePlan(d EnvelopeReply) string { fmt.Fprintf(&b, "\n**На день: %s**\n", m.Fmt(DailyLimit(FlexibleTHB(d.Plan.Shares), days))) b.WriteString(dailyLimitScope(d.Plan.Shares)) + b.WriteString(upcomingBlock(d.Plan.Upcoming, m)) + for _, w := range d.Plan.Warnings { fmt.Fprintf(&b, "\n\n⚠️ %s", w) } return b.String() } +// upcomingBlock печатает платежи, которые придутся уже на следующий период. +// +// Деньги на них этим приходом не отложены — окно финансирования равно периоду +// конверта. Но и промолчать нельзя: аренда, не показанная нигде, будет съедена +// как свободные деньги, а через две недели встретит пустой карман. Блок +// намеренно стоит ПОСЛЕ дневного лимита и вне моноблока — это не часть колонки, +// которая сходится с приходом, а напоминание на будущее. +func upcomingBlock(upcoming []budget.EnvelopeShare, m Display) string { + if len(upcoming) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\n\n**Впереди, из следующего прихода**\n") + for _, sh := range upcoming { + fmt.Fprintf(&b, "%s — %s", normalizeLabel(sh.Name), m.Fmt(sh.Allocated)) + if sh.DueDate != nil { + fmt.Fprintf(&b, ", %s", sh.DueDate.Format("02.01")) + } + b.WriteString("\n") + } + return b.String() +} + // envelopeRow — одна строка моноблока: метка, дата платежа (или пусто) и сумма // уже в валюте показа и уже целая. Округление делается ОДИН раз здесь, потому // что складываться в итог обязаны именно напечатанные числа. From ffe196fe3c3105672b5ae377d4275db32af03628 Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Mon, 24 Aug 2026 20:57:46 +0700 Subject: [PATCH 2/6] =?UTF-8?q?fix(budget):=20=D0=B7=D0=BD=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D1=8B=20=D1=83=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=B6=D0=B4=D0=BE=D0=B9=20=D0=B4=D0=B5=D0=BD=D0=B5=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В моноблоке конвертов колонка сумм печаталась голыми числами — по ней нельзя было сказать, баты это или рубли. В safe-to-spend знак рубля был прибит к формату руками, местами рядом с суммой, уже переведённой в баты. Все суммы идут через Display: он держит валюту и курс вместе, разъехаться им нечем. Добавлены Display.Signed для уже округлённых чисел моноблока и FmtAmount для сумм в своей валюте без перевода (подтверждение лимита). Колонка суммы расширена до 10 знаков под знак валюты. Тест currency_sign_test.go разбирает СОБРАННЫЙ ответ регекспом и падает на любом числе без знака — так он поймает и новый Fprintf мимо Display. simpleAI-302i --- internal/skills/budget/limits.go | 6 +- .../skills/safetospend/currency_sign_test.go | 133 ++++++++++++++++++ internal/skills/safetospend/display.go | 18 ++- internal/skills/safetospend/format.go | 54 ++++--- .../safetospend/format_envelope_test.go | 42 ++++-- internal/skills/safetospend/remaining.go | 19 +-- internal/skills/safetospend/skill_test.go | 5 +- 7 files changed, 228 insertions(+), 49 deletions(-) create mode 100644 internal/skills/safetospend/currency_sign_test.go diff --git a/internal/skills/budget/limits.go b/internal/skills/budget/limits.go index 5c745de..1ffd55d 100644 --- a/internal/skills/budget/limits.go +++ b/internal/skills/budget/limits.go @@ -63,8 +63,10 @@ func (s *BudgetSkill) setShareLimit(ctx context.Context, req budgetInput) (strin slog.Default().InfoContext(ctx, "set_share_limit", "chat_id", chatID, "share", name, "amount", req.Amount, "currency", currency) - head := fmt.Sprintf("📌 Лимит на «%s» — %.0f %s. Запомнил: применю и к следующим приходам, пока не скажешь «убери лимит на %s».", - name, req.Amount, currency, name) + // Знак валюты, а не её код: оператор назвал сумму в рублях или батах, и + // «15000 RUB» посреди русской фразы читается хуже, чем «15 000 ₽». + head := fmt.Sprintf("📌 Лимит на «%s» — %s. Запомнил: применю и к следующим приходам, пока не скажешь «убери лимит на %s».", + name, safetospend.FmtAmount(req.Amount, currency), name) return head + s.replanTail(ctx, chatID, req), nil } diff --git a/internal/skills/safetospend/currency_sign_test.go b/internal/skills/safetospend/currency_sign_test.go new file mode 100644 index 0000000..dc7b560 --- /dev/null +++ b/internal/skills/safetospend/currency_sign_test.go @@ -0,0 +1,133 @@ +package safetospend + +import ( + "regexp" + "strings" + "testing" + "time" + + "simpleAI/internal/budget" +) + +// Ни одна денежная строка не печатается без знака валюты (simpleAI-302i). +// +// Оператор смотрел на колонку конвертов и не мог сказать, баты там или рубли: +// в моноблоке суммы шли голыми числами, а в safe-to-spend знак рубля был прибит +// к формату руками и стоял местами рядом с батовой суммой. +// +// Проверка — регекспом по СОБРАННОМУ ответу, а не по коду: только так тест +// ловит новый Fprintf, добавленный завтра мимо Display. +// +// Мутация: убрать знак из Display.Signed — тест краснеет. +func TestFormat_EveryAmountCarriesCurrencySign(t *testing.T) { + from := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC) + due := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC) + + shares := []budget.EnvelopeShare{ + {Name: "Кредит потребительский Сбербанк", Kind: budget.ShareKindFixed, Allocated: 11242.05, + DueDate: ptrTime(time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC))}, + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5025.73}, + {Name: "накопления", Kind: budget.ShareKindSave, Allocated: 9237.54, CarriedIn: 1200}, + } + + cases := []struct { + name string + out string + }{ + { + name: "раскладка конвертов, баты", + out: FormatEnvelopePlan(EnvelopeReply{ + Plan: EnvelopePlan{ + Shares: shares, + Upcoming: []budget.EnvelopeShare{ + {Name: "аренда", Kind: budget.ShareKindFixed, Allocated: 18000, DueDate: &due}, + }, + Result: Result{IncomeTHB: 50490.91}, + }, + RubPerTHB: 2.5351, + Display: NewDisplay("THB", 2.5351), + From: from, + To: to, + IncomeAmount: 128000, + IncomeCurrency: "RUB", + }), + }, + { + name: "раскладка конвертов, рубли", + out: FormatEnvelopePlan(EnvelopeReply{ + Plan: EnvelopePlan{Shares: shares, Result: Result{IncomeTHB: 50490.91}}, + RubPerTHB: 2.5351, + Display: NewDisplay("RUB", 2.5351), + From: from, + To: to, + IncomeAmount: 128000, + IncomeCurrency: "RUB", + }), + }, + { + name: "safe-to-spend: сколько свободно", + out: formatReply(replyData{ + res: Result{ + IncomeTHB: 50490.91, RecurringTHB: 12425.42, DebtTHB: 3000, PlannedTHB: 1500, + ForecastSpendTHB: 10769, FreeAfterObligations: 33565.49, RealisticFree: 22796.49, + }, + rubPerTHB: 2.5351, + period: "ближайшие 2 недели", + planned: []CategorySpend{{Category: "кредитка", THB: 1500}}, + variable: []CategorySpend{{Category: "Еда", THB: 8000}, {Category: "Транспорт", THB: 2769}}, + advice: []string{"срезать доставку"}, + }), + }, + { + name: "остаток по конверту", + out: formatRemaining(RemainingResult{ + IncomeTHB: 50490.91, RecurringTHB: 12425.42, DebtTHB: 3000, + PlannedTHB: 1500, ActualSpentTHB: 4200, RemainingTHB: 29365.49, + }, 2.5351, &budget.Envelope{PeriodStart: from, PeriodEnd: to}), + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if bare := bareAmounts(c.out); len(bare) > 0 { + t.Errorf("суммы без знака валюты: %v\n---\n%s", bare, c.out) + } + }) + } +} + +// bareAmounts ищет числа, за которыми не стоит знак валюты. +// +// Из проверки исключено то, что деньгами не является: даты (02.01), длина +// периода в днях, сам курс (2,53 ₽/฿ — знак там принадлежит дроби, а не сумме) +// и номера в служебных строках. Исключения перечислены явно: молчаливое +// «похоже не на деньги» и есть способ, которым дыра вернётся. +func bareAmounts(out string) []string { + notMoney := []*regexp.Regexp{ + regexp.MustCompile(`\d{2}\.\d{2}`), // дата 27.08 + regexp.MustCompile(`\d+[,.]\d+ ₽/฿`), // курс + regexp.MustCompile(`\d+ (день|дня|дней|недел\S*)`), // длина периода + } + // Число (возможно с пробелами-разрядами), за которым НЕ следует знак валюты. + amount := regexp.MustCompile(`\d[\d ]*\d|\d`) + + var bare []string + for _, line := range strings.Split(out, "\n") { + clean := line + for _, re := range notMoney { + clean = re.ReplaceAllString(clean, "") + } + for _, loc := range amount.FindAllStringIndex(clean, -1) { + rest := strings.TrimLeft(clean[loc[1]:], " ") + if strings.HasPrefix(rest, "฿") || strings.HasPrefix(rest, "₽") || + strings.HasPrefix(rest, "$") || strings.HasPrefix(rest, "€") { + continue + } + bare = append(bare, strings.TrimSpace(clean[loc[0]:loc[1]])+" ← "+strings.TrimSpace(line)) + } + } + return bare +} + +func ptrTime(t time.Time) *time.Time { return &t } diff --git a/internal/skills/safetospend/display.go b/internal/skills/safetospend/display.go index e923e6d..1295380 100644 --- a/internal/skills/safetospend/display.go +++ b/internal/skills/safetospend/display.go @@ -66,7 +66,15 @@ func (d Display) Sign() string { // Разряды группируются пробелом, копеек нет: негруппированные цифры человек // считает справа налево, а десятые в сводке — шум (ресёрч вёрстки, п. П2.5). func (d Display) Fmt(thb float64) string { - return fmt.Sprintf("%s %s", groupDigits(roundInt(d.Amount(thb))), d.Sign()) + return d.Signed(roundInt(d.Amount(thb))) +} + +// Signed — та же строка «сумма знак», но для числа, УЖЕ переведённого в валюту +// показа и уже целого. Нужна моноблоку: там суммы округляются один раз заранее, +// потому что складываться в итог обязаны именно напечатанные числа, и повторный +// проход через Amount() перевёл бы их второй раз (simpleAI-302i). +func (d Display) Signed(amount int) string { + return fmt.Sprintf("%s %s", groupDigits(amount), d.Sign()) } // ParseDisplayCurrency вытаскивает валюту конвертов из фразы оператора: @@ -92,3 +100,11 @@ func ParseDisplayCurrency(text string) string { } return "" } + +// FmtAmount — сумма в СВОЕЙ валюте, без перевода: «15 000 ₽», «18 000 ฿». +// Нужна там, где число уже названо оператором в конкретной валюте и переводить +// его нельзя — подтверждение лимита, эхо введённой суммы. Отдельно от Display +// намеренно: Display держит валюту ПОКАЗА и курс, а здесь курса нет вовсе. +func FmtAmount(amount float64, currency string) string { + return fmt.Sprintf("%s %s", groupDigits(roundInt(amount)), currencySign(currency)) +} diff --git a/internal/skills/safetospend/format.go b/internal/skills/safetospend/format.go index 1e9d611..f691db3 100644 --- a/internal/skills/safetospend/format.go +++ b/internal/skills/safetospend/format.go @@ -26,38 +26,42 @@ type replyData struct { // затем прозрачная раскладка. Ложного зелёного на «свободно» нет. func formatReply(d replyData) string { r := d.res - rub := func(thb float64) float64 { return thb * d.rubPerTHB } + // Единая точка печати денег и здесь: раньше каждая строка собирала «число + + // ₽» руками, и знак рубля стоял рядом с суммой, местами уже переведённой в + // баты. Display держит валюту и курс вместе, разъехаться им нечем + // (simpleAI-302i). + m := NewDisplay("RUB", d.rubPerTHB) var b strings.Builder // 1) ВЕРДИКТ по финальному запасу (free − повседневные). - verdict := rub(r.RealisticFree) + verdict := r.RealisticFree if verdict >= 0 { - fmt.Fprintf(&b, "✅ Можно отложить ~%.0f ₽ за %s.\n", verdict, d.period) + fmt.Fprintf(&b, "✅ Можно отложить ~%s за %s.\n", m.Fmt(verdict), d.period) } else { - fmt.Fprintf(&b, "❌ Отложить нельзя — не хватает ~%.0f ₽ за %s.\n", -verdict, d.period) + fmt.Fprintf(&b, "❌ Отложить нельзя — не хватает ~%s за %s.\n", m.Fmt(-verdict), d.period) } - fmt.Fprintf(&b, "🗓 %s · курс %.1f ₽/฿\n\n", d.period, d.rubPerTHB) + fmt.Fprintf(&b, "🗓 %s · курс %s ₽/฿\n\n", d.period, decimalComma(d.rubPerTHB)) // 2) Раскладка. - fmt.Fprintf(&b, "💰 Приход: %.0f ₽\n", rub(r.IncomeTHB)) - fmt.Fprintf(&b, "➖ Обязательные платежи: %.0f ₽\n", rub(r.RecurringTHB+r.DebtTHB)) + fmt.Fprintf(&b, "💰 Приход: %s\n", m.Fmt(r.IncomeTHB)) + fmt.Fprintf(&b, "➖ Обязательные платежи: %s\n", m.Fmt(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, "➖ Запланированные покупки: %s\n", m.Fmt(r.PlannedTHB)) + b.WriteString(formatItems(d.planned, m, len(d.planned))) } - fmt.Fprintf(&b, "= Остаётся до повседневных трат: %.0f ₽\n", rub(r.FreeAfterObligations)) + fmt.Fprintf(&b, "= Остаётся до повседневных трат: %s\n", m.Fmt(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)) + fmt.Fprintf(&b, "\n➖ Повседневные траты (по статистике, %s): %s\n", d.period, m.Fmt(r.ForecastSpendTHB)) + b.WriteString(formatItems(d.variable, m, categoriesTopN)) } // 4) Итог + связка с советами. if verdict >= 0 { - fmt.Fprintf(&b, "\n⚖️ Итог: запас %.0f ₽ — можно отложить.\n", verdict) + fmt.Fprintf(&b, "\n⚖️ Итог: запас %s — можно отложить.\n", m.Fmt(verdict)) } else { - fmt.Fprintf(&b, "\n⚖️ Итог: нехватка %.0f ₽. Чтобы выйти в ноль — срезать столько же:\n", -verdict) + fmt.Fprintf(&b, "\n⚖️ Итог: нехватка %s. Чтобы выйти в ноль — срезать столько же:\n", m.Fmt(-verdict)) } for _, line := range d.advice { fmt.Fprintf(&b, "• %s\n", line) @@ -89,9 +93,12 @@ type EnvelopeReply struct { // сумма по правому краю. Третья полноценная колонка (процент, остаток) на // телефоне уже не помещается. const ( - labelWidth = 18 - dueWidth = 6 - amountWidth = 8 + labelWidth = 18 + dueWidth = 6 + // amountWidth — колонка суммы ВМЕСТЕ со знаком валюты. Знак стоит у каждой + // строки, а не один раз в шапке: оператор читает колонку сверху вниз и без + // знака не может сказать, баты это или рубли (simpleAI-302i). + amountWidth = 10 ) // FormatEnvelopePlan печатает раскладку прихода по конвертам в формате, @@ -145,9 +152,9 @@ func FormatEnvelopePlan(d EnvelopeReply) string { for _, r := range rows { lineSum += r.amount fmt.Fprintf(&b, "%s%s%s\n", - padRight(r.label, labelWidth), padLeft(r.due, dueWidth), padLeft(groupDigits(r.amount), amountWidth)) + padRight(r.label, labelWidth), padLeft(r.due, dueWidth), padLeft(m.Signed(r.amount), amountWidth)) } - totalStr := groupDigits(lineSum) + totalStr := m.Signed(lineSum) fmt.Fprintf(&b, "%s\n", padLeft(strings.Repeat("-", utf8.RuneCountInString(totalStr)), labelWidth+dueWidth+amountWidth)) fmt.Fprintf(&b, "%s\n```\n", padLeft(totalStr, labelWidth+dueWidth+amountWidth)) @@ -420,8 +427,9 @@ func currencySign(code string) string { } // formatItems печатает разбивку: до topN пунктов + свёртка остатка в «прочее». -// Категории нормализуются по регистру (единый вид). -func formatItems(items []CategorySpend, rubPerTHB float64, topN int) string { +// Категории нормализуются по регистру (единый вид). Суммы — через Display, а не +// «число + ₽» руками: знак обязан приходить из того же места, что и курс. +func formatItems(items []CategorySpend, m Display, topN int) string { if len(items) == 0 { return "" } @@ -432,10 +440,10 @@ func formatItems(items []CategorySpend, rubPerTHB float64, topN int) string { other += cs.THB continue } - fmt.Fprintf(&b, " • %-16s %.0f ₽\n", normalizeLabel(cs.Category), cs.THB*rubPerTHB) + fmt.Fprintf(&b, " • %-16s %s\n", normalizeLabel(cs.Category), m.Fmt(cs.THB)) } if other > 0 { - fmt.Fprintf(&b, " • %-16s %.0f ₽\n", "Остальные статьи", other*rubPerTHB) + fmt.Fprintf(&b, " • %-16s %s\n", "Остальные статьи", m.Fmt(other)) } return b.String() } diff --git a/internal/skills/safetospend/format_envelope_test.go b/internal/skills/safetospend/format_envelope_test.go index a5f37d0..a96e194 100644 --- a/internal/skills/safetospend/format_envelope_test.go +++ b/internal/skills/safetospend/format_envelope_test.go @@ -61,18 +61,18 @@ const referenceEnvelopeText = "24.08 — 06.09 · 14 дней\n" + "\n" + "**Куда уйдут**\n" + "```\n" + - "Аренда 10.09 18 000\n" + - "Кредит 27.08 9 194\n" + - "Ежемесячный платеж 01.09 968\n" + - "Подписка Клауд 10.09 548\n" + - "Еда 5 400\n" + - "Транспорт 1 700\n" + - "Здоровье 1 700\n" + - "Развлечения 1 400\n" + - "Прочее 1 200\n" + - "Накопления 858\n" + - " ------\n" + - " 40 968\n" + + "Аренда 10.09 18 000 ฿\n" + + "Кредит 27.08 9 194 ฿\n" + + "Ежемесячный платеж 01.09 968 ฿\n" + + "Подписка Клауд 10.09 548 ฿\n" + + "Еда 5 400 ฿\n" + + "Транспорт 1 700 ฿\n" + + "Здоровье 1 700 ฿\n" + + "Развлечения 1 400 ฿\n" + + "Прочее 1 200 ฿\n" + + "Накопления 858 ฿\n" + + " --------\n" + + " 40 968 ฿\n" + "```\n" + "\n" + "**На день: 814 ฿**\n" + @@ -118,7 +118,18 @@ func TestFormatEnvelopePlan_ColumnSumsToIncome(t *testing.T) { var sum int var total int seenSeparator := false + // Считаем ТОЛЬКО внутри моноблока: с тех пор как знак валюты стоит у каждой + // суммы (simpleAI-302i), «Пришло 128 000 ₽ · 50 491 ฿» из шапки тоже + // разбирается как сумма и задваивает колонку. + inBlock := false for _, line := range lines { + if strings.HasPrefix(line, "```") { + inBlock = !inBlock + continue + } + if !inBlock { + continue + } if strings.Contains(line, "------") { seenSeparator = true continue @@ -144,9 +155,14 @@ func TestFormatEnvelopePlan_ColumnSumsToIncome(t *testing.T) { } } -// trailingAmount вытаскивает число из хвоста строки моноблока («18 000» → 18000). +// trailingAmount вытаскивает число из хвоста строки моноблока +// («18 000 ฿» → 18000). Знак валюты стоит у КАЖДОЙ строки (simpleAI-302i) и +// отбрасывается здесь: тест проверяет сходимость колонки, а не вёрстку. func trailingAmount(line string) (int, bool) { fields := strings.Fields(line) + if n := len(fields); n > 0 && (fields[n-1] == "฿" || fields[n-1] == "₽") { + fields = fields[:n-1] + } if len(fields) == 0 { return 0, false } diff --git a/internal/skills/safetospend/remaining.go b/internal/skills/safetospend/remaining.go index 7460c35..aae7642 100644 --- a/internal/skills/safetospend/remaining.go +++ b/internal/skills/safetospend/remaining.go @@ -60,19 +60,22 @@ func (s *SafeToSpendSkill) runRemaining(ctx context.Context, chatID int64, rates } func formatRemaining(r RemainingResult, rubPerTHB float64, env *budget.Envelope) string { - rub := func(thb float64) float64 { return thb * rubPerTHB } + // Через Display, а не «число + ₽» руками: знак и курс обязаны приходить из + // одного места, иначе рублёвый знак однажды встанет рядом с батами + // (simpleAI-302i). + m := NewDisplay("RUB", 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)) + fmt.Fprintf(&b, "🧧 Конверт: приход ~%s (%s — %s)\n\n", + m.Fmt(r.IncomeTHB), env.PeriodStart.Format("02.01"), env.PeriodEnd.Format("02.01")) + fmt.Fprintf(&b, " 🔁 Регулярные: ~%s\n", m.Fmt(r.RecurringTHB)) + fmt.Fprintf(&b, " 💳 Долги: ~%s\n", m.Fmt(r.DebtTHB)) if r.PlannedTHB > 0 { - fmt.Fprintf(&b, " 📝 Плановые: ~%.0f ₽\n", rub(r.PlannedTHB)) + fmt.Fprintf(&b, " 📝 Плановые: ~%s\n", m.Fmt(r.PlannedTHB)) } - fmt.Fprintf(&b, " 🛒 Уже потрачено: ~%.0f ₽\n", rub(r.ActualSpentTHB)) + fmt.Fprintf(&b, " 🛒 Уже потрачено: ~%s\n", m.Fmt(r.ActualSpentTHB)) b.WriteString(" ──────────────────────\n") - fmt.Fprintf(&b, " 💚 Свободно осталось: ~%.0f ₽\n", rub(r.RemainingTHB)) + fmt.Fprintf(&b, " 💚 Свободно осталось: ~%s\n", m.Fmt(r.RemainingTHB)) return b.String() } diff --git a/internal/skills/safetospend/skill_test.go b/internal/skills/safetospend/skill_test.go index fc19176..f2dc84d 100644 --- a/internal/skills/safetospend/skill_test.go +++ b/internal/skills/safetospend/skill_test.go @@ -88,8 +88,9 @@ func TestNumbersIndependentOfLLM(t *testing.T) { } // И проверим само число: приход 127000 − recurring 20000 = 107000. - if !regexpContains(out1, `Остаётся до повседневных трат: 107000 ₽`) { - t.Errorf("ожидалось 'Остаётся до повседневных трат: 107000 ₽', got:\n%s", out1) + // Разряды группируются пробелом — суммы печатаются через Display (simpleAI-302i). + if !regexpContains(out1, `Остаётся до повседневных трат: 107 000 ₽`) { + t.Errorf("ожидалось 'Остаётся до повседневных трат: 107 000 ₽', got:\n%s", out1) } } From 30aab88276a18ade4b7426a7fa1009c40730bb50 Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Mon, 24 Aug 2026 21:07:28 +0700 Subject: [PATCH 3/6] =?UTF-8?q?feat(budget):=20=D0=BA=D1=83=D1=80=D1=81=20?= =?UTF-8?q?=D0=B1=D0=B0=D1=82=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=91=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Автокурс приходит из open.er-api.com раз в сутки — это межбанк, а меняют наличными по другому курсу. Повлиять на цифру было нельзя ничем, включая рестарт бота, и все конверты считались по ней. «курс 2,7» ставит ручной курс, «курс авто» возвращает автоматический, «какой сейчас курс» показывает действующий и его происхождение. Ручной курс живёт в отдельной колонке exchange_rate.manual_rate_to_rub: положить его туда же, куда пишет воркер, значит потерять через сутки. Воркер продолжает обновлять автокурс и под override'ом. Чтение выбирает ручной через COALESCE — «какой курс действует» имеет один ответ. Курс выше 10 ₽/฿ отклоняется с переспросом: «курс 27» почти наверняка сказано без запятой, а принять его молча значит раздуть конверты вдесятеро. Заодно: знак валюты в показе конвертов (две колонки печатались голыми числами — нашлось живым прогоном) и починен TestStartEnvelope, который ждал формат ответа, отменённый до этих задач. simpleAI-su6l --- evals/golden_set.jsonl | 4 + internal/agent/router.go | 5 +- internal/agent/routing_rules_test.go | 41 ++++++ internal/budget/manual_rate_test.go | 136 ++++++++++++++++++ internal/budget/store.go | 96 ++++++++++++- .../migrations/00020_exchange_rate_manual.sql | 34 +++++ internal/skills/budget/envelope_test.go | 11 +- internal/skills/budget/rate.go | 98 +++++++++++++ internal/skills/budget/skill.go | 12 +- .../skills/safetospend/currency_sign_test.go | 11 ++ internal/skills/safetospend/display.go | 12 ++ internal/skills/safetospend/format.go | 16 ++- .../format_share_remaining_test.go | 33 +++-- internal/skills/safetospend/skill_test.go | 6 +- 14 files changed, 487 insertions(+), 28 deletions(-) create mode 100644 internal/budget/manual_rate_test.go create mode 100644 internal/db/migrations/00020_exchange_rate_manual.sql create mode 100644 internal/skills/budget/rate.go diff --git a/evals/golden_set.jsonl b/evals/golden_set.jsonl index 741db91..8cf01a0 100644 --- a/evals/golden_set.jsonl +++ b/evals/golden_set.jsonl @@ -55,3 +55,7 @@ {"id":"r050","input":"пришло 127000 рублей, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","income_arrived","acceptance","ru"],"notes":"simpleAI-faeq.10 — эталонный кейс приёмки: 127к делятся на ВСЕ расходы периода, включая регулярные платежи отдельными строками; повтор той же фразы обязан давать тот же результат (carried_in не растёт)"} {"id":"r051","input":"пришло 10 рублей, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","small_income","ru"],"notes":"simpleAI-faeq.11 §5 — малый приход не ломает раскладку: дневной лимит считается от гибких конвертов, приход в формуле не участвует"} {"id":"r052","input":"сколько можно тратить в день","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","daily_limit","read_only","ru"],"notes":"simpleAI-faeq.11 §5 — дневной лимит = гибкие конверты / оставшиеся дни, пересчитывается после каждой траты; read-only"} +{"id":"r053","input":"курс 2,7","expected":{"skill":"budget","action":"set_rate"},"tags":["exchange_rate","manual_rate","ru"],"notes":"simpleAI-su6l — ручной курс словами. Число = рубли за один бат, НЕ трата и НЕ лимит конверта: в фразе нет ни прошедшего времени, ни категории"} +{"id":"r054","input":"курс авто","expected":{"skill":"budget","action":"clear_rate"},"tags":["exchange_rate","manual_rate","ru"],"notes":"simpleAI-su6l — возврат к автокурсу из open.er-api.com; ручной override снимается, автокурс всё это время обновлялся воркером"} +{"id":"r055","input":"какой сейчас курс","expected":{"skill":"budget","action":"rate_status"},"tags":["exchange_rate","read_only","ru"],"notes":"simpleAI-su6l — ответ обязан назвать, ручной курс или автоматический: иначе оператор не отличит подействовавшую команду от проигнорированной"} +{"id":"r056","input":"покажи конверты в рублях","expected":{"skill":"safe_to_spend","action":""},"tags":["exchange_rate","display_currency","regression","ru"],"notes":"контраст к r053: это ПОКАЗ (display_currency=RUB), курс пересчёта не трогается. Разграничение уже ломалось на паре currency/display_currency"} diff --git a/internal/agent/router.go b/internal/agent/router.go index 78279da..7eeccc0 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -37,7 +37,10 @@ func buildToolsSystemPrompt(manifests []plugin.Manifest) string { sb.WriteString("5. Вопрос про ОСТАТОК по категории или по конвертам («сколько осталось на еду», «сколько в конвертах», «сколько осталось в конверте на транспорт») — skill=safe_to_spend, сумму НЕ передавать. Это не budget.summary: спрашивают не сколько потрачено, а сколько ещё можно потратить.\n") sb.WriteString("6. ВАЛЮТА КОНВЕРТОВ. По умолчанию конверты показываются В БАТАХ (THB) — поле display_currency НЕ заполняй. Если оператор попросил рубли («покажи конверты в рублях», «разложи и покажи в рублях», «сколько это в рублях») — передай display_currency=\"RUB\"; если явно попросил баты («в батах») — display_currency=\"THB\". Поле display_currency — только про ПОКАЗ; сумму из сообщения оно не меняет.\n") sb.WriteString(" Валюта САМОЙ СУММЫ идёт в currency: «пришло 127000₽» → currency=\"RUB\"; «на еду хватит 5000 бат» → currency=\"THB\"; «на еду хватит 15000 рублей» → currency=\"RUB\". Не путай currency с display_currency: первая описывает названную сумму, вторая — валюту ответа.\n") - sb.WriteString("7. Если в сообщении нет суммы и нет глагола в прошедшем времени — это НЕ add_expense.\n") + sb.WriteString("7. КУРС БАТА. «курс 2,7», «ставь курс 2.65», «курс бата 2,7» — skill=budget, action=set_rate, поле amount=<курс>. Это НЕ трата и НЕ лимит: число — сколько РУБЛЕЙ за один БАТ, деньги никуда не двигались. «курс авто», «верни автоматический курс», «бери курс сам» — action=clear_rate. «какой сейчас курс», «по какому курсу считаешь» — action=rate_status.\n") + sb.WriteString(" Разграничение с правилом 6: «покажи конверты в рублях» — это ПОКАЗ, display_currency=RUB, курс не трогаем. Правило 7 меняет сам курс пересчёта.\n") + sb.WriteString(" Разграничение с правилом 3: «на еду хватит 15000» — лимит конверта (названа КАТЕГОРИЯ); «курс 2,7» — категории нет, есть слово «курс».\n") + sb.WriteString("8. Если в сообщении нет суммы и нет глагола в прошедшем времени — это НЕ add_expense.\n") sb.WriteString("\nДоступные инструменты:\n") for _, m := range manifests { fmt.Fprintf(&sb, "- %s: %s\n", m.ID, m.Description) diff --git a/internal/agent/routing_rules_test.go b/internal/agent/routing_rules_test.go index 490372d..11afe8d 100644 --- a/internal/agent/routing_rules_test.go +++ b/internal/agent/routing_rules_test.go @@ -50,3 +50,44 @@ func TestBuildToolsSystemPrompt_ShareLimitRuleSeparatedFromExpense(t *testing.T) t.Errorf("правило про лимит должно идти после правила про add_expense (expense=%d, limit=%d)", expenseIdx, limitIdx) } } + +// Правило про курс живёт в том же блоке ROUTING RULES и разграничено с двумя +// соседями, на которых оно естественно налипает (simpleAI-su6l): +// - «покажи конверты в рублях» — это ПОКАЗ (display_currency), курс не трогаем; +// - «на еду хватит 15000» — лимит конверта, там названа КАТЕГОРИЯ. +// +// Как и тест выше, доказывает только присутствие правила в промпте. Что модель +// ему следует — гоняют golden-кейсы r053–r056 на боевой модели. +func TestBuildToolsSystemPrompt_RateRuleSeparatedFromDisplayAndLimit(t *testing.T) { + prompt := buildToolsSystemPrompt([]plugin.Manifest{ + {ID: "budget", Description: "budget tracker"}, + {ID: "safe_to_spend", Description: "safe to spend"}, + }) + rules := prompt[:strings.Index(prompt, "Доступные инструменты")] + + mustContain := map[string]string{ + "set_rate": "нет правила про ручной курс", + "clear_rate": "нет правила про возврат автокурса", + "rate_status": "нет правила про «какой сейчас курс»", + "курс авто": "нет фразы-триггера возврата к автокурсу", + "РУБЛЕЙ за один БАТ": "не сказано, что число — курс, а не сумма денег", + } + for marker, why := range mustContain { + if !strings.Contains(rules, marker) { + t.Errorf("%s: в ROUTING RULES нет %q", why, marker) + } + } + + // Правило про курс обязано стоять ПОСЛЕ правил про display_currency и про + // лимит: разграничение читается как уточнение к ним, а не наоборот. + rateAt := strings.Index(rules, "КУРС БАТА") + displayAt := strings.Index(rules, "ВАЛЮТА КОНВЕРТОВ") + limitAt := strings.Index(rules, "Правка ЛИМИТА конверта") + if rateAt < 0 || displayAt < 0 || limitAt < 0 { + t.Fatalf("не найдены заголовки правил: курс=%d показ=%d лимит=%d", rateAt, displayAt, limitAt) + } + if rateAt < displayAt || rateAt < limitAt { + t.Errorf("правило про курс стоит раньше правил, от которых его отделяют (курс=%d показ=%d лимит=%d)", + rateAt, displayAt, limitAt) + } +} diff --git a/internal/budget/manual_rate_test.go b/internal/budget/manual_rate_test.go new file mode 100644 index 0000000..6998779 --- /dev/null +++ b/internal/budget/manual_rate_test.go @@ -0,0 +1,136 @@ +package budget + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func rateTestStore(t *testing.T) (*Store, context.Context) { + t.Helper() + url := os.Getenv("BOTCLIENT_DATABASE_URL_RW") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + return &Store{pool: pool}, ctx +} + +// Ручной курс обязан пережить суточный тик воркера (simpleAI-su6l). +// +// Это главный гейт задачи: положи ручной курс в ту же колонку, что пишет +// воркер, — и он молча исчезнет в ближайшие 24 часа. Оператор увидит, что +// команда «сработала», а через сутки конверты снова посчитаются по межбанку. +// +// Мутация: убрать COALESCE из GetExchangeRates либо начать писать manual в +// SaveExchangeRate — тест краснеет. +func TestManualRate_SurvivesWorkerTick(t *testing.T) { + s, ctx := rateTestStore(t) + const cur = "THB" + + before, existed, err := s.GetRateSource(ctx, cur) + if err != nil { + t.Fatalf("read rate source: %v", err) + } + t.Cleanup(func() { + // Вернуть реплику в исходное состояние: тест ходит в живую базу. + if existed && before.Manual { + _ = s.SetManualRate(ctx, cur, before.RateToRUB) + return + } + _ = s.ClearManualRate(ctx, cur) + if existed { + _ = s.SaveExchangeRate(ctx, cur, before.Auto) + } + }) + + if err := s.SetManualRate(ctx, cur, 2.7); err != nil { + t.Fatalf("set manual rate: %v", err) + } + + rates, err := s.GetExchangeRates(ctx) + if err != nil { + t.Fatalf("get rates: %v", err) + } + if got := rates[cur]; got != 2.7 { + t.Fatalf("действующий курс %v, ожидался ручной 2,7", got) + } + + // Тик воркера: тот же вызов, что делает rates.Worker раз в сутки. + if err := s.SaveExchangeRate(ctx, cur, 2.5351); err != nil { + t.Fatalf("worker tick: %v", err) + } + + rates, err = s.GetExchangeRates(ctx) + if err != nil { + t.Fatalf("get rates after tick: %v", err) + } + if got := rates[cur]; got != 2.7 { + t.Errorf("после тика воркера курс %v — ручной курс затёрт автоматическим", got) + } + + src, ok, err := s.GetRateSource(ctx, cur) + if err != nil || !ok { + t.Fatalf("read rate source after tick: err=%v ok=%v", err, ok) + } + if !src.Manual { + t.Error("курс перестал считаться ручным") + } + if src.Auto != 2.5351 { + t.Errorf("автокурс %v — воркер обязан продолжать его обновлять и под override'ом", src.Auto) + } + + // «курс авто» возвращает то, что за это время принёс воркер. + if err := s.ClearManualRate(ctx, cur); err != nil { + t.Fatalf("clear manual rate: %v", err) + } + rates, err = s.GetExchangeRates(ctx) + if err != nil { + t.Fatalf("get rates after clear: %v", err) + } + if got := rates[cur]; got != 2.5351 { + t.Errorf("после «курс авто» действует %v, ожидался автокурс 2,5351", got) + } +} + +// Повторный «курс авто» — не ошибка: оператору важно состояние «ручного курса +// нет», а не факт удаления строки. +func TestManualRate_ClearIsIdempotent(t *testing.T) { + s, ctx := rateTestStore(t) + const cur = "THB" + + before, existed, err := s.GetRateSource(ctx, cur) + if err != nil { + t.Fatalf("read rate source: %v", err) + } + t.Cleanup(func() { + if existed && before.Manual { + _ = s.SetManualRate(ctx, cur, before.RateToRUB) + } + }) + + if err := s.ClearManualRate(ctx, cur); err != nil { + t.Fatalf("first clear: %v", err) + } + if err := s.ClearManualRate(ctx, cur); err != nil { + t.Errorf("повторный «курс авто» вернул ошибку: %v", err) + } +} + +// Курс — величина строго положительная: нулевой делит на ноль в ToTHB, +// отрицательный печатает отрицательные конверты. +func TestManualRate_RejectsNonPositive(t *testing.T) { + s, ctx := rateTestStore(t) + for _, bad := range []float64{0, -2.7} { + if err := s.SetManualRate(ctx, "THB", bad); err == nil { + t.Errorf("курс %v принят, ожидалась ошибка", bad) + } + } +} diff --git a/internal/budget/store.go b/internal/budget/store.go index 4ad1efe..265795d 100644 --- a/internal/budget/store.go +++ b/internal/budget/store.go @@ -1609,8 +1609,13 @@ func AggregateForecast(rows []MonthlyCategoryExpense, rates map[string]float64) // --- Курсы валют --- // GetExchangeRates возвращает все курсы из БД как map[currency]rate_to_rub. +// +// Ручной курс перекрывает автоматический (simpleAI-su6l): COALESCE выбирает его +// прямо в запросе, чтобы «какой курс действует» имело ровно один ответ и +// вызывающему не приходилось помнить про override. func (s *Store) GetExchangeRates(ctx context.Context) (map[string]float64, error) { - rows, err := s.pool.Query(ctx, `SELECT currency, rate_to_rub FROM exchange_rate`) + rows, err := s.pool.Query(ctx, + `SELECT currency, COALESCE(manual_rate_to_rub, rate_to_rub) FROM exchange_rate`) if err != nil { return nil, fmt.Errorf("get exchange rates: %w", err) } @@ -1628,7 +1633,94 @@ func (s *Store) GetExchangeRates(ctx context.Context) (map[string]float64, error return rates, rows.Err() } -// SaveExchangeRate сохраняет или обновляет курс валюты. +// RateSource — откуда взялся действующий курс валюты. +type RateSource struct { + Currency string + RateToRUB float64 // действующий курс: ручной, если задан, иначе автоматический + Auto float64 // последний курс из API — остаётся жить и под override'ом + Manual bool // курс задан руками + UpdatedAt time.Time // когда действующий курс появился +} + +// GetRateSource отдаёт действующий курс валюты вместе с его происхождением. +// Нужен подтверждению «курс 2,7»: не показав, ручной курс или автоматический, +// мы оставляем оператора гадать, подействовала его команда или нет. +func (s *Store) GetRateSource(ctx context.Context, currency string) (RateSource, bool, error) { + var ( + out = RateSource{Currency: currency} + manual *float64 + mAt *time.Time + aAt time.Time + ) + err := s.pool.QueryRow(ctx, ` + SELECT rate_to_rub, updated_at, manual_rate_to_rub, manual_set_at + FROM exchange_rate WHERE currency = $1 + `, currency).Scan(&out.Auto, &aAt, &manual, &mAt) + if errors.Is(err, pgx.ErrNoRows) { + return out, false, nil + } + if err != nil { + return out, false, fmt.Errorf("get rate source: %w", err) + } + out.RateToRUB, out.UpdatedAt = out.Auto, aAt + if manual != nil { + out.RateToRUB, out.Manual = *manual, true + if mAt != nil { + out.UpdatedAt = *mAt + } + } + return out, true, nil +} + +// SetManualRate задаёт ручной курс валюты. Автоматический не трогается — он +// продолжает обновляться воркером и ждёт «курс авто». +func (s *Store) SetManualRate(ctx context.Context, currency string, rateToRUB float64) error { + if rateToRUB <= 0 { + return fmt.Errorf("set manual rate: курс должен быть больше нуля, получено %v", rateToRUB) + } + tag, err := s.pool.Exec(ctx, ` + UPDATE exchange_rate + SET manual_rate_to_rub = $2, manual_set_at = NOW() + WHERE currency = $1 + `, currency, rateToRUB) + if err != nil { + return fmt.Errorf("set manual rate: %w", err) + } + if tag.RowsAffected() == 0 { + // Валюты нет в таблице — воркер её ещё не приносил. Заводим строку с + // ручным курсом в обеих колонках: без rate_to_rub (NOT NULL по схеме) + // вставка не пройдёт, а «курс авто» до первого тика воркера честно + // вернёт ровно то же число. + _, err = s.pool.Exec(ctx, ` + INSERT INTO exchange_rate (currency, rate_to_rub, updated_at, manual_rate_to_rub, manual_set_at) + VALUES ($1, $2, NOW(), $2, NOW()) + ON CONFLICT (currency) DO UPDATE + SET manual_rate_to_rub = EXCLUDED.manual_rate_to_rub, manual_set_at = NOW() + `, currency, rateToRUB) + if err != nil { + return fmt.Errorf("set manual rate (insert): %w", err) + } + } + return nil +} + +// ClearManualRate снимает ручной курс: действовать снова начинает автоматический. +// Идемпотентна — «курс авто» без заданного override'а не ошибка. +func (s *Store) ClearManualRate(ctx context.Context, currency string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE exchange_rate + SET manual_rate_to_rub = NULL, manual_set_at = NULL + WHERE currency = $1 + `, currency) + if err != nil { + return fmt.Errorf("clear manual rate: %w", err) + } + return nil +} + +// SaveExchangeRate сохраняет или обновляет АВТОМАТИЧЕСКИЙ курс валюты. +// Колонку manual_rate_to_rub не трогает: ручной курс обязан пережить суточный +// тик воркера, иначе его затрёт в ближайшие 24 часа (simpleAI-su6l). func (s *Store) SaveExchangeRate(ctx context.Context, currency string, rateToRUB float64) error { _, err := s.pool.Exec(ctx, ` INSERT INTO exchange_rate (currency, rate_to_rub, updated_at) diff --git a/internal/db/migrations/00020_exchange_rate_manual.sql b/internal/db/migrations/00020_exchange_rate_manual.sql new file mode 100644 index 0000000..69b6b5c --- /dev/null +++ b/internal/db/migrations/00020_exchange_rate_manual.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- +goose StatementBegin +-- Ручной курс валюты (simpleAI-su6l). +-- +-- Автокурс тянется раз в сутки из open.er-api.com — это межбанк. Оператор +-- живёт в Тайланде и меняет наличными, где курс другой; повлиять на цифру он +-- не мог никак, а все конверты считаются именно по ней. +-- +-- ОТДЕЛЬНАЯ колонка, а не перезапись rate_to_rub: воркер продолжает писать +-- автокурс каждые сутки, и ручное значение, положенное в ту же колонку, он +-- затёр бы в ближайший тик. Чтение отдаёт manual_rate_to_rub, когда он есть. +-- «курс авто» = обнулить эту колонку, не трогая автокурс. +ALTER TABLE exchange_rate + ADD COLUMN IF NOT EXISTS manual_rate_to_rub DOUBLE PRECISION; + +ALTER TABLE exchange_rate + ADD COLUMN IF NOT EXISTS manual_set_at TIMESTAMPTZ; + +-- Курс — величина строго положительная. Нулевой курс делит на ноль в ToTHB, +-- отрицательный печатает отрицательные конверты. +ALTER TABLE exchange_rate + DROP CONSTRAINT IF EXISTS exchange_rate_manual_positive; + +ALTER TABLE exchange_rate + ADD CONSTRAINT exchange_rate_manual_positive + CHECK (manual_rate_to_rub IS NULL OR manual_rate_to_rub > 0); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE exchange_rate DROP CONSTRAINT IF EXISTS exchange_rate_manual_positive; +ALTER TABLE exchange_rate DROP COLUMN IF EXISTS manual_set_at; +ALTER TABLE exchange_rate DROP COLUMN IF EXISTS manual_rate_to_rub; +-- +goose StatementEnd diff --git a/internal/skills/budget/envelope_test.go b/internal/skills/budget/envelope_test.go index 110e2e6..3d5309e 100644 --- a/internal/skills/budget/envelope_test.go +++ b/internal/skills/budget/envelope_test.go @@ -134,11 +134,20 @@ func TestStartEnvelope_WritesSharesAndReply(t *testing.T) { t.Errorf("в ответе нет строки по доле %q:\n%s", sh.Name, reply) } } - for _, want := range []string{"Приход", "Обязательства", "К раскладке", "Свободно", "Вне конвертов"} { + // Строки утверждённого оператором формата (simpleAI-faeq.11). Прежние + // «Приход / Обязательства / К раскладке / Вне конвертов» он отверг: сводная + // строка обязательств прятала и сумму, и повод, а «вне конвертов» показывала + // 0 ฿ при аренде 18 000, потому что считала ФАКТ прошедших трат. + for _, want := range []string{"Пришло", "Курс", "Куда уйдут", "На день"} { if !strings.Contains(reply, want) { t.Errorf("в ответе нет обязательной строки %q:\n%s", want, reply) } } + // Знак валюты у каждой суммы (simpleAI-302i): без него колонку нельзя + // прочитать — баты там или рубли. + if !strings.Contains(reply, "฿") { + t.Errorf("в ответе нет знака валюты:\n%s", reply) + } // Повторный приход деактивирует прошлый конверт, а не плодит второй активный. if _, err := skill.Run(ctx, `{"action":"start_envelope","amount":50000,"currency":"RUB"}`); err != nil { diff --git a/internal/skills/budget/rate.go b/internal/skills/budget/rate.go new file mode 100644 index 0000000..c80fd55 --- /dev/null +++ b/internal/skills/budget/rate.go @@ -0,0 +1,98 @@ +package budgetskill + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "simpleAI/internal/skills/safetospend" +) + +// rateCurrency — валюта, курс которой правится словами. +// +// Одна и только одна: rates хранит «₽ за 1 единицу», рубль в этой шкале равен +// единице по определению, а доллар и евро в конвертах не участвуют. Оператор +// живёт в батах и спрашивает про бат — «курс 2,7» без названия валюты значит +// именно его. +const rateCurrency = "THB" + +// maxPlausibleRate — потолок правдоподобия для курса ₽ за 1 ฿. Бат к рублю +// исторически держится в единицах (2–4). Значение выше почти наверняка сказано +// без запятой («курс 27» вместо «2,7»), и принять его молча значит раздуть все +// конверты в десять раз убедительно выглядящими числами. +const maxPlausibleRate = 10.0 + +// setRate задаёт ручной курс бата (simpleAI-su6l). +// +// Зачем это вообще: автокурс приходит из open.er-api.com — межбанк. Оператор +// меняет наличными в Тайланде, где курс другой, и все конверты считались по +// цифре, на которую он не мог повлиять никак, даже рестартом бота. +// +// Ручной курс живёт в отдельной колонке и переживает суточный тик воркера — +// иначе его затёрло бы в ближайшие 24 часа. +func (s *BudgetSkill) setRate(ctx context.Context, req budgetInput) (string, error) { + rate := req.Amount + if rate <= 0 { + return "Не понял курс. Скажи «курс 2,7» — сколько рублей за один бат.", nil + } + // Курс бата к рублю живёт в единицах, а не в десятках: «курс 27» — это почти + // наверняка «2,7», сказанное без запятой. Молча принять такое значение + // нельзя: конверты раздуются в десять раз и будут выглядеть правдоподобно. + if rate > maxPlausibleRate { + return fmt.Sprintf("Курс %s ₽/฿ — это в разы больше обычного (около 2,5). "+ + "Если правда столько, скажи ещё раз с десятыми: «курс %s».", + safetospend.FmtRate(rate), safetospend.FmtRate(rate/10)), nil + } + + if err := s.store.SetManualRate(ctx, rateCurrency, rate); err != nil { + slog.Default().ErrorContext(ctx, "set_rate", "err", err, "rate", rate) + return "Не удалось сохранить курс — попробуй ещё раз.", nil + } + slog.Default().InfoContext(ctx, "set_rate", "rate", rate, "currency", rateCurrency) + + return fmt.Sprintf("💱 Курс: %s ₽/฿ (вручную). Считаю по нему, пока не скажешь «курс авто».", + safetospend.FmtRate(rate)), nil +} + +// clearRate возвращает автоматический курс. Идемпотентно: «курс авто» без +// заданного ручного — не ошибка, а подтверждение того же состояния. +func (s *BudgetSkill) clearRate(ctx context.Context) (string, error) { + if err := s.store.ClearManualRate(ctx, rateCurrency); err != nil { + slog.Default().ErrorContext(ctx, "clear_rate", "err", err) + return "Не удалось вернуть автоматический курс — попробуй ещё раз.", nil + } + src, ok, err := s.store.GetRateSource(ctx, rateCurrency) + if err != nil || !ok { + slog.Default().WarnContext(ctx, "clear_rate: read back", "err", err, "found", ok) + return "💱 Вернул автоматический курс.", nil + } + return fmt.Sprintf("💱 Вернул автоматический курс %s ₽/฿ (обновлён %s).", + safetospend.FmtRate(src.RateToRUB), src.UpdatedAt.Local().Format("02.01 15:04")), nil +} + +// rateStatus — «какой сейчас курс». Отдельным ответом показывает, ручной он или +// автоматический: без этого оператор не отличит подействовавшую команду от +// проигнорированной. +func (s *BudgetSkill) rateStatus(ctx context.Context) (string, error) { + src, ok, err := s.store.GetRateSource(ctx, rateCurrency) + if err != nil { + slog.Default().ErrorContext(ctx, "rate_status", "err", err) + return "Не удалось получить курс — попробуй позже.", nil + } + if !ok { + return "Курса бата пока нет — он подтянется автоматически в ближайшие сутки.", nil + } + var b strings.Builder + if src.Manual { + fmt.Fprintf(&b, "💱 Курс: %s ₽/฿ (вручную, задан %s).", + safetospend.FmtRate(src.RateToRUB), src.UpdatedAt.Local().Format("02.01 15:04")) + fmt.Fprintf(&b, "\nАвтоматический сейчас %s ₽/฿ — скажи «курс авто», чтобы вернуться к нему.", + safetospend.FmtRate(src.Auto)) + return b.String(), nil + } + fmt.Fprintf(&b, "💱 Курс: %s ₽/฿ (автоматически, обновлён %s).", + safetospend.FmtRate(src.RateToRUB), src.UpdatedAt.Local().Format("02.01 15:04")) + b.WriteString("\nЕсли в обменнике другой — скажи «курс 2,7», буду считать по нему.") + return b.String(), nil +} diff --git a/internal/skills/budget/skill.go b/internal/skills/budget/skill.go index 1b9eb31..5f9e49f 100644 --- a/internal/skills/budget/skill.go +++ b/internal/skills/budget/skill.go @@ -60,6 +60,10 @@ func (s *BudgetSkill) Manifest() plugin.Manifest { "action='set_share_limit' CORRECTS the LIMIT of a category envelope by hand ('на еду хватит 15000', 'на транспорт закладывай 5000', 'лимит на развлечения 3000', 'ставь на еду 15000') — pass name=<категория>, amount, currency. " + "It is NOT a transaction: nothing was spent, the user is fixing the PLAN. The correction is remembered and applied to every following income until removed. " + "action='clear_share_limit' REMOVES that manual limit ('убери лимит на еду', 'сними лимит с транспорта', 'считай лимит на еду сам') — the limit goes back to being computed from spending history; pass name=<категория>. " + + "action='set_rate' SETS the ฿→₽ EXCHANGE RATE by hand ('курс 2,7', 'ставь курс 2.65', 'курс бата 2,7') — pass amount=. " + + "It is NOT a transaction and NOT a limit: the number is rubles per one baht, not money spent or planned. " + + "action='clear_rate' returns to the automatic rate ('курс авто', 'верни автоматический курс', 'бери курс сам'). " + + "action='rate_status' answers 'какой сейчас курс', 'по какому курсу считаешь'. " + "Use budget.summary for plain numerical totals only.", Version: "1.0.0", InputSchema: &plugin.Schema{ @@ -70,7 +74,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, add_planned_expense, start_envelope, set_share_limit, clear_share_limit", + "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, set_share_limit, clear_share_limit, set_rate, clear_rate, rate_status", }, "amount": map[string]any{ "type": "number", @@ -285,6 +289,12 @@ func (s *BudgetSkill) Run(ctx context.Context, input string) (string, error) { return s.setShareLimit(ctx, req) case "clear_share_limit": return s.clearShareLimit(ctx, req) + case "set_rate": + return s.setRate(ctx, req) + case "clear_rate": + return s.clearRate(ctx) + case "rate_status": + return s.rateStatus(ctx) default: return "", fmt.Errorf("unknown action: %s", req.Action) } diff --git a/internal/skills/safetospend/currency_sign_test.go b/internal/skills/safetospend/currency_sign_test.go index dc7b560..309186d 100644 --- a/internal/skills/safetospend/currency_sign_test.go +++ b/internal/skills/safetospend/currency_sign_test.go @@ -79,6 +79,17 @@ func TestFormat_EveryAmountCarriesCurrencySign(t *testing.T) { advice: []string{"срезать доставку"}, }), }, + { + // Дырка, найденная живым прогоном 24.08: показ конвертов печатал ДВЕ + // колонки чисел, и обе без знака — этот форматтер в проверку не входил. + name: "сколько в конвертах", + out: formatShareRemaining([]ShareRemaining{ + {Name: "Кредит потребительский Сбербанк", Kind: budget.ShareKindFixed, Allocated: 11242.05, Remaining: 11242.05}, + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5025.73, SpentTHB: 553, Remaining: 4472.73}, + {Name: "Транспорт", Kind: budget.ShareKindSpend, Allocated: 919.83, SpentTHB: 1100, Remaining: -180.17}, + {Name: "накопления", Kind: budget.ShareKindSave, Allocated: 27797.54, Remaining: 27797.54}, + }, NewDisplay("THB", 2.5351), &budget.Envelope{PeriodStart: from, PeriodEnd: to}, from), + }, { name: "остаток по конверту", out: formatRemaining(RemainingResult{ diff --git a/internal/skills/safetospend/display.go b/internal/skills/safetospend/display.go index 1295380..a62b052 100644 --- a/internal/skills/safetospend/display.go +++ b/internal/skills/safetospend/display.go @@ -2,6 +2,7 @@ package safetospend import ( "fmt" + "strconv" "strings" ) @@ -108,3 +109,14 @@ func ParseDisplayCurrency(text string) string { func FmtAmount(amount float64, currency string) string { return fmt.Sprintf("%s %s", groupDigits(roundInt(amount)), currencySign(currency)) } + +// FmtRate — курс с запятой: русский текст, «2.7» в нём читается как сбой. +// Десятых две, а не одна: оператор задаёт курс словами и «2,53» обязано +// вернуться ему как «2,53», а не округлиться до «2,5» (simpleAI-su6l). +func FmtRate(v float64) string { + s := strconv.FormatFloat(v, 'f', -1, 64) + if dot := strings.IndexByte(s, '.'); dot >= 0 && len(s)-dot > 3 { + s = strconv.FormatFloat(v, 'f', 2, 64) + } + return strings.Replace(s, ".", ",", 1) +} diff --git a/internal/skills/safetospend/format.go b/internal/skills/safetospend/format.go index f691db3..f13e18f 100644 --- a/internal/skills/safetospend/format.go +++ b/internal/skills/safetospend/format.go @@ -487,15 +487,19 @@ func parseAdviceLines(raw string) []string { } // Ширина колонок моноблока ПОКАЗА конвертов. Колонок здесь три: имя и два -// числа — потрачено и осталось. Сумма 14+9+9 = 32, тот же порог 36, что и у -// раскладки: pre в Telegram не переносит строки по словам. +// числа — потрачено и осталось. Сумма 14+10+10 = 34, в пределах порога 36: +// pre в Telegram не переносит строки по словам, и лишний знак уводит колонку +// в горизонтальный скролл на узком экране. // // Имя ужато с 18 до 14 знаков именно ради второго числа: без «потрачено» // оператор видит остаток, но не видит, с чего тот упал. +// +// По 10 на число, а не по 9: знак валюты стоит у КАЖДОЙ суммы (simpleAI-302i). +// Самая длинная реальная строка — «27 798 ฿», восемь знаков. const ( remLabelWidth = 14 - remSpentWidth = 9 - remLeftWidth = 9 + remSpentWidth = 10 + remLeftWidth = 10 remTotalsWidth = remLabelWidth + remSpentWidth + remLeftWidth ) @@ -557,9 +561,9 @@ func formatShareRemaining(items []ShareRemaining, m Display, env *budget.Envelop spentTotal += r.spent leftTotal += r.left fmt.Fprintf(&b, "%s%s%s\n", padRight(r.label, remLabelWidth), - padLeft(groupDigits(r.spent), remSpentWidth), padLeft(groupDigits(r.left), remLeftWidth)) + padLeft(m.Signed(r.spent), remSpentWidth), padLeft(m.Signed(r.left), remLeftWidth)) } - spentStr, leftStr := groupDigits(spentTotal), groupDigits(leftTotal) + spentStr, leftStr := m.Signed(spentTotal), m.Signed(leftTotal) fmt.Fprintf(&b, "%s%s%s\n", padRight("", remLabelWidth), padLeft(strings.Repeat("-", utf8.RuneCountInString(spentStr)), remSpentWidth), padLeft(strings.Repeat("-", utf8.RuneCountInString(leftStr)), remLeftWidth)) diff --git a/internal/skills/safetospend/format_share_remaining_test.go b/internal/skills/safetospend/format_share_remaining_test.go index f45f3b7..1ba8d89 100644 --- a/internal/skills/safetospend/format_share_remaining_test.go +++ b/internal/skills/safetospend/format_share_remaining_test.go @@ -51,15 +51,15 @@ const referenceRemainingText = "24.08 — 06.09 · осталось 13 дней\ "\n" + "**Что осталось**\n" + "```\n" + - " потрачено осталось\n" + - "Аренда 0 18 000\n" + - "Кредит 0 9 194\n" + - "Еда 2 000 3 400\n" + - "Транспорт 1 900 -200\n" + - "Прочее 0 1 200\n" + - "Накопления 0 858\n" + - " ----- ------\n" + - " 3 900 32 452\n" + + " потрачено осталось\n" + + "Аренда 0 ฿ 18 000 ฿\n" + + "Кредит 0 ฿ 9 194 ฿\n" + + "Еда 2 000 ฿ 3 400 ฿\n" + + "Транспорт 1 900 ฿ -200 ฿\n" + + "Прочее 0 ฿ 1 200 ฿\n" + + "Накопления 0 ฿ 858 ฿\n" + + " ------- --------\n" + + " 3 900 ฿ 32 452 ฿\n" + "```\n" + "\n" + "**На день: 338 ฿**\n" + @@ -169,10 +169,15 @@ func parseTwoAmounts(line string) (int, int, bool) { return spent, left, true } -// parseGrouped читает число одной колонки: пробелы-разряды выкидываются, -// нецифровое содержимое (шапка «потрачено», линейка «-----») отвергается. +// parseGrouped читает число одной колонки: пробелы-разряды и знак валюты +// выкидываются, нецифровое содержимое (шапка «потрачено», линейка «-----») +// отвергается. Знак валюты стоит у каждой суммы (simpleAI-302i) и к разбору +// колонки отношения не имеет — здесь проверяется сходимость, а не вёрстка. func parseGrouped(cell string) (int, bool) { - digits := strings.ReplaceAll(strings.TrimSpace(cell), " ", "") + digits := strings.TrimSpace(cell) + digits = strings.TrimSuffix(digits, "฿") + digits = strings.TrimSuffix(digits, "₽") + digits = strings.ReplaceAll(strings.TrimSpace(digits), " ", "") neg := strings.HasPrefix(digits, "-") digits = strings.TrimPrefix(digits, "-") if digits == "" { @@ -252,7 +257,7 @@ func TestDaysLeft_AfterPeriodEndNeverZero(t *testing.T) { // Пробитый конверт видно: минусом в колонке остатка и отдельной строкой снизу. func TestFormatShareRemaining_OverspentIsVisible(t *testing.T) { got := formatShareRemaining(remainingReference(), NewDisplay("THB", 3.1), remainingEnv(), referenceNow) - if !strings.Contains(got, "Транспорт 1 900 -200") { + if !strings.Contains(got, "Транспорт 1 900 ฿ -200 ฿") { t.Errorf("пробитый конверт не показан минусом в колонке остатка:\n%s", got) } if !strings.Contains(got, "⚠️ Пробито: Транспорт") { @@ -271,7 +276,7 @@ func TestFormatShareRemaining_OverspentIsVisible(t *testing.T) { // арифметика: в рублях все числа умножены на курс, знак — ₽. func TestFormatShareRemaining_DisplayRUB(t *testing.T) { got := formatShareRemaining(remainingReference(), NewDisplay("RUB", 2), remainingEnv(), referenceNow) - if !strings.Contains(got, "Еда 4 000 6 800") { + if !strings.Contains(got, "Еда 4 000 ₽ 6 800 ₽") { t.Errorf("суммы не переведены в рубли по курсу 2:\n%s", got) } if !strings.Contains(got, "**На день: 677 ₽**") { diff --git a/internal/skills/safetospend/skill_test.go b/internal/skills/safetospend/skill_test.go index f2dc84d..8e63a3d 100644 --- a/internal/skills/safetospend/skill_test.go +++ b/internal/skills/safetospend/skill_test.go @@ -158,7 +158,7 @@ func TestRunShares_UsesRecurringFreeSource(t *testing.T) { } // 10000 − 1000 = 9000 ฿ (валюту не просили — печатаем батами). Снапшотный // факт (100 THB по «Еда») в остаток попасть не должен. - if !regexpContains(out, `Еда\s+1 000\s+9 000`) { + if !regexpContains(out, `Еда\s+1 000 ฿\s+9 000 ฿`) { t.Errorf("ожидался остаток «Еда» 9000 ฿ при потраченных 1000 ฿, got:\n%s", out) } } @@ -194,7 +194,7 @@ func TestRunShares_DisplayRUB(t *testing.T) { if err != nil { t.Fatal(err) } - if !regexpContains(out, `Еда\s+2 600\s+23 400`) { + if !regexpContains(out, `Еда\s+2 600 ₽\s+23 400 ₽`) { t.Errorf("ожидался остаток «Еда» 23400 ₽ при потраченных 2600 ₽, got:\n%s", out) } if strings.Contains(amountsOnly(out), "฿") { @@ -212,7 +212,7 @@ func TestRunShares_DisplayTHBByWords(t *testing.T) { if err != nil { t.Fatal(err) } - if !regexpContains(out, `Еда\s+1 000\s+9 000`) { + if !regexpContains(out, `Еда\s+1 000 ฿\s+9 000 ฿`) { t.Errorf("ожидался остаток «Еда» 9000 ฿ при потраченных 1000 ฿, got:\n%s", out) } } From 770288ff7f14c12b3ab271088cca590cfe997936 Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Mon, 24 Aug 2026 21:08:10 +0700 Subject: [PATCH 4/6] =?UTF-8?q?chore(beads):=20sync=20=E2=80=94=20=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=202=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D0=B2=D0=B5=D1=80=D1=82=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ea96cdb..7dbab09 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -27,6 +27,7 @@ {"id":"simpleAI-2sl","title":"RAG: хранение сырого текста и артефактов","description":"Эпик: RAG по тратам и чекам (V1). Определить где хранить raw_text и артефакты.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:09:58.711758+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T13:08:50.257714+07:00","closed_at":"2026-02-08T13:08:50.257714+07:00","close_reason":"Closed","comments":[{"id":69,"issue_id":"simpleAI-2sl","author":"Taras Malinovskii","text":"WIP: добавлена таблица receipt_artifact и обновлены docs по хранению raw_text/артефактов.","created_at":"2026-02-08T06:08:08Z"},{"id":70,"issue_id":"simpleAI-2sl","author":"Taras Malinovskii","text":"Готово: таблица receipt_artifact, docs/README/TECHNICAL_README описывают хранение raw_text и артефактов.","created_at":"2026-02-08T06:09:00Z"}]} {"id":"simpleAI-2t2","title":"Докстринги пакетов: улучшение описаний","description":"Заменить заглушки docstring-комментариев на подробные описания пакетов по примеру config (назначение, зоны, принципы, точки входа).","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T13:36:39.449191+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T15:10:06.675337+07:00","closed_at":"2026-02-08T15:10:06.675337+07:00","close_reason":"Closed"} {"id":"simpleAI-2vw","title":"refactor: выделить router/loop из agent/service.go","description":"## Цель\nservice.go (287 строк) смешивает три ответственности: построение system prompt с tools, парсинг tool calls из ответа LLM, агентный loop с накоплением результатов. Разделить по образцу assister/rag_mm/pipelines/. Подзадача EPIC simpleAI-4fc.\n\n## Предложение\n```\ninternal/agent/\n├── service.go # Service.Ask / AskWithMeta — координация\n├── router.go # buildToolsSystemPrompt + parseToolCalls + unmarshalCalls\n├── loop.go # agentic loop с итерациями + accumulatedResults\n└── core.go # уже есть\n```\n\n## DoD\n- Файлы разделены без изменения публичного API Service\n- Все существующие тесты проходят без изменений\n- Lint green\n- Eval-suite не регрессит\n\n## Зависимости\n- ПОСЛЕ Eval Suite epic","status":"closed","priority":3,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-05T08:33:12.793795+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-09T08:14:58.087259+07:00","closed_at":"2026-05-09T08:14:58.087259+07:00","close_reason":"service.go (335 строк) разбит на: service.go (координация, 101л), router.go (buildToolsSystemPrompt+parseToolCalls+unmarshalCalls, 118л), loop.go (runAgentLoop+runSkill+appendTrace, 139л). Публичный API не изменён. go test ./... green.","dependencies":[{"issue_id":"simpleAI-2vw","depends_on_id":"simpleAI-wr7","type":"blocks","created_at":"2026-05-05T08:33:19.492044+07:00","created_by":"Taras Malinovskii"}]} +{"id":"simpleAI-302i","title":"конверты 2/3: знак валюты у каждой денежной строки через единую точку печати","description":"[TASK 2/3] Печатать знак валюты у каждой денежной строки через единую точку\n\n## DoD\n- `go test ./internal/skills/...` — зелёно.\n- Тест-регексп по собранному ответу safe_to_spend и budget: число, не сопровождённое `฿` или `₽`, роняет тест.\n- Ни один `Fprintf` с деньгами не печатает знак валюты сам — только через `Display.Fmt`.\n- Мутация: убрать знак из `Display.Fmt` → тест знака краснеет.\n\n## Для человека\nВ выдаче бота у сумм не видно валюты, и понять, баты это или рубли, нельзя. Местами рядом с батовым числом стоит знак рубля.\nРядом с каждой цифрой встанет знак — один символ, ฿ или ₽.\nЗависимостей нет, можно брать сразу.\n\n## Для агента\n`internal/skills/safetospend/display.go:66` — `Display.Fmt`, единственная легальная точка печати денег. Уже группирует разряды и округляет.\n\nМимо неё печатают:\n- `internal/skills/safetospend/format.go` — `%.0f ₽` хардкодом, строки 35, 37, 42, 43, 45, 48, 52, 58, 60, 410, 413. Знак рубля прибит к числу, которое местами уже в батах.\n- `internal/skills/budget/limits.go:66` — `%.0f %s`, валюта приходит строкой.\n\nСвести всё к `Display.Fmt`. Где `Display` в области видимости нет — протащить, а не заводить второй форматтер: разъехавшиеся «код валюты отдельно, курс отдельно» и дают рублёвый знак на батовом числе.\n\nСтроки курса (`format.go:137`, `:512`, «Курс X ₽/฿») — не деньги, а сам курс; их не трогать, но исключить из регекспа явно, а не случайно.\n\nБлокирует: [TASK 3/3] Задавать курс словами — курс печатается через ту же точку.\n","notes":"Закрыта. Все суммы через safetospend.Display; добавлены Display.Signed (для округлённых чисел моноблока) и FmtAmount (сумма в своей валюте без перевода). Колонка моноблока 8→10 знаков. Файлы: safetospend/{display.go,format.go,remaining.go}, budget/limits.go. Тест currency_sign_test.go — регексп по собранному ответу. Мутации: снять знак в Signed и в formatItems — обе краснят. Коммит ffe196f.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-08-24T19:57:34.533707+07:00","created_by":"Taras Malinovskii","updated_at":"2026-08-24T20:57:58.742022+07:00","closed_at":"2026-08-24T20:57:58.742022+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-302i","depends_on_id":"simpleAI-rrn8","type":"parent-child","created_at":"2026-08-24T19:57:34.535134+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-30u","title":"Архитектура Telegram-бота","description":"Эпик: Telegram-бот как фасад системы. Модульная архитектура, расширяемые обработчики и маршрутизация команд/событий.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:28:46.665604+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T11:42:53.530014+07:00","closed_at":"2026-02-08T11:42:53.530014+07:00","close_reason":"Closed","comments":[{"id":13,"issue_id":"simpleAI-30u","author":"Taras Malinovskii","text":"WIP: добавлены роутер/контекст/миддлвари для Telegram, модульные handlers (start/help/default).","created_at":"2026-02-08T04:36:22Z"},{"id":15,"issue_id":"simpleAI-30u","author":"Taras Malinovskii","text":"WIP: добавлен worker pool для обработчиков (TELEGRAM_WORKERS), логирование апдейтов, базовый роутер/миддлвари.","created_at":"2026-02-08T04:37:57Z"},{"id":19,"issue_id":"simpleAI-30u","author":"Taras Malinovskii","text":"Готово: базовая архитектура бота (router/middleware/context), логирование апдейтов, worker pool.","created_at":"2026-02-08T04:42:53Z"},{"id":11,"issue_id":"simpleAI-30u","author":"Taras Malinovskii","text":"WIP: стартую архитектуру Telegram-бота: модульные хендлеры, маршрутизация, контекст/сессии.","created_at":"2026-02-08T04:29:52Z"}]} {"id":"simpleAI-36n","title":"fix(budget): категория 'прочее' вместо пользовательской при add_expense","description":"Баг: пользователь передаёт 'уход / косметика', бот записывает как 'прочее'.\n\nПричина: manifest BudgetSkill описывает категорию как закрытый список (еда, транспорт, ..., прочее). LLM видит список, не находит совпадения и выбирает 'прочее' самостоятельно, не передавая оригинальное название в скилл.\n\nХотя в addTransaction есть фолбэк AddCategory (создаст любую категорию) — LLM до него не доходит.\n\nДва фикса:\n1. internal/skills/budget_skill.go — расширить описание поля category в manifest: указать, что если пользователь назвал категорию явно — передавать её как есть, система создаст если не найдёт\n2. migrations/00009_budget_category_beauty.sql — добавить 'Красота' (expense) как стандартную категорию\n\nЗатрагиваемые файлы:\n- internal/skills/budget_skill.go (manifest description)\n- migrations/00009_budget_category_beauty.sql (новый файл)","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T19:00:09.982952+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T19:01:55.153142+07:00","closed_at":"2026-03-09T19:01:55.153142+07:00","close_reason":"Fixed manifest + added Красота category migration. Branch fix/simpleAI-36n-category-passthrough"} {"id":"simpleAI-36sq","title":"epic: иерархический budget summary с drill-down кнопками","description":"Трёхуровневый UI для бюджетного отчёта. L1: income/balance/avg → кнопки. L2: корзины (BucketConfig). L3: детали корзины. Config-driven: один []Bucket, default bucket для fallback, ровно один default (validation). Additive: старый формат остаётся.","status":"closed","priority":1,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-05-11T12:47:41.629584+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-11T12:55:06.779175+07:00","closed_at":"2026-05-11T12:55:06.779175+07:00","close_reason":"Эпик завершён. 4 слоя: core/adapter, router/context, buckets+format, callback wire."} @@ -81,6 +82,7 @@ {"id":"simpleAI-a7w","title":"epic/python-migration: [Фаза 5] Python: agent loop + LLM skills","description":"Родитель: simpleAI-0ma\nФаза: 5 — после фазы 4\n\nПортировать agent loop на Python:\n- python/bot/agent/service.py: async AskWithMeta, tool calling loop\n- python/bot/agent/parser.py: parseToolCalls (JSON, markdown)\n- python/bot/skills/base.py: Skill protocol/ABC\n- python/bot/skills/budget.py: все BudgetSkill actions (включая FSM для прочее)\n- python/bot/skills/rag.py: RAGSearchSkill\n- asyncpg для DB, openai SDK для LLM\n\nКлючевое преимущество:\n- Категория прочее: FSM ConversationHandler, без LLM round-trip\n- set_reminder/get_reminder: chat_id берётся из aiogram Message.chat.id напрямую\n\nAC:\n- Все budget actions работают через Python бота\n- Тесты agent loop (аналог фаза-1b) зелёные на pytest","status":"open","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T11:30:34.419821+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T11:30:34.419821+07:00"} {"id":"simpleAI-adoo","title":"[STS-4] H3 persistence: budget_envelope + CRUD + chat_id-изоляция","description":"Миграция budget_envelope (income_event+период+план-раскладка, БЕЗ хранимого decrement-баланса), chat-scoped, feature-flag (ADR-004). CRUD в BudgetSkill/Store (не в reasoning-skill, ADR §5). AC: изоляция chat A≠B тест-инвариант; нет хранимого остатка. Epic simpleAI-k5j4.","notes":"DONE. Миграция 00016_budget_envelope (income_event + период + active, БЕЗ хранимого остатка — H3). Store: CreateEnvelope (tx, деактивирует прошлый — уник. индекс на один активный/chat), GetActiveEnvelope. Budget action start_envelope (envelope.go) + manifest + golden r041. Integration тест: один активный + chat-scope изоляция (chatA≠chatB). Живой e2e: 'запомни приход 127000 на 2 недели' → конверт создан.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.65329+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T16:16:45.289267+07:00","closed_at":"2026-07-23T16:16:45.289267+07:00","close_reason":"budget_envelope persistence: миграция + Store CRUD (один активный/chat) + start_envelope action + chat-scope integration. Живой e2e.","dependencies":[{"issue_id":"simpleAI-adoo","depends_on_id":"simpleAI-nz5b","type":"blocks","created_at":"2026-07-23T12:15:20.481736+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-adoo","depends_on_id":"simpleAI-km3w","type":"blocks","created_at":"2026-07-23T12:15:20.600365+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-afs","title":"feat(rates): live exchange rates from open.er-api.com with DB cache","description":"Заменить хардкод курсов валют в budget_skill.go на актуальные данные.\\n\\nАрхитектура:\\n1. Миграция: internal/db/migrations/00010_exchange_rates.sql\\n - exchange_rate(currency TEXT PK, rate_to_rub FLOAT, updated_at TIMESTAMPTZ)\\n2. internal/budget/store.go:\\n - GetExchangeRates(ctx) (map[string]float64, error)\\n - SaveExchangeRate(ctx, currency string, rate float64) error\\n3. internal/rates/worker.go:\\n - Фоновый job, раз в день GET https://open.er-api.com/v6/latest/RUB\\n - rate_to_rub = 1 / response.rates[currency]\\n - Сохраняет THB, USD, EUR в БД\\n4. internal/skills/budget_skill.go:\\n - toRUB загружает курсы из store при старте skill (или инжектируется)\\n - Fallback на hardcoded rubRates если store вернул ошибку\\n5. cmd/app/main.go: запуск RatesWorker\\n\\nAC:\\n- Курсы обновляются раз в день без рестарта бота\\n- При недоступном API используются предыдущие курсы из БД\\n- При пустой БД используется хардкод-fallback","status":"closed","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-04-01T13:48:23.531075+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-01T13:52:57.807002+07:00","closed_at":"2026-04-01T13:52:57.807002+07:00","close_reason":"Реализован rates.Worker (internal/rates/worker.go): раз в сутки фетчит THB/USD/EUR из open.er-api.com, сохраняет в exchange_rate. budget_skill использует DB-курсы с fallback на хардкод. Миграция 00010."} +{"id":"simpleAI-agz4","title":"конверты 1/3: считать только обязательства, попадающие в период конверта","description":"[TASK 1/3] Считать в конверте только обязательства, попадающие в его период\n\n## DoD\n- `go test ./internal/skills/safetospend/...` — зелёно.\n- Конверт 24.08–06.09 при `next_date` аренды 10.09 не содержит share «аренда» и «подписка Клауд личная»; на гибкие конверты уходит на 18 671 ฿ больше.\n- Граница: due = period_end входит, due = period_end + 1 день не входит. Оба случая — отдельными тестами.\n- Отсечённый платёж напечатан строкой «впереди» с суммой и датой — тест проверяет наличие в собранном ответе.\n- Мутация: `return nil` первой строкой в `fixedShares` → тесты краснеют.\n\n## Для человека\nАренда платится 10 сентября, но деньги на неё бот запирает уже сейчас — свободных на текущие две недели меньше почти на девятнадцать тысяч бат. Оператору цифра кажется враньём: столько он реально может потратить.\nТеперь в период войдёт только то, что платится внутри его рамок. Аренда и подписка уедут в следующий период, а в ответе останутся отдельной строкой «впереди» — чтобы про них не забыть и не потратить всё.\nЗависимостей нет, можно брать сразу.\n\n## Для агента\n`internal/skills/safetospend/allocate.go:123` — `fixedShares`. Окно `windowEnd := from.AddDate(0, 0, fixedFundingDays)`, `fixedFundingDays = 31` в `config.go:56`. Заменить на границу периода конверта (`budget.Horizon.To`, включительно).\n\nКомментарий-обоснование над функцией сейчас обосновывает ровно обратное — sinking fund, «отложить надо сейчас». Переписать под новое решение оператора, иначе следующий агент вернёт 31 день обратно.\n\nОтсечённые платежи не выбрасывать молча: собрать в отдельный список и напечатать. Канал уже есть — функция возвращает `warnings` вторым значением.\n\nВсе даты — `DATE`, границы включительные (см. решение по закрытию конверта в родительском интенте). `fixedFundingDays` после правки может остаться без пользователей — тогда удалить, а не оставлять мёртвую константу.\n\nРешение оператора (interview 2026-08-24): окно = период конверта. Отвергнуто: sinking fund целиком; пропорциональное резервирование части суммы.\n\nБлокирует: ничего. Родитель — интент эпика конвертов, итерация 2.\n","notes":"Ритм прихода — дважды в месяц (оператор, 24.08.2026). Период конверта совпадает с ритмом: в каждом периоде ровно один приход, поэтому обязательство следующего периода финансируется приходом того периода. Дополнительной страховки «покрыт ли платёж» не закладываем — бот всё равно не знает ритма, приход не пишется в ledger (ADR-002).\nЗакрыта. Окно финансирования fixed-shares = период конверта (было 31 день). Отсечённые платежи уходят в EnvelopePlan.Upcoming и печатаются блоком «Впереди». Файлы: internal/skills/safetospend/{allocate.go,config.go,format.go}, internal/skills/budget/{envelope.go,limits.go}. Тесты: fixed_window_test.go, обновлён envelope_invariant_test.go. Мутация return nil в fixedShares краснит все три теста. Коммит 3d886c7.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-08-24T19:57:34.384959+07:00","created_by":"Taras Malinovskii","updated_at":"2026-08-24T20:52:49.731015+07:00","closed_at":"2026-08-24T20:52:49.731015+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-agz4","depends_on_id":"simpleAI-rrn8","type":"parent-child","created_at":"2026-08-24T19:57:34.387316+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-at6","title":"fix(advisor): таблица budget_recurring отсутствует — миграция в мёртвой папке","description":"Bug: AdvisorSkill отдаёт 'Временная ошибка при сборе финансового снимка — попробуй позже.' на сообщения вида 'Планирую купить байк, что скажешь?'.\n\nКорень: GetAdvisorSnapshot (internal/budget/store.go:870) делает CTE с FROM budget_recurring r. Таблица создаётся миграцией 00011_budget_recurring.sql, но файл лежит только в МЁРТВОЙ /migrations/ (не git ls-tree HEAD internal/db/migrations/00011_budget_recurring.sql — пусто). Активная папка — internal/db/migrations/ (go:embed в internal/db/migrate.go:15). Таблицы в БД нет → SQL error → fallback сообщение.\n\nАналогичный баг фиксили коммитом b4b85c5 (budget_reminder). Регрессия повторилась.\n\nFix:\n- Скопировать migrations/00011_budget_recurring.sql в internal/db/migrations/00011_budget_recurring.sql (готово, файл untracked).\n- Прогнать goose миграцию на dev — таблица budget_recurring создаётся.\n- Воспроизвести 'Планирую купить байк' → ожидаемый ответ от advisor (verdict + recommendation), не fallback.\n\nDoD:\n- internal/db/migrations/00011_budget_recurring.sql закоммичен.\n- go build ./... зелёный.\n- go test ./internal/budget/... ./internal/skills/... зелёный.\n- Воспроизведение бага на стенде → ответ advisor, не fallback.\n- /review → PASS.","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-06T18:58:15.43661+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-06T18:59:17.60277+07:00","closed_at":"2026-05-06T18:59:17.60277+07:00","close_reason":"Скопирована миграция 00011_budget_recurring.sql в активную internal/db/migrations/. go build + go test ok. /review PASS. Untracked файл готов к коммиту. Ручная верификация на dev — зона деплоя."} {"id":"simpleAI-axp","title":"fix(agent): batch multiple actions into array tool call","description":"Когда пользователь присылает несколько действий в одном сообщении (несколько блоков Действие:), LLM генерирует одиночный tool call вместо массива. Второе и последующие действия игнорируются. Нужно добавить явную инструкцию в buildToolsSystemPrompt чтобы LLM всегда использовала JSON-массив при нескольких действиях.","status":"closed","priority":2,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-03-09T20:14:07.619197+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T20:14:58.052942+07:00","closed_at":"2026-03-09T20:14:58.052942+07:00","close_reason":"Добавлена инструкция в buildToolsSystemPrompt: батчевать все действия одним массивом. Файл: internal/agent/service.go:242. Тесты OK."} {"id":"simpleAI-b1r","title":"task/simpleAI-finance-ui: telegram/context.go — добавить FinanceStore, IngestStore, StateManager, LLMClient и хелперы","description":"Текущая ветка: task/simpleAI-finance-ui\nЭпик: simpleAI-nzc\nЗатрагиваемые файлы: internal/telegram/context.go\nЗависит от: simpleAI-vet (state), simpleAI-vdm (finance), simpleAI-2gp (ingest)\nЗадача: Расширить struct Context:\n FinanceStore *finance.Store — для чтения чеков\n IngestStore *ingest.Store — для записи чеков\n StateManager *state.Manager — для conversation state\n LLMClient core.LLMClient — для парсинга через LLM\nДобавить хелпер-методы:\n SendWithKeyboard(text string, kb core.InlineKeyboard) error — type-assert Bot к интерфейсу с SendWithKeyboard, fallback на Reply\n EditMessage(messageID int, text string, kb *core.InlineKeyboard) error — type-assert Bot к editor\n AnswerCallback(notification string) error — type-assert Bot к callbackAnswerer, только если IsCallback==true\nВажно: использовать локальные interface{} для type-assert чтобы не создавать circular imports\nAcceptance: go build ./internal/telegram/... без ошибок","status":"closed","priority":2,"issue_type":"task","assignee":"Taras Malinovskii","owner":"bar.norilsk@gmail.com","created_at":"2026-02-15T18:54:37.353419+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-15T18:59:48.069312+07:00","closed_at":"2026-02-15T18:59:48.069312+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-b1r","depends_on_id":"simpleAI-nzc","type":"parent-child","created_at":"2026-02-15T18:55:57.964245+07:00","created_by":"Taras Malinovskii"}]} @@ -178,11 +180,13 @@ {"id":"simpleAI-qqra","title":"budget: BucketConfig + GroupByBuckets + format L1/L2/L3","description":"buckets.go: Bucket{ID,Name,Icon,Categories,Default bool}, BucketConfig []Bucket, ровно один Default (validation). GroupByBuckets(cats, config). formatSummaryL1 (текст+кнопки), formatBuckets (L2), formatBucketDetail (L3).","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-11T12:47:56.152816+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-11T12:50:49.149698+07:00","closed_at":"2026-05-11T12:50:49.149698+07:00","close_reason":"buckets.go: Bucket, BucketConfig, Validate (ровно 1 default), GroupByBuckets. format.go: formatSummaryL1, formatBucketsL2, formatBucketDetailL3. DefaultBuckets конфиг.","dependencies":[{"issue_id":"simpleAI-qqra","depends_on_id":"simpleAI-iorx","type":"blocks","created_at":"2026-05-11T12:48:00.286236+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-qqra","depends_on_id":"simpleAI-36sq","type":"blocks","created_at":"2026-05-11T12:48:00.883808+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-rcm","title":"Автоматические бэкапы БД: скрипт + крон","description":"Нужен скрипт бэкапа PostgreSQL + крон для автозапуска.\n\nТребования:\n- pg_dump через docker exec simpleai-postgres\n- Сжатие gzip\n- Локальное хранение на сервере (последние 7 дней, ротация)\n- Загрузка как GitHub release asset в приватный репо\n- Крон: раз в день (например в 3:00)\n- Уведомление в Telegram при ошибке\n\nОкружение сервера: Ubuntu, /home/deploy/, docker compose, gh CLI нужно проверить","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-04-04T18:31:54.775588+07:00","created_by":"Taras Malinovskii","updated_at":"2026-04-04T18:42:55.776315+07:00","closed_at":"2026-04-04T18:42:55.776315+07:00","close_reason":"Скрипт /home/deploy/backup.sh: pg_dump через docker exec → gzip → ротация 7 дней → GitHub release asset. gh CLI установлен в /home/deploy/bin/gh. Крон: 0 3 * * *. Тест прошёл успешно (40K)."} {"id":"simpleAI-ri4","title":"fix(help): обновить MsgTelegramHelp — ссылки на /budget вместо /help budget","description":"MsgTelegramHelp в messages.go показывает устаревшие команды вида '/help budget', но зарегистрированные команды бота — /budget, /recurring, /forecast, /reminders. Нужно обновить текст чтобы отражал реальные команды.","status":"closed","priority":2,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T11:48:24.745841+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-04T11:48:42.676498+07:00","closed_at":"2026-05-04T11:48:42.676498+07:00","close_reason":"Обновлён MsgTelegramHelp: /help budget → /budget, /help recurring → /recurring, /help forecast → /forecast, /help reminders → /reminders. Файл: internal/constants/messages.go:30. Билд чистый."} +{"id":"simpleAI-rrn8","title":"epic: конверты, итерация 2 — периметр обязательств, курс словами, знак валюты","description":"**Декомпозиция — 8 задач.** Граф: 1 → 2 и 3 (параллельно); 2,3 → 4; 4 → 6 → 5; 6 → 7; 2,3 → 8.\nЗадачи и эпик — в `.beads/issues.jsonl` репозитория simpleAI.\n\n---\n\n# Итерация 2 — правки по живой выдаче (24.08.2026)\n\nПервая итерация закрыта: PR #62 `feature/envelope-shares` слит в main, деплой прошёл. Оператор посмотрел живой конверт (24.08–06.09, приход 128 000 ₽) и дал три правки.\n\n## DoD — итерация 2\n- `go build ./... \u0026\u0026 go test ./...` — зелёно.\n- Обязательства: конверт 24.08–06.09 при `next_date` аренды 10.09 НЕ содержит share «аренда» и «подписка Клауд личная»; на гибкие конверты уходит на 18 671 ฿ больше. Тест на границе: due = period_end входит, due = period_end + 1 день не входит.\n- Впереди-строка: ответ бота содержит предупреждение о платежах вне периода с суммой и датой.\n- Знак валюты: ни одна денежная строка в выдаче safe_to_spend и budget не печатается без знака ฿ или ₽. Тест — регексп по собранному ответу: число, не сопровождённое знаком, роняет тест.\n- Курс словами: «курс 2,7» → конверты пересчитаны по 2,7; «курс авто» → вернулся курс из API. Ручной курс переживает рестарт бота и суточный тик `rates.Worker`.\n- Мутация: `return nil` первой строкой в `fixedShares` → тесты обязательств краснеют; снятие знака валюты в `Display.Fmt` → тест знака краснеет.\n\n## Для человека — итерация 2\nОператор увидел три вещи, мешающие доверять цифрам.\nОбязательства следующего периода заперты в текущем: аренда платится 10 сентября, а деньги отложены уже сейчас — свободных на две недели меньше почти на девятнадцать тысяч бат. Решено: в период входит только то, что платится внутри его рамок, остальное отдельной строкой «впереди», чтобы не забыть.\nКурс бата берётся из межбанка и расходится с обменником, повлиять на него было нельзя. Теперь курс можно назначить словами и вернуть автоматический.\nУ сумм не видно валюты — рядом с каждой цифрой встанет знак.\nЗависит от: эпик конвертов, итерация 1 — закрыта и в проде.\n\n## Для агента — итерация 2\n\n**Решения оператора (interview 2026-08-24):**\n- **Периметр обязательств.** Окно финансирования fixed-share = период конверта, НЕ 31 день. Отвергнуто: sinking fund целиком (текущее поведение — оператор видит заниженный свободный остаток) и пропорциональное резервирование (усложняет без пользы, оператор выбрал явный вариант).\n- **Курс.** Ручной override словами + «курс авто» для возврата. Отвергнуто: только принудительное обновление автокурса — не решает расхождение межбанка с обменником.\n- **Валюта.** Знак у каждой денежной строки, единая точка печати.\n\n**Точки в коде:**\n- `internal/skills/safetospend/allocate.go:123` — `fixedShares`; окно `windowEnd := from.AddDate(0, 0, fixedFundingDays)`. `fixedFundingDays = 31` в `config.go:56`. Заменить на границу периода из `budget.Horizon`. Комментарий-обоснование sinking fund над функцией переписать под новое решение — иначе следующий агент вернёт 31 день обратно.\n- Отсечённые платежи не выбрасывать молча: вернуть их отдельным списком и напечатать строкой «впереди». Сейчас функция уже возвращает `warnings` — тот же канал.\n- `internal/skills/safetospend/display.go:66` — `Display.Fmt`, единственная легальная точка печати денег. `internal/skills/safetospend/format.go` печатает `%.0f ₽` хардкодом минимум в 12 местах (строки 35, 37, 42, 43, 45, 48, 52, 58, 60, 410, 413) — это и есть рубль на батовом числе. `internal/skills/budget/limits.go:66` печатает `%.0f %s`.\n- Курс: `internal/rates/worker.go` — суточный фетч `open.er-api.com`, `internal/budget/store.go:1611` `GetExchangeRates`, `:1631` `SaveExchangeRate`, таблица `exchange_rate(currency, rate_to_rub, updated_at)`, миграция `00010`. Ручной override должен пережить тик воркера — значит отдельная колонка/таблица, а не перезапись `rate_to_rub`.\n- Роутинг новых фраз — `internal/agent/router.go`, правила пронумерованы; golden-сценарии — `evals/golden_set.jsonl`.\n\n**Состояние реплики на момент interview:** конверт `b1c32dd1`, 24.08–06.09, 128 000 ₽, 12 shares. Recurring: Кредит Сбербанк 28 500 ₽ (27.08), Ежемесячный 3 000 ₽ (01.09), аренда 18 000 ฿ (10.09), подписка Клауд 1 700 ₽ (10.09). Курс THB 2,5351 ₽ от 24.08 12:12 МСК.\n\n**Декомпозиция итерации 2 — 3 задачи.** Граф: 1 независима; 2 → 3.\n","notes":"Все три задачи закрыты, ветка feature/envelope-iter2, коммиты 3d886c7, ffe196f, 30aab88.","status":"closed","priority":1,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-08-24T19:57:25.607867+07:00","created_by":"Taras Malinovskii","updated_at":"2026-08-24T21:07:41.548583+07:00","closed_at":"2026-08-24T21:07:41.548583+07:00","close_reason":"Closed"} {"id":"simpleAI-rvs","title":"advisor: tests + smoke + ship","description":"Phase 4 + 5 из spec/advisor/plan.yaml.\n\nПолное тестовое покрытие + manual smoke + /review + PR.\n\nТесты в internal/skills/advisor_skill_test.go:\n- Manifest content test: positive trigger phrases ('purchase', 'afford', 'prioritization') + negative phrase ('Do NOT ... budget skill')\n- Prompt rendering test (table-driven, snapshot fixture × question, low_data вариант)\n- LLM JSON parsing test (valid / invalid / missing fields / wrong verdict)\n- Run() integration test с моками store + llm: happy / low_data / RUB question / no THB rate / unknown currency\n- Format helper unit-test\n\nSmoke на dev:\n- 'можем купить велосипед за 25000?' → happy path\n- 'можем купить за 50000 руб?' → RUB конверсия в выводе\n- 'купил молоко за 50' → НЕ роутится в advisor (роутится в budget.add_expense)\n- low_data сценарий → verdict='Условно' + объяснение\n\nAcceptance criteria:\n- go test ./... зелёный\n- AC traceability: каждый AC из spec/advisor/acceptance_criteria.md (кроме A1/A2/I1) покрыт тестом\n- A1/A2 — manual verification (manifest content test покрывает manifest sanity)\n- I1 (latency p95) — design goal, не CI\n- /review = PASS\n- PR открыт в main, conventional commits: feat(advisor): financial advisor skill\n\nЗависит от всех предыдущих task.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-04T12:35:37.047668+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-04T20:03:11.21492+07:00","closed_at":"2026-05-04T20:03:11.21492+07:00","close_reason":"Phase 4-5: rapid property tests (budget + skills) + golangci-lint = 0 issues. /review WARNINGS закрыты (doc-comment, UTF-8 truncate, MD escape, chatID warn, testdata gitignore). Manual smoke на dev — отдельно.","dependencies":[{"issue_id":"simpleAI-rvs","depends_on_id":"simpleAI-yvq","type":"blocks","created_at":"2026-05-04T12:35:42.540146+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-s1wj","title":"feat(budget): прогнозный Фонд Да + Обязательства с учётом recurring","description":"Архитектура: repository возвращает facts, skill интерпретирует state.\n\nЧасти:\n1. Миграция 00012: ALTER TABLE budget_transaction ADD COLUMN recurring_id UUID NULL; отдельно FK; composite index (chat_id, recurring_id, created_at). ON DELETE SET NULL. Backfill не нужен.\n2. Transaction struct: RecurringID *uuid.UUID. ListTransactions: добавить в SELECT. CreateRecurringTransaction: добавить в INSERT.\n3. Skill: ListTransactions(period) → executedRecurring set. ListRecurring(chatID) → filter enabled + next_date в месяце + not in set. Прогноз = факт + pending. UI: две строки если прогноз != факт. Buckets: факт + план по категориям recurring.","status":"closed","priority":2,"issue_type":"feature","owner":"bar.norilsk@gmail.com","created_at":"2026-05-12T08:16:48.314447+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-23T09:25:36.235505+07:00","closed_at":"2026-05-23T09:25:36.235505+07:00","close_reason":"Реализовано в fe5a752: migration 00014, RecurringID в Transaction, GetExecutedRecurringIDs, pendingRecurring set-diff, прогнозный UI с тремя состояниями, buckets toLower матчинг"} {"id":"simpleAI-sa5","title":"Почта: Gmail API провайдер","description":"Эпик: Агент почты (hourly). Интеграция Gmail API (OAuth).","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T10:10:29.903075+07:00","created_by":"Taras Malinovskii","updated_at":"2026-02-08T10:23:32.931601+07:00","closed_at":"2026-02-08T10:23:32.931601+07:00","close_reason":"Closed","comments":[{"id":5,"issue_id":"simpleAI-sa5","author":"Taras Malinovskii","text":"Готово: Gmail провайдер в internal/mail/gmail.go и интерфейс провайдера internal/mail/provider.go.","created_at":"2026-02-08T03:23:38Z"}]} {"id":"simpleAI-spu","title":"fix(budget): routing+filter для income-запросов","description":"Запрос 'покажи доходы предыдущего месяца' роутится в list_transactions без фильтра по type → возвращаются все транзакции (доминируют expense).\n\n## Состав фикса (вариант C: routing + filter)\n\n### A. Routing\n- budget manifest summary description: явно claim запросы со словами 'доходы / заработал / итоги / income / earned'\n- list_transactions description: defer в income-overview случаях → summary\n- summary action description: упомянуть что показывает оба (после prev-fix)\n\n### B. Filter\n- budgetInput.Type (income | expense) — optional\n- listTransactions: если Type указан — добавить в TransactionFilter\n- TransactionFilter.Type — поле в budget package\n- ListTransactions store-метод применяет фильтр к WHERE\n\n## Файлы\n\n- internal/skills/budget_skill.go (manifest + listTransactions handler + budgetInput)\n- internal/budget/store.go (TransactionFilter применение)\n- internal/budget/model.go (TransactionFilter.Type)\n- evals/golden_set.jsonl\n\n## DoD\n\n- [ ] manifest claims income queries → summary\n- [ ] list_transactions поддерживает type=income filter\n- [ ] go build/vet/test PASS\n- [ ] golden_set: 'покажи доходы за месяц' → summary; 'перечисли все доходы за апрель' → list_transactions type=income\n- [ ] eval \u003e= 0.83","status":"closed","priority":2,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-07T09:02:52.697267+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-07T09:06:12.072851+07:00","closed_at":"2026-05-07T09:06:12.072851+07:00","close_reason":"manifest: summary claims income queries, list_transactions поддерживает transaction_type=income/expense filter; handler передаёт req.TransactionType в TransactionFilter; golden_set +3 кейса (r034-r036, все pass); eval 0.92."} {"id":"simpleAI-sqz","title":"refactor: разбить advisor_skill.go → skills/advisor/ (подзадача C)","description":"## Цель\nПереместить advisor_skill.go в internal/skills/advisor/ (отдельный пакет). Подзадача C эпика simpleAI-4fc.\n\n## Текущее состояние\nadvisor_skill.go = 632 строки, всё в одном файле: struct, manifest, Run(), два action (advice/analyze), все хелперы, форматирование.\n\n## Что сделать\n1. Создать internal/skills/advisor/\n2. Разбить на файлы в пакете `advisorskill`:\n - skill.go — AdvisorSkill struct, NewAdvisorSkill, Manifest(), Run()\n - advice.go — runAdvice, buildAdvisorPrompt, computeForecastRemaining, parseAdvisorLLMResponse, formatAdvisorReply, advisorLLMResponse, advisorPromptTemplate\n - analyze.go — runAnalyze, buildAnalyzePrompt, parseAnalyzePeriod, parseAnalyzeLLMResponse, formatAnalyzeReply, analyzeLLMResponse, analyzePromptTemplate\n - format.go — escapeTelegramMarkdown, formatSpentByCategory, formatTopExpenses, truncateRunes, trimList (shared между advice/analyze)\n3. Извлечь stripMarkdownFences() — дублируется в parseAdvisorLLMResponse и parseAnalyzeLLMResponse\n4. Обновить импорты в cmd/app/main.go, evals/\n\n## Не делать здесь\n- Менять сигнатуры (string returns) — задача D\n- Переносить format.go в bot/ — задача E\n\n## DoD\n- internal/skills/advisor/ создан, package advisorskill\n- go test ./... green\n- lint green\n- /review PASS","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-05-08T20:32:16.644344+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-08T20:46:37.205981+07:00","closed_at":"2026-05-08T20:46:37.205981+07:00","close_reason":"Перенесён в internal/skills/advisor/ (package advisorskill). Файлы: skill.go, advice.go, analyze.go, format.go. Извлечён stripMarkdownFences и unmarshalJSON в format.go. Тесты переехали в advisor_test.go. go test green.","dependencies":[{"issue_id":"simpleAI-sqz","depends_on_id":"simpleAI-ckb","type":"blocks","created_at":"2026-05-08T20:32:35.33667+07:00","created_by":"Taras Malinovskii"}]} +{"id":"simpleAI-su6l","title":"конверты 3/3: задавать курс бата словами и возвращать автокурс","description":"[TASK 3/3] Задавать курс бата словами и возвращать автоматический\n\n## DoD\n- `go test ./internal/budget/... ./internal/rates/... ./internal/skills/...` — зелёно.\n- «курс 2,7» → бот подтверждает и пересчитывает конверты по 2,7; «курс авто» → возвращает курс из API.\n- Ручной курс переживает суточный тик `rates.Worker` и рестарт бота — тест: сохранить override, прогнать `fetchAndSave`, прочитать курс, он прежний.\n- Golden-сценарии роутинга «курс 2,7» и «курс авто» в `evals/golden_set.jsonl` проходят.\n- Мутация: игнорировать override при чтении курсов → тест переживания тика краснеет.\n\n## Для человека\nКурс бата бот берёт из межбанка раз в сутки, а в обменнике курс другой — цифры расходятся, и повлиять на это было нельзя.\nТеперь курс можно назначить словами и так же словами вернуть автоматический.\nЗависит от: [TASK 2/3] Печатать знак валюты у каждой денежной строки через единую точку\n\n## Для агента\nСейчас: `internal/rates/worker.go` раз в сутки тянет `open.er-api.com` и пишет в `exchange_rate(currency, rate_to_rub, updated_at)` через `store.go:1631` `SaveExchangeRate`. Чтение — `store.go:1611` `GetExchangeRates`.\n\nOverride обязан пережить тик воркера — значит отдельная колонка (`manual_rate_to_rub NUMERIC NULL`) или отдельная таблица, а не перезапись `rate_to_rub`. Воркер продолжает писать автокурс; чтение отдаёт override, когда он есть. «курс авто» = обнулить override, не трогая автокурс.\n\nНовая миграция goose с down-миграцией.\n\nРоутинг: `internal/agent/router.go`, правила пронумерованы — добавить правило про курс. Развести с уже существующим `display_currency` («покажи конверты в рублях» — это про показ, а не про курс) и с суммами («курс 2,7» — не трата на 2,7).\n\nПечать курса — через ту же точку, что и деньги (см. [TASK 2/3]); в подтверждении показать, ручной курс или автоматический, иначе оператор не отличит.\n\nРешение оператора (interview 2026-08-24): ручной override словами + «курс авто». Отвергнуто: только принудительное обновление автокурса — расхождение межбанка с обменником оно не снимает.\n\nЗависит от задачи 2/3. Блокирует: ничего.\n","notes":"Закрыта. Ручной курс в exchange_rate.manual_rate_to_rub (миграция 00020, отдельная колонка — переживает тик воркера). GetExchangeRates выбирает через COALESCE. Действия set_rate/clear_rate/rate_status в internal/skills/budget/rate.go, правило 7 в router.go, golden r053-r056. Тесты: internal/budget/manual_rate_test.go (переживание тика, идемпотентность, отказ на неположительном). Мутации: снять COALESCE и затирать manual в SaveExchangeRate — обе краснят. Живой прогон на реплике: курс 2,7 → clear → 2,5351. Коммит 30aab88.","status":"closed","priority":2,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-08-24T19:57:34.683582+07:00","created_by":"Taras Malinovskii","updated_at":"2026-08-24T21:07:41.312028+07:00","closed_at":"2026-08-24T21:07:41.312028+07:00","close_reason":"Closed","dependencies":[{"issue_id":"simpleAI-su6l","depends_on_id":"simpleAI-rrn8","type":"parent-child","created_at":"2026-08-24T19:57:34.685221+07:00","created_by":"Taras Malinovskii"},{"issue_id":"simpleAI-su6l","depends_on_id":"simpleAI-302i","type":"blocks","created_at":"2026-08-24T19:57:34.803697+07:00","created_by":"Taras Malinovskii"}]} {"id":"simpleAI-syzl","title":"fix: RUB hardcode as base currency in GetExchangeRates","description":"GetExchangeRates не возвращает RUB (его нет в exchange_rate таблице). aggregateAdvisorSnapshot тихо скипает все RUB-транзакции → FreeCash считается неверно (доход в RUB игнорируется). Фикс: после загрузки rates из БД — принудительно ставить rates[RUB]=1.0 если отсутствует.","status":"closed","priority":1,"issue_type":"bug","owner":"bar.norilsk@gmail.com","created_at":"2026-05-09T09:20:41.289282+07:00","created_by":"Taras Malinovskii","updated_at":"2026-05-09T09:22:17.176516+07:00","closed_at":"2026-05-09T09:22:17.176516+07:00","close_reason":"RUB hardcoded as base currency (1.0) in GetExchangeRates. Фикс: internal/budget/store.go:824. Тесты прошли, запушено."} {"id":"simpleAI-t2d","title":"Константы для строк и API ошибок","description":"Вынести сырые строки и API ошибки в централизованные константы, упорядочить доступ и переиспользование.","notes":"Старт: создан пакет internal/constants (messages/errors) + начальная миграция LLM ошибок на константы; README/TECHNICAL_README обновлены. Полная миграция строк/ошибок остается в simpleAI-73b.\nСделано: internal/constants/errors.go + messages.go, LLM ошибки переведены на константы; README/TECHNICAL_README обновлены. Коммит 9ae949f.","status":"closed","priority":2,"issue_type":"epic","owner":"bar.norilsk@gmail.com","created_at":"2026-02-08T12:10:29.377853+07:00","created_by":"Taras Malinovskii","updated_at":"2026-03-09T18:35:52.487918+07:00","closed_at":"2026-03-09T18:35:52.487918+07:00","close_reason":"Stale: work completed in Feb 2026, constants package created and used, closing retroactively"} {"id":"simpleAI-tsg4","title":"[STS-1] Store: мультипериодная агрегация (произвольный период, chat-scoped)","description":"Новый метод Store: доходы/расходы/обязательства за произвольный период (from/to), chat-scoped, в THB. Не ломать MTD-путь GetAdvisorSnapshot. ADR-007 §6. AC: юнит-тесты границ периода (from inclusive, to inclusive/exclusive явно); chat-scope изоляция; конверсия thb=amount*rates[cur]/rates[THB]; мутация-проверка. Epic simpleAI-k5j4.","notes":"DONE. internal/budget/store.go: GetPeriodSnapshot(ctx,chatID,from,to,rates) + periodSnapshotQuery (явные границы [from,to] включ., обязательства \u003c= to как в месячном снапшоте). Переиспользует aggregateAdvisorSnapshot. Тесты period_snapshot_test.go: unit invalid-range (без DB) + integration против реплики (границы дня 2026-03-09 + chat-scope изоляция). Мутация: (r.chat_id=$3 OR TRUE) → чужой chatID получает recurring=30769 → тест краснеет; восстановил → ok. Build+весь go test зелёные, gofmt чисто.","status":"closed","priority":1,"issue_type":"task","owner":"bar.norilsk@gmail.com","created_at":"2026-07-23T12:15:19.184473+07:00","created_by":"Taras Malinovskii","updated_at":"2026-07-23T12:19:20.663137+07:00","closed_at":"2026-07-23T12:19:20.663137+07:00","close_reason":"Мультипериодная агрегация GetPeriodSnapshot + тесты (unit+integration+мутация chat-scope). Границы и изоляция доказаны на реплике."} From 684139e5f5639585221553c800faa4f315b66a19 Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Mon, 24 Aug 2026 21:11:42 +0700 Subject: [PATCH 5/6] =?UTF-8?q?fix(budget):=20=D0=BE=D0=B4=D0=B8=D0=BD=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D1=82=D0=B5=D1=80=20=D0=BA?= =?UTF-8?q?=D1=83=D1=80=D1=81=D0=B0=20=D0=B8=20=D0=BE=D0=BA=D0=BD=D0=BE=20?= =?UTF-8?q?=C2=AB=D0=B2=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B8=C2=BB=20=D0=B2?= =?UTF-8?q?=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По findings ревью. Курс печатался двумя форматтерами: FmtRate до двух знаков и decimalComma с жёстким %.1f. Одна и та же строка «курс X ₽/฿» выходила как 2,54 в ответе про курс и 2,5 в раскладке конвертов. decimalComma удалён. Окно блока «Впереди, из следующего прихода» было 31 день от конца периода. Приход дважды в месяц — в блок затягивало платежи периода ПОСЛЕ следующего и подписывало их деньгами, которых к тому моменту ещё нет. Окно теперь равно длине самого периода. simpleAI-agz4 simpleAI-su6l --- internal/skills/safetospend/allocate.go | 8 +++- internal/skills/safetospend/config.go | 4 -- internal/skills/safetospend/display.go | 16 +++++-- .../skills/safetospend/fixed_window_test.go | 42 +++++++++++++++++++ internal/skills/safetospend/format.go | 11 ++--- 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/internal/skills/safetospend/allocate.go b/internal/skills/safetospend/allocate.go index b721fdf..3c92ec4 100644 --- a/internal/skills/safetospend/allocate.go +++ b/internal/skills/safetospend/allocate.go @@ -144,7 +144,13 @@ func fixedShares(rec []budget.RecurringPayment, rates map[string]float64, from, // период. Полночь следующих суток берётся именно для этого. periodStart := dayStart(from) periodEnd := dayStart(to).AddDate(0, 0, 1) - lookaheadEnd := periodEnd.AddDate(0, 0, upcomingLookaheadDays) + // «Впереди» заглядывает РОВНО на один следующий период, а не на фиксированные + // 31 день. Блок озаглавлен «из следующего прихода», а приход у оператора + // дважды в месяц: месячное окно затянуло бы туда платежи периода ПОСЛЕ + // следующего и подписало бы их деньгами, которых к тому моменту ещё нет. + // Длина окна равна длине самого периода — так оно следует за горизонтом, + // а не за календарным месяцем. + lookaheadEnd := periodEnd.AddDate(0, 0, int(periodEnd.Sub(periodStart).Hours()/24)) fixed = make([]budget.EnvelopeShare, 0, len(rec)) for _, r := range rec { diff --git a/internal/skills/safetospend/config.go b/internal/skills/safetospend/config.go index 63abf8f..15b91ca 100644 --- a/internal/skills/safetospend/config.go +++ b/internal/skills/safetospend/config.go @@ -47,10 +47,6 @@ const ( // предупреждении. Остальные сворачиваются в «и ещё N»: живой прогон дал // одиннадцать таких категорий, и одиннадцать строк шума прятали сам ответ. lowDataNamesShown = 3 - // upcomingLookaheadDays — насколько далеко за конец периода заглядывать в - // строке «впереди». Платёж дальше этого окна оператору сегодня не нужен: он - // придётся не на следующий приход, а на один из последующих. - upcomingLookaheadDays = 31 // kopecksInUnit — округление сумм долей до копейки: раскладка показывается // человеку, а не хранит доли рубля. kopecksInUnit = 100.0 diff --git a/internal/skills/safetospend/display.go b/internal/skills/safetospend/display.go index a62b052..3c1574c 100644 --- a/internal/skills/safetospend/display.go +++ b/internal/skills/safetospend/display.go @@ -111,11 +111,21 @@ func FmtAmount(amount float64, currency string) string { } // FmtRate — курс с запятой: русский текст, «2.7» в нём читается как сбой. -// Десятых две, а не одна: оператор задаёт курс словами и «2,53» обязано -// вернуться ему как «2,53», а не округлиться до «2,5» (simpleAI-su6l). +// Единственный форматтер курса на оба пакета: пока их было два (этот и +// decimalComma с жёстким %.1f), одна и та же строка «курс X ₽/฿» печаталась +// по-разному — 2,54 в ответе про курс и 2,5 в раскладке конвертов. +// +// Знаков после запятой от одного до двух. Один — потому что «2,0» читается +// как курс, а «2» как количество. Два — потому что оператор задаёт курс +// словами, и «2,53» обязано вернуться ему как «2,53», а не округлиться до +// «2,5»: по этому числу он сверяет ответ бота с обменником. func FmtRate(v float64) string { s := strconv.FormatFloat(v, 'f', -1, 64) - if dot := strings.IndexByte(s, '.'); dot >= 0 && len(s)-dot > 3 { + dot := strings.IndexByte(s, '.') + switch { + case dot < 0: + s = strconv.FormatFloat(v, 'f', 1, 64) + case len(s)-dot > 3: s = strconv.FormatFloat(v, 'f', 2, 64) } return strings.Replace(s, ".", ",", 1) diff --git a/internal/skills/safetospend/fixed_window_test.go b/internal/skills/safetospend/fixed_window_test.go index 3da9e92..17b1c20 100644 --- a/internal/skills/safetospend/fixed_window_test.go +++ b/internal/skills/safetospend/fixed_window_test.go @@ -153,3 +153,45 @@ func eqTol(a, b, tol float64) bool { d := a - b return d < tol && d > -tol } + +// «Впереди» показывает ровно СЛЕДУЮЩИЙ период, а не фиксированный месяц. +// +// Блок озаглавлен «из следующего прихода», а приход дважды в месяц. Окно в 31 +// день затягивало бы туда платежи периода ПОСЛЕ следующего и подписывало их +// деньгами, которых к тому моменту ещё нет. +func TestFixedShares_LookaheadIsOneNextPeriod(t *testing.T) { + from := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC) // 14 дней + rates := map[string]float64{"RUB": 1, "THB": 2.5351} + + rec := []budget.RecurringPayment{ + {Name: "аренда", Type: "expense", Amount: 18000, Currency: "THB", Enabled: true, + NextDate: time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)}, // следующий период + {Name: "страховка", Type: "expense", Amount: 5000, Currency: "THB", Enabled: true, + NextDate: time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)}, // период ПОСЛЕ следующего + } + + _, upcoming, _ := fixedShares(rec, rates, from, to) + if len(upcoming) != 1 { + t.Fatalf("в «впереди» %d платежей, ожидался 1 (только аренда): %+v", len(upcoming), upcoming) + } + if upcoming[0].Name != "аренда" { + t.Errorf("в «впереди» попал платёж не следующего периода: %+v", upcoming[0]) + } +} + +// Курс печатается ОДНИМ форматтером во всех строках: пока их было два, +// «курс X ₽/฿» выходил как 2,54 в одном ответе и 2,5 в другом. +func TestFmtRate_SingleFormatterForRate(t *testing.T) { + cases := map[float64]string{ + 2: "2,0", // целый курс всё равно с десятой: «2» читается как количество + 3.1: "3,1", + 2.7: "2,7", + 2.5351: "2,54", // заданный словами курс не схлопывается до 2,5 + } + for in, want := range cases { + if got := FmtRate(in); got != want { + t.Errorf("FmtRate(%v) = %q, ожидалось %q", in, got, want) + } + } +} diff --git a/internal/skills/safetospend/format.go b/internal/skills/safetospend/format.go index f13e18f..a577f86 100644 --- a/internal/skills/safetospend/format.go +++ b/internal/skills/safetospend/format.go @@ -40,7 +40,7 @@ func formatReply(d replyData) string { } else { fmt.Fprintf(&b, "❌ Отложить нельзя — не хватает ~%s за %s.\n", m.Fmt(-verdict), d.period) } - fmt.Fprintf(&b, "🗓 %s · курс %s ₽/฿\n\n", d.period, decimalComma(d.rubPerTHB)) + fmt.Fprintf(&b, "🗓 %s · курс %s ₽/฿\n\n", d.period, FmtRate(d.rubPerTHB)) // 2) Раскладка. fmt.Fprintf(&b, "💰 Приход: %s\n", m.Fmt(r.IncomeTHB)) @@ -141,7 +141,7 @@ func FormatEnvelopePlan(d EnvelopeReply) string { if carried := TotalCarriedIn(d.Plan.Shares); carried > 0 { fmt.Fprintf(&b, "Перенос с прошлого раза %s\n", m.Fmt(carried)) } - fmt.Fprintf(&b, "Курс %s ₽/฿ на %s\n", decimalComma(d.RubPerTHB), d.From.Format("02.01")) + fmt.Fprintf(&b, "Курс %s ₽/฿ на %s\n", FmtRate(d.RubPerTHB), d.From.Format("02.01")) // Один моноблок на всё сообщение: колонка чисел в Telegram держится ТОЛЬКО // внутри pre — системный шрифт пропорциональный, и выравнивания пробелами @@ -379,11 +379,6 @@ func groupDigits(n int) string { return sign + strings.Join(append([]string{s}, parts...), " ") } -// decimalComma — курс с запятой: русский текст, «3.1» в нём читается как сбой. -func decimalComma(v float64) string { - return strings.Replace(fmt.Sprintf("%.1f", v), ".", ",", 1) -} - func roundInt(v float64) int { return int(math.Round(v)) } // padRight / padLeft считают ширину В РУНАХ: %-18s в Go меряет БАЙТЫ, и на @@ -546,7 +541,7 @@ func formatShareRemaining(items []ShareRemaining, m Display, env *budget.Envelop // числа с тем, что видит в банке. fmt.Fprintf(&b, "%s — %s · осталось %d %s\n", env.PeriodStart.Format("02.01"), env.PeriodEnd.Format("02.01"), daysLeft, pluralDays(daysLeft)) - fmt.Fprintf(&b, "Курс %s ₽/฿ на %s\n", decimalComma(m.RubPerTHB), now.Format("02.01")) + fmt.Fprintf(&b, "Курс %s ₽/฿ на %s\n", FmtRate(m.RubPerTHB), now.Format("02.01")) b.WriteString("\n**Что осталось**\n```\n") fmt.Fprintf(&b, "%s%s%s\n", From 681a5d1961cab76ec071d576b0beb7eb2ce2ba75 Mon Sep 17 00:00:00 2001 From: Taras Malinovskii Date: Mon, 24 Aug 2026 21:17:57 +0700 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20golangci-lint=20=E2=80=94=20=D0=B1=D1=8B?= =?UTF-8?q?=D0=BB=D0=BE=2020=20=D0=BD=D0=B0=D1=80=D1=83=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9,=20=D1=81=D1=82=D0=B0=D0=BB=D0=BE=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint красный на main с прошлого мержа. Разгребено целиком, а не только свои строки: красный CI перестаёт быть сигналом, если к нему привыкнуть. Своё: непроверенные ошибки восстановления реплики в тестах курса, дублирующийся пробел в классе символов, срез по Index без проверки на -1 (вернул бы панику вместо внятного сообщения). Долг прошлой итерации: min и close затеняли встроенные имена, ошибка определения чата глоталась молча, switch с одной веткой, неиспользуемые константа и параметр. Чужое: непроверенные Write и type assertion в тестах телеграма — пустая строка молча проходила мимо Contains и тест зеленел ни на чём. --- internal/adapters/telegram/adapter_test.go | 4 +++- internal/agent/routing_rules_test.go | 21 ++++++++++++---- internal/bot/format/telegram_html.go | 6 ++--- internal/budget/manual_rate_test.go | 17 +++++++++---- internal/notify/envelope_command.go | 10 ++++---- internal/notify/telegram_test.go | 24 +++++++++++++++---- internal/skills/budget/envelope_test.go | 3 +-- internal/skills/safetospend/allocate.go | 3 +-- internal/skills/safetospend/config.go | 4 ---- .../skills/safetospend/currency_sign_test.go | 3 ++- internal/telegram/handlers.go | 5 ++++ 11 files changed, 68 insertions(+), 32 deletions(-) diff --git a/internal/adapters/telegram/adapter_test.go b/internal/adapters/telegram/adapter_test.go index ce3add1..b6aec68 100644 --- a/internal/adapters/telegram/adapter_test.go +++ b/internal/adapters/telegram/adapter_test.go @@ -56,7 +56,9 @@ func newFakeTelegram(t *testing.T) *fakeTelegram { func writeJSON(w http.ResponseWriter, code int, body string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) - _, _ = w.Write([]byte(body)) + if _, err := w.Write([]byte(body)); err != nil { + panic(err) // тестовый сервер: писать некуда — тест уже недействителен + } } func (f *fakeTelegram) sendCalls() []map[string]string { diff --git a/internal/agent/routing_rules_test.go b/internal/agent/routing_rules_test.go index 11afe8d..a14b9b5 100644 --- a/internal/agent/routing_rules_test.go +++ b/internal/agent/routing_rules_test.go @@ -24,10 +24,7 @@ func TestBuildToolsSystemPrompt_ShareLimitRuleSeparatedFromExpense(t *testing.T) {ID: "safe_to_spend", Description: "safe to spend"}, }) - rules := prompt[:strings.Index(prompt, "Доступные инструменты")] - if rules == "" { - t.Fatal("в промпте нет блока правил до списка инструментов") - } + rules := rulesBlock(t, prompt) mustContain := map[string]string{ "set_share_limit": "нет правила про правку лимита конверта", @@ -63,7 +60,7 @@ func TestBuildToolsSystemPrompt_RateRuleSeparatedFromDisplayAndLimit(t *testing. {ID: "budget", Description: "budget tracker"}, {ID: "safe_to_spend", Description: "safe to spend"}, }) - rules := prompt[:strings.Index(prompt, "Доступные инструменты")] + rules := rulesBlock(t, prompt) mustContain := map[string]string{ "set_rate": "нет правила про ручной курс", @@ -91,3 +88,17 @@ func TestBuildToolsSystemPrompt_RateRuleSeparatedFromDisplayAndLimit(t *testing. rateAt, displayAt, limitAt) } } + +// rulesBlock вырезает блок ROUTING RULES — всё до списка инструментов. +// +// Отдельной функцией, а не срезом по strings.Index на месте: Index возвращает +// −1, когда маркера нет, и prompt[:-1] уронил бы тест паникой вместо внятного +// «в промпте нет блока правил». +func rulesBlock(t *testing.T, prompt string) string { + t.Helper() + at := strings.Index(prompt, "Доступные инструменты") + if at <= 0 { + t.Fatal("в промпте нет блока правил до списка инструментов") + } + return prompt[:at] +} diff --git a/internal/bot/format/telegram_html.go b/internal/bot/format/telegram_html.go index f2bb1cd..10cd8a3 100644 --- a/internal/bot/format/telegram_html.go +++ b/internal/bot/format/telegram_html.go @@ -65,9 +65,9 @@ func parseItems(src string) []item { inPre := make([]bool, len(lines)) skip := make([]bool, len(lines)) for i := 0; i+1 < len(fences); i += 2 { - open, close := fences[i], fences[i+1] - skip[open], skip[close] = true, true - for j := open + 1; j < close; j++ { + start, end := fences[i], fences[i+1] + skip[start], skip[end] = true, true + for j := start + 1; j < end; j++ { inPre[j] = true } } diff --git a/internal/budget/manual_rate_test.go b/internal/budget/manual_rate_test.go index 6998779..4732ee8 100644 --- a/internal/budget/manual_rate_test.go +++ b/internal/budget/manual_rate_test.go @@ -41,13 +41,20 @@ func TestManualRate_SurvivesWorkerTick(t *testing.T) { } t.Cleanup(func() { // Вернуть реплику в исходное состояние: тест ходит в живую базу. + // Ошибки восстановления не глушим — незамеченный сбой оставит реплику с + // чужим курсом, и следующий тест упадёт непонятно почему. + restore := func(err error) { + if err != nil { + t.Errorf("не удалось вернуть реплику в исходное состояние: %v", err) + } + } if existed && before.Manual { - _ = s.SetManualRate(ctx, cur, before.RateToRUB) + restore(s.SetManualRate(ctx, cur, before.RateToRUB)) return } - _ = s.ClearManualRate(ctx, cur) + restore(s.ClearManualRate(ctx, cur)) if existed { - _ = s.SaveExchangeRate(ctx, cur, before.Auto) + restore(s.SaveExchangeRate(ctx, cur, before.Auto)) } }) @@ -112,7 +119,9 @@ func TestManualRate_ClearIsIdempotent(t *testing.T) { } t.Cleanup(func() { if existed && before.Manual { - _ = s.SetManualRate(ctx, cur, before.RateToRUB) + if err := s.SetManualRate(ctx, cur, before.RateToRUB); err != nil { + t.Errorf("не удалось вернуть ручной курс: %v", err) + } } }) diff --git a/internal/notify/envelope_command.go b/internal/notify/envelope_command.go index 35a22bf..2bbe914 100644 --- a/internal/notify/envelope_command.go +++ b/internal/notify/envelope_command.go @@ -38,7 +38,7 @@ type EnvelopeReminderStore interface { // ни одной фразы оператора). var ( // «в 7:30», «в 7.30», «в 9» — время после предлога, чтобы не подобрать сумму из соседней фразы. - envelopeTimeRe = regexp.MustCompile(`(?:^|[^\p{L}])в\s+(\d{1,2})(?:[:.](\d{2}))?(?:[^\d]|$)`) + envelopeTimeRe = regexp.MustCompile(`(?:^|[^\p{L}])в\s+(\d{1,2})(?:[:.](\d{2}))?(?:\D|$)`) negationRe = regexp.MustCompile(`(?:^|[^\p{L}])(не|выключи|отключи|перестань|прекрати|хватит)(?:[^\p{L}]|$)`) positiveRe = regexp.MustCompile(`(?:^|[^\p{L}])(присылай|шли|высылай|включи|напоминай|показывай|отправляй)(?:[^\p{L}]|$)`) ) @@ -70,14 +70,14 @@ func ParseEnvelopeCommand(text string) (EnvelopeCommand, bool) { if err != nil || h > 23 { return cmd, true } - min := 0 + minute := 0 if m[2] != "" { - min, err = strconv.Atoi(m[2]) - if err != nil || min > 59 { + minute, err = strconv.Atoi(m[2]) + if err != nil || minute > 59 { return cmd, true } } - cmd.HasTime, cmd.Hour, cmd.Minute = true, h, min + cmd.HasTime, cmd.Hour, cmd.Minute = true, h, minute } return cmd, true default: diff --git a/internal/notify/telegram_test.go b/internal/notify/telegram_test.go index 763221c..f1739a8 100644 --- a/internal/notify/telegram_test.go +++ b/internal/notify/telegram_test.go @@ -38,7 +38,9 @@ func newFakeAPI(t *testing.T) *fakeAPI { return } w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) + if _, err := w.Write([]byte(`{"ok":true}`)); err != nil { + t.Errorf("тестовый сервер не смог ответить: %v", err) + } })) t.Cleanup(f.srv.Close) return f @@ -80,7 +82,10 @@ func TestSendToChatID_UsesHTML(t *testing.T) { if calls[0]["parse_mode"] != "HTML" { t.Errorf("пуш ушёл без parse_mode=HTML: %#v", calls[0]) } - text, _ := calls[0]["text"].(string) + text, ok := calls[0]["text"].(string) + if !ok { + t.Fatalf("в запросе нет текстового поля text: %#v", calls[0]) + } if !strings.Contains(text, "
Аренда         15.09  12 000\nЕда                    8 000
") { t.Errorf("моноблок пуша не стал pre:\n%s", text) } @@ -101,7 +106,10 @@ func TestSendToChatID_EscapesEvilStrings(t *testing.T) { if err := tg.SendToChatID(context.Background(), 7, "Кафе <Мама & Папа> — 15.09"); err != nil { t.Fatalf("send: %v", err) } - text, _ := f.sent()[0]["text"].(string) + text, ok := f.sent()[0]["text"].(string) + if !ok { + t.Fatalf("в запросе нет текстового поля text: %#v", f.sent()[0]) + } if !strings.Contains(text, "<Мама & Папа>") { t.Fatalf("не экранировано: %q", text) } @@ -121,7 +129,10 @@ func TestSendToChatID_SplitsLongPush(t *testing.T) { t.Fatalf("длинный пуш ушёл %d куском(ами)", len(calls)) } for i, c := range calls { - text, _ := c["text"].(string) + text, ok := c["text"].(string) + if !ok { + t.Fatalf("в куске %d нет текстового поля text: %#v", i, c) + } if n := len([]rune(text)); n > 4096 { t.Errorf("кусок %d длиной %d — Telegram вернёт 400", i, n) } @@ -143,7 +154,10 @@ func TestSendToChatID_FallsBackToPlainOn400(t *testing.T) { if _, has := last["parse_mode"]; has { t.Errorf("фоллбэк ушёл с parse_mode: %#v", last) } - text, _ := last["text"].(string) + text, ok := last["text"].(string) + if !ok { + t.Fatalf("в запросе нет текстового поля text: %#v", last) + } if !strings.Contains(text, "Аренда") { t.Errorf("фоллбэк потерял содержимое: %q", text) } diff --git a/internal/skills/budget/envelope_test.go b/internal/skills/budget/envelope_test.go index 3d5309e..ec50307 100644 --- a/internal/skills/budget/envelope_test.go +++ b/internal/skills/budget/envelope_test.go @@ -107,8 +107,7 @@ func TestStartEnvelope_WritesSharesAndReply(t *testing.T) { var hasFallback, hasSavings bool for _, sh := range shares { sum += sh.Allocated - switch strings.ToLower(sh.Name) { - case budget.FallbackShareName: + if strings.EqualFold(sh.Name, budget.FallbackShareName) { hasFallback = true } if sh.Kind == budget.ShareKindSave { diff --git a/internal/skills/safetospend/allocate.go b/internal/skills/safetospend/allocate.go index 3c92ec4..f659306 100644 --- a/internal/skills/safetospend/allocate.go +++ b/internal/skills/safetospend/allocate.go @@ -326,7 +326,7 @@ func allocateShares( overrides map[string]float64, history map[string]int, ) ([]budget.EnvelopeShare, []string) { - drafts, warnings := buildDrafts(free, fc, rates, days, history, nil) + drafts, warnings := buildDrafts(fc, rates, days, history, nil) drafts, warnings = applyOverrides(drafts, overrides, warnings) warnings = truncateToFree(drafts, free, warnings) @@ -337,7 +337,6 @@ func allocateShares( // мелочь и категории без истории — в «прочее». Последний черновик — всегда // «прочее»: доля-приёмник обязана существовать даже с нулевым лимитом. func buildDrafts( - free float64, fc []budget.CategoryForecast, rates map[string]float64, days int, diff --git a/internal/skills/safetospend/config.go b/internal/skills/safetospend/config.go index 15b91ca..2270e5b 100644 --- a/internal/skills/safetospend/config.go +++ b/internal/skills/safetospend/config.go @@ -51,7 +51,3 @@ const ( // человеку, а не хранит доли рубля. kopecksInUnit = 100.0 ) - -// lowShareFraction — доля лимита, ниже которой конверт помечается жёлтым -// («скоро кончится»). Порог отображения, на счёт не влияет. -const lowShareFraction = 0.2 diff --git a/internal/skills/safetospend/currency_sign_test.go b/internal/skills/safetospend/currency_sign_test.go index 309186d..1664d5a 100644 --- a/internal/skills/safetospend/currency_sign_test.go +++ b/internal/skills/safetospend/currency_sign_test.go @@ -121,7 +121,8 @@ func bareAmounts(out string) []string { regexp.MustCompile(`\d+ (день|дня|дней|недел\S*)`), // длина периода } // Число (возможно с пробелами-разрядами), за которым НЕ следует знак валюты. - amount := regexp.MustCompile(`\d[\d ]*\d|\d`) + // Разряды бьются ОБЫЧНЫМ пробелом: groupDigits ставит именно его. + amount := regexp.MustCompile(`\d[\d ]*\d|\d`) var bare []string for _, line := range strings.Split(out, "\n") { diff --git a/internal/telegram/handlers.go b/internal/telegram/handlers.go index 631365e..23718a3 100644 --- a/internal/telegram/handlers.go +++ b/internal/telegram/handlers.go @@ -4,6 +4,7 @@ package telegram import ( "context" "fmt" + "log/slog" "strings" "simpleAI/internal/constants" @@ -145,6 +146,10 @@ func handleEnvelopeSchedule(ctx context.Context, tctx *Context, text string) (bo } chatID, err := tctx.ChatID() if err != nil { + // Чат не определился — команда не наша, отдаём её дальше по цепочке. + // Ошибка не проглатывается молча: без неё непонятно, почему фраза + // про конверты вдруг ушла в общий обработчик. + slog.Default().WarnContext(ctx, "envelope command: chat id missing", "err", err) return false, nil }