Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .beads/issues.jsonl

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions evals/golden_set.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@
{"id":"r022","input":"хочу купить блендер за 5000","expected":{"skill":"advisor","action":"advice"},"tags":["future_purchase","with_amount","ru"]}
{"id":"r023","input":"сколько откладывать на машину","expected":{"skill":"advisor","action":"advice"},"tags":["advice","saving_plan","ru"]}
{"id":"r024","input":"do we have enough money for vacation?","expected":{"skill":"advisor","action":"advice"},"tags":["future_purchase","en","no_amount"]}
{"id":"r025","input":"проанализируй мои траты за этот месяц","expected":{"skill":"advisor","action":"analyze"},"tags":["advisor_analyze","month","ru"]}
{"id":"r026","input":"найди аномалии в моих расходах","expected":{"skill":"advisor","action":"analyze"},"tags":["advisor_analyze","anomalies","ru"]}
{"id":"r027","input":"дай советы по экономии","expected":{"skill":"advisor","action":"analyze"},"tags":["advisor_analyze","savings","ru"]}
{"id":"r028","input":"analyze my spending this month","expected":{"skill":"advisor","action":"analyze"},"tags":["advisor_analyze","en"]}
{"id":"r029","input":"какие тренды в моих тратах","expected":{"skill":"advisor","action":"analyze"},"tags":["advisor_analyze","trends","ru"]}
{"id":"r030","input":"сколько я потратил в этом месяце","expected":{"skill":"budget","action":"summary"},"tags":["summary","month","ru","contrast_analyze"]}
{"id":"r031","input":"итоги за апрель","expected":{"skill":"budget","action":"summary"},"tags":["summary","month","ru","contrast_analyze"]}
11 changes: 11 additions & 0 deletions internal/budget/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ type AdvisorSnapshot struct {
LowData bool // TxCount < MinTxForConfidence (порог в пакете skills)
}

// TopExpense — одна из топ-N самых дорогих расходных транзакций за период,
// сконвертированная в THB. Используется AdvisorSkill action='analyze' для
// передачи LLM детализированного среза трат.
type TopExpense struct {
Date time.Time
Category string
AmountTHB float64
OrigAmt float64
Currency string
}

// RecurringPayment — повторяющийся платёж, создающий транзакцию автоматически по расписанию.
type RecurringPayment struct {
ID uuid.UUID
Expand Down
75 changes: 71 additions & 4 deletions internal/budget/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -861,13 +862,15 @@ SELECT kind, subtype, currency, category, total, cnt FROM recurring_agg
// GetAdvisorSnapshot собирает финансовый снимок для AdvisorSkill одним SQL CTE.
// Все суммы конвертируются в THB через rates (map[currency]rate_to_rub).
//
// - $1: today (определяет границы месяца)
// - $2: chat_id (фильтр для budget_recurring; budget_transaction и budget_debt — глобальные)
// - today: точка отсчёта (определяет границы месяца через date_trunc)
// - monthOffset: 0 — текущий месяц, 1 — прошлый, и т.д.
// - chatID: фильтр для budget_recurring; budget_transaction и budget_debt — глобальные
// - rates: map currency → rate_to_rub. Должна содержать "THB", иначе ошибка.
//
// ForecastRemaining не заполняется — это делает skill отдельным вызовом GetForecastData.
func (s *Store) GetAdvisorSnapshot(ctx context.Context, chatID int64, today time.Time, rates map[string]float64) (*AdvisorSnapshot, error) {
rows, err := s.pool.Query(ctx, advisorSnapshotQuery, today, chatID)
func (s *Store) GetAdvisorSnapshot(ctx context.Context, chatID int64, today time.Time, monthOffset int, rates map[string]float64) (*AdvisorSnapshot, error) {
target := today.AddDate(0, -monthOffset, 0)
rows, err := s.pool.Query(ctx, advisorSnapshotQuery, target, chatID)
if err != nil {
return nil, fmt.Errorf("advisor snapshot query: %w", err)
}
Expand All @@ -887,6 +890,70 @@ func (s *Store) GetAdvisorSnapshot(ctx context.Context, chatID int64, today time
return aggregateAdvisorSnapshot(collected, rates)
}

// topExpenseQuery — топ-N самых дорогих расходов за месяц (определённый через
// date_trunc от $1::date), глобально по всем chat_id (как budget_transaction).
// Сортировка по amount DESC в исходной валюте — не идеально для multi-currency,
// поэтому финальный ORDER в Go-коде после конверсии в THB.
const topExpenseQuery = `
WITH params AS (
SELECT
date_trunc('month', $1::date)::date AS month_start,
(date_trunc('month', $1::date) + interval '1 month - 1 day')::date AS month_end
)
SELECT
t.transaction_date,
COALESCE(c.name, 'Прочее') AS category,
t.amount,
t.currency
FROM budget_transaction t
LEFT JOIN budget_category c ON c.id = t.category_id
CROSS JOIN params p
WHERE t.type = 'expense'
AND t.transaction_date >= p.month_start
AND t.transaction_date <= p.month_end
`

// GetTopExpenseTransactions — топ-N самых дорогих расходов за указанный месяц
// (today, monthOffset аналогично GetAdvisorSnapshot), сконвертированных в THB.
// Сортировка финальная по AmountTHB DESC в Go-коде (после конверсии).
func (s *Store) GetTopExpenseTransactions(ctx context.Context, today time.Time, monthOffset int, limit int, rates map[string]float64) ([]TopExpense, error) {
if limit <= 0 {
limit = 20
}
thbRate, ok := rates["THB"]
if !ok || thbRate == 0 {
return nil, fmt.Errorf("top expenses: THB exchange rate missing")
}
target := today.AddDate(0, -monthOffset, 0)
rows, err := s.pool.Query(ctx, topExpenseQuery, target)
if err != nil {
return nil, fmt.Errorf("top expenses query: %w", err)
}
defer rows.Close()

var all []TopExpense
for rows.Next() {
var e TopExpense
if err := rows.Scan(&e.Date, &e.Category, &e.OrigAmt, &e.Currency); err != nil {
return nil, fmt.Errorf("scan top expense: %w", err)
}
rubRate, hasRate := rates[e.Currency]
if !hasRate || rubRate == 0 {
continue
}
e.AmountTHB = e.OrigAmt * rubRate / thbRate
all = append(all, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("top expenses rows: %w", err)
}
sort.Slice(all, func(i, j int) bool { return all[i].AmountTHB > all[j].AmountTHB })
if len(all) > limit {
all = all[:limit]
}
return all, nil
}

// aggregateAdvisorSnapshot — чистая агрегация: rows → AdvisorSnapshot в THB.
//
// Конверсия: amount * rates[currency] / rates["THB"].
Expand Down
Loading
Loading