502 lines
19 KiB
Python
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())
|