chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 45bc449ea0
53 changed files with 12329 additions and 0 deletions
@@ -0,0 +1,402 @@
# -*- coding: utf-8 -*-
"""
Zutaten-Kategorien — Supermarkt-Kategorien den Zutaten zuweisen.
Liest die vorhandenen Kategorien und die Zutaten, lässt (optional) ChatGPT den
noch nicht eingeordneten Zutaten eine der VORHANDENEN Kategorien zuweisen und
schreibt die Auswahl nach Freigabe zurück. Nichts wird automatisch ausgeführt;
jede Änderung ist gesichert und rückspielbar.
Befehle:
pruefen Kategorien + Zutaten lesen, Plan schreiben
vorschlagen ChatGPT ordnet die kategorielosen Zutaten ein (Plan --plan)
anwenden Freigegebene Zuweisungen schreiben (Plan --plan [--apply])
zurueck Einen Lauf zurückspielen (--lauf)
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 = 40
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-categories"
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)
# ------------------------------------------------------------------ Prüfen
def scan(client: TandoorClient) -> dict[str, Any]:
out("Kategorien werden gelesen …")
categories = [{"id": c["id"], "name": c.get("name") or ""}
for c in client.list_objects("supermarket-category")]
out(f" {len(categories)} Kategorien vorhanden.")
out("Zutaten werden gelesen …")
foods = client.list_objects("food")
counter = {"n": 0}
def new_aid() -> str:
counter["n"] += 1
return f"c{counter['n']:04d}-{secrets.token_hex(2)}"
eintraege = []
for f in foods:
sc = f.get("supermarket_category") or None
cur_id = sc.get("id") if isinstance(sc, dict) else None
cur_name = sc.get("name") if isinstance(sc, dict) else ""
eintraege.append({
"aid": new_aid(),
"id": f["id"],
"name": f.get("name"),
"current_id": cur_id,
"current_name": cur_name or "",
# Vorschlag = aktueller Stand, bis die KI etwas anderes sagt.
"proposed_id": cur_id,
"proposed_name": cur_name or "",
"ai": False,
"accept": False,
})
# Ohne Kategorie zuerst, dann alphabetisch.
eintraege.sort(key=lambda e: (bool(e["current_id"]), (e["name"] or "").casefold()))
ohne = sum(1 for e in eintraege if not e["current_id"])
out(f" {len(eintraege)} Zutaten, davon {ohne} ohne Kategorie.")
return {
"created_at": datetime.now(timezone.utc).isoformat(),
"tandoor": client.base_url,
"categories": categories,
"foods": eintraege,
}
def print_scan(plan: dict[str, Any]) -> None:
ohne = [f for f in plan["foods"] if not f["current_id"]]
out()
out("" * 60)
out(f" {len(plan['categories'])} Kategorien · {len(plan['foods'])} Zutaten · "
f"{len(ohne)} ohne Kategorie")
out("" * 60)
for f in ohne[:20]:
out(f" {f['name']} [{f['id']}]")
if len(ohne) > 20:
out(f" … und {len(ohne) - 20} weitere ohne Kategorie")
# ------------------------------------------------------------- KI-Vorschlag
def vorschlagen(plan: dict[str, Any], model: str, alle: bool) -> int:
kats = plan["categories"]
if not kats:
out("Keine Kategorien vorhanden — in Tandoor zuerst welche anlegen.")
return 0
katnamen = [c["name"] for c in kats]
by_name = {c["name"].casefold(): c for c in kats}
ziele = plan["foods"] if alle else [f for f in plan["foods"] if not f["current_id"]]
if not ziele:
out("Nichts zu tun — alle Zutaten haben bereits eine Kategorie.")
return 0
system = (
"Du ordnest Lebensmittel den Abteilungen eines Supermarkts zu. Wähle für "
"jede Zutat GENAU EINE der vorgegebenen Kategorien — die am besten "
"passende. Erfinde keine neuen Kategorien. Passt keine sinnvoll, gib für "
"diese Zutat null zurück. Antworte ausschließlich mit JSON."
)
gesetzt = 0
for start in range(0, len(ziele), BATCH):
teil = ziele[start:start + BATCH]
namen = [{"id": f["aid"], "zutat": f["name"]} for f in teil]
user = (
"Vorhandene Kategorien: " + json.dumps(katnamen, ensure_ascii=False)
+ "\n\nGib ein JSON-Objekt zurück: Schlüssel ist die „id“, Wert ist "
"der exakte Kategoriename aus der Liste (oder null).\n\nZutaten:\n"
+ json.dumps(namen, ensure_ascii=False)
)
antwort = ai.chat_json(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
model,
)
if not isinstance(antwort, dict):
continue
for f in teil:
wahl = antwort.get(f["aid"])
if not isinstance(wahl, str):
continue
cat = by_name.get(wahl.strip().casefold())
if not cat:
continue
f["proposed_id"] = cat["id"]
f["proposed_name"] = cat["name"]
f["ai"] = True
if cat["id"] != f["current_id"]:
f["accept"] = True
gesetzt += 1
return gesetzt
# --------------------------------------------------------------- Anwenden
def _write_category(client: TandoorClient, food: dict[str, Any],
cat_id: int | None, cat_name: str) -> str:
"""
Setzt supermarket_category robust. Wie bei den Einheiten kann Tandoor bei
einem bloßen Teil-PATCH zicken; deshalb zuerst der übliche PATCH, dann ein
vollständiger PUT (alle Felder erhalten). Rückgabe: verwendete Methode.
"""
url = f"api/food/{food['id']}/"
wert = {"id": cat_id, "name": cat_name} if cat_id else None
try:
client.patch_json(url, {"supermarket_category": wert})
return "PATCH"
except TandoorError as exc:
letzter = exc
try:
voll = dict(food)
voll["supermarket_category"] = wert
client.put_json(url, voll)
return "PUT"
except TandoorError:
raise letzter
def anwenden(client: TandoorClient, plan: dict[str, Any], args: argparse.Namespace) -> int:
ziele = [f for f in plan["foods"]
if f.get("accept") and f.get("proposed_id") != f.get("current_id")]
if not ziele:
out("Nichts angehakt (oder alles unverändert).")
return 0
out(f"Modus: {'ANWENDEN' if args.apply else 'TROCKENÜBUNG'}")
out(f"Tandoor: {client.base_url}")
out(f"{len(ziele)} Kategorie-Zuweisungen")
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-categories",
"label": "Kategorien zuweisen",
"tandoor": client.base_url,
"mode": "apply" if args.apply else "dry",
"steps": [],
}
done, failed = 0, 0
for nummer, f in enumerate(ziele, start=1):
prefix = f"[{nummer}/{len(ziele)}] „{f['name']}“ [{f['id']}] → „{f['proposed_name']}"
try:
aktuell = client.get_json(f"api/food/{f['id']}/")
except TandoorError:
out(f"{prefix}: übersprungen — Zutat nicht mehr vorhanden")
continue
alt = aktuell.get("supermarket_category") or None
alt_id = alt.get("id") if isinstance(alt, dict) else None
if alt_id == f["proposed_id"]:
out(f"{prefix}: unverändert — übersprungen")
continue
if not args.apply:
out(f"{prefix}: würde zuweisen")
done += 1
continue
try:
methode = _write_category(client, aktuell, f["proposed_id"], f["proposed_name"])
except TandoorError as exc:
out(f"{prefix}: FEHLER — {exc}")
failed += 1
if not args.continue_on_error:
return 1
continue
(run / f"food-{f['id']:05d}.json").write_text(
json.dumps(aktuell, ensure_ascii=False, indent=2), encoding="utf-8")
manifest["steps"].append({
"n": nummer,
"action": "set_category",
"food_id": f["id"],
"food_name": f["name"],
"category_before_id": alt_id,
"category_before_name": (alt.get("name") if isinstance(alt, dict) else "") or "",
"category_after_id": f["proposed_id"],
"category_after_name": f["proposed_name"],
"restore_level": "voll",
"status": "done",
})
backups.write_manifest(run, manifest)
out(f"{prefix}: ✓ zugewiesen" + (f" [{methode}]" if methode != "PATCH" else ""))
done += 1
out()
out("" * 60)
verb = "zugewiesen" if args.apply else "würden zugewiesen"
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:
runs = data_dir() / "laeufe"
run = backups.resolve_run(runs, 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") != "set_category":
continue
fid = step["food_id"]
alt_id = step.get("category_before_id")
alt_name = step.get("category_before_name", "")
prefix = f"[{step['n']}] „{step['food_name']}“ [{fid}] zurück"
if not args.apply:
out(f"{prefix}: würde auf „{alt_name or ''}“ zurücksetzen")
zurueck += 1
continue
try:
aktuell = client.get_json(f"api/food/{fid}/")
_write_category(client, aktuell, alt_id, alt_name)
out(f"{prefix}: ✓")
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} zurückgespielt · {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="Zutaten-Kategorien")
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="Kategorien und Zutaten lesen")
p = sub.add_parser("vorschlagen", help="ChatGPT ordnet Zutaten ein")
p.add_argument("--plan", required=True)
p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5"))
p.add_argument("--alle", action="store_true",
help="auch bereits kategorisierte neu vorschlagen")
p = sub.add_parser("anwenden", help="Freigegebene Zuweisungen schreiben")
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 ordnet Zutaten ein (Modell {args.model}) …")
n = vorschlagen(plan, args.model, alle=args.alle)
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} Zutaten eingeordnet. Bitte prüfen, bevor du anwendest.")
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())