From e002d8205f21651bfd0cb46420a621fd23317f8214312a57a56959348f7d7320 Mon Sep 17 00:00:00 2001 From: wlfb Date: Mon, 20 Jul 2026 20:30:31 +0200 Subject: [PATCH] docs: Spec + Plan Ausbaustufe 9 (Vorschlags-Algorithmus v2) Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-20-ausbaustufe-9.md | 196 ++++++++++++++++++ ...-07-20-vorschlags-algorithmus-v2-design.md | 81 ++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-ausbaustufe-9.md create mode 100644 docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md diff --git a/docs/superpowers/plans/2026-07-20-ausbaustufe-9.md b/docs/superpowers/plans/2026-07-20-ausbaustufe-9.md new file mode 100644 index 0000000..af5d778 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-ausbaustufe-9.md @@ -0,0 +1,196 @@ +# Ausbaustufe 9 Implementation Plan — Vorschlags-Algorithmus v2 (v0.9.0) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `suggest_recurring` erkennt monatliche/vierteljährliche/jährliche Serien mit letztem Betrag, Aktiv-Check, Betrags-Clustern, Umfirmierungs-Merge und robustem Bestandsabgleich; GUI zeigt Rhythmus/Start/Hinweis; Release v0.9.0 mit Live-Gate gegen die echten Daten. + +**Architecture:** Vollständiger Rewrite von `app/services/suggestions.py` (reine Session-in/dict-out-Funktion, Parameter als Modul-Konstanten); `SuggestionOut`-Erweiterung in `routers/planning.py`; Template-Anpassung der Vorschlags-Tabelle. Kein Datenmodell-/Migrationsbedarf. + +**Tech Stack:** SQLAlchemy 2, Pydantic v2, Jinja2, pytest (synthetische Daten). + +**Spec:** `docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md` — die dortigen Abschnitte „Algorithmus" (8 Schritte, Konstanten) und „Tests" sind bindend und Teil dieses Plans. + +## Global Constraints + +- Beträge `Decimal` (keine float-Arithmetik, auch nicht in Toleranzvergleichen — relative Differenzen als `Decimal`-Quotienten). +- `date.today()` nur an EINER Stelle (Parameter `today: date | None = None` der Hauptfunktion, Default heute) — Tests injizieren ein festes Datum. +- Tests ausschließlich mit synthetischen Daten (DATENSCHUTZ: keine echten Namen/Beträge aus der Live-DB in Tests/Commits). +- GUI deutsch, TT.MM.JJJJ, `|eur`, `|de_label`; API Punkt-Dezimal. +- Fable-Testagent-Gate je Task VOR Commit; Ledger-Eintrag je Task. +- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 187 passed, muss grün bleiben (drei bestehende Suggestion-Tests DÜRFEN an die neue Semantik angepasst werden, siehe Task 1 Step 4). + +--- + +### Task 1: Algorithmus-Rewrite + API-Schema + +**Files:** +- Rewrite: `app/services/suggestions.py` +- Modify: `app/routers/planning.py` (`SuggestionOut`) +- Modify: `tests/test_planning_api.py` + +**Interfaces:** +- Produces: `suggest_recurring(session, today: date | None = None) -> list[dict]` mit Keys `name, amount (Decimal), rhythm, due_day, start_date (date|None), category_id, hinweis (str)`; `SuggestionOut` mit denselben Feldern (`start_date: date | None = None`, `hinweis: str = ""`). Task 2 rendert genau diese Felder. + +- [ ] **Step 1: Failing Tests** — in `tests/test_planning_api.py` die drei bestehenden Suggestion-Tests ERSETZEN/ERWEITERN durch die Spec-Fälle (Helper zum Anlegen synthetischer Buchungen schreiben; `dedup_hash` eindeutig, `status="confirmed"`; ein Account genügt; `today=date(2026, 7, 20)` in alle Aufrufe injizieren): + +```python +from datetime import date +from decimal import Decimal + +def _tx(db, acc_id, d, amount, cp, cat=None): + db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount), + purpose="p", counterparty=cp, category_id=cat, + status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}")) + +TODAY = date(2026, 7, 20) + +def test_suggest_letzter_betrag_bei_preiserhoehung(db): + acc = _acc(db) # Helper: Account anlegen, gibt id zurueck + for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"), + (date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"), + (date(2026, 7, 1), "-202.94")]: + _tx(db, acc, d, a, "Entis Lebensversicherung AG") + db.commit() + out = suggest_recurring(db, today=TODAY) + assert len(out) == 1 + s = out[0] + assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly" + assert s["due_day"] == 1 and s["start_date"] is None + +def test_suggest_quartal_mit_phase(db): + acc = _acc(db) + for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]: + _tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF") + db.commit() + out = suggest_recurring(db, today=TODAY) + assert len(out) == 1 + assert out[0]["rhythm"] == "quarterly" + assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15 + +def test_suggest_jahr_mit_zwei_belegen(db): + acc = _acc(db) + for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]: + _tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.") + db.commit() + out = suggest_recurring(db, today=TODAY) + assert len(out) == 1 + assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33") + assert out[0]["start_date"] == date(2026, 6, 16) + assert "409.92" in out[0]["hinweis"] # Betrag zuletzt gestiegen + +def test_suggest_tote_serie_kein_vorschlag(db): + acc = _acc(db) + for m in (9, 10, 11, 12): + _tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt") + db.commit() + assert suggest_recurring(db, today=TODAY) == [] + +def test_suggest_umfirmierung_merge(db): + acc = _acc(db) + for m in (11, 12): + _tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben") + for m in (1, 2, 3): + _tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung") + for m in (4, 5, 6, 7): + _tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung") + db.commit() + out = suggest_recurring(db, today=TODAY) + assert len(out) == 1 + assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94") + +def test_suggest_bestandsabgleich_trotz_preisdrift(db): + acc = _acc(db) + db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"), + rhythm="monthly", due_day=1)) + for m in (4, 5, 6, 7): + _tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG") + db.commit() + assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an + +def test_suggest_zwei_vertraege_getrennt(db): + acc = _acc(db) + for m in (4, 5, 6, 7): + _tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV") + _tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV") + db.commit() + out = suggest_recurring(db, today=TODAY) + assert len(out) == 2 + assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")} +``` + +(`_acc`-Helper analog bestehender Tests; `RecurringItem`/`Transaction`-Importe existieren.) Die drei Alt-Tests (`three_consecutive_months_with_year_wrap`, `two_months_no_suggestion`, `excludes_existing_recurring_item`) an die neue Signatur/Semantik anpassen: feste `today`-Injektion; Daten ggf. ins Fenster schieben; der Exclusion-Test bleibt inhaltlich gültig (Name-Match). + +Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → neue Tests FAIL + +- [ ] **Step 2: Rewrite `app/services/suggestions.py`** gemäß Spec-Abschnitt „Algorithmus" (8 Schritte, Konstanten `WINDOW_DAYS=460`, Rhythmus-Tabelle monthly 25-36/≥3, quarterly 80-105/≥3, yearly 330-400/≥2, `STEP_DAYS={"monthly":30,"quarterly":91,"yearly":365}`, `ACTIVITY_FACTOR` 7/4 als `Fraction` oder Tage-Vergleich ganzzahlig, Cluster 35 %, Merge 25 %, Bestand 10 % — alle Toleranzvergleiche als `Decimal`). Struktur: `_norm`, `_rel_diff`, `_amount_clusters` (greedy gegen letztes Mitglied, gleiches Vorzeichen), `_classify` (Median der Abstände), Merge-Pass je Konto über alle Serien, `_covered_by_existing`, Hauptfunktion `suggest_recurring(session, today=None)`. Deutsche Docstrings/Kommentare zur Begründung der Toleranzen. + +- [ ] **Step 3: `SuggestionOut` erweitern** — `routers/planning.py`: + +```python +class SuggestionOut(BaseModel): + name: str + amount: Decimal + rhythm: str + due_day: int + start_date: date | None = None + category_id: int | None = None + hinweis: str = "" +``` + +- [ ] **Step 4: Tests + Suite grün** — `.venv/bin/python -m pytest -q` → PASS (Alt-Test-Anpassungen im Report begründen). + +- [ ] **Step 5: Fable-Testagent-Abnahme** (Faktencheck: Toleranz-Arithmetik Decimal-rein; Aktiv-Check-Grenzen; Merge-Bedingungen; keine `date.today()`-Streuung; Alt-Test-Anpassungen berechtigt). Erst nach VERIFIED weiter. + +- [ ] **Step 6: Commit** — `git add finance/app/services/suggestions.py finance/app/routers/planning.py finance/tests/test_planning_api.py && git commit -m "feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)"` + +--- + +### Task 2: GUI — Rhythmus/Start/Hinweis in der Vorschlags-Tabelle + +**Files:** +- Modify: `app/templates/planning.html` (Fieldset „Vorschläge aus Buchungen") +- Modify: `tests/test_gui.py` + +**Interfaces:** +- Consumes: `SuggestionOut`-Felder aus Task 1; Filter `|eur`/`|de_label`. + +- [ ] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` (synthetische Serie seeden, `/planung` laden): + +```python +def test_vorschlaege_zeigen_rhythmus_und_start(client, db): + client.post("/login", data={"username": "admin", "password": "geheim"}) + acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro") + db.add(acc) + db.flush() + for d in (date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)): + db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"), + purpose="p", counterparty="Rundfunk Synth", status="confirmed", + dedup_hash=f"sug-{d}")) + db.commit() + r = client.get("/planung").text + assert "vierteljährlich" in r # de_label des Rhythmus + assert "15.06.2026" in r # Start-Spalte TT.MM.JJJJ + assert 'name="start_date"' in r # hidden input der Uebernahme +``` + +WICHTIG: Der Test hängt von `date.today()` der App ab (Aktiv-Check!) — Serie so legen, dass sie um den echten Testlauf-Zeitpunkt herum aktiv ist, oder (besser) `suggest_recurring` in `planung_page` unverändert lassen und den Test mit relativen Daten um `date.today()` konstruieren (letzte Buchung ≤ 45 Tage vor heute, Quartalsschritte rückwärts). Die Variante mit relativen Daten umsetzen; die obigen Fixdaten sind als Muster zu verstehen und auf `date.today()`-relative Werte umzustellen (inkl. erwartetem Start-String via `.strftime('%d.%m.%Y')`). + +- [ ] **Step 2: Template** — Vorschlags-Tabelle: Kopf `Name | Betrag | Rhythmus | Fälligkeitstag | Start | (Aktion)`; Zellen `{{ s.rhythm|de_label }}`, `{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}`; Betrag-Zelle ergänzt `{% if s.hinweis %}{{ s.hinweis }}{% endif %}`; Übernahme-Formular: hidden inputs unverändert plus `` (json-form macht leer → null). Hinweistext unter dem Fieldset: „Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung." + +- [ ] **Step 3: Suite grün**; **Step 4: Fable-Abnahme** (Live-Approximation: Rendering + Übernahme-Roundtrip eines Quartals-Vorschlags inkl. start_date); **Step 5: Commit** `feat: Vorschlaege mit Rhythmus, Start und Hinweis`. + +--- + +### Task 3: Release v0.9.0 + Live-Gate gegen echte Daten + +- [ ] **Step 1:** Suite final; `VERSION` → 0.9.0; Commit; `./create_pod_finance.sh`; `/api/version` == 0.9.0. +- [ ] **Step 2: Fable-Release-Gate (LIVE, lesend):** `GET /api/recurring/suggestions` gegen die echte DB. Prüfen: (a) KEINER der bestehenden ~41 Fixposten wird erneut vorgeschlagen (Bestandsabgleich wirkt, auch bei gedrifteten Beträgen); (b) keine bekannten toten Serien (gelöschte PayPal-−4,99-Serie, ausgelaufene WWK-−35-Police) im Ergebnis; (c) verbleibende Vorschläge einzeln gegen die Buchungen plausibilisieren (echte aktive Serie? korrekte Werte?). Ergebnisliste NUR im Chat/Bericht, nie committen. Bei Fehlklassifikationen: Befund zurück an Task 1 (Toleranzen), Fix + Re-Gate. +- [ ] **Step 3:** Ledger (generisch) + Plan-Häkchen + Push; Kandidatenliste dem Nutzer berichten. + +--- + +## Self-Review + +- Spec-Abdeckung: Algorithmus/Schema → Task 1; GUI → Task 2; Release/Live-Gate → Task 3. Testfälle der Spec vollständig in Task 1 Step 1 kodiert. +- Platzhalter: Task 1 Step 2 verweist bewusst auf den bindenden Spec-Abschnitt (8 nummerierte Schritte + Konstanten) statt den vollen Code zu duplizieren; alle Schnittstellen/Konstanten sind exakt benannt. +- Typ-Konsistenz: `suggest_recurring(session, today)`-Signatur = Testaufrufe; `SuggestionOut`-Felder = Template-Zugriffe (`s.rhythm`, `s.start_date`, `s.hinweis`). diff --git a/docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md b/docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md new file mode 100644 index 0000000..d2ec70b --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md @@ -0,0 +1,81 @@ +# Design — Ausbaustufe 9: Vorschlags-Algorithmus v2 (v0.9.0) + +> Status: vom Nutzer freigegeben (Chat 2026-07-20, Direktdurchlauf). Anlass: +> Der bisherige `suggest_recurring` gruppiert nach exaktem Betrag (jede +> Preiserhöhung zerreißt die Serie), erkennt nur monatliche Serien, prüft +> keine Aktivität (schlägt tote Serien vor) und nutzt Median-Werte. Ein +> manueller Vollabgleich (A8-Datenpflege, siehe Ledger) fand 16 fehlende +> Posten — der Algorithmus soll solche Serien künftig selbst finden. + +## Anforderungen (Nutzer) + +- Betrag = **letzte** Buchung, nicht Median (Preissteigerungen relevant). +- Erkennung **monatlich, vierteljährlich, jährlich**. +- Empfänger-Gruppierung robust (Schreibweisen, Preisänderungen, + Umfirmierungen); mehrere Verträge desselben Anbieters getrennt. +- Keine „Leichen": abgerissene Serien werden nicht vorgeschlagen. +- Kein Wiedervorschlagen bereits gepflegter Posten (auch bei zwischenzeitlich + geändertem Betrag). + +## Algorithmus (`app/services/suggestions.py`, vollständiger Rewrite) + +Parameter als Modul-Konstanten (Toleranzen zentral änderbar): +`WINDOW_DAYS=460` (~15 Monate), Rhythmen mit Intervallgrenzen und +Mindestbelegen: monthly 25–36 Tage/≥3, quarterly 80–105/≥3, yearly +330–400/≥2; `ACTIVITY_FACTOR=1.75`; Betrags-Cluster-Toleranz 35 %; +Merge-Toleranz 25 %; Bestandsabgleich-Toleranz 10 %. + +1. **Datenbasis:** bestätigte Buchungen der letzten `WINDOW_DAYS`, je Konto. +2. **Gruppierung:** Schlüssel = (account_id, normalisierter Empfänger) + (`casefold`, Whitespace kollabiert). +3. **Betrags-Cluster** innerhalb der Gruppe (chronologisch, greedy gegen das + jeweils letzte Cluster-Mitglied, gleiches Vorzeichen, relative Differenz + ≤ 35 %) — trennt parallele Verträge, hält Preisdrift zusammen. +4. **Rhythmus je Cluster:** Median der Buchungsabstände gegen die + Intervallgrenzen; Mindestbelege je Rhythmus. +5. **Aktiv-Check:** letzte Buchung ≤ `ACTIVITY_FACTOR` × Rhythmus-Schrittweite + (30/91/365 Tage) her, sonst kein Vorschlag. +6. **Umfirmierungs-Merge** (über Gruppengrenzen, je Konto): Serie A endet, + Serie B beginnt danach (Lücke 0,4–1,6 Schrittweiten), gleicher Rhythmus, + Fälligkeitstag ±3, Betrag ±25 % → eine Serie; Name/Betrag der neueren. +7. **Vorschlagswerte:** Name = Empfänger-Schreibweise der neuesten Buchung; + Betrag = neueste Buchung; Fälligkeitstag = Tag der neuesten Buchung; + `start_date` = Datum der neuesten Buchung bei quarterly/yearly (Phase!), + sonst None; Kategorie = häufigste in der Serie; `hinweis` = Text + „Betrag zuletzt gestiegen (vorher X)" wenn die vorletzte Buchung + betragskleiner war, sonst leer. +8. **Bestandsabgleich:** Vorschlag entfällt, wenn ein `RecurringItem` + existiert mit (a) Namens-Substring-Match (normalisiert, in beide + Richtungen) ODER (b) gleichem Rhythmus + Fälligkeitstag ±2 + Betrag + ±10 %. + +## API/GUI + +- `SuggestionOut` (routers/planning.py): + `start_date: date | None`, + + `hinweis: str = ""`. +- Vorschlags-Tabelle (planning.html): Spalten Rhythmus (`|de_label`) und + Start (TT.MM.JJJJ bzw. „–"); `hinweis` als `muted`-Text hinter dem Betrag; + „Vorschlag übernehmen" überträgt `start_date` mit (hidden input). +- Hinweistext unter der Tabelle aktualisiert: monatliche/vierteljährliche/ + jährliche Serien, Betrag = letzte Buchung. + +## Tests (synthetische Daten, keine Fixtures) + +Preiserhöhungs-Serie → letzter Betrag + hinweis; Quartals-/Jahres-Serie mit +korrektem start_date; tote Serie (letzte Buchung zu alt) → kein Vorschlag; +Umbenennungs-Merge → ein Vorschlag mit neuem Namen; Bestandsabgleich: +existierender Posten mit altem Betrag verhindert Wiedervorschlag; zwei +parallele Verträge eines Anbieters → zwei getrennte Vorschläge; bestehende +drei Suggestion-Tests an die neue Semantik anpassen. + +## Release + +`VERSION` → 0.9.0, Redeploy, **Live-Gate gegen echte Daten**: kein einziger +der bestehenden Fixposten darf erneut vorgeschlagen werden; keine als +beendet bekannten Serien (z.B. gelöschte PayPal-Leiche, ausgelaufene +WWK-Police) im Ergebnis; verbleibende Vorschläge werden dem Nutzer als +Kandidatenliste berichtet (nur Chat, kein Commit). Fable-Gate je Task. + +**Außerhalb des Scopes:** halbjährliche Rhythmen (nicht im Datenmodell), +automatische Übernahme ohne Nutzer-Klick, Einnahmen-Prognose des +Geschäftskontos.