Skip to content
Open
4 changes: 4 additions & 0 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions evals/golden_set.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
4 changes: 3 additions & 1 deletion internal/adapters/telegram/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion internal/agent/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 56 additions & 4 deletions internal/agent/routing_rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": "нет правила про правку лимита конверта",
Expand All @@ -50,3 +47,58 @@ func TestBuildToolsSystemPrompt_ShareLimitRuleSeparatedFromExpense(t *testing.T)
t.Errorf("правило про лимит должно идти после правила про add_expense (expense=%d, limit=%d)", expenseIdx, limitIdx)
}
}

// Правило про курс живёт в том же блоке ROUTING RULES и разграничено с двумя
// соседями, на которых оно естественно налипает (simpleAI-su6l):
// - «покажи конверты в рублях» — это ПОКАЗ (display_currency), курс не трогаем;
// - «на еду хватит 15000» — лимит конверта, там названа КАТЕГОРИЯ.
//
// Как и тест выше, доказывает только присутствие правила в промпте. Что модель
// ему следует — гоняют golden-кейсы r053–r056 на боевой модели.
func TestBuildToolsSystemPrompt_RateRuleSeparatedFromDisplayAndLimit(t *testing.T) {
prompt := buildToolsSystemPrompt([]plugin.Manifest{
{ID: "budget", Description: "budget tracker"},
{ID: "safe_to_spend", Description: "safe to spend"},
})
rules := rulesBlock(t, prompt)

mustContain := map[string]string{
"set_rate": "нет правила про ручной курс",
"clear_rate": "нет правила про возврат автокурса",
"rate_status": "нет правила про «какой сейчас курс»",
"курс авто": "нет фразы-триггера возврата к автокурсу",
"РУБЛЕЙ за один БАТ": "не сказано, что число — курс, а не сумма денег",
}
for marker, why := range mustContain {
if !strings.Contains(rules, marker) {
t.Errorf("%s: в ROUTING RULES нет %q", why, marker)
}
}

// Правило про курс обязано стоять ПОСЛЕ правил про display_currency и про
// лимит: разграничение читается как уточнение к ним, а не наоборот.
rateAt := strings.Index(rules, "КУРС БАТА")
displayAt := strings.Index(rules, "ВАЛЮТА КОНВЕРТОВ")
limitAt := strings.Index(rules, "Правка ЛИМИТА конверта")
if rateAt < 0 || displayAt < 0 || limitAt < 0 {
t.Fatalf("не найдены заголовки правил: курс=%d показ=%d лимит=%d", rateAt, displayAt, limitAt)
}
if rateAt < displayAt || rateAt < limitAt {
t.Errorf("правило про курс стоит раньше правил, от которых его отделяют (курс=%d показ=%d лимит=%d)",
rateAt, displayAt, limitAt)
}
}

// rulesBlock вырезает блок ROUTING RULES — всё до списка инструментов.
//
// Отдельной функцией, а не срезом по strings.Index на месте: Index возвращает
// −1, когда маркера нет, и prompt[:-1] уронил бы тест паникой вместо внятного
// «в промпте нет блока правил».
func rulesBlock(t *testing.T, prompt string) string {
t.Helper()
at := strings.Index(prompt, "Доступные инструменты")
if at <= 0 {
t.Fatal("в промпте нет блока правил до списка инструментов")
}
return prompt[:at]
}
6 changes: 3 additions & 3 deletions internal/bot/format/telegram_html.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
145 changes: 145 additions & 0 deletions internal/budget/manual_rate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package budget

import (
"context"
"os"
"testing"

"github.com/jackc/pgx/v5/pgxpool"
)

