Files
2026-07-24 21:37:03 +02:00

1143 lines
42 KiB
Python

#!/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 <datei>
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())