Files
boehmitools-main/plugins/tandoor-conversions/tool/conversions.py
T
2026-07-24 21:37:03 +02:00

502 lines
19 KiB
Python

# -*- 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())