Zuletzt aktualisiert:

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 korrekt different (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.

§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).