Artikel-Identität / Stammdaten — Backend-Vertrag (#50)
Stand 09.06.2026, StratoClaude. Backend zur nativen „Artikel/Stammdaten"-Welt +
Dedup-Entscheidungshilfe (Synthese 49 §6). Alle Endpoints hinter Auth
(Session oder X-Internal-Token). Familien-Kaufdaten → Vertrag hier, keine
Daten.
Der Steckbrief selbst läuft über das bestehende GET /api/foods/{id}
(Artikel + Kaufstatistik + Vorrat). Die Kür:
§6.1 — OFF-Volldaten (deterministisch, gratis)
GET /api/foods/off-detail?ean= → {found, name, brands, kcal, protein_g,
carbs_g, fat_g, nutriscore, categories, quantity (Füllmenge, z. B. „400 g"),
labels, image_url}. Ungültige EAN → 400, kein OFF-Treffer → {found:false}.
(off-preview bleibt der schnelle Scan-Pfad, ADR-24.)
§6.2 — Server-Vergleich (deterministisch)
GET /api/foods/compare?ids=A,B → {a:{…}, b:{…}, verdict, diff, token_overlap,
shared_id}. Briefs enthalten name/raw_name/group/Makros/nutriscore/portion_g/
tax_type/eans/art_ids/bon_names. diff = Liste abweichender Feldnamen.
Heuristik-Verdikt (bewusst konservativ): geteilte EAN/art_id ODER
(Token-Overlap≥0.5 + gleiche Warengruppe + nahe Makros) → same; disjunkte
Tokens + andere Gruppe → different; sonst unclear. ids=A,B (zwei) nötig → 400,
fehlend → 404.
§6.3 — KI-Vergleich (on-demand, kostet, NIE Auto-Merge)
POST /api/foods/compare-ai {id_a,id_b} → {queued,job_id} (food_compare-Job,
gemini-3.1-flash-lite, Haiku-Fallback). Paar normiert (Reihenfolge egal); gleiche
ID → 400, fehlend → 404. GET /api/foods/compare-ai?ids=A,B → {found, verdict:
same|different|unclear, reason, status}. Client: Sonar-Pill, GET pollen bis
status ∉ {none, pending}.
Mehrwert ggü. §6.2: „Coca-Cola Zero Sugar" vs „Coca-Cola Zero Vanille" — Heuristik sagt
same(gleiche Makros/Tokens), KI sagt korrektdifferent(Vanille = andere Variante). Genau die Dedup-Entscheidung.
§6.4 — persistenter Prüf-Status (food_pairs)
POST /api/foods/pairs/review {id_a,id_b,verdict} = Dennis' manuelle
Entscheidung → status=reviewed. Reviewed gewinnt und bleibt: der Worker
überschreibt es nie, compare-ai rechnet erledigte Paare nicht neu (kein
ewiges Wiederkehren). status-Lauf: none → pending → done → reviewed.
verdict ∉ {same,different,unclear} → 400.
§6.5 — Durchwühlbare Liste + Dublette an der Quelle (Synthese 51)
Q-B — Herkunft-Achse (war schon da): GET /api/articles liefert pro Zeile
bereits source und store (via row2food). Kein Backend-Change. Wert-
Semantik für die Client-Filterachse Lidl/Kaufland/manuell/HelloFresh:
source |
store |
Herkunft (Client-Ableitung) |
|---|---|---|
mapping |
Lidl |
Lidl (Stammartikel) |
receipt |
Lidl / Kaufland … |
Lidl bzw. Kaufland (Bon) |
manual |
meist None |
manuell (EAN-Scan) |
custom |
(frei) | manuell (Tagebuch) |
hellofresh |
None |
HelloFresh |
Empfehlung MacClaude umgesetzt: Liste lädt ?size=2000 (alles), facettiert
clientseitig. has_ean/no_ean sind ebenfalls da.
Q-D — EAN-Dedup an der Quelle (POST /api/foods/by-ean): War schon
idempotent (bekannte EAN → bestehender Artikel statt Dublette). Jetzt zusätzlich
konservative Anreicherung des Treffers, ohne kuratierte Daten zu überschreiben:
- Name nur ersetzen, wenn er der Anlage-Platzhalter EAN <ziffern> ist
(sonst nie) — neuer Name aus name-Param oder OFF.
- Makros/Nutri-Score/Portion nur füllen, wenn kcal leer und der Artikel
nicht schon erfolglos OFF-geprüft (flagged) ist → kein erneuter OFF-Call
bei jedem Re-Scan eines OFF-losen Produkts.
- Antwort: {id, existing:true, enriched:bool}; Anlage-Pfad unverändert
{id, existing:false, off_found:bool}. Idempotent: zweiter identischer
Scan → enriched:false. (Die umgekehrte Spannung — Bon-food bekommt die EAN
später via OFF-Matching, während ein manueller Scan-Artikel sie schon trägt —
bleibt über die dedup-Gruppe heilbar, nicht über diesen Pfad.)
§6.6 — Non-Food an der Quelle + Gebinde (Bus #58/#60)
is_food beim Bon-Import (receipt_import.resolve_is_food): zweistufig —
(1) Namens-Netz guess_is_food ist der harte Non-Food-Boden (ASCII-
normalisierte Keywords für Drogerie/Haushalt/Papier/Party; bewusst KEIN
tax_type='B', das träfe auch Getränke); (2) greift es nicht, zählt der
KI-Hinweis aus der Extraktion. Der aufgebohrte PDF-Prompt (ai_jobs._BON_RULES
Regel 7) klassifiziert is_food pro Position und fängt den Long-Tail, den
Keywords verfehlen (Tierfutter „Purina ONE Huhn", „Luftschlangen") — LLM-as-
Judge-verifiziert (app/benchmark_bon_prompt.py: auf einem 57-Positionen-Bon
null Kern-Regression, is_food korrekt). Match in _match_food läuft über ALLE
foods (auch is_food=0) → keine Non-Food-Dubletten. Manuelle Korrektur:
PATCH /api/foods/{id} {is_food}.
pack_units (foods.pack_units INTEGER NULL): Stück pro Gebinde (z. B. 24er-
Pack). In GET /api/foods/{id} + allen /api/articles-Zeilen, via FoodPatch
(1..1000) editierbar. Reine Stammdaten — der Vorrat bleibt in der gekauften
Einheit (1 Pack = qty 1); pack_units ist Ableitungsfaktor für Anzeige/Verbrauch,
kein Auto-Explodieren beim Import (Anti-Drift; sonst Doppelzählung Pack-SKU vs.
Einzel-SKU).
§6.7 — Kuratierbare Verderblichkeit (SILV-262, für #34/SILV-92)
Ob ein Artikel als Frischware gilt, entscheidet sonst die Server-Heuristik
(staples.fresh_status aus Kategorie-Haltbarkeit SHELF_DAYS). Dennis kann diese
Frische-Klasse kuratieren — sie steuert, ob der Artikel in der Vorrat-Liste in
die „Bald weg"-Sektion bricht. #38 bleibt unberührt: der Override ändert nur die
Frische-Klasse→Sektions-Zuordnung, nicht Band/Radar.
- Speicher:
foods.fresh_class_override TEXT NULL, Werte{fast,short,long,none};NULL= Automatik (Kategorie-Heuristik). Migration idempotent indb.py. - Timing (
staples.fresh_status): ein gesetzter Override gewinnt vor der Tax-A/Kategorie-Heuristik und zieht seine Tage ausFRESH_CLASS_DAYS(best,mx) — Richtwertefast≈5 T · short≈10 T · long≈15 T(serverseitig, frei kalibrierbar; der Client zeigt KEINE Tage).none= bewusst nicht verderblich → nie eine Ampel. Ohne Override identisches Verhalten wie bisher (kein Default-Delta). - Zwei additive Lese-Felder (in
GET /api/foods/{id},GET /api/articlesund jeder Vorrat-Zeile, viarow2food):fresh_class_override(string|null, Picker-Vorbelegung) undfresh_class_effective(∈{fast,short,long,none}=override ?? auto-abgeleitet, für „Automatisch — derzeit: X"). Non-Food ⇒effective='none'. PATCH /api/foods/{id}(FoodPatch.fresh_class_override): akzeptiert{fast,short,long,none,"auto"}. Sentinel"auto"löscht den Override (→NULL, zurück auf Automatik) — der iOS-Partial-PATCH-Client (encodeIfPresent) kann kein JSON-nullsenden. Weggelassen = unverändert. Ungültiger Wert =422.POST /api/foods/bulk{ids:[int], fresh_class_override?, is_food?, is_staple_manual?}: ein Endpoint für die Kuratier-Achsen — Frische, Non-Food und (SILV-264) den manuellen „immer im Haus"-Pinis_staple_manual(=/api/foods/{id}/pin, nur bulk; Radar/Kadenz/Auto-Staple unberührt). Idempotent, gibt die betroffenen Artikel zur Reconcile zurück ({items:[…row2food…]}). Steckbrief ruft mit 1 id, Liste mit n.is_staple_manualliegt (wiepack_units) bereits in jederrow2food→ auch inGET /api/articlespro Row (Karten-Icon, SILV-264).- P3 (Non-Food ⇒ keine Klasse): setzt PATCH/bulk
is_food=false, wirdfresh_class_overrideimplizit aufNULLgecleart (im Compute ohnehin ignoriert).
§6.8 — Artikel-gebundene HF-Zuordnung (SILV-273/285, Steckbrief-Einstieg)
Stand 16.06.2026, StratoClaude. Einstieg in der Stammdaten-Welt über den
generischen HF-Platzhalter-Artikel (source='hellofresh', kcal NULL, keine
EAN = _is_generic_hf). ⚠️ Modellwechsel (SILV-285, Dennis-Lock 16.06.): das
ursprüngliche destruktive „Falten" (hf-map-apply: food_id-Reassign + hf_aliases
+ Platzhalter löschen) wurde zurückgerollt. Es ist durch eine nicht-
destruktive Verknüpfung ersetzt — die dish_ingredients bleiben am Platzhalter,
sodass Zutatenliste und Zubereitungsschritte immer den HF-Namen zeigen,
nie den Lidl-Namen. kein OFF-Pfad (Dennis: erst nur Stammdaten).
POST /api/foods/{id}/hf-map-suggest(id= der HF-Platzhalter-Artikel, kein Body — unverändert gültig). Leitethf_nameaus dendish_ingredients-Zeilen mitfood_id=idab (erste nicht-leere,ORDER BY id; Fallback = Artikelname). Gemini (gemini-3.1-flash-litevia_or_text) gegen den Nicht-HF-Katalog (is_food=1 AND source!='hellofresh', ohne den Platzhalter selbst). Blockierend ~2 s. →{hf_name, current, suggestions:[{id,name,group}]}(bis 3, bester zuerst; leer = nichts passt).idunbekannt →404; ohneOPENROUTER_API_KEY→503(Testcontainer-Vertrag).current= Anzeigename des Platzhalters. Client: Sonar-Pill/Spinner Pflicht (ADR-13), Sparkles-Icon (nie Roboter).POST /api/foods/{id}/hf-linkBody{food_id}(int = setzen/ändern,null= lösen). Ersetzthf-map-apply(destruktiv → entfernt). Setzt nicht-destruktivfoods.mapped_food_idam Platzhalter (id) auf den echten Ziel-Artikel. Kein Löschen, keinUPDATE dish_ingredients SET food_id, keinhf_aliases-Reassign. →{linked, mapped_food_id}.food_id==id→400, Platzhalter/Ziel unbekannt →404. Idempotent (erneut setzen/lösen = derselbe Endzustand).- Resolve-through (
_effective_food_id, eine Stufe): die Verknüpfung wird lesend aufgelöst, wo der echte Artikel die Wahrheit liefert: - kcal/Makro (
dish_macros): HF-Zutat summiert die Nährwerte des Ziel- Artikels statt des datenleeren Platzhalters. - Preisvergleich (
GET /api/dishes/{id}/price-compare): Preis, Packungs- gewicht und Vorrats-Ampel kommen vom Ziel-Artikel; Anzeige-Name bleibt der HF-/Zutat-Name. - Klon (
POST /api/dishes/{id}/clone): verknüpfte HF-Zutaten werden im Klon gegen den echten Artikel getauscht (dort ist der Lidl-Name ok); das HF- Original bleibt unverändert. Unverknüpfte Zutaten unverändert. - Lesefelder:
mapped_food_id(int|null) liegt in jederrow2food(GET /api/foods/{id},GET /api/articles, Zutatenzeilen).mapped_name(Anzeigename des Ziel-Artikels, null=keine Verknüpfung) zusätzlich in den dish-Zutatenzeilen (via JOIN, kein N+1 → „verknüpft mit X" pro Zutat) und inGET /api/foods/{id}(Einzel-Lookup). In Listen (/api/articles) bewusst nicht (N+1) — dortmapped_food_id+ bei BedarfGET /api/foods/{id}. - Geteiltes Platzhalter-Modell (Dennis-Lock 16.06., „Rinderhack ist
Rinderhack"): Pro normalisiertem
hf_name(hellofresh._norm) gibt es GENAU EINEN HF-Platzhalter (global, nicht pro Gericht/Zeile); alle dish_ingredients dieses Namens zeigen darauf, die Verknüpfung steht einmal am Platzhalter.hf-linkwirkt damit für alle Gerichte dieses Namens. - HF-Import (
hellofresh.match_food) umgestellt: liefert IMMER den geteilten Platzhalter. Existiert er → mitsamtmapped_food_idwiederverwenden (neues HF-Gericht mit bekanntemhf_nameist sofort richtig verknüpft, ohne Neu-Zuordnung). Sonst neu anlegen + einmal auto-verknüpfen (_match_realauf den echten Katalogsource!='hellofresh'; kein Treffer =NULL). Kein direktes food_id-Binden an echte Artikel mehr → der Import faltet nicht. - Migration erledigt (
backend/migrations/silv285_unfold.py, idempotent): alle 140 gefalteten Alt-Zuordnungen (31 hf_names, inkl. Basics; 0 Konflikte) auf das geteilte Modell zurückgerollt — 28 Platzhalter neu, 3 wiederverwendet, 140 dish_ingredients umgehängt, 17hf_aliasesgestrippt. Endzustand verifiziert: 0 gefaltete Zeilen, 0 Rest-hf_aliases, 31 verknüpfte Platzhalter. Dry-Run (Default) /--apply. - Seed-Vertrag (
testmode.pyv1, additiv):food 13= generischer HF-Platzhalter „Frühlingszwiebeln" +dish_ingredient 9(Gericht 2,hf_name='Frühlingszwiebeln', zeigt auf 13). Macht Verknüpfen + alle drei Resolve-Pfade +mapped_namegegen echte Daten testbar (hf-link(13→3)).