chore: initial import
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user