func rateTestStore(t *testing.T) (*Store, context.Context) {
t.Helper()
url := os.Getenv("BOTCLIENT_DATABASE_URL_RW")
if url == "" {
t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(pool.Close)
return &Store{pool: pool}, ctx
}

// Ручной курс обязан пережить суточный тик воркера (simpleAI-su6l).
//
// Это главный гейт задачи: положи ручной курс в ту же колонку, что пишет
// воркер, — и он молча исчезнет в ближайшие 24 часа. Оператор увидит, что
// команда «сработала», а через сутки конверты снова посчитаются по межбанку.
//
// Мутация: убрать COALESCE из GetExchangeRates либо начать писать manual в
// SaveExchangeRate — тест краснеет.
func TestManualRate_SurvivesWorkerTick(t *testing.T) {
s, ctx := rateTestStore(t)
const cur = "THB"

before, existed, err := s.GetRateSource(ctx, cur)
if err != nil {
t.Fatalf("read rate source: %v", err)
}
t.Cleanup(func() {
// Вернуть реплику в исходное состояние: тест ходит в живую базу.
// Ошибки восстановления не глушим — незамеченный сбой оставит реплику с
// чужим курсом, и следующий тест упадёт непонятно почему.
restore := func(err error) {
if err != nil {
t.Errorf("не удалось вернуть реплику в исходное состояние: %v", err)
}
}
if existed && before.Manual {
restore(s.SetManualRate(ctx, cur, before.RateToRUB))
return
}
restore(s.ClearManualRate(ctx, cur))
if existed {
restore(s.SaveExchangeRate(ctx, cur, before.Auto))
}
})

if err := s.SetManualRate(ctx, cur, 2.7); err != nil {
t.Fatalf("set manual rate: %v", err)
}

rates, err := s.GetExchangeRates(ctx)
if err != nil {
t.Fatalf("get rates: %v", err)
}
if got := rates[cur]; got != 2.7 {
t.Fatalf("действующий курс %v, ожидался ручной 2,7", got)
}

// Тик воркера: тот же вызов, что делает rates.Worker раз в сутки.
if err := s.SaveExchangeRate(ctx, cur, 2.5351); err != nil {
t.Fatalf("worker tick: %v", err)
}

rates, err = s.GetExchangeRates(ctx)
if err != nil {
t.Fatalf("get rates after tick: %v", err)
}
if got := rates[cur]; got != 2.7 {
t.Errorf("после тика воркера курс %v — ручной курс затёрт автоматическим", got)
}

src, ok, err := s.GetRateSource(ctx, cur)
if err != nil || !ok {
t.Fatalf("read rate source after tick: err=%v ok=%v", err, ok)
}
if !src.Manual {
t.Error("курс перестал считаться ручным")
}
if src.Auto != 2.5351 {
t.Errorf("автокурс %v — воркер обязан продолжать его обновлять и под override'ом", src.Auto)
}

// «курс авто» возвращает то, что за это время принёс воркер.
if err := s.ClearManualRate(ctx, cur); err != nil {
t.Fatalf("clear manual rate: %v", err)
}
rates, err = s.GetExchangeRates(ctx)
if err != nil {
t.Fatalf("get rates after clear: %v", err)
}
if got := rates[cur]; got != 2.5351 {
t.Errorf("после «курс авто» действует %v, ожидался автокурс 2,5351", got)
}
}

// Повторный «курс авто» — не ошибка: оператору важно состояние «ручного курса
// нет», а не факт удаления строки.
func TestManualRate_ClearIsIdempotent(t *testing.T) {
s, ctx := rateTestStore(t)
const cur = "THB"

before, existed, err := s.GetRateSource(ctx, cur)
if err != nil {
t.Fatalf("read rate source: %v", err)
}
t.Cleanup(func() {
if existed && before.Manual {
if err := s.SetManualRate(ctx, cur, before.RateToRUB); err != nil {
t.Errorf("не удалось вернуть ручной курс: %v", err)
}
}
})

if err := s.ClearManualRate(ctx, cur); err != nil {
t.Fatalf("first clear: %v", err)
}
if err := s.ClearManualRate(ctx, cur); err != nil {
t.Errorf("повторный «курс авто» вернул ошибку: %v", err)
}
}

// Курс — величина строго положительная: нулевой делит на ноль в ToTHB,
// отрицательный печатает отрицательные конверты.
func TestManualRate_RejectsNonPositive(t *testing.T) {
s, ctx := rateTestStore(t)
for _, bad := range []float64{0, -2.7} {
if err := s.SetManualRate(ctx, "THB", bad); err == nil {
t.Errorf("курс %v принят, ожидалась ошибка", bad)
}
}
}
Loading
Loading