#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Nährwerte der Tandoor-Zutaten prüfen und vervollständigen. Drei Schritte, jeder einzeln aufrufbar: nutrition.py pruefen Liest alle Foods und Eigenschaften und schreibt einen Bericht: Wo fehlt was? Welche Lücken tun weh (= stecken in vielen Rezepten)? Verändert nichts. nutrition.py vorschlagen --foods 1,2,3 Lässt fehlende Werte von OpenAI je 100 g schätzen und schreibt sie in eine Vorschlagsdatei. Verändert in Tandoor nichts. nutrition.py uebernehmen --vorschlag Schreibt die Werte aus der Vorschlagsdatei per PATCH nach Tandoor. Ohne --apply nur eine Trockenübung. nutrition.py selftest Prüft lokal, dass numerische Nullwerte beim Lesen und Schreiben erhalten bleiben. Die Werte sind Schätzungen eines Sprachmodells, keine Laboranalysen. Sie sind für Hausgebrauch und Größenordnung gedacht — wer Nährwerte medizinisch braucht, sollte sie gegen eine echte Datenbank prüfen. Standalone: python3 plugins/tandoor-nutrition/tool/nutrition.py pruefen """ from __future__ import annotations import argparse import csv import json import math import os import sys from datetime import datetime, timezone from pathlib import Path from typing import Any _PARENTS = Path(__file__).resolve().parents SUITE_ROOT = _PARENTS[3] if len(_PARENTS) > 3 else Path.cwd() if str(SUITE_ROOT) not in sys.path: sys.path.insert(0, str(SUITE_ROOT)) from core.tandoor import ( # noqa: E402 TandoorClient, TandoorError, ) from core import backups # noqa: E402 BATCH = 20 _MISSING = object() def _number_or_none(value: Any) -> float | None: """ Liefert eine endliche, nichtnegative Zahl oder ``None``. Wichtig: Numerische Null ist ein gültiger Nährwert und darf niemals über eine Wahrheitsprüfung wie ``if value`` verworfen werden. Auch numerische Strings aus APIs bzw. JSON-Dateien werden akzeptiert. """ if value is None or isinstance(value, bool): return None if isinstance(value, (int, float)): number = float(value) elif isinstance(value, str): raw = value.strip() if not raw: return None # Dezimalkomma tolerieren, sofern nicht bereits ein Punkt vorkommt. if "," in raw and "." not in raw: raw = raw.replace(",", ".") try: number = float(raw) except ValueError: return None else: return None if not math.isfinite(number) or number < 0: return None return number def _property_type_id(prop: dict[str, Any]) -> int | None: raw_type = prop.get("property_type") raw_id = raw_type.get("id") if isinstance(raw_type, dict) else raw_type try: return int(raw_id) except (TypeError, ValueError): return None def food_property_map(food: dict[str, Any]) -> dict[int, float]: """Liest Tandoor-Eigenschaften nullwertsicher als ``Typ-ID → Wert``.""" values: dict[int, float] = {} for prop in food.get("properties") or []: if not isinstance(prop, dict): continue type_id = _property_type_id(prop) amount = _number_or_none(prop.get("property_amount")) if type_id is not None and amount is not None: # 0.0 wird ausdrücklich übernommen und nicht als fehlend behandelt. values[type_id] = amount return values def merged_properties_payload( food: dict[str, Any], updates: dict[int, float], ) -> list[dict[str, Any]]: """ Baut die vollständige Tandoor-Property-Liste ohne Truthy/Falsy-Fallen. Vorhandene IDs und Property-Type-Namen bleiben erhalten. Leere bzw. ungültige Alt-Einträge werden nicht erneut übertragen. Neue Werte von 0 werden als ``0.0`` in den Payload aufgenommen. """ normalised: dict[int, float] = {} for raw_type_id, raw_amount in updates.items(): try: type_id = int(raw_type_id) except (TypeError, ValueError) as exc: raise ValueError(f"Ungültige Eigenschafts-ID: {raw_type_id!r}") from exc amount = _number_or_none(raw_amount) if amount is None: raise ValueError( f"Ungültiger Nährwert für Eigenschaft {type_id}: {raw_amount!r}" ) normalised[type_id] = amount result: list[dict[str, Any]] = [] seen: set[int] = set() for prop in food.get("properties") or []: if not isinstance(prop, dict): continue type_id = _property_type_id(prop) if type_id is None or type_id in seen: continue if type_id in normalised: amount = normalised[type_id] else: amount = _number_or_none(prop.get("property_amount")) if amount is None: continue raw_type = prop.get("property_type") type_payload: dict[str, Any] = {"id": type_id} if isinstance(raw_type, dict): name = str(raw_type.get("name") or "").strip() if name: type_payload["name"] = name entry: dict[str, Any] = { "property_amount": amount, "property_type": type_payload, } try: property_id = int(prop.get("id")) except (TypeError, ValueError): property_id = None if property_id is not None: entry["id"] = property_id result.append(entry) seen.add(type_id) for type_id, amount in normalised.items(): if type_id in seen: continue result.append({ "property_amount": amount, "property_type": {"id": type_id}, }) return result def _normalise_proposal_values( values: Any, ) -> tuple[dict[int, float], list[str]]: """Validiert Vorschlagswerte. Null bleibt erhalten, nur echte Lücken fallen weg.""" valid: dict[int, float] = {} invalid: list[str] = [] if not isinstance(values, dict): return valid, ["values ist kein JSON-Objekt"] for raw_key, raw_value in values.items(): try: type_id = int(raw_key) except (TypeError, ValueError): invalid.append(f"ungültige ID {raw_key!r}") continue amount = _number_or_none(raw_value) if amount is None: invalid.append(f"{type_id}={raw_value!r}") continue valid[type_id] = amount return valid, invalid def _property_type_names(types: list[dict[str, Any]]) -> dict[int, str]: """Tandoor-Eigenschaftstypen als ID→Name abbilden.""" names: dict[int, str] = {} for item in types: try: type_id = int(item.get("id")) except (TypeError, ValueError): continue name = str(item.get("name") or "").strip() if name: names[type_id] = name return names def _complete_property_type_names( properties: list[dict[str, Any]], names: dict[int, str], ) -> list[dict[str, Any]]: """ Ergänzt in Tandoor-Property-Payloads den Namen des Property-Typs. Tandoors verschachtelter PropertyTypeSerializer verlangt beim Schreiben neben der ID auch ``name``. GET-Antworten enthalten ihn bereits, neu von ``merged_properties_payload`` erzeugte Einträge können jedoch nur die ID enthalten und werden dann mit HTTP 400 abgelehnt. """ completed: list[dict[str, Any]] = [] for prop in properties: entry = dict(prop) raw_type = entry.get("property_type") if isinstance(raw_type, dict): property_type = dict(raw_type) try: type_id = int(property_type.get("id")) except (TypeError, ValueError): type_id = None if not str(property_type.get("name") or "").strip() and type_id is not None: if name := names.get(type_id): property_type["name"] = name entry["property_type"] = property_type completed.append(entry) return completed def out(text: str = "") -> None: print(text, flush=True) def data_dir() -> Path: configured = os.environ.get("DATA_DIR", "").strip() base = Path(configured) if configured else SUITE_ROOT / "data" / "tandoor-nutrition" base.mkdir(parents=True, exist_ok=True) return base def stamp() -> str: return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") def write_json(path: Path, payload: Any) -> Path: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) temporary.replace(path) return path # ---------------------------------------------------------------- Bestand def collect(client: TandoorClient) -> dict[str, Any]: out("Eigenschaften werden gelesen …") types = sorted( client.list_objects("property-type"), key=lambda t: (t.get("order") or 0, (t.get("name") or "").casefold()), ) out(f" {len(types)} Eigenschaften: " + ", ".join(t.get("name", "?") for t in types)) out("Zutaten werden gelesen …") foods = client.list_objects("food") out(f" {len(foods)} Zutaten") return {"types": types, "foods": foods} def analyse(types: list[dict[str, Any]], foods: list[dict[str, Any]]) -> dict[str, Any]: type_ids = [t["id"] for t in types] rows: list[dict[str, Any]] = [] for food in foods: values = food_property_map(food) missing = [tid for tid in type_ids if values.get(tid) is None] rows.append({ "id": food.get("id"), "name": food.get("name") or "", "plural_name": food.get("plural_name") or "", "numrecipe": food.get("numrecipe") or 0, "base_amount": food.get("properties_food_amount"), "base_unit": (food.get("properties_food_unit") or {}).get("name") if isinstance(food.get("properties_food_unit"), dict) else None, "values": {str(tid): values.get(tid) for tid in type_ids}, "missing": [str(tid) for tid in missing], "complete": not missing, }) rows.sort(key=lambda r: (r["complete"], -r["numrecipe"], r["name"].casefold())) per_type = [] for t in types: have = sum(1 for r in rows if r["values"].get(str(t["id"])) is not None) per_type.append({ "id": t["id"], "name": t.get("name") or "", "unit": t.get("unit") or "", "have": have, "missing": len(rows) - have, "percent": round(have / len(rows) * 100, 1) if rows else 0.0, }) complete = sum(1 for r in rows if r["complete"]) incomplete_used = [r for r in rows if not r["complete"] and r["numrecipe"] > 0] no_base = [r for r in rows if not r["complete"] and not r["base_amount"]] return { "created_at": datetime.now(timezone.utc).isoformat(), "types": [{"id": t["id"], "name": t.get("name"), "unit": t.get("unit")} for t in types], "summary": { "foods": len(rows), "complete": complete, "incomplete": len(rows) - complete, "incomplete_used_in_recipes": len(incomplete_used), "unused": sum(1 for r in rows if r["numrecipe"] == 0), "percent_complete": round(complete / len(rows) * 100, 1) if rows else 0.0, "without_base_amount": len(no_base), }, "per_type": per_type, "foods": rows, } def write_report(report: dict[str, Any], target: Path) -> None: write_json(target / "bericht.json", report) names = {str(t["id"]): t["name"] for t in report["types"]} with (target / "luecken.csv").open("w", encoding="utf-8", newline="") as handle: writer = csv.writer(handle, delimiter=";") writer.writerow(["id", "Zutat", "Rezepte", "Basis"] + [f"{t['name']} ({t['unit']})" for t in report["types"]] + ["fehlt"]) for row in report["foods"]: base = (f"{row['base_amount']:g} {row['base_unit']}" if row["base_amount"] and row["base_unit"] else "") writer.writerow( [row["id"], row["name"], row["numrecipe"], base] + [("" if row["values"][str(t["id"])] is None else row["values"][str(t["id"])]) for t in report["types"]] + [", ".join(names[m] for m in row["missing"])] ) def print_report(report: dict[str, Any]) -> None: s = report["summary"] out() out("─" * 62) out(f" Zutaten gesamt {s['foods']}") out(f" davon vollständig {s['complete']} ({s['percent_complete']} %)") out(f" davon unvollständig {s['incomplete']}") out(f" … und in Rezepten benutzt {s['incomplete_used_in_recipes']} ← die tun weh") out(f" ohne Bezugsmenge {s['without_base_amount']}") out("─" * 62) out() out(" Je Eigenschaft:") for t in report["per_type"]: bar = "█" * round(t["percent"] / 5) + "·" * (20 - round(t["percent"] / 5)) out(f" {t['name']:<16} {bar} {t['percent']:5.1f} % fehlt bei {t['missing']}") out() worst = [r for r in report["foods"] if not r["complete"]][:12] if worst: out(" Größte Lücken zuerst (nach Rezeptzahl):") for row in worst: fehlt = ", ".join( next(t["name"] for t in report["types"] if str(t["id"]) == m) for m in row["missing"] ) out(f" {row['numrecipe']:>3} Rezepte · {row['name']:<28} fehlt: {fehlt}") # ------------------------------------------------------------- Vorschläge def _parse_json(text: str) -> dict[str, Any]: """Antworttext zu einem Dict machen, auch wenn das Modell schludert.""" text = (text or "").strip() if text.startswith("```"): text = text.strip("`") if text[:4].lower() == "json": text = text[4:] text = text.strip() data = json.loads(text) # Manche Modelle verpacken die Antwort, z. B. {"lebensmittel": {…}}. # Nur auspacken, wenn der einzige Wert selbst eine Tabelle von Tabellen ist. if isinstance(data, dict) and len(data) == 1: (only,) = data.values() if isinstance(only, dict) and only and all(isinstance(v, dict) for v in only.values()): return only return data # Merkt sich, welche Aufruf-Variante das Modell akzeptiert, damit nicht jede der # vielen Anfragen erneut durchprobiert wird. _CHAT_VARIANT: dict[str, Any] | None = None def _chat_completion(client, model: str, messages: list[dict[str, str]]): """ Chat-Abfrage mit sanftem Rückfall. Neuere Modelle (o-Reihe, GPT-5-Reihe) lehnen `temperature` ungleich dem Standard und teils `response_format` ab. Statt daran zu scheitern, wird von der genausten zur schlichtesten Variante durchprobiert — aber nur bei Parameter-Fehlern. Echte Fehler (falsches Modell, Auth, Netz) werden sofort durchgereicht, damit man sie sieht. """ global _CHAT_VARIANT varianten = [ {"response_format": {"type": "json_object"}, "temperature": 0}, {"response_format": {"type": "json_object"}}, {"temperature": 0}, {}, ] if _CHAT_VARIANT is not None: varianten = [_CHAT_VARIANT] letzter = None for extra in varianten: try: antwort = client.chat.completions.create(model=model, messages=messages, **extra) _CHAT_VARIANT = extra return antwort except Exception as exc: # noqa: BLE001 letzter = exc text = str(exc).lower() parameterfehler = any(w in text for w in ( "temperature", "response_format", "unsupported", "not supported", "unknown_parameter", "invalid_request", "unexpected keyword", )) if not parameterfehler: raise raise letzter def ask_openai( foods: list[dict[str, Any]], types: list[dict[str, Any]], model: str, ) -> dict[str, dict[str, float | None]]: from openai import OpenAI client = OpenAI() liste = "\n".join(f"- {f['name']}" for f in foods) eigenschaften = "\n".join(f"- {t['name']} (Einheit: {t['unit'] or '?'})" for t in types) system = ( "Du bist Ernährungswissenschaftler und lieferst Nährwerte für " "Lebensmittel. Antworte ausschließlich mit JSON, ohne Fließtext und " "ohne Markdown." ) user = f"""Gib für jedes Lebensmittel die Nährwerte **je 100 g** an (bei Flüssigkeiten je 100 ml). Beziehe dich auf das rohe, unverarbeitete Lebensmittel im handelsüblichen Zustand. Lebensmittel: {liste} Benötigte Werte je Lebensmittel: {eigenschaften} Regeln: - Zahlen als Dezimalzahl mit Punkt, ohne Einheit. - Kalorien in kcal je 100 g. - Wenn ein Wert sachlich null ist (z. B. Fett in Salz), gib 0 an. - Nur wenn du einen Wert wirklich nicht sinnvoll angeben kannst: null. - Für Gewürze und Kräuter gilt derselbe 100-g-Bezug, auch wenn nie 100 g verwendet werden. Antworte als JSON-Objekt mit genau dieser Form: {{"Lebensmittelname": {{"Eigenschaftsname": Zahl oder null}}}} Benutze exakt die oben genannten Namen als Schlüssel.""" response = _chat_completion( client, model, [{"role": "system", "content": system}, {"role": "user", "content": user}], ) text = response.choices[0].message.content or "{}" return _parse_json(text) def probe(args: argparse.Namespace) -> int: """Eine einzelne Testabfrage — zeigt den echten Fehler bzw. Rohtext.""" demo_foods = [{"name": "Apfel"}, {"name": "Kochschinken"}] demo_types = [{"name": "Kalorien", "unit": "kcal"}, {"name": "Proteine", "unit": "g"}] out(f"Modell: {args.model}") out("Sende eine einzelne Testabfrage an OpenAI …") out() try: ergebnis = ask_openai(demo_foods, demo_types, args.model) except Exception as exc: # noqa: BLE001 import traceback out("FEHLGESCHLAGEN. Das ist der vollständige Fehler:") out() out(traceback.format_exc().strip()) out() low = str(exc).lower() if "model" in low and ("not" in low or "exist" in low or "unknown" in low): out("→ Das Modell scheint nicht zu existieren oder nicht freigeschaltet zu sein.") out(" In den Einstellungen ein anderes Modell setzen (OPENAI_MODEL).") elif "api key" in low or "authentication" in low or "401" in low: out("→ Der API-Schlüssel wird nicht akzeptiert. OPENAI_API_KEY prüfen.") elif "quota" in low or "insufficient" in low or "429" in low: out("→ Kontingent erschöpft oder zu viele Anfragen. Bei OpenAI das Guthaben prüfen.") return 1 out("Erfolg. Die API antwortet und die Antwort ließ sich lesen:") out() out(json.dumps(ergebnis, ensure_ascii=False, indent=2)) out() out(f"Verwendete Aufruf-Variante: {_CHAT_VARIANT}") out("Damit sollte „Vorschlagen“ funktionieren.") return 0 def propose( client: TandoorClient, args: argparse.Namespace, bestand: dict[str, Any], ) -> Path: types = bestand["types"] foods = bestand["foods"] by_id = {f["id"]: f for f in foods} if args.foods: wanted = [int(x) for x in args.foods.split(",") if x.strip()] unknown = [i for i in wanted if i not in by_id] if unknown: raise SystemExit(f"Unbekannte Zutaten-IDs: {unknown}") selected = [by_id[i] for i in wanted] else: selected = foods type_filter = ([int(x) for x in args.properties.split(",") if x.strip()] if args.properties else [t["id"] for t in types]) active_types = [t for t in types if t["id"] in type_filter] if not active_types: raise SystemExit("Keine gültige Eigenschaft ausgewählt.") todo = [] for food in selected: values = food_property_map(food) missing = [t for t in active_types if args.overwrite or values.get(t["id"]) is None] if missing: todo.append((food, missing)) if not todo: out("Nichts zu tun: Für die Auswahl ist bereits alles gefüllt.") raise SystemExit(0) if args.limit: todo = todo[:args.limit] out(f"Modell: {args.model}") out(f"{len(todo)} Zutaten mit Lücken, {len(active_types)} Eigenschaften.") out(f"Das sind etwa {(len(todo) + BATCH - 1) // BATCH} Anfragen an OpenAI.") out() proposals: list[dict[str, Any]] = [] failed: list[str] = [] unresolved: list[dict[str, Any]] = [] first_error: str | None = None for start in range(0, len(todo), BATCH): chunk = todo[start:start + BATCH] out(f" Anfrage {start // BATCH + 1}: {', '.join(f['name'] for f, _ in chunk)}") try: answer = ask_openai([f for f, _ in chunk], active_types, args.model) except Exception as exc: out(f" Fehler: {exc}") if first_error is None: first_error = str(exc) failed.extend(f["name"] for f, _ in chunk) continue lookup = {str(k).strip().casefold(): v for k, v in answer.items() if isinstance(v, dict)} for food, missing in chunk: got = lookup.get(food["name"].strip().casefold()) if got is None: out(f" ohne Antwort: {food['name']}") failed.append(food["name"]) continue werte: dict[str, float] = {} offene_felder: list[str] = [] for t in missing: raw = got.get(t["name"], _MISSING) number = None if raw is _MISSING else _number_or_none(raw) if number is not None: # Auch number == 0 wird als vorhandener Wert gespeichert. werte[str(t["id"])] = round(number, 2) else: offene_felder.append(t["name"]) if werte: proposals.append({ "food_id": food["id"], "food_name": food["name"], "numrecipe": food.get("numrecipe") or 0, "current": {str(k): v for k, v in food_property_map(food).items()}, "values": werte, "accept": True, }) out(f" {food['name']}: " + ", ".join( f"{t['name']} {werte[str(t['id'])]:g}{t['unit'] or ''}" for t in missing if str(t["id"]) in werte)) if offene_felder: unresolved.append({ "food_id": food["id"], "food_name": food["name"], "properties": offene_felder, }) out(f" weiterhin offen: {', '.join(offene_felder)}") else: failed.append(food["name"]) if offene_felder: unresolved.append({ "food_id": food["id"], "food_name": food["name"], "properties": offene_felder, }) target = data_dir() / "vorschlaege" / f"{stamp()}.json" write_json(target, { "created_at": datetime.now(timezone.utc).isoformat(), "model": args.model, "base_amount": args.base_amount, "base_unit": args.base_unit, "types": [{"id": t["id"], "name": t["name"], "unit": t.get("unit")} for t in active_types], "proposals": proposals, "failed": failed, "unresolved": unresolved, "error": first_error, }) out() out(f"{len(proposals)} Vorschläge geschrieben, {len(failed)} ohne Ergebnis.") if unresolved: out(f"{len(unresolved)} Zutaten enthalten noch einzelne offene Werte.") out(f"Vorschlagsdatei: {target}") out() if not proposals and failed: out("Kein einziger Vorschlag — das deutet auf ein grundsätzliches Problem hin,") out("nicht auf einzelne Zutaten.") if first_error: out(f"Erster Fehler der API: {first_error}") else: out("Die API hat geantwortet, aber die Namen passten nicht zusammen.") out("Zur genauen Ursache: `nutrition.py probe` macht eine einzelne") out("Testabfrage und zeigt den vollständigen Fehler.") out() out("Die Werte sind Schätzungen des Sprachmodells. Vor dem Übernehmen") out("bitte in der Oberfläche durchsehen.") return target # ------------------------------------------------------------ Übernehmen def apply_proposal(client: TandoorClient, args: argparse.Namespace) -> int: file = Path(args.vorschlag).expanduser().resolve() if not file.is_file(): raise SystemExit(f"Vorschlagsdatei nicht gefunden: {file}") payload = json.loads(file.read_text(encoding="utf-8")) proposals = [p for p in payload.get("proposals", []) if p.get("accept", True)] if not proposals: out("In der Vorschlagsdatei ist nichts angehakt.") return 0 base_amount = payload.get("base_amount") or args.base_amount base_unit_name = payload.get("base_unit") or args.base_unit # Die Vorschlagsdatei enthält bereits die Namen der bearbeiteten Typen. # Beim echten Schreiben ergänzen wir die vollständige, aktuelle Liste aus # Tandoor, damit auch bereits vorhandene Eigenschaften sicher abgedeckt sind. property_type_names = _property_type_names(payload.get("types") or []) if args.apply: property_type_names.update( _property_type_names(client.list_objects("property-type")) ) out(f"Modus: {'ÜBERNEHMEN' if args.apply else 'TROCKENÜBUNG'}") out(f"Tandoor: {client.base_url}") out(f"Zutaten: {len(proposals)}") out(f"Bezug: {base_amount:g} {base_unit_name}") out() base_unit = None if args.apply: units = client.list_objects("unit") base_unit = next( (u for u in units if (u.get("name") or "").strip().casefold() == base_unit_name.casefold()), None, ) if base_unit is None: out(f"Einheit „{base_unit_name}“ gibt es in Tandoor nicht.") out("Bitte anlegen oder eine andere Bezugseinheit wählen.") return 2 run = data_dir() / "laeufe" / stamp() run.mkdir(parents=True, exist_ok=True) manifest: dict[str, Any] = { "created_at": datetime.now(timezone.utc).isoformat(), "plugin": "tandoor-nutrition", "label": f"Nährwerte übernehmen ({file.stem})", "tandoor": client.base_url, "mode": "apply" if args.apply else "dry", "proposal": file.name, "steps": [], } written, skipped, errors = 0, 0, 0 for index, item in enumerate(proposals, start=1): food_id = item["food_id"] prefix = f"[{index}/{len(proposals)}] {item['food_name']} [{food_id}]" try: food = client.get_json(f"api/food/{food_id}/") except TandoorError as exc: out(f"{prefix}: nicht lesbar — {exc}") errors += 1 if not args.continue_on_error: return 1 continue updates, invalid_values = _normalise_proposal_values(item.get("values")) if invalid_values: out(f"{prefix}: ungültige Vorschlagswerte ignoriert: {', '.join(invalid_values)}") if not args.overwrite: vorhanden = food_property_map(food) updates = {k: v for k, v in updates.items() if vorhanden.get(k) is None} if not updates: out(f"{prefix}: inzwischen schon gefüllt, übersprungen") skipped += 1 continue merged_properties = merged_properties_payload(food, updates) body: dict[str, Any] = { "properties": _complete_property_type_names( merged_properties, property_type_names ) } if food.get("properties_food_amount") is None: body["properties_food_amount"] = base_amount if not food.get("properties_food_unit") and base_unit: body["properties_food_unit"] = {"id": base_unit["id"], "name": base_unit["name"]} werte = ", ".join(f"{k}={v:g}" for k, v in updates.items()) if not args.apply: out(f"{prefix}: würde setzen → {werte}") written += 1 continue (run / f"vorher-{food_id:05d}.json").write_text( json.dumps(food, ensure_ascii=False, indent=2), encoding="utf-8" ) # Der Schritt wird VOR dem Schreiben vermerkt. Bricht der Prozess # mittendrin ab, weiß das Rückspielen trotzdem, was angefasst wurde. step: dict[str, Any] = { "n": index, "action": "properties", "food_id": food_id, "food_name": item["food_name"], "before": { "properties": food.get("properties") or [], "properties_food_amount": food.get("properties_food_amount"), "properties_food_unit": food.get("properties_food_unit"), }, "written": {str(k): v for k, v in updates.items()}, "restore_level": "voll", "status": "angefangen", } manifest["steps"].append(step) backups.write_manifest(run, manifest) try: result = client.patch_json(f"api/food/{food_id}/", body) except TandoorError as exc: out(f"{prefix}: FEHLER — {exc}") step["status"] = "fehler" step["error"] = str(exc) backups.write_manifest(run, manifest) errors += 1 if not args.continue_on_error: out("Abbruch. Die bereits geschriebenen Zutaten bleiben gesetzt;") out(f"die Sicherungen liegen in {run}") return 1 continue step["status"] = "done" backups.write_manifest(run, manifest) (run / f"nachher-{food_id:05d}.json").write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" ) # Nicht nur der PATCH-Antwort vertrauen: frisch aus Tandoor lesen und # ausdrücklich prüfen, ob auch 0-Werte wirklich gespeichert wurden. try: verified_food = client.get_json(f"api/food/{food_id}/") except TandoorError as exc: out(f"{prefix}: geschrieben, aber Rückprüfung nicht möglich — {exc}") verified_food = result nachher = food_property_map(verified_food) fehlend = [k for k in updates if nachher.get(k) is None] abweichend = [ k for k, expected in updates.items() if nachher.get(k) is not None and abs(nachher[k] - expected) > 1e-6 ] if fehlend or abweichend: details = [] if fehlend: details.append(f"fehlend: {fehlend}") if abweichend: details.append( "abweichend: " + ", ".join( f"{k} erwartet {updates[k]:g}, erhalten {nachher.get(k)!r}" for k in abweichend ) ) out(f"{prefix}: geschrieben, aber Rückprüfung fehlgeschlagen ({'; '.join(details)})") errors += 1 else: out(f"{prefix}: ✓ gesetzt und rückgeprüft → {werte}") written += 1 out() out("─" * 62) verb = "gesetzt" if args.apply else "würden gesetzt" out(f" {written} {verb} · {skipped} übersprungen · {errors} Fehler") if args.apply: out(f" Sicherungen: {run}") out(f" Zurückspielen: Reiter „Sicherungen“ oder") out(f" nutrition.py zurueck --lauf {run.name} --apply") else: out(" Nichts verändert. Mit --apply wird geschrieben.") out("─" * 62) return 1 if errors else 0 # ---------------------------------------------------------- Zurückspielen def restore(client: TandoorClient, args: argparse.Namespace) -> int: runs_dir = data_dir() / "laeufe" try: run = backups.resolve_run(runs_dir, args.lauf) except FileNotFoundError as exc: out(str(exc)) return 2 manifest = backups.read_manifest(run) or {} steps = [s for s in manifest.get("steps", []) if s.get("status") == "done"] if not steps: out("In diesem Lauf wurde nichts geschrieben — nichts zurückzuspielen.") return 0 out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'TROCKENÜBUNG'}") out(f"Lauf: {run.name} ({manifest.get('label')})") out(f" vom {manifest.get('created_at')}") out(f"Tandoor: {client.base_url}") if manifest.get("tandoor") and manifest["tandoor"] != client.base_url: out() out(f"ACHTUNG: Der Lauf ging gegen {manifest['tandoor']},") out(f" jetzt eingestellt ist {client.base_url}.") if not args.force: out(" Abbruch. Mit --force trotzdem.") return 2 verlauf = backups.restore_history(run) if verlauf and not args.force: out() out(f"Dieser Lauf wurde bereits am {verlauf[-1]['at']} zurückgespielt.") out("Mit --force noch einmal.") return 2 out(f"{len(steps)} Zutaten") out() property_type_names: dict[int, str] = {} if args.apply: property_type_names = _property_type_names( client.list_objects("property-type") ) zurueck, uebersprungen, fehler = 0, 0, 0 for step in steps: food_id = step["food_id"] prefix = f"[{step['n']}] {step['food_name']} [{food_id}]" try: jetzt = client.get_json(f"api/food/{food_id}/") except TandoorError as exc: out(f"{prefix}: nicht lesbar — {exc}") fehler += 1 continue before = step["before"] # Schutz: Wurde die Zutat seit dem Lauf von Hand angefasst? aktuell = food_property_map(jetzt) geschrieben = {int(k): v for k, v in step["written"].items()} abweichung = [ k for k, v in geschrieben.items() if aktuell.get(k) is not None and abs(aktuell[k] - float(v)) > 1e-6 ] if abweichung and not args.force: out(f"{prefix}: übersprungen — Werte wurden zwischenzeitlich geändert. " f"Mit --force trotzdem.") uebersprungen += 1 continue # Die alte properties-Liste eins zu eins zurückschreiben. Da Tandoor die # Liste komplett ersetzt, verschwindet damit genau das, was dieser Lauf # hinzugefügt hat. alt = [] for prop in before.get("properties") or []: raw_type = prop.get("property_type") or {} ptype = raw_type.get("id") if not isinstance(ptype, int): continue type_payload: dict[str, Any] = {"id": ptype} if name := str(raw_type.get("name") or "").strip(): type_payload["name"] = name eintrag = {"property_amount": prop.get("property_amount"), "property_type": type_payload} if isinstance(prop.get("id"), int): eintrag["id"] = prop["id"] alt.append(eintrag) body: dict[str, Any] = { "properties": _complete_property_type_names(alt, property_type_names) } if before.get("properties_food_amount") is None: body["properties_food_amount"] = None if before.get("properties_food_unit") is None: body["properties_food_unit"] = None namen = ", ".join(str(k) for k in geschrieben) if not args.apply: out(f"{prefix}: würde zurücksetzen auf {len(alt)} Eigenschaften " f"(entfernt: {namen})") zurueck += 1 continue try: ergebnis = client.patch_json(f"api/food/{food_id}/", body) except TandoorError as exc: out(f"{prefix}: FEHLER — {exc}") fehler += 1 continue nachher = food_property_map(ergebnis) rest = [k for k in geschrieben if nachher.get(k) is not None and food_property_map({"properties": before.get("properties") or []}).get(k) is None] if rest: out(f"{prefix}: zurückgeschrieben, aber {rest} sind noch gesetzt") fehler += 1 else: out(f"{prefix}: ✓ auf den Stand vor dem Lauf zurückgesetzt") zurueck += 1 out() out("─" * 62) verb = "zurückgespielt" if args.apply else "würden zurückgespielt" out(f" {zurueck} {verb} · {uebersprungen} übersprungen · {fehler} Fehler") if args.apply: backups.mark_restored(run, {"restored": zurueck, "skipped": uebersprungen, "errors": fehler}) out(" Der Lauf ist als zurückgespielt vermerkt.") else: out(" Nichts verändert. Mit --apply wird zurückgeschrieben.") out("─" * 62) return 1 if fehler else 0 # ---------------------------------------------------------------- Selbsttest def selftest() -> int: """Prüft lokal, dass 0 weder beim Lesen noch beim Payload-Bau verloren geht.""" food = { "properties": [ { "id": 11, "property_amount": 0, "property_type": {"id": 1, "name": "Fett"}, }, { "id": 12, "property_amount": "0.00", "property_type": {"id": 2, "name": "Zucker"}, }, { "id": 13, "property_amount": 4.2, "property_type": {"id": 3, "name": "Protein"}, }, ] } values = food_property_map(food) assert values == {1: 0.0, 2: 0.0, 3: 4.2}, values payload = merged_properties_payload(food, {1: 0, 2: "0", 4: 0.0}) payload_values = food_property_map({"properties": payload}) assert payload_values == {1: 0.0, 2: 0.0, 3: 4.2, 4: 0.0}, payload_values proposal_values, invalid = _normalise_proposal_values({"1": 0, "2": "0.00"}) assert proposal_values == {1: 0.0, 2: 0.0}, proposal_values assert invalid == [], invalid out("Selbsttest erfolgreich: numerische 0 bleibt in allen Verarbeitungsschritten erhalten.") return 0 # ----------------------------------------------------------------- Aufruf def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__.split("\n")[1]) parser.add_argument("--base-url", default=None) parser.add_argument("--token", default=None) parser.add_argument("--auth-scheme", default=None) parser.add_argument("--timeout", type=float, default=None) parser.add_argument("--insecure", action="store_true") sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("pruefen", help="Bericht über fehlende Nährwerte") p.add_argument("--json", action="store_true", help="Bericht auf stdout ausgeben") p = sub.add_parser("vorschlagen", help="Fehlende Werte von OpenAI schätzen lassen") p.add_argument("--foods", default="", help="IDs, kommagetrennt. Leer = alle") p.add_argument("--properties", default="", help="Eigenschafts-IDs. Leer = alle") p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) p.add_argument("--limit", type=int, default=0, help="höchstens so viele Zutaten") p.add_argument("--overwrite", action="store_true", help="auch vorhandene Werte neu schätzen") p.add_argument("--base-amount", type=float, default=100.0) p.add_argument("--base-unit", default="g") p = sub.add_parser("probe", help="Eine einzelne Testabfrage an OpenAI") p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) sub.add_parser("selftest", help="Nullwert-Behandlung lokal prüfen") p = sub.add_parser("zurueck", help="Einen Lauf zurückspielen") p.add_argument("--lauf", required=True, help="Ordnername unter laeufe/") p.add_argument("--apply", action="store_true", help="wirklich zurückschreiben") p.add_argument("--force", action="store_true", help="auch bei zwischenzeitlichen Änderungen oder anderem Tandoor") p = sub.add_parser("uebernehmen", help="Vorschlagsdatei nach Tandoor schreiben") p.add_argument("--vorschlag", required=True) p.add_argument("--apply", action="store_true", help="wirklich schreiben") p.add_argument("--overwrite", action="store_true") p.add_argument("--continue-on-error", action="store_true") p.add_argument("--base-amount", type=float, default=100.0) p.add_argument("--base-unit", default="g") return parser def main() -> int: args = build_parser().parse_args() if args.command == "selftest": return selftest() # Der Probe-Befehl braucht kein Tandoor, nur OpenAI. if args.command == "probe": if not os.environ.get("OPENAI_API_KEY"): out("OPENAI_API_KEY fehlt — siehe Einstellungen.") return 2 return probe(args) try: client = TandoorClient.from_env( base_url=args.base_url, token=args.token, auth_scheme=args.auth_scheme, timeout=args.timeout, verify=False if args.insecure else None, ) except TandoorError as exc: out(f"Tandoor-Zugang fehlt: {exc}") return 2 try: if args.command == "pruefen": bestand = collect(client) report = analyse(bestand["types"], bestand["foods"]) target = data_dir() / "bericht" write_report(report, target) print_report(report) out() out(f"Bericht: {target / 'bericht.json'}") out(f"Tabelle: {target / 'luecken.csv'}") if args.json: out(json.dumps(report, ensure_ascii=False)) return 0 if args.command == "vorschlagen": if not os.environ.get("OPENAI_API_KEY"): out("OPENAI_API_KEY fehlt — siehe Einstellungen.") return 2 bestand = collect(client) propose(client, args, bestand) return 0 if args.command == "uebernehmen": return apply_proposal(client, args) if args.command == "zurueck": return restore(client, args) except TandoorError as exc: out(f"Tandoor meldet: {exc}") return 1 except KeyboardInterrupt: out("Abgebrochen.") return 130 return 0 if __name__ == "__main__": raise SystemExit(main())