feat: expense tracker Python parity (Wave 10)
New `expenses_handlers.py`: - parse_expense(phrase) -> (amount, category) | None — pure fn, fully unit-tested. Handles "потратил 500 на еду", comma decimals "12,50", EN "spent 12.5 on coffee", default category "разное". - do_expenses_log: append entry to expenses.json (atomic write-then-rename). - do_expenses_today: sum today's entries (local-day boundary). - do_expenses_week: rolling 7-day total + count. - do_expenses_breakdown: per-category, top-5 + tail collapse. extensions.py / main.py / commands.yaml: 4 new dispatch entries. .gitignore: + expenses.json. tests/test_expenses_handlers.py: 8 unit tests covering parse, log, today/week math, breakdown ordering + tail collapse. Tests: 96 → 104 (+8). Total Python coverage now mirrors Rust pack behaviour for personal finance.
This commit is contained in:
parent
b3cfaa5171
commit
f102b8dc95
6 changed files with 343 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,6 +17,7 @@ llm_backend.txt
|
||||||
llm_history.json
|
llm_history.json
|
||||||
active_profile.txt
|
active_profile.txt
|
||||||
time_tracker.json
|
time_tracker.json
|
||||||
|
expenses.json
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
|
||||||
|
|
@ -1709,3 +1709,34 @@ routines_coffee_break:
|
||||||
- перерыв на чай
|
- перерыв на чай
|
||||||
- отойду минут на пять
|
- отойду минут на пять
|
||||||
action: {type: coffee_break}
|
action: {type: coffee_break}
|
||||||
|
|
||||||
|
expenses_log:
|
||||||
|
phrases:
|
||||||
|
- потратил
|
||||||
|
- записать трату
|
||||||
|
- запиши трату
|
||||||
|
- купил за
|
||||||
|
- заплатил за
|
||||||
|
action: {type: expenses_log}
|
||||||
|
|
||||||
|
expenses_today:
|
||||||
|
phrases:
|
||||||
|
- сколько я потратил сегодня
|
||||||
|
- траты сегодня
|
||||||
|
- сколько ушло сегодня
|
||||||
|
action: {type: expenses_today}
|
||||||
|
|
||||||
|
expenses_week:
|
||||||
|
phrases:
|
||||||
|
- сколько я потратил на этой неделе
|
||||||
|
- траты на неделе
|
||||||
|
- недельные траты
|
||||||
|
action: {type: expenses_week}
|
||||||
|
|
||||||
|
expenses_breakdown:
|
||||||
|
phrases:
|
||||||
|
- куда ушли деньги
|
||||||
|
- разбивка по тратам
|
||||||
|
- на что я тратил
|
||||||
|
- разбивка по категориям
|
||||||
|
action: {type: expenses_breakdown}
|
||||||
|
|
|
||||||
177
expenses_handlers.py
Normal file
177
expenses_handlers.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""Personal finance tracker — Python parity for the Rust `expenses/` pack.
|
||||||
|
|
||||||
|
Stores entries in `<here>/expenses.json` (atomic write-through), exposes
|
||||||
|
the same four voice entry points as the Rust pack:
|
||||||
|
|
||||||
|
do_expenses_log "потратил 500 на еду"
|
||||||
|
do_expenses_today "сколько потратил сегодня"
|
||||||
|
do_expenses_week "сколько на неделе"
|
||||||
|
do_expenses_breakdown "куда ушли деньги"
|
||||||
|
|
||||||
|
State shape: { "entries": [{"ts": int, "amount": float, "category": str}, ...] }
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time as _time
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_PATH = os.path.join(_HERE, 'expenses.json')
|
||||||
|
|
||||||
|
_speak: Optional[Callable[[str], None]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_speak(fn: Callable[[str], None]) -> None:
|
||||||
|
global _speak
|
||||||
|
_speak = fn
|
||||||
|
|
||||||
|
|
||||||
|
def _say(text: str) -> None:
|
||||||
|
if _speak is not None:
|
||||||
|
_speak(text)
|
||||||
|
else:
|
||||||
|
print(f"[expenses] {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> dict:
|
||||||
|
"""Return current state. Empty-shape dict if file missing or unreadable."""
|
||||||
|
if not os.path.isfile(_PATH):
|
||||||
|
return {'entries': []}
|
||||||
|
try:
|
||||||
|
with open(_PATH, encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get('entries'), list):
|
||||||
|
return {'entries': []}
|
||||||
|
return data
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {'entries': []}
|
||||||
|
|
||||||
|
|
||||||
|
def _save(data: dict) -> None:
|
||||||
|
tmp = _PATH + '.tmp'
|
||||||
|
try:
|
||||||
|
with open(tmp, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, _PATH)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"[expenses] save failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Pulled out so tests can exercise parsing without going through TTS.
|
||||||
|
_TRIGGERS = [
|
||||||
|
"потратил", "записать трату", "запиши трату", "купил за", "заплатил за",
|
||||||
|
"spent", "log expense", "paid for", "bought",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_trigger(phrase: str) -> str:
|
||||||
|
p = (phrase or "").lower().strip()
|
||||||
|
for t in _TRIGGERS:
|
||||||
|
if p.startswith(t):
|
||||||
|
return p[len(t):].strip()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def parse_expense(phrase: str):
|
||||||
|
"""Return (amount: float, category: str) or None if the phrase doesn't
|
||||||
|
contain a parseable expense. Pure function — used by both `do_expenses_log`
|
||||||
|
and the test suite."""
|
||||||
|
rest = _strip_trigger(phrase)
|
||||||
|
m = re.match(r"(-?\d+[.,]?\d*)\s*(.*)", rest)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
amount_str = m.group(1).replace(",", ".")
|
||||||
|
try:
|
||||||
|
amount = float(amount_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if amount <= 0:
|
||||||
|
return None
|
||||||
|
after = m.group(2) or ""
|
||||||
|
category = "разное"
|
||||||
|
# "на X" (RU) / "for X" / "on X" (EN)
|
||||||
|
cat_match = re.search(r"\bна\s+(\S+)", after) \
|
||||||
|
or re.search(r"\bfor\s+(\S+)", after) \
|
||||||
|
or re.search(r"\bon\s+(\S+)", after)
|
||||||
|
if cat_match:
|
||||||
|
category = cat_match.group(1).strip(".,!?")
|
||||||
|
return amount, category
|
||||||
|
|
||||||
|
|
||||||
|
def do_expenses_log(action=None, voice=None) -> None:
|
||||||
|
parsed = parse_expense(voice or "")
|
||||||
|
if parsed is None:
|
||||||
|
_say("Не понял сумму, сэр.")
|
||||||
|
return
|
||||||
|
amount, category = parsed
|
||||||
|
data = load()
|
||||||
|
data['entries'].append({
|
||||||
|
'ts': int(_time.time()),
|
||||||
|
'amount': amount,
|
||||||
|
'category': category,
|
||||||
|
})
|
||||||
|
_save(data)
|
||||||
|
_say(f"Записал: {amount:g} на {category}.")
|
||||||
|
|
||||||
|
|
||||||
|
def _today_bounds() -> tuple[int, int]:
|
||||||
|
now = _time.localtime()
|
||||||
|
start = _time.mktime((now.tm_year, now.tm_mon, now.tm_mday,
|
||||||
|
0, 0, 0, 0, 0, -1))
|
||||||
|
return int(start), int(start) + 86400
|
||||||
|
|
||||||
|
|
||||||
|
def do_expenses_today(action=None, voice=None) -> None:
|
||||||
|
data = load()
|
||||||
|
if not data['entries']:
|
||||||
|
_say("Сегодня ничего не тратили, сэр.")
|
||||||
|
return
|
||||||
|
start, end = _today_bounds()
|
||||||
|
total = sum(e['amount'] for e in data['entries']
|
||||||
|
if isinstance(e.get('ts'), int) and start <= e['ts'] < end)
|
||||||
|
if total == 0:
|
||||||
|
_say("Сегодня ничего не тратили, сэр.")
|
||||||
|
else:
|
||||||
|
_say(f"Сегодня потратили {total:g}.")
|
||||||
|
|
||||||
|
|
||||||
|
def do_expenses_week(action=None, voice=None) -> None:
|
||||||
|
data = load()
|
||||||
|
if not data['entries']:
|
||||||
|
_say("На этой неделе ничего не тратили.")
|
||||||
|
return
|
||||||
|
cutoff = int(_time.time()) - 7 * 86400
|
||||||
|
rows = [e for e in data['entries']
|
||||||
|
if isinstance(e.get('ts'), int) and e['ts'] >= cutoff]
|
||||||
|
if not rows:
|
||||||
|
_say("На этой неделе тратами не похваляешься, сэр.")
|
||||||
|
return
|
||||||
|
total = sum(e['amount'] for e in rows)
|
||||||
|
_say(f"За неделю {len(rows)} трат на сумму {total:g}.")
|
||||||
|
|
||||||
|
|
||||||
|
def do_expenses_breakdown(action=None, voice=None) -> None:
|
||||||
|
data = load()
|
||||||
|
if not data['entries']:
|
||||||
|
_say("Нечего разбивать, сэр.")
|
||||||
|
return
|
||||||
|
cutoff = int(_time.time()) - 7 * 86400
|
||||||
|
by_cat: dict[str, float] = {}
|
||||||
|
for e in data['entries']:
|
||||||
|
if isinstance(e.get('ts'), int) and e['ts'] >= cutoff:
|
||||||
|
cat = e.get('category', 'разное')
|
||||||
|
by_cat[cat] = by_cat.get(cat, 0.0) + e['amount']
|
||||||
|
if not by_cat:
|
||||||
|
_say("На этой неделе тишина по тратам, сэр.")
|
||||||
|
return
|
||||||
|
rows = sorted(by_cat.items(), key=lambda kv: kv[1], reverse=True)
|
||||||
|
top = rows[:5]
|
||||||
|
rest = rows[5:]
|
||||||
|
parts = [f"{cat} {amt:g}" for cat, amt in top]
|
||||||
|
tail = ""
|
||||||
|
if rest:
|
||||||
|
rest_total = sum(amt for _, amt in rest)
|
||||||
|
tail = f" и другое {rest_total:g}"
|
||||||
|
_say(f"По категориям: {', '.join(parts)}{tail}.")
|
||||||
|
|
@ -32,6 +32,7 @@ import personality_handlers
|
||||||
import time_tracker_handlers
|
import time_tracker_handlers
|
||||||
import outlook_handlers
|
import outlook_handlers
|
||||||
import wave68_handlers
|
import wave68_handlers
|
||||||
|
import expenses_handlers
|
||||||
|
|
||||||
_speak_fn = print
|
_speak_fn = print
|
||||||
_set_clipboard_fn = None
|
_set_clipboard_fn = None
|
||||||
|
|
@ -49,6 +50,7 @@ def set_speak(fn):
|
||||||
time_tracker_handlers.set_speak(fn)
|
time_tracker_handlers.set_speak(fn)
|
||||||
outlook_handlers.set_speak(fn)
|
outlook_handlers.set_speak(fn)
|
||||||
wave68_handlers.set_speak(fn)
|
wave68_handlers.set_speak(fn)
|
||||||
|
expenses_handlers.set_speak(fn)
|
||||||
|
|
||||||
|
|
||||||
def set_clipboard_fn(fn):
|
def set_clipboard_fn(fn):
|
||||||
|
|
@ -1166,3 +1168,11 @@ def do_trivia(a, v): wave68_handlers.do_trivia(a, v)
|
||||||
def do_good_night(a, v): wave68_handlers.do_good_night(a, v)
|
def do_good_night(a, v): wave68_handlers.do_good_night(a, v)
|
||||||
def do_good_morning(a, v): wave68_handlers.do_good_morning(a, v)
|
def do_good_morning(a, v): wave68_handlers.do_good_morning(a, v)
|
||||||
def do_coffee_break(a, v): wave68_handlers.do_coffee_break(a, v)
|
def do_coffee_break(a, v): wave68_handlers.do_coffee_break(a, v)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Expenses proxies (to expenses_handlers) ────────────────────────────────
|
||||||
|
|
||||||
|
def do_expenses_log(a, v): expenses_handlers.do_expenses_log(a, v)
|
||||||
|
def do_expenses_today(a, v): expenses_handlers.do_expenses_today(a, v)
|
||||||
|
def do_expenses_week(a, v): expenses_handlers.do_expenses_week(a, v)
|
||||||
|
def do_expenses_breakdown(a, v): expenses_handlers.do_expenses_breakdown(a, v)
|
||||||
|
|
|
||||||
8
main.py
8
main.py
|
|
@ -1735,6 +1735,14 @@ def run_action(action):
|
||||||
extensions.do_good_morning(action, _current_voice or '')
|
extensions.do_good_morning(action, _current_voice or '')
|
||||||
elif t == 'coffee_break':
|
elif t == 'coffee_break':
|
||||||
extensions.do_coffee_break(action, _current_voice or '')
|
extensions.do_coffee_break(action, _current_voice or '')
|
||||||
|
elif t == 'expenses_log':
|
||||||
|
extensions.do_expenses_log(action, _current_voice or '')
|
||||||
|
elif t == 'expenses_today':
|
||||||
|
extensions.do_expenses_today(action, _current_voice or '')
|
||||||
|
elif t == 'expenses_week':
|
||||||
|
extensions.do_expenses_week(action, _current_voice or '')
|
||||||
|
elif t == 'expenses_breakdown':
|
||||||
|
extensions.do_expenses_breakdown(action, _current_voice or '')
|
||||||
|
|
||||||
|
|
||||||
_current_voice: str = ''
|
_current_voice: str = ''
|
||||||
|
|
|
||||||
116
tests/test_expenses_handlers.py
Normal file
116
tests/test_expenses_handlers.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Tests for `expenses_handlers.py` — voice-driven personal finance tracker."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time as _time
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(eh):
|
||||||
|
captured = []
|
||||||
|
eh.set_speak(captured.append)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_expense_extracts_amount_and_category(isolated_state):
|
||||||
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||||
|
assert eh.parse_expense("потратил 500 на еду") == (500.0, "еду")
|
||||||
|
assert eh.parse_expense("spent 12.50 on coffee") == (12.5, "coffee")
|
||||||
|
# Comma decimal (RU keyboard)
|
||||||
|
assert eh.parse_expense("потратил 12,50 на транспорт") == (12.5, "транспорт")
|
||||||
|
# No category → default
|
||||||
|
amount, cat = eh.parse_expense("потратил 100")
|
||||||
|
assert amount == 100.0
|
||||||
|
assert cat == "разное"
|
||||||
|
# Garbage
|
||||||
|
assert eh.parse_expense("какая погода") is None
|
||||||
|
assert eh.parse_expense("потратил bla") is None
|
||||||
|
assert eh.parse_expense("потратил -100") is None
|
||||||
|
assert eh.parse_expense("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_appends_entry(isolated_state):
|
||||||
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||||
|
captured = _capture(eh)
|
||||||
|
eh.do_expenses_log(voice="потратил 250 на кофе")
|
||||||
|
data = eh.load()
|
||||||
|
assert len(data['entries']) == 1
|
||||||
|
e = data['entries'][0]
|
||||||
|
assert e['amount'] == 250.0
|
||||||
|
assert e['category'] == "кофе"
|
||||||
|
assert "250" in captured[0]
|
||||||
|
assert "кофе" in captured[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_ignores_unparseable(isolated_state):
|
||||||
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||||
|
captured = _capture(eh)
|
||||||
|
eh.do_expenses_log(voice="ну блин")
|
||||||
|
assert eh.load()['entries'] == []
|
||||||
|
assert "не понял" in captured[0].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_sums_only_today(isolated_state):
|
||||||
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||||
|
# Seed data: one entry today, one entry yesterday
|
||||||
|
now = int(_time.time())
|
||||||
|
eh._save({'entries': [
|
||||||
|
{'ts': now, 'amount': 100.0, 'category': 'еда'},
|
||||||
|
{'ts': now - 86400, 'amount': 999.0, 'category': 'старое'},
|
||||||
|
]})
|
||||||
|
captured = _capture(eh)
|
||||||
|
eh.do_expenses_today()
|
||||||
|
assert "100" in captured[0]
|
||||||
|
assert "999" not in captured[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_sums_last_7_days(isolated_state):
|
||||||
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||||
|
now = int(_time.time())
|
||||||
|
eh._save({'entries': [
|
||||||
|
{'ts': now - 3 * 86400, 'amount': 100.0, 'category': 'a'},
|
||||||
|
{'ts': now - 5 * 86400, 'amount': 200.0, 'category': 'b'},
|
||||||
|
{'ts': now - 10 * 86400, 'amount': 999.0, 'category': 'старое'},
|
||||||
|
]})
|
||||||
|
captured = _capture(eh)
|
||||||
|
eh.do_expenses_week()
|
||||||
|
# 100 + 200 = 300 in last 7 days; 999 excluded
|
||||||
|
assert "300" in captured[0]
|
||||||
|
assert "999" not in captured[0]
|
||||||
|
assert "2 трат" in captured[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_breakdown_sorts_by_amount(isolated_state):
|
||||||
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||||
|
now = int(_time.time())
|
||||||
|
eh._save({'entries': [
|
||||||
|
{'ts': now, 'amount': 100, 'category': 'small'},
|
||||||
|
{'ts': now, 'amount': 500, 'category': 'big'},
|
||||||
|
{'ts': now, 'amount': 200, 'category': 'medium'},
|
||||||
|
]})
|
||||||
|
captured = _capture(eh)
|
||||||
|
eh.do_expenses_breakdown()
|
||||||
|
text = captured[0]
|
||||||
|
# big should appear before medium before small
|
||||||
|
assert text.index("big") < text.index("medium") < text.index("small")
|
||||||
|
|
||||||
|
|
||||||
|
def test_breakdown_collapses_tail(isolated_state):
|
||||||
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||||
|
now = int(_time.time())
|
||||||
|
eh._save({'entries': [
|
||||||
|
{'ts': now, 'amount': float(i + 1) * 10, 'category': f'c{i}'}
|
||||||
|
for i in range(8) # 8 categories — top 5 spoken, 3 collapse
|
||||||
|
]})
|
||||||
|
captured = _capture(eh)
|
||||||
|
eh.do_expenses_breakdown()
|
||||||
|
assert "и другое" in captured[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_when_empty_says_so(isolated_state):
|
||||||
|
eh = isolated_state('expenses_handlers', ['_PATH'])
|
||||||
|
captured = _capture(eh)
|
||||||
|
eh.do_expenses_today()
|
||||||
|
assert "ничего" in captured[0].lower()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue