# -*- coding: utf-8 -*- """ Einheiten-Umrechnungen — fehlende, sinnvolle Umrechnungen finden und eintragen. Sinnvoll ist eine Umrechnung vor allem dort, wo eine Zutat in Rezepten mit mehreren, nicht ohne Weiteres ineinander umrechenbaren Einheiten vorkommt (z. B. „Stück“ und „g“) und dafür noch keine Umrechnung hinterlegt ist. Ohne sie kann Tandoor solche Mengen nicht skalieren oder auf die Einkaufsliste bringen. WICHTIG: Die von der KI gelieferten Werte (z. B. „1 Zwiebel ≈ 110 g“) sind SCHÄTZUNGEN. Sie sind vor dem Eintragen zu prüfen. Befehle: pruefen Rezepte, Einheiten und vorhandene Umrechnungen lesen vorschlagen ChatGPT schlägt Werte für die fehlenden Umrechnungen vor anwenden Freigegebene Umrechnungen anlegen zurueck Einen Lauf zurückspielen (löscht die angelegten Umrechnungen) probe OpenAI-Verbindung testen """ from __future__ import annotations import argparse import json import os import secrets 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 TandoorClient, TandoorError # noqa: E402 from core import backups # noqa: E402 from core import ai # noqa: E402 BATCH = 30 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-conversions" 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 make_client(args: argparse.Namespace) -> TandoorClient: base = args.base_url or os.environ.get("TANDOOR_URL", "") token = args.token or os.environ.get("TANDOOR_TOKEN", "") scheme = args.auth_scheme or os.environ.get("TANDOOR_AUTH_SCHEME", "Bearer") return TandoorClient(base, token, auth_scheme=scheme, verify=not args.insecure, timeout=args.timeout) def _unit_of(ing: dict) -> tuple[int, str] | None: u = ing.get("unit") if isinstance(u, dict) and isinstance(u.get("id"), int): return u["id"], (u.get("name") or "") return None # ------------------------------------------------------------------ Prüfen # Einheiten, die bereits „gewichtsartig“ und generisch nach Gramm umrechenbar # sind — dafür braucht es keine food-spezifische Umrechnung. _GRAM_COMPATIBLE = { "g", "gramm", "gram", "gramme", "kg", "kilo", "kilogramm", "kilogram", "mg", "milligramm", "dag", "dkg", "dekagramm", "pfund", } def _norm(name: str) -> str: return (name or "").strip().casefold() def _find_gram_unit(units: list[dict]) -> dict | None: # Bevorzugt exakt „g“, sonst „gramm“/„gram“. for wanted in ("g",): for u in units: if _norm(u.get("name")) == wanted: return u for u in units: if _norm(u.get("name")) in ("gramm", "gram", "gramme"): return u return None def scan(client: TandoorClient) -> dict[str, Any]: out("Einheiten werden gelesen …") units = client.list_objects("unit") unit_name = {u["id"]: (u.get("name") or "") for u in units} gram = _find_gram_unit(units) if not gram: out(" ⚠ Keine Gramm-Einheit („g“) in Tandoor gefunden — bitte zuerst " "eine Einheit „g“ anlegen. Es kann nichts nach Gramm umgerechnet werden.") gram_id = gram["id"] if gram else None gram_name = gram.get("name") if gram else "g" # Alle Einheiten, die ohne food-spezifisches Wissen bei Gramm ankommen # (Gramm selbst + generische Gewichtseinheiten). gram_seed = {gram_id} if gram_id else set() for u in units: if _norm(u.get("name")) in _GRAM_COMPATIBLE: gram_seed.add(u["id"]) out("Vorhandene Umrechnungen werden gelesen …") existing = client.list_objects("unit-conversion") # Kanten (welche Einheiten sind durch eine Umrechnung verbunden) je Zutat, # plus globale Umrechnungen (food=None), die für alle Zutaten gelten. edges: dict[int | None, list[tuple[int, int]]] = {} conv_units: dict[int, set[int]] = {} # food_id -> Einheiten aus Umrechnungen conv_food_name: dict[int, str] = {} for uc in existing: food = uc.get("food") or {} fid = food.get("id") if isinstance(food, dict) else None bu = (uc.get("base_unit") or {}).get("id") cu = (uc.get("converted_unit") or {}).get("id") if not (bu and cu): continue edges.setdefault(fid, []).append((bu, cu)) if isinstance(fid, int): conv_units.setdefault(fid, set()).update({bu, cu}) if food.get("name"): conv_food_name[fid] = food["name"] out(f" {len(existing)} Umrechnungen vorhanden.") out("Rezepte werden gelesen (welche Zutat mit welchen Einheiten) …") overview = client.list_objects("recipe") used: dict[int, set[int]] = {} food_name: dict[int, str] = {} for i, entry in enumerate(overview, start=1): try: recipe = client.get_json(f"api/recipe/{entry['id']}/") except TandoorError: continue for step in recipe.get("steps") or []: for ing in step.get("ingredients") or []: food = ing.get("food") or {} fid = food.get("id") unit = _unit_of(ing) if isinstance(fid, int) and unit: used.setdefault(fid, set()).add(unit[0]) unit_name.setdefault(unit[0], unit[1]) food_name.setdefault(fid, food.get("name") or "") if i % 25 == 0 or i == len(overview): out(f" {i}/{len(overview)}") def reaches_gram(food_id: int) -> set[int]: """Alle Einheiten, die für diese Zutat (transitiv über vorhandene Umrechnungen + globale) bei Gramm ankommen.""" reached = set(gram_seed) kanten = edges.get(food_id, []) + edges.get(None, []) changed = True while changed: changed = False for a, b in kanten: if a in reached and b not in reached: reached.add(b); changed = True elif b in reached and a not in reached: reached.add(a); changed = True return reached counter = {"n": 0} def new_aid() -> str: counter["n"] += 1 return f"u{counter['n']:04d}-{secrets.token_hex(2)}" # Relevante Zutaten: kommen in Rezepten vor ODER haben bereits Umrechnungen. alle_foods = set(used) | set(conv_units) kandidaten = [] ohne_gramm_einheit = False for fid in alle_foods: name = food_name.get(fid) or conv_food_name.get(fid) or f"#{fid}" relevant = set(used.get(fid, set())) | set(conv_units.get(fid, set())) reached = reaches_gram(fid) if gram_id else set() for uid in sorted(relevant, key=lambda x: (unit_name.get(x, "") or "").casefold()): uname = unit_name.get(uid, str(uid)) if not gram_id: ohne_gramm_einheit = True continue if uid == gram_id or _norm(uname) in _GRAM_COMPATIBLE: continue # schon gewichtsartig / Gramm if uid in reached: continue # erreicht Gramm bereits (auch mehrstufig) in_recipe = uid in used.get(fid, set()) kandidaten.append({ "aid": new_aid(), "food_id": fid, "food_name": name, "unit": {"id": uid, "name": uname}, "gram_id": gram_id, "gram_name": gram_name, "quelle": "rezept" if in_recipe else "umrechnung", "base_amount": 1, "grams": None, "reason": "", "ai": False, "accept": False, }) kandidaten.sort(key=lambda k: ((k["food_name"] or "").casefold(), k["unit"]["name"])) aus_umr = sum(1 for k in kandidaten if k["quelle"] == "umrechnung") out(f" {len(kandidaten)} Einheiten ohne Weg zu Gramm " f"({len(kandidaten) - aus_umr} aus Rezepten, {aus_umr} aus vorhandenen Umrechnungen).") return { "created_at": datetime.now(timezone.utc).isoformat(), "tandoor": client.base_url, "existing_count": len(existing), "gram_id": gram_id, "gram_name": gram_name, "candidates": kandidaten, } def print_scan(plan: dict[str, Any]) -> None: out() out("─" * 60) out(f" {plan['existing_count']} vorhandene Umrechnungen · " f"{len(plan['candidates'])} ohne Weg zu Gramm") out("─" * 60) for k in plan["candidates"][:20]: marke = "" if k.get("quelle") == "rezept" else " (aus Umrechnung)" out(f" {k['food_name']} [{k['food_id']}]: " f"1 {k['unit']['name']} = ? {plan.get('gram_name', 'g')}{marke}") if len(plan["candidates"]) > 20: out(f" … und {len(plan['candidates']) - 20} weitere") # ------------------------------------------------------------- KI-Vorschlag def vorschlagen(plan: dict[str, Any], model: str) -> int: gram_name = plan.get("gram_name", "g") ziele = [k for k in plan["candidates"] if k.get("grams") is None] if not ziele: out("Nichts offen.") return 0 system = ( "Du bist Experte für Lebensmittel und Küchenmengen. Für jede Zutat und " "ihre Einheit gibst du an, wie viel GRAMM eine typische Menge dieser " "Einheit wiegt (z. B. „1 Stück Zwiebel ≈ 110 g“, „1 EL Öl ≈ 9 g“, " "„1 Prise Salz ≈ 0,4 g“). Die Werte sind Schätzungen für übliche Größen. " "Gib pro Eintrag zusätzlich an, auf welche Ausgangsmenge (base_amount) " "sich das Gramm-Gewicht bezieht — meist 1, bei sehr kleinen Mengen darf " "es auch 100 sein (z. B. 100 ml). Kannst du eine Zutat/Einheit gar nicht " "sinnvoll schätzen, setze grams auf null. Antworte nur mit JSON." ) gesetzt = 0 for start in range(0, len(ziele), BATCH): teil = ziele[start:start + BATCH] anfrage = [{"id": k["aid"], "zutat": k["food_name"], "einheit": k["unit"]["name"]} for k in teil] user = ( f"Zieleinheit ist immer „{gram_name}“ (Gramm). Gib ein JSON-Objekt " "zurück: Schlüssel ist die „id“, Wert ist " '{"base_amount": Zahl (meist 1), "grams": Gramm für diese ' 'base_amount (oder null), "grund": "kurz"}.\n\nEinträge:\n' + json.dumps(anfrage, ensure_ascii=False) ) antwort = ai.chat_json( [{"role": "system", "content": system}, {"role": "user", "content": user}], model, ) if not isinstance(antwort, dict): continue for k in teil: d = antwort.get(k["aid"]) k["ai"] = True if not isinstance(d, dict): continue k["reason"] = str(d.get("grund") or "")[:200] try: grams = float(d["grams"]) base = float(d.get("base_amount") or 1) except (KeyError, TypeError, ValueError): continue if grams <= 0 or base <= 0: continue k["base_amount"] = base k["grams"] = grams k["accept"] = True gesetzt += 1 return gesetzt # --------------------------------------------------------------- Anwenden def anwenden(client: TandoorClient, plan: dict[str, Any], args: argparse.Namespace) -> int: gram_id = plan.get("gram_id") gram_name = plan.get("gram_name", "g") if not gram_id: out("Keine Gramm-Einheit in Tandoor — bitte zuerst „g“ anlegen. Abbruch.") return 1 ziele = [k for k in plan["candidates"] if k.get("accept") and k.get("grams") and k.get("base_amount")] if not ziele: out("Nichts angehakt (oder ohne Wert).") return 0 out(f"Modus: {'ANWENDEN' if args.apply else 'TROCKENÜBUNG'}") out(f"Tandoor: {client.base_url}") out(f"{len(ziele)} Umrechnungen nach {gram_name}") out() run = data_dir() / "laeufe" / stamp() if args.apply: run.mkdir(parents=True, exist_ok=True) manifest: dict[str, Any] = { "created_at": datetime.now(timezone.utc).isoformat(), "plugin": "tandoor-conversions", "label": "Umrechnungen anlegen", "tandoor": client.base_url, "mode": "apply" if args.apply else "dry", "steps": [], } done, failed = 0, 0 for nummer, k in enumerate(ziele, start=1): text = (f"{k['base_amount']:g} {k['unit']['name']} " f"= {k['grams']:g} {gram_name}") prefix = f"[{nummer}/{len(ziele)}] „{k['food_name']}“: {text}" payload = { "food": {"id": k["food_id"], "name": k["food_name"]}, "base_amount": k["base_amount"], "base_unit": {"id": k["unit"]["id"], "name": k["unit"]["name"]}, "converted_amount": k["grams"], "converted_unit": {"id": gram_id, "name": gram_name}, } if not args.apply: out(f"{prefix}: würde anlegen") done += 1 continue try: created = client.post_json("api/unit-conversion/", payload) except TandoorError as exc: out(f"{prefix}: FEHLER — {exc}") failed += 1 if not args.continue_on_error: return 1 continue new_id = created.get("id") if isinstance(created, dict) else None manifest["steps"].append({ "n": nummer, "action": "create_conversion", "conversion_id": new_id, "food_name": k["food_name"], "text": text, "restore_level": "voll" if new_id else "nein", "status": "done", }) backups.write_manifest(run, manifest) out(f"{prefix}: ✓ angelegt (id {new_id})") done += 1 out() out("─" * 60) verb = "angelegt" if args.apply else "würden angelegt" out(f" {done} {verb} · {failed} Fehler") if args.apply and manifest["steps"]: out(f" Sicherung: {run}") out("─" * 60) return 1 if failed else 0 # ------------------------------------------------------------ Zurückspielen def restore(client: TandoorClient, args: argparse.Namespace) -> int: run = backups.resolve_run(data_dir() / "laeufe", args.lauf) manifest = backups.read_manifest(run) if not manifest: raise SystemExit("Kein manifest.json in diesem Lauf.") out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'VORSCHAU'}") zurueck, fehler = 0, 0 for step in reversed(manifest.get("steps", [])): if step.get("action") != "create_conversion": continue cid = step.get("conversion_id") prefix = f"[{step['n']}] „{step['food_name']}“: {step.get('text','')}" if not cid: out(f"{prefix}: keine ID gespeichert — nicht löschbar") continue if not args.apply: out(f"{prefix}: würde Umrechnung {cid} löschen") zurueck += 1 continue try: client.delete(f"api/unit-conversion/{cid}/") out(f"{prefix}: ✓ gelöscht") zurueck += 1 except TandoorError as exc: out(f"{prefix}: FEHLER — {exc}") fehler += 1 if args.apply: backups.mark_restored(run, {"zurueck": zurueck, "fehler": fehler}) out(f"{zurueck} gelöscht · {fehler} Fehler") return 1 if fehler else 0 # ------------------------------------------------------------------- CLI def load_plan(path: str) -> tuple[Path, dict[str, Any]]: file = Path(path).expanduser().resolve() if not file.is_file(): raise SystemExit(f"Plandatei nicht gefunden: {file}") return file, json.loads(file.read_text(encoding="utf-8")) def main() -> int: parser = argparse.ArgumentParser(description="Einheiten-Umrechnungen") parser.add_argument("--base-url", default="") parser.add_argument("--token", default="") parser.add_argument("--auth-scheme", default="") parser.add_argument("--timeout", type=float, default=45.0) parser.add_argument("--insecure", action="store_true") sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("pruefen", help="fehlende Umrechnungen finden") p = sub.add_parser("vorschlagen", help="ChatGPT schlägt Werte vor") p.add_argument("--plan", required=True) p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) p = sub.add_parser("anwenden", help="freigegebene Umrechnungen anlegen") p.add_argument("--plan", required=True) p.add_argument("--apply", action="store_true") p.add_argument("--continue-on-error", action="store_true") p = sub.add_parser("zurueck", help="einen Lauf zurückspielen") p.add_argument("--lauf", required=True) p.add_argument("--apply", action="store_true") p = sub.add_parser("probe", help="OpenAI-Verbindung testen") p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) args = parser.parse_args() if args.command == "probe": if not os.environ.get("OPENAI_API_KEY"): out("OPENAI_API_KEY fehlt — siehe Einstellungen.") return 2 ok, meldung = ai.probe(args.model) out(meldung) return 0 if ok else 1 if args.command == "vorschlagen": if not os.environ.get("OPENAI_API_KEY"): out("OPENAI_API_KEY fehlt — siehe Einstellungen.") return 2 file, plan = load_plan(args.plan) out(f"KI schlägt Umrechnungen vor (Modell {args.model}) …") n = vorschlagen(plan, args.model) plan["ai_prefilled_at"] = datetime.now(timezone.utc).isoformat() file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") out(f"{n} Umrechnungen vorgeschlagen. Werte sind SCHÄTZUNGEN — bitte prüfen.") return 0 client = make_client(args) if args.command == "pruefen": plan = scan(client) file = data_dir() / "plaene" / f"{stamp()}.json" file.parent.mkdir(parents=True, exist_ok=True) file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") print_scan(plan) out() out(f"Plan: {file}") return 0 if args.command == "anwenden": _file, plan = load_plan(args.plan) return anwenden(client, plan, args) if args.command == "zurueck": return restore(client, args) return 2 if __name__ == "__main__": raise SystemExit(main())