1378 lines
57 KiB
Python
1378 lines
57 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Stammdaten aufräumen: Zutaten, Einheiten und Schlagworte.
|
||
|
||
cleanup.py pruefen
|
||
Sucht Dubletten, ähnliche Namen und unbenutzte Einträge und schreibt
|
||
einen Plan. Verändert nichts.
|
||
|
||
cleanup.py ausfuehren --plan <datei> [--apply]
|
||
Arbeitet den Plan ab. Ohne --apply nur eine Trockenübung.
|
||
|
||
Warum das mit Vorsicht zu genießen ist
|
||
--------------------------------------
|
||
Tandoors merge hängt alle Verweise der Quelle auf das Ziel um und löscht die
|
||
Quelle danach. Das ist **nicht umkehrbar**. Im Tandoor-Quelltext steht dazu:
|
||
|
||
# TODO these checks could be improved to merge existing properties and
|
||
# conversion in a smart way. For now it will just loose them to prevent
|
||
# duplicates
|
||
if isinstance(source, Food):
|
||
source.properties.all().delete()
|
||
|
||
Die Nährwerte der Quelle gehen also verloren. Deshalb rettet dieses Werkzeug
|
||
sie vorher: Werte, die nur die Quelle hat, werden zuerst ans Ziel geschrieben
|
||
(--ohne-rettung schaltet das ab). Und deshalb wird nur zusammengeführt, was
|
||
praktisch sicher dasselbe ist — alles Grenzwertige landet nur in einer Liste
|
||
zum Anschauen.
|
||
|
||
Standalone:
|
||
python3 plugins/tandoor-cleanup/tool/cleanup.py pruefen
|
||
"""
|
||
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
|
||
|
||
SUITE_ROOT = Path(__file__).resolve().parents[3]
|
||
if str(SUITE_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(SUITE_ROOT))
|
||
|
||
from core.tandoor import ( # noqa: E402
|
||
TandoorClient,
|
||
TandoorError,
|
||
food_property_map,
|
||
merged_properties_payload,
|
||
)
|
||
from core.foodmatch import ( # noqa: E402
|
||
duplicate_groups,
|
||
similar_pairs,
|
||
looks_like_plural,
|
||
guess_plural,
|
||
)
|
||
from core import backups # noqa: E402
|
||
from core import ai # noqa: E402
|
||
|
||
KINDS = {
|
||
"food": {"endpoint": "food", "label": "Zutat", "plural": "Zutaten"},
|
||
"unit": {"endpoint": "unit", "label": "Einheit", "plural": "Einheiten"},
|
||
"keyword": {"endpoint": "keyword", "label": "Schlagwort", "plural": "Schlagworte"},
|
||
}
|
||
|
||
|
||
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-cleanup"
|
||
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 short(obj: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"id": obj.get("id"),
|
||
"name": obj.get("name"),
|
||
"plural_name": obj.get("plural_name") or "",
|
||
"numrecipe": obj.get("numrecipe") or 0,
|
||
"properties": len(obj.get("properties") or []),
|
||
}
|
||
|
||
|
||
def reference_index(client: TandoorClient) -> dict[str, Any]:
|
||
"""
|
||
Wer zeigt worauf? Einmal alle Rezepte lesen und merken.
|
||
|
||
Das muss VOR dem Zusammenführen passieren: Danach zeigen die Verweise auf
|
||
das Ziel und die alte Zuordnung ist nicht mehr rekonstruierbar. Genau diese
|
||
Aufzeichnung ist es, die ein Rückspielen später überhaupt möglich macht.
|
||
"""
|
||
out("Rezeptverweise werden aufgezeichnet …")
|
||
foods: dict[int, list[dict[str, Any]]] = {}
|
||
units: dict[int, list[dict[str, Any]]] = {}
|
||
keywords: dict[int, list[dict[str, Any]]] = {}
|
||
|
||
overview = client.list_objects("recipe")
|
||
for index, entry in enumerate(overview, start=1):
|
||
rid = entry.get("id")
|
||
try:
|
||
recipe = client.get_json(f"api/recipe/{rid}/")
|
||
except TandoorError:
|
||
continue
|
||
|
||
kw_ids = [k["id"] for k in recipe.get("keywords") or []
|
||
if isinstance(k, dict) and isinstance(k.get("id"), int)]
|
||
for kid in kw_ids:
|
||
keywords.setdefault(kid, []).append({
|
||
"recipe_id": rid,
|
||
"recipe_name": recipe.get("name"),
|
||
# Die vollständige Liste, nicht nur der eine Treffer: Beim
|
||
# Zurückspielen wird sie eins zu eins wiederhergestellt.
|
||
"keywords_before": kw_ids,
|
||
})
|
||
|
||
for step in recipe.get("steps") or []:
|
||
for ing in step.get("ingredients") or []:
|
||
iid = ing.get("id")
|
||
if not isinstance(iid, int):
|
||
continue
|
||
food = (ing.get("food") or {}).get("id")
|
||
unit = (ing.get("unit") or {}).get("id")
|
||
if isinstance(food, int):
|
||
foods.setdefault(food, []).append({
|
||
"ingredient_id": iid, "recipe_id": rid,
|
||
"recipe_name": recipe.get("name"), "food_id": food,
|
||
})
|
||
if isinstance(unit, int):
|
||
units.setdefault(unit, []).append({
|
||
"ingredient_id": iid, "recipe_id": rid,
|
||
"recipe_name": recipe.get("name"), "unit_id": unit,
|
||
})
|
||
if index % 25 == 0 or index == len(overview):
|
||
out(f" {index}/{len(overview)}")
|
||
|
||
return {"food": foods, "unit": units, "keyword": keywords}
|
||
|
||
|
||
# ------------------------------------------------------------------ Prüfen
|
||
|
||
def scan(client: TandoorClient, args: argparse.Namespace) -> dict[str, Any]:
|
||
kinds = [k.strip() for k in args.arten.split(",") if k.strip()] if args.arten else list(KINDS)
|
||
unknown = [k for k in kinds if k not in KINDS]
|
||
if unknown:
|
||
raise SystemExit(f"Unbekannte Art: {unknown}. Erlaubt: {', '.join(KINDS)}")
|
||
|
||
result: dict[str, Any] = {
|
||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
"kinds": {},
|
||
"actions": [],
|
||
}
|
||
|
||
# Rezepte einmal lesen, um benutzte Zutaten/Einheiten/Schlagworte zu
|
||
# bestimmen. Wichtig: Tandoors Food-LISTE liefert „numrecipe“ oft nicht mit
|
||
# (dann stünde es überall auf 0 und ALLE Zutaten wären scheinbar unbenutzt).
|
||
# Deshalb bestimmen wir die Benutzung verlässlich aus den Rezepten selbst.
|
||
used_foods: set[int] = set()
|
||
used_units: set[int] = set()
|
||
used_keywords: set[int] = set()
|
||
if kinds:
|
||
out("Rezepte werden gelesen, um Benutzung zu bestimmen …")
|
||
overview = client.list_objects("recipe")
|
||
for index, entry in enumerate(overview, start=1):
|
||
try:
|
||
recipe = client.get_json(f"api/recipe/{entry['id']}/")
|
||
except TandoorError:
|
||
continue
|
||
for kw in recipe.get("keywords") or []:
|
||
if isinstance(kw, dict) and isinstance(kw.get("id"), int):
|
||
used_keywords.add(kw["id"])
|
||
for step in recipe.get("steps") or []:
|
||
for ing in step.get("ingredients") or []:
|
||
food = (ing.get("food") or {}).get("id")
|
||
if isinstance(food, int):
|
||
used_foods.add(food)
|
||
unit = ing.get("unit")
|
||
if isinstance(unit, dict) and isinstance(unit.get("id"), int):
|
||
used_units.add(unit["id"])
|
||
if index % 25 == 0 or index == len(overview):
|
||
out(f" {index}/{len(overview)}")
|
||
|
||
counter = {"n": 0}
|
||
|
||
def new_aid() -> str:
|
||
counter["n"] += 1
|
||
return f"a{counter['n']:04d}"
|
||
|
||
for kind in kinds:
|
||
endpoint = KINDS[kind]["endpoint"]
|
||
out(f"{KINDS[kind]['plural']} werden geprüft …")
|
||
objects = client.list_objects(endpoint)
|
||
by_id = {o["id"]: o for o in objects}
|
||
out(f" {len(objects)} Einträge")
|
||
|
||
groups = duplicate_groups(objects)
|
||
pairs = similar_pairs(objects)
|
||
|
||
def plural_for_merge(source_obj: dict, target_obj: dict) -> str:
|
||
"""
|
||
Beim Zusammenführen: Wenn die Quelle wie ein Plural des Ziels aussieht
|
||
und das Ziel noch keinen Plural gepflegt hat, diesen vorschlagen.
|
||
"""
|
||
if kind != "food":
|
||
return ""
|
||
if (target_obj.get("plural_name") or "").strip():
|
||
return ""
|
||
src = (source_obj.get("name") or "").strip()
|
||
tgt = (target_obj.get("name") or "").strip()
|
||
return src if looks_like_plural(tgt, src) else ""
|
||
|
||
# Unbenutzt = kommt in keinem Rezept vor. Für Zutaten ist der
|
||
# Rezept-Scan maßgeblich; numrecipe zählt zusätzlich als „benutzt“,
|
||
# falls es doch mitgeliefert wird — so wird nie etwas fälschlich als
|
||
# unbenutzt (und damit löschbar) markiert.
|
||
if kind == "food":
|
||
unused = [short(o) for o in objects
|
||
if o.get("id") not in used_foods and not (o.get("numrecipe") or 0)]
|
||
elif kind == "unit":
|
||
unused = [short(o) for o in objects if o.get("id") not in used_units]
|
||
else:
|
||
unused = [short(o) for o in objects if o.get("id") not in used_keywords]
|
||
unused.sort(key=lambda o: (o["name"] or "").casefold())
|
||
|
||
# ---------------------------------------------------- sichere Dubletten
|
||
plan_groups = []
|
||
for group in groups:
|
||
target = group["target"]
|
||
target_obj = by_id[target["id"]]
|
||
sources = []
|
||
for source in group["sources"]:
|
||
source_obj = by_id[source["id"]]
|
||
verlust = []
|
||
if kind == "food":
|
||
ziel_werte = food_property_map(target_obj)
|
||
quell_werte = food_property_map(source_obj)
|
||
verlust = [
|
||
tid for tid, wert in quell_werte.items()
|
||
if wert is not None and ziel_werte.get(tid) is None
|
||
]
|
||
pv = plural_for_merge(source_obj, target_obj)
|
||
aid = new_aid()
|
||
sources.append({
|
||
**short(source),
|
||
"aid": aid,
|
||
"rescue_properties": verlust,
|
||
"plural_suggestion": pv,
|
||
})
|
||
result["actions"].append({
|
||
"aid": aid,
|
||
"kind": kind,
|
||
"endpoint": endpoint,
|
||
"action": "merge",
|
||
"origin": "duplicate",
|
||
"source_id": source["id"],
|
||
"source_name": source.get("name"),
|
||
"target_id": target["id"],
|
||
"target_name": target.get("name"),
|
||
"rescue_properties": verlust,
|
||
"reason": group["reason"],
|
||
"score": group["score"],
|
||
# Für Food: Plural am Ziel ergänzen, wenn sinnvoll — und
|
||
# dann auch gleich vorausgewählt, denn das ist genau der
|
||
# Fall, den man haben will.
|
||
"set_plural": bool(pv),
|
||
"plural_value": pv,
|
||
"accept": True,
|
||
})
|
||
plan_groups.append({
|
||
"target": short(target),
|
||
"sources": sources,
|
||
"score": group["score"],
|
||
"reason": group["reason"],
|
||
"recipes_affected": group["recipes_affected"],
|
||
})
|
||
|
||
# ------------------------------------------------------- ähnliche Paare
|
||
# Anders als bisher: jetzt bedienbar. Häkchen und Richtung wählt der
|
||
# Nutzer in der Oberfläche; hier wird nur der Kandidat vorbereitet,
|
||
# ohne Vorauswahl und ohne Richtung.
|
||
similar = []
|
||
for pair in pairs:
|
||
left, right = short(pair["left"]), short(pair["right"])
|
||
aid = new_aid()
|
||
pv_lr = plural_for_merge(by_id[left["id"]], by_id[right["id"]]) # links->rechts
|
||
pv_rl = plural_for_merge(by_id[right["id"]], by_id[left["id"]]) # rechts->links
|
||
similar.append({
|
||
"aid": aid,
|
||
"left": left,
|
||
"right": right,
|
||
"score": pair["score"],
|
||
"reason": pair["reason"],
|
||
"plural_left_to_right": pv_lr,
|
||
"plural_right_to_left": pv_rl,
|
||
})
|
||
result["actions"].append({
|
||
"aid": aid,
|
||
"kind": kind,
|
||
"endpoint": endpoint,
|
||
"action": "merge",
|
||
"origin": "similar",
|
||
"left_id": left["id"],
|
||
"left_name": left["name"],
|
||
"right_id": right["id"],
|
||
"right_name": right["name"],
|
||
# Richtung offen — die Oberfläche setzt source_id/target_id auf
|
||
# genau eines der beiden Paar-Mitglieder.
|
||
"source_id": None,
|
||
"source_name": None,
|
||
"target_id": None,
|
||
"target_name": None,
|
||
"rescue_properties": [],
|
||
"reason": pair["reason"],
|
||
"score": pair["score"],
|
||
"set_plural": False,
|
||
"plural_value": "",
|
||
# Pluralvorschlag je Richtung, damit die Oberfläche beim
|
||
# Umschalten sofort das Richtige anbietet.
|
||
"plural_left_to_right": pv_lr,
|
||
"plural_right_to_left": pv_rl,
|
||
"accept": False,
|
||
})
|
||
|
||
# ---------------------------------------------------- Plurale pflegen
|
||
# Vollständige Sicht auf die Plurale — für Zutaten UND Einheiten. Jeder
|
||
# Eintrag ist einzeln korrigier- und eintragbar. Nicht destruktiv, voll
|
||
# rückspielbar, nichts vorausgewählt. Schlagworte haben keinen Plural.
|
||
plurals = []
|
||
if kind in ("food", "unit"):
|
||
for o in objects:
|
||
aktuell = (o.get("plural_name") or "").strip()
|
||
aid = new_aid()
|
||
vorschlag = guess_plural(o.get("name") or "") if not aktuell else ""
|
||
# Benutzung aus dem Rezept-Scan (numrecipe ist unzuverlässig).
|
||
if kind == "food":
|
||
benutzt = 1 if (o.get("id") in used_foods or (o.get("numrecipe") or 0)) else 0
|
||
else:
|
||
benutzt = 1 if o.get("id") in used_units else 0
|
||
plurals.append({
|
||
"aid": aid,
|
||
"id": o["id"],
|
||
"name": o.get("name"),
|
||
"plural": aktuell,
|
||
"used": benutzt,
|
||
"suggestion": vorschlag,
|
||
})
|
||
result["actions"].append({
|
||
"aid": aid,
|
||
"kind": kind,
|
||
"endpoint": endpoint,
|
||
"action": "set_plural",
|
||
"source_id": o["id"],
|
||
"source_name": o.get("name"),
|
||
"target_id": None,
|
||
"target_name": None,
|
||
# Vorbelegung = aktueller Plural, damit Bearbeiten den Stand zeigt.
|
||
"plural_value": aktuell,
|
||
"plural_before_scan": aktuell,
|
||
"reason": "Plural pflegen",
|
||
"score": 0,
|
||
"accept": False,
|
||
})
|
||
# Fehlende Plurale zuerst, dann die meistbenutzten, dann alphabetisch.
|
||
plurals.sort(key=lambda m: (bool(m["plural"]), -(m["used"]),
|
||
(m["name"] or "").casefold()))
|
||
|
||
# ------------------------------------------------------------ Löschen
|
||
for item in unused:
|
||
result["actions"].append({
|
||
"aid": new_aid(),
|
||
"kind": kind,
|
||
"endpoint": endpoint,
|
||
"action": "delete",
|
||
"source_id": item["id"],
|
||
"source_name": item["name"],
|
||
"target_id": None,
|
||
"target_name": None,
|
||
"rescue_properties": [],
|
||
"reason": "in keinem Rezept verwendet",
|
||
"score": 0,
|
||
# Löschen wird bewusst NICHT vorausgewählt.
|
||
"accept": False,
|
||
})
|
||
|
||
result["kinds"][kind] = {
|
||
"label": KINDS[kind]["label"],
|
||
"plural": KINDS[kind]["plural"],
|
||
"total": len(objects),
|
||
"duplicate_groups": plan_groups,
|
||
"similar": similar,
|
||
"unused": unused,
|
||
"plurals": plurals,
|
||
}
|
||
|
||
# KI-Vorfilter: definitiv verschiedene Paare aussortieren, bevor die Liste
|
||
# überhaupt erscheint. Nur wenn gewünscht und ein Schlüssel da ist.
|
||
if getattr(args, "ki_filter", False):
|
||
if not os.environ.get("OPENAI_API_KEY"):
|
||
out("KI-Vorauswahl übersprungen — OPENAI_API_KEY fehlt.")
|
||
else:
|
||
model = getattr(args, "model", None) or os.environ.get("OPENAI_MODEL", "gpt-5.5")
|
||
try:
|
||
z = _ai_classify_similars(result, model)
|
||
result["ai_filtered"] = True
|
||
out(f"KI-Vorauswahl: {z['geprueft']} Paare — {z['gleich']} gleich "
|
||
f"(angehakt), {z['unklar']} unklar, {z['verschieden']} verschieden "
|
||
f"(ausgeblendet).")
|
||
except Exception as exc: # noqa: BLE001
|
||
out(f"KI-Vorauswahl fehlgeschlagen (Paare bleiben unverändert): {exc}")
|
||
|
||
return result
|
||
|
||
|
||
def print_scan(plan: dict[str, Any]) -> None:
|
||
out()
|
||
out("─" * 64)
|
||
for kind, data in plan["kinds"].items():
|
||
merges = sum(len(g["sources"]) for g in data["duplicate_groups"])
|
||
out(f" {data['plural']:<14} {data['total']:>4} Einträge · "
|
||
f"{merges} zusammenführbar · {len(data['similar'])} zur Ansicht · "
|
||
f"{len(data['unused'])} unbenutzt")
|
||
out("─" * 64)
|
||
|
||
for kind, data in plan["kinds"].items():
|
||
if data["duplicate_groups"]:
|
||
out()
|
||
out(f" {data['plural']} — sicher dasselbe:")
|
||
for group in data["duplicate_groups"]:
|
||
t = group["target"]
|
||
out(f" Ziel: {t['name']} [{t['id']}] "
|
||
f"({t['numrecipe']} Rezepte, {t['properties']} Eigenschaften)")
|
||
for s in group["sources"]:
|
||
rettung = (f" ⚠ {len(s['rescue_properties'])} Werte werden vorher gerettet"
|
||
if s["rescue_properties"] else "")
|
||
out(f" ← {s['name']} [{s['id']}] "
|
||
f"({s['numrecipe']} Rezepte){rettung}")
|
||
out(f" {group['reason']} · {group['score']}%")
|
||
|
||
if data["similar"]:
|
||
out()
|
||
out(f" {data['plural']} — ähnlich, bitte selbst entscheiden "
|
||
f"(nicht vorausgewählt):")
|
||
for pair in data["similar"][:15]:
|
||
out(f" {pair['left']['name']} [{pair['left']['id']}] ~ "
|
||
f"{pair['right']['name']} [{pair['right']['id']}] "
|
||
f"{pair['score']}% · {pair['reason']}")
|
||
|
||
if data["unused"]:
|
||
out()
|
||
out(f" {data['plural']} — unbenutzt ({len(data['unused'])}, "
|
||
f"Löschen ist nicht vorausgewählt):")
|
||
for item in data["unused"][:15]:
|
||
out(f" {item['name']} [{item['id']}]")
|
||
if len(data["unused"]) > 15:
|
||
out(f" … und {len(data['unused']) - 15} weitere")
|
||
|
||
|
||
# -------------------------------------------------------------- Ausführen
|
||
|
||
def rescue_properties(
|
||
client: TandoorClient,
|
||
source_id: int,
|
||
target_id: int,
|
||
dry: bool,
|
||
) -> tuple[str, list[int]]:
|
||
"""
|
||
Werte, die nur die Quelle hat, vor dem Zusammenführen ans Ziel retten.
|
||
|
||
Ermittelt selbst, welche Eigenschaften das Ziel noch fehlen und die Quelle
|
||
hat — nötig, weil bei „ähnlichen“ Paaren die Richtung erst in der Oberfläche
|
||
gewählt wird. Gibt (Notiz, gerettete Eigenschafts-IDs) zurück.
|
||
"""
|
||
source = client.get_json(f"api/food/{source_id}/")
|
||
target = client.get_json(f"api/food/{target_id}/")
|
||
quelle = food_property_map(source)
|
||
ziel = food_property_map(target)
|
||
|
||
updates = {
|
||
tid: wert for tid, wert in quelle.items()
|
||
if wert is not None and ziel.get(tid) is None
|
||
}
|
||
if not updates:
|
||
return "", []
|
||
if dry:
|
||
return f"würde {len(updates)} Werte ans Ziel retten", list(updates)
|
||
|
||
body: dict[str, Any] = {"properties": merged_properties_payload(target, updates)}
|
||
if not target.get("properties_food_amount") and source.get("properties_food_amount"):
|
||
body["properties_food_amount"] = source["properties_food_amount"]
|
||
unit = source.get("properties_food_unit")
|
||
if isinstance(unit, dict) and unit.get("id"):
|
||
body["properties_food_unit"] = {"id": unit["id"], "name": unit.get("name")}
|
||
client.patch_json(f"api/food/{target_id}/", body)
|
||
return f"{len(updates)} Werte gerettet", list(updates)
|
||
|
||
|
||
def _set_plural(client: TandoorClient, endpoint: str, obj: dict[str, Any], plural: str) -> str:
|
||
"""
|
||
Setzt ``plural_name`` so robust wie möglich und gibt die erfolgreiche
|
||
Methode zurück.
|
||
|
||
Zutaten akzeptiert Tandoor über einen schlichten Teil-PATCH. Einheiten
|
||
lassen sich damit in manchen Tandoor-Versionen NICHT ändern — der Server
|
||
antwortet mit HTTP 500. Deshalb wird dort zuerst ein vollständiger PUT
|
||
versucht (anderer Code-Pfad, alle vorhandenen Felder bleiben erhalten, damit
|
||
z. B. eine hinterlegte Basiseinheit nicht verloren geht), danach PATCH mit
|
||
Name und zuletzt der schlichte PATCH. Scheitert alles, wirft es den letzten
|
||
Fehler.
|
||
"""
|
||
url = f"api/{endpoint}/{obj['id']}/"
|
||
if endpoint == "food":
|
||
client.patch_json(url, {"plural_name": plural})
|
||
return "PATCH"
|
||
|
||
voll = dict(obj)
|
||
voll["plural_name"] = plural
|
||
versuche = (
|
||
("PUT", lambda: client.put_json(url, voll)),
|
||
("PATCH name+plural",
|
||
lambda: client.patch_json(url, {"name": obj.get("name"), "plural_name": plural})),
|
||
("PATCH plural", lambda: client.patch_json(url, {"plural_name": plural})),
|
||
)
|
||
letzter: Exception | None = None
|
||
for _label, call in versuche:
|
||
try:
|
||
call()
|
||
return _label
|
||
except TandoorError as exc:
|
||
letzter = exc
|
||
raise letzter # type: ignore[misc]
|
||
|
||
|
||
def execute(client: TandoorClient, args: argparse.Namespace) -> int:
|
||
file = Path(args.plan).expanduser().resolve()
|
||
if not file.is_file():
|
||
raise SystemExit(f"Plandatei nicht gefunden: {file}")
|
||
plan = json.loads(file.read_text(encoding="utf-8"))
|
||
|
||
actions = [a for a in plan.get("actions", []) if a.get("accept")]
|
||
merges = [a for a in actions if a["action"] == "merge"]
|
||
set_plurals = [a for a in actions if a["action"] == "set_plural"]
|
||
deletes = [a for a in actions if a["action"] == "delete"]
|
||
if not actions:
|
||
out("Im Plan ist nichts angehakt.")
|
||
return 0
|
||
|
||
# Angehakte „ähnliche“ Paare ohne gewählte Richtung sind unvollständig.
|
||
ohne_richtung = [a for a in merges
|
||
if not (isinstance(a.get("source_id"), int)
|
||
and isinstance(a.get("target_id"), int)
|
||
and a["source_id"] != a["target_id"])]
|
||
if ohne_richtung:
|
||
for a in ohne_richtung:
|
||
paar = f"„{a.get('left_name')}“ ~ „{a.get('right_name')}“"
|
||
out(f"Übersprungen (keine Richtung gewählt): {paar}")
|
||
merges = [a for a in merges if a not in ohne_richtung]
|
||
|
||
if not merges and not set_plurals and not deletes:
|
||
out("Nichts Ausführbares angehakt.")
|
||
return 0
|
||
|
||
out(f"Modus: {'AUSFÜHREN' if args.apply else 'TROCKENÜBUNG'}")
|
||
out(f"Tandoor: {client.base_url}")
|
||
out(f"{len(merges)} Zusammenführungen · {len(set_plurals)} Plural-Ergänzungen "
|
||
f"· {len(deletes)} Löschungen")
|
||
if args.apply:
|
||
out()
|
||
out("Zusammenführen und Löschen sind in Tandoor NICHT umkehrbar.")
|
||
out("Dieses Werkzeug zeichnet vorher auf, was nötig ist, um den")
|
||
out("Zustand später wieder herzustellen — siehe Reiter „Sicherungen“.")
|
||
out()
|
||
|
||
index: dict[str, Any] = {"food": {}, "unit": {}, "keyword": {}}
|
||
if merges:
|
||
index = reference_index(client)
|
||
out()
|
||
|
||
run = data_dir() / "laeufe" / stamp()
|
||
run.mkdir(parents=True, exist_ok=True)
|
||
(run / "plan.json").write_text(
|
||
json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
manifest: dict[str, Any] = {
|
||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
"plugin": "tandoor-cleanup",
|
||
"label": f"Aufräumen ({file.stem})",
|
||
"tandoor": client.base_url,
|
||
"mode": "apply" if args.apply else "dry",
|
||
"plan": file.name,
|
||
"steps": [],
|
||
}
|
||
|
||
done, failed = 0, 0
|
||
|
||
# Erst zusammenführen, dann löschen: Eine Zusammenführung kann einen
|
||
# Eintrag unbenutzt machen, andersherum wäre die Quelle schon weg.
|
||
for nummer, action in enumerate(merges, start=1):
|
||
kind = action["kind"]
|
||
label = KINDS[kind]["label"]
|
||
verweise = index.get(kind, {}).get(action["source_id"], [])
|
||
|
||
try:
|
||
quelle = client.get_json(f"api/{action['endpoint']}/{action['source_id']}/")
|
||
ziel = client.get_json(f"api/{action['endpoint']}/{action['target_id']}/")
|
||
except TandoorError as exc:
|
||
out(f"[{nummer}/{len(merges)}] {label} "
|
||
f"[{action['source_id']}] → [{action['target_id']}]: nicht lesbar — {exc}")
|
||
failed += 1
|
||
if not args.continue_on_error:
|
||
return 1
|
||
continue
|
||
|
||
source_name = action.get("source_name") or quelle.get("name")
|
||
target_name = action.get("target_name") or ziel.get("name")
|
||
prefix = (f"[{nummer}/{len(merges)}] {label}: "
|
||
f"„{source_name}“ [{action['source_id']}] → "
|
||
f"„{target_name}“ [{action['target_id']}]")
|
||
|
||
# Plural am Ziel ergänzen? Nur Food, nur wenn Ziel noch keinen hat.
|
||
will_plural = (kind == "food" and action.get("set_plural")
|
||
and (action.get("plural_value") or "").strip()
|
||
and not (ziel.get("plural_name") or "").strip())
|
||
plural_value = (action.get("plural_value") or "").strip()
|
||
|
||
step: dict[str, Any] = {
|
||
"n": nummer,
|
||
"action": "merge",
|
||
"kind": kind,
|
||
"endpoint": action["endpoint"],
|
||
"origin": action.get("origin", "duplicate"),
|
||
"source": quelle,
|
||
"source_id": action["source_id"],
|
||
"source_name": action["source_name"] or quelle.get("name"),
|
||
"target_id": action["target_id"],
|
||
"target_name": action["target_name"] or ziel.get("name"),
|
||
"target_before": ziel,
|
||
"references": verweise,
|
||
"rescued": [],
|
||
"plural_set": plural_value if will_plural else "",
|
||
"target_plural_before": ziel.get("plural_name") or "",
|
||
"restore_level": "teilweise",
|
||
"status": "geplant",
|
||
}
|
||
|
||
try:
|
||
if args.apply:
|
||
for rolle, obj in (("quelle", quelle), ("ziel", ziel)):
|
||
(run / f"{kind}-{rolle}-{obj['id']:05d}.json").write_text(
|
||
json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
note = ""
|
||
if kind == "food" and not args.ohne_rettung:
|
||
note, gerettet = rescue_properties(
|
||
client, action["source_id"], action["target_id"], dry=not args.apply)
|
||
step["rescued"] = gerettet
|
||
|
||
plural_note = f"Plural „{plural_value}“ ergänzt" if will_plural else ""
|
||
|
||
if not args.apply:
|
||
out(f"{prefix}: würde zusammenführen"
|
||
+ (f" · {note}" if note else "")
|
||
+ (f" · {len(verweise)} Verweise werden umgehängt" if verweise else "")
|
||
+ (f" · würde {plural_note}" if will_plural else ""))
|
||
done += 1
|
||
continue
|
||
|
||
step["status"] = "angefangen"
|
||
manifest["steps"].append(step)
|
||
backups.write_manifest(run, manifest)
|
||
|
||
client.merge(action["endpoint"], action["source_id"], action["target_id"])
|
||
client.forget(action["endpoint"])
|
||
|
||
if will_plural:
|
||
client.patch_json(f"api/food/{action['target_id']}/",
|
||
{"plural_name": plural_value})
|
||
|
||
step["status"] = "done"
|
||
backups.write_manifest(run, manifest)
|
||
out(f"{prefix}: ✓ zusammengeführt"
|
||
+ (f" · {note}" if note else "")
|
||
+ (f" · {len(verweise)} Verweise umgehängt" if verweise else "")
|
||
+ (f" · {plural_note}" if will_plural else ""))
|
||
done += 1
|
||
except TandoorError as exc:
|
||
out(f"{prefix}: FEHLER — {exc}")
|
||
step["status"] = "fehler"
|
||
step["error"] = str(exc)
|
||
if args.apply:
|
||
backups.write_manifest(run, manifest)
|
||
failed += 1
|
||
if not args.continue_on_error:
|
||
out("Abbruch. Bereits ausgeführte Schritte bleiben bestehen.")
|
||
return 1
|
||
|
||
# ---------------------------------------------------- Plurale pflegen
|
||
plural_kaputt: set[str] = set() # Endpunkte, die Tandoor systematisch ablehnt
|
||
for nummer, action in enumerate(set_plurals, start=1):
|
||
endpoint = action.get("endpoint", "food")
|
||
kind = action.get("kind", "food")
|
||
obj_id = action["source_id"]
|
||
wert = (action.get("plural_value") or "").strip()
|
||
label = KINDS.get(kind, {}).get("label", "Eintrag")
|
||
prefix = f"[{nummer}/{len(set_plurals)}] Plural {label}: „{action['source_name']}“ [{obj_id}]"
|
||
|
||
if endpoint in plural_kaputt:
|
||
out(f"{prefix}: übersprungen — {label}-Plurale werden von Tandoor "
|
||
f"abgelehnt (siehe erste Meldung; bitte in Tandoor selbst setzen)")
|
||
continue
|
||
|
||
try:
|
||
obj = client.get_json(f"api/{endpoint}/{obj_id}/")
|
||
except TandoorError:
|
||
# Eintrag existiert nicht mehr (z. B. vorher zusammengeführt) —
|
||
# das ist kein Fehler, nur nichts mehr zu tun.
|
||
out(f"{prefix}: übersprungen — Eintrag nicht mehr vorhanden")
|
||
continue
|
||
|
||
aktuell = (obj.get("plural_name") or "").strip()
|
||
if wert == aktuell:
|
||
out(f"{prefix}: unverändert („{aktuell}“) — übersprungen")
|
||
continue
|
||
|
||
hinweis = f"„{aktuell}“ → „{wert}“" if aktuell else f"„{wert}“"
|
||
if not args.apply:
|
||
out(f"{prefix}: würde Plural setzen ({hinweis})")
|
||
done += 1
|
||
continue
|
||
|
||
try:
|
||
methode = _set_plural(client, endpoint, obj, wert)
|
||
except TandoorError as exc:
|
||
# Einheiten/Schlagworte: lehnt Tandoor den Schreibzugriff komplett
|
||
# ab, sind es fast sicher alle — nicht weiter hämmern.
|
||
if endpoint != "food":
|
||
plural_kaputt.add(endpoint)
|
||
out(f"{prefix}: FEHLER — Tandoor akzeptiert {label}-Plurale nicht "
|
||
f"(alle Schreibwege scheiterten). Weitere {label}-Plurale "
|
||
f"werden übersprungen; bitte direkt in Tandoor setzen.")
|
||
out(f" Letzte Serverantwort: {str(exc)[:200]}")
|
||
else:
|
||
out(f"{prefix}: FEHLER — {exc}")
|
||
failed += 1
|
||
if not args.continue_on_error:
|
||
return 1
|
||
continue
|
||
|
||
# Erst nach erfolgreichem Schreiben sichern.
|
||
(run / f"{kind}-plural-{obj_id:05d}.json").write_text(
|
||
json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
manifest["steps"].append({
|
||
"n": len(merges) + nummer,
|
||
"action": "set_plural",
|
||
"kind": kind,
|
||
"endpoint": endpoint,
|
||
"source_id": obj_id,
|
||
"source_name": action["source_name"],
|
||
"plural_before": aktuell,
|
||
"plural_after": wert,
|
||
"restore_level": "voll",
|
||
"status": "done",
|
||
})
|
||
backups.write_manifest(run, manifest)
|
||
out(f"{prefix}: ✓ Plural gesetzt ({hinweis})"
|
||
+ (f" [{methode}]" if methode not in ("PATCH", "PUT") else ""))
|
||
done += 1
|
||
|
||
for nummer, action in enumerate(deletes, start=1):
|
||
kind = action["kind"]
|
||
label = KINDS[kind]["label"]
|
||
prefix = (f"[{nummer}/{len(deletes)}] {label} löschen: "
|
||
f"„{action['source_name']}“ [{action['source_id']}]")
|
||
try:
|
||
obj = client.get_json(f"api/{action['endpoint']}/{action['source_id']}/")
|
||
step = {
|
||
"n": len(merges) + len(set_plurals) + nummer,
|
||
"action": "delete",
|
||
"kind": kind,
|
||
"endpoint": action["endpoint"],
|
||
"source": obj,
|
||
"source_id": action["source_id"],
|
||
"source_name": action["source_name"],
|
||
"references": [],
|
||
"restore_level": "neue_id",
|
||
"status": "geplant",
|
||
}
|
||
if not args.apply:
|
||
out(f"{prefix}: würde löschen")
|
||
done += 1
|
||
continue
|
||
|
||
(run / f"geloescht-{kind}-{obj['id']:05d}.json").write_text(
|
||
json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
step["status"] = "angefangen"
|
||
manifest["steps"].append(step)
|
||
backups.write_manifest(run, manifest)
|
||
|
||
client.delete(f"api/{action['endpoint']}/{action['source_id']}/")
|
||
step["status"] = "done"
|
||
backups.write_manifest(run, manifest)
|
||
out(f"{prefix}: ✓ gelöscht")
|
||
done += 1
|
||
except TandoorError as exc:
|
||
out(f"{prefix}: FEHLER — {exc}")
|
||
failed += 1
|
||
if not args.continue_on_error:
|
||
return 1
|
||
|
||
if args.apply:
|
||
backups.write_manifest(run, manifest)
|
||
|
||
out()
|
||
out("─" * 64)
|
||
verb = "ausgeführt" if args.apply else "würden ausgeführt"
|
||
out(f" {done} Schritte {verb} · {failed} Fehler")
|
||
if args.apply:
|
||
out(f" Sicherungen: {run}")
|
||
out(f" Zurückspielen: Reiter „Sicherungen“ oder")
|
||
out(f" cleanup.py zurueck --lauf {run.name} --apply")
|
||
else:
|
||
out(" Nichts verändert. Mit --apply wird es ernst.")
|
||
out("─" * 64)
|
||
return 1 if failed else 0
|
||
|
||
|
||
# ---------------------------------------------------------- Zurückspielen
|
||
|
||
def recreate(
|
||
client: TandoorClient,
|
||
endpoint: str,
|
||
payload: dict[str, Any],
|
||
forbidden_id: int | None,
|
||
) -> tuple[dict[str, Any], str]:
|
||
"""
|
||
Einen gelöschten Eintrag wieder anlegen.
|
||
|
||
Tücke: Tandoor legt beim POST nicht immer etwas Neues an. Der Quelltext
|
||
macht ein get_or_create über den Namen —
|
||
|
||
Food/Unit filter(Q(name__iexact=n) | Q(plural_name__iexact=n))
|
||
Keyword get_or_create(name=n) (case-sensitiv)
|
||
|
||
— und gibt sonst den vorhandenen Eintrag zurück. Nach einem
|
||
„Tomaten → Tomate“ ist „Tomaten“ als *Plural* von „Tomate“ belegt: Ein POST
|
||
„Tomaten“ liefert dann wieder „Tomate“, und die Wiederherstellung wäre
|
||
stillschweigend wirkungslos.
|
||
|
||
Deshalb: erst anlegen, prüfen was zurückkam, und nur wenn nötig den Umweg
|
||
über einen freien Zwischennamen gehen. Das Umbenennen per PATCH prüft den
|
||
Namen nicht — und die Datenbank stört sich nicht daran, weil ihre
|
||
Eindeutigkeit case-sensitiv ist (Food hat gar keine).
|
||
"""
|
||
gewuenscht = (payload.get("name") or "").strip()
|
||
neu = client.post_json(f"api/{endpoint}/", payload)
|
||
client.forget(endpoint)
|
||
|
||
passt = (neu.get("id") != forbidden_id
|
||
and (neu.get("name") or "").strip() == gewuenscht)
|
||
if passt:
|
||
return neu, "angelegt"
|
||
|
||
# Tandoor hat einen vorhandenen Eintrag zurückgegeben. Umweg gehen.
|
||
zwischen = dict(payload)
|
||
zwischen["name"] = f"{gewuenscht}-wiederherstellung-{secrets.token_hex(3)}"
|
||
neu = client.post_json(f"api/{endpoint}/", zwischen)
|
||
client.forget(endpoint)
|
||
if neu.get("id") == forbidden_id:
|
||
raise TandoorError(
|
||
"Tandoor gibt beim Anlegen den Zieleintrag zurück — "
|
||
"der Name lässt sich nicht wieder belegen."
|
||
)
|
||
try:
|
||
neu = client.patch_json(f"api/{endpoint}/{neu['id']}/", {
|
||
"name": gewuenscht,
|
||
"plural_name": payload.get("plural_name") or "",
|
||
})
|
||
client.forget(endpoint)
|
||
except TandoorError as exc:
|
||
# Keine Leiche hinterlassen.
|
||
try:
|
||
client.delete(f"api/{endpoint}/{neu['id']}/")
|
||
except TandoorError:
|
||
pass
|
||
raise TandoorError(f"Umbenennen nach dem Anlegen schlug fehl: {exc}") from exc
|
||
|
||
if (neu.get("name") or "").strip() != gewuenscht:
|
||
raise TandoorError(
|
||
f"Eintrag heißt nach dem Umbenennen „{neu.get('name')}“ "
|
||
f"statt „{gewuenscht}“."
|
||
)
|
||
return neu, "angelegt (über Zwischennamen, da der Name belegt war)"
|
||
|
||
|
||
def restore(client: TandoorClient, args: argparse.Namespace) -> int:
|
||
"""
|
||
Einen Aufräum-Lauf rückgängig machen.
|
||
|
||
Was das kann und was nicht:
|
||
|
||
Gelöschte Einträge werden neu angelegt. Sie bekommen eine neue ID. Da nur
|
||
unbenutzte Einträge gelöscht werden, zeigte ohnehin nichts auf sie.
|
||
|
||
Zusammengeführte Einträge werden neu angelegt und die aufgezeichneten
|
||
Rezeptverweise wieder auf sie zurückgehängt. Auch hier: neue ID. Was
|
||
nicht aufgezeichnet wurde — Einkaufslisten, Einheiten-Umrechnungen,
|
||
Automatisierungen —, bleibt beim Ziel. Deshalb „teilweise“.
|
||
"""
|
||
runs_dir = data_dir() / "laeufe"
|
||
try:
|
||
run = backups.resolve_run(runs_dir, args.lauf)
|
||
except FileNotFoundError as exc:
|
||
out(str(exc))
|
||
return 2
|
||
manifest = backups.read_manifest(run) or {}
|
||
steps = [s for s in manifest.get("steps", []) if s.get("status") == "done"]
|
||
if not steps:
|
||
out("In diesem Lauf wurde nichts ausgeführt — nichts zurückzuspielen.")
|
||
return 0
|
||
|
||
out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'TROCKENÜBUNG'}")
|
||
out(f"Lauf: {run.name} ({manifest.get('label')})")
|
||
out(f" vom {manifest.get('created_at')}")
|
||
out(f"Tandoor: {client.base_url}")
|
||
if manifest.get("tandoor") and manifest["tandoor"] != client.base_url:
|
||
out()
|
||
out(f"ACHTUNG: Der Lauf ging gegen {manifest['tandoor']},")
|
||
out(f" jetzt eingestellt ist {client.base_url}.")
|
||
if not args.force:
|
||
out(" Abbruch. Mit --force trotzdem.")
|
||
return 2
|
||
verlauf = backups.restore_history(run)
|
||
if verlauf and not args.force:
|
||
out()
|
||
out(f"Dieser Lauf wurde bereits am {verlauf[-1]['at']} zurückgespielt.")
|
||
out("Mit --force noch einmal.")
|
||
return 2
|
||
|
||
out(f"{len(steps)} Schritte")
|
||
out()
|
||
out("Wichtig: Wiederhergestellte Einträge bekommen eine NEUE ID. Die alte")
|
||
out("ist in Tandoor vergeben und weg. Rezepte zeigen danach wieder auf den")
|
||
out("richtigen Eintrag, nur eben unter neuer Nummer.")
|
||
out()
|
||
|
||
zurueck, fehler = 0, 0
|
||
|
||
# Rückwärts: der letzte Eingriff wird zuerst rückgängig gemacht.
|
||
for step in reversed(steps):
|
||
# Reine Plural-Ergänzung: nur den alten Plural zurückschreiben.
|
||
if step.get("action") == "set_plural":
|
||
endpoint = step.get("endpoint", "food")
|
||
fid = step["source_id"]
|
||
alt = step.get("plural_before", "")
|
||
prefix = f"[{step['n']}] Plural „{step['source_name']}“ [{fid}]"
|
||
if not args.apply:
|
||
out(f"{prefix}: würde Plural auf „{alt}“ zurücksetzen")
|
||
zurueck += 1
|
||
continue
|
||
try:
|
||
obj = client.get_json(f"api/{endpoint}/{fid}/")
|
||
_set_plural(client, endpoint, obj, alt)
|
||
out(f"{prefix}: ✓ Plural zurückgesetzt")
|
||
zurueck += 1
|
||
except TandoorError as exc:
|
||
out(f"{prefix}: FEHLER — {exc}")
|
||
fehler += 1
|
||
continue
|
||
|
||
kind = step["kind"]
|
||
label = KINDS[kind]["label"]
|
||
endpoint = step["endpoint"]
|
||
quelle = step["source"]
|
||
prefix = f"[{step['n']}] {label} „{step['source_name']}“"
|
||
|
||
# Wurde beim Zusammenführen ein Plural am Ziel ergänzt, muss dieser
|
||
# ZUERST zurück — sonst gäbe Tandoor beim Anlegen der Quelle (deren Name
|
||
# jetzt der Plural des Ziels ist) den Zieleintrag zurück.
|
||
if args.apply and step.get("plural_set") and isinstance(step.get("target_id"), int):
|
||
try:
|
||
client.patch_json(f"api/food/{step['target_id']}/",
|
||
{"plural_name": step.get("target_plural_before", "")})
|
||
client.forget(endpoint)
|
||
except TandoorError:
|
||
pass
|
||
|
||
# 1. Eintrag wieder anlegen
|
||
payload: dict[str, Any] = {
|
||
"name": quelle.get("name"),
|
||
"plural_name": quelle.get("plural_name") or "",
|
||
}
|
||
if quelle.get("description"):
|
||
payload["description"] = quelle["description"]
|
||
if kind == "food":
|
||
props = []
|
||
for prop in quelle.get("properties") or []:
|
||
tid = (prop.get("property_type") or {}).get("id")
|
||
if isinstance(tid, int):
|
||
props.append({"property_amount": prop.get("property_amount"),
|
||
"property_type": {"id": tid}})
|
||
if props:
|
||
payload["properties"] = props
|
||
if quelle.get("properties_food_amount") is not None:
|
||
payload["properties_food_amount"] = quelle["properties_food_amount"]
|
||
pfu = quelle.get("properties_food_unit")
|
||
if isinstance(pfu, dict) and pfu.get("id"):
|
||
payload["properties_food_unit"] = {"id": pfu["id"], "name": pfu.get("name")}
|
||
|
||
verweise = step.get("references") or []
|
||
if not args.apply:
|
||
teile = [f"würde {label.lower()} „{quelle.get('name')}“ neu anlegen"]
|
||
if kind == "food" and payload.get("properties"):
|
||
teile.append(f"{len(payload['properties'])} Eigenschaften zurück")
|
||
if verweise:
|
||
teile.append(f"{len(verweise)} Verweise zurückhängen")
|
||
out(f"{prefix}: " + " · ".join(teile))
|
||
zurueck += 1
|
||
continue
|
||
|
||
try:
|
||
neu, wie = recreate(client, endpoint, payload, step.get("target_id"))
|
||
except TandoorError as exc:
|
||
out(f"{prefix}: konnte nicht angelegt werden — {exc}")
|
||
fehler += 1
|
||
continue
|
||
|
||
neue_id = neu.get("id")
|
||
if neue_id == step["source_id"]:
|
||
hinweis = f"wieder unter ID {neue_id}"
|
||
else:
|
||
hinweis = f"neu unter ID {neue_id} (vorher {step['source_id']})"
|
||
if "Zwischennamen" in wie:
|
||
hinweis += ", Name war belegt"
|
||
|
||
# 2. Verweise zurückhängen
|
||
umgehaengt, verfehlt = 0, 0
|
||
for ref in verweise:
|
||
try:
|
||
if kind in ("food", "unit"):
|
||
iid = ref["ingredient_id"]
|
||
aktuell = None
|
||
# Schutz: Nur zurückhängen, was tatsächlich noch auf dem
|
||
# Ziel steht. Wer die Zutat seither von Hand geändert hat,
|
||
# soll das nicht überschrieben bekommen.
|
||
recipe = client.get_json(f"api/recipe/{ref['recipe_id']}/")
|
||
for st in recipe.get("steps") or []:
|
||
for ing in st.get("ingredients") or []:
|
||
if ing.get("id") == iid:
|
||
aktuell = ing
|
||
if aktuell is None:
|
||
verfehlt += 1
|
||
continue
|
||
feld = "food" if kind == "food" else "unit"
|
||
zeigt_auf = (aktuell.get(feld) or {}).get("id")
|
||
if zeigt_auf != step["target_id"] and not args.force:
|
||
verfehlt += 1
|
||
continue
|
||
client.patch_json(f"api/ingredient/{iid}/",
|
||
{feld: {"id": neue_id, "name": neu.get("name")}})
|
||
umgehaengt += 1
|
||
else: # keyword
|
||
vorher = ref.get("keywords_before") or []
|
||
liste = [{"id": neue_id if k == step["source_id"] else k}
|
||
for k in vorher]
|
||
client.patch_json(f"api/recipe/{ref['recipe_id']}/",
|
||
{"keywords": liste})
|
||
umgehaengt += 1
|
||
except TandoorError as exc:
|
||
out(f" Verweis {ref}: {exc}")
|
||
verfehlt += 1
|
||
|
||
teile = [f"✓ {hinweis}"]
|
||
if umgehaengt:
|
||
teile.append(f"{umgehaengt} Verweise zurückgehängt")
|
||
if verfehlt:
|
||
teile.append(f"{verfehlt} übersprungen (zwischenzeitlich geändert)")
|
||
out(f"{prefix}: " + " · ".join(teile))
|
||
zurueck += 1
|
||
|
||
out()
|
||
out("─" * 64)
|
||
verb = "zurückgespielt" if args.apply else "würden zurückgespielt"
|
||
out(f" {zurueck} Schritte {verb} · {fehler} Fehler")
|
||
if args.apply:
|
||
backups.mark_restored(run, {"restored": zurueck, "errors": fehler})
|
||
out(" Der Lauf ist als zurückgespielt vermerkt.")
|
||
out()
|
||
out(" Was NICHT zurückkommt: Einkaufslisten-Einträge, Einheiten-")
|
||
out(" Umrechnungen und Automatisierungen, die auf den alten Eintrag")
|
||
out(" zeigten. Die hat Tandoor beim Zusammenführen aufs Ziel gehängt")
|
||
out(" und dort bleiben sie.")
|
||
else:
|
||
out(" Nichts verändert. Mit --apply wird zurückgespielt.")
|
||
out("─" * 64)
|
||
return 1 if fehler else 0
|
||
|
||
|
||
# ----------------------------------------------------------------- Aufruf
|
||
|
||
# ======================================================== KI-Vorauswahl (ChatGPT)
|
||
|
||
PLURAL_BATCH = 40
|
||
|
||
_KIND_PLURAL = {"food": "Zutaten", "unit": "Einheiten", "keyword": "Schlagworte"}
|
||
_KIND_SINGULAR = {"food": "Zutat", "unit": "Maßeinheit", "keyword": "Schlagwort"}
|
||
|
||
|
||
def _ai_classify_similars(result: dict[str, Any], model: str) -> dict[str, int]:
|
||
"""
|
||
KI ordnet jedes ähnliche Paar in drei Töpfe:
|
||
|
||
* **gleich** – zweifelsfrei dasselbe (Schreibvarianten, fehlendes
|
||
Leerzeichen, Groß/Klein, Singular/Plural). Wird ANGEHAKT und die Richtung
|
||
vorgewählt (der bleibende Name ist der allgemeinere/korrekt geschriebene).
|
||
* **unklar** – könnte dasselbe sein, aber nicht sicher. Wird angezeigt, aber
|
||
nicht vorgewählt — der Mensch entscheidet.
|
||
* **verschieden** – zweifelsfrei verschiedene Sorten/Produkte. Wird
|
||
standardmäßig ausgeblendet (per Filter wieder einblendbar).
|
||
|
||
Jedes Paar bekommt ``ai_verdict`` + ``ai_reason``; die Merge-Aktionen der
|
||
„gleich“-Paare erhalten Richtung und ``accept``. Nichts wird gelöscht.
|
||
"""
|
||
by_aid = {a["aid"]: a for a in result["actions"]
|
||
if a.get("action") == "merge" and a.get("origin") == "similar"}
|
||
paare = []
|
||
for kind, data in result["kinds"].items():
|
||
for s in data.get("similar", []):
|
||
paare.append({
|
||
"id": s["aid"],
|
||
"art": _KIND_PLURAL.get(kind, kind),
|
||
"a": {"id": s["left"]["id"], "name": s["left"]["name"]},
|
||
"b": {"id": s["right"]["id"], "name": s["right"]["name"]},
|
||
})
|
||
if not paare:
|
||
return {"geprueft": 0, "gleich": 0, "unklar": 0, "verschieden": 0}
|
||
|
||
system = (
|
||
"Du bist ein sorgfältiger Datenpfleger für eine Rezept-Datenbank und "
|
||
"ordnest jedes Namenspaar in genau eine Kategorie:\n"
|
||
"• „gleich“ = zweifelsfrei dasselbe: Schreib-/Tippvarianten, fehlendes "
|
||
"Leerzeichen, Groß-/Kleinschreibung, Singular/Plural oder dieselbe "
|
||
"Sache nur anders geschrieben.\n"
|
||
"• „verschieden“ = zweifelsfrei verschiedene Sorten, Typen, Grade oder "
|
||
"Produkte (z. B. „Weizenmehl 550“ vs. „Weizenmehl 1050“, „Frischkäse“ "
|
||
"vs. „körniger Frischkäse“, fettreduziert vs. normal, mit/ohne Zusatz "
|
||
"wie „in Öl“) oder klar verschiedene Zutaten.\n"
|
||
"• „unklar“ = könnte dasselbe sein, ist aber nicht sicher.\n"
|
||
"Sei konservativ: „gleich“ nur, wenn es wirklich zweifelsfrei dasselbe "
|
||
"ist. Bei „gleich“ nenne, welcher Eintrag BLEIBEN soll — der "
|
||
"allgemeinere bzw. korrekt geschriebene Name. Antworte nur mit JSON."
|
||
)
|
||
user = (
|
||
"Gib ein JSON-Objekt zurück: Schlüssel ist die „id“ des Paars, Wert ist "
|
||
'{"urteil": "gleich"|"unklar"|"verschieden", "keep_id": <id des '
|
||
'bleibenden Eintrags bei „gleich“, sonst null>, "grund": "kurz"}.'
|
||
"\n\nPaare:\n" + json.dumps(paare, ensure_ascii=False)
|
||
)
|
||
antwort = ai.chat_json(
|
||
[{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||
model,
|
||
)
|
||
if not isinstance(antwort, dict):
|
||
antwort = {}
|
||
|
||
zähler = {"geprueft": len(paare), "gleich": 0, "unklar": 0, "verschieden": 0}
|
||
for kind, data in result["kinds"].items():
|
||
for s in data.get("similar", []):
|
||
d = antwort.get(s["aid"]) if isinstance(antwort.get(s["aid"]), dict) else {}
|
||
urteil = d.get("urteil")
|
||
if urteil not in ("gleich", "unklar", "verschieden"):
|
||
urteil = "unklar"
|
||
grund = str(d.get("grund") or "").strip()[:200]
|
||
s["ai_verdict"] = urteil
|
||
s["ai_reason"] = grund
|
||
zähler[urteil] += 1
|
||
|
||
action = by_aid.get(s["aid"])
|
||
if action is None:
|
||
continue
|
||
if urteil == "gleich":
|
||
keep = d.get("keep_id")
|
||
links = keep == s["left"]["id"]
|
||
if keep not in (s["left"]["id"], s["right"]["id"]):
|
||
links = True # Vorgabe: linken (i. d. R. kürzeren) behalten
|
||
action["target_id"] = s["left"]["id"] if links else s["right"]["id"]
|
||
action["target_name"] = s["left"]["name"] if links else s["right"]["name"]
|
||
action["source_id"] = s["right"]["id"] if links else s["left"]["id"]
|
||
action["source_name"] = s["right"]["name"] if links else s["left"]["name"]
|
||
action["accept"] = True
|
||
action["ai_reason"] = grund or "KI: dasselbe"
|
||
else:
|
||
action["accept"] = False
|
||
action["source_id"] = action["target_id"] = None
|
||
action["source_name"] = action["target_name"] = None
|
||
action["ai_reason"] = grund
|
||
action["ai_verdict"] = urteil
|
||
return zähler
|
||
|
||
|
||
def _fill_plurals(actions: list[dict[str, Any]], model: str, alle: bool) -> int:
|
||
"""
|
||
Lässt die KI korrekte deutsche Plurale eintragen. Standard: nur dort, wo
|
||
noch keiner gepflegt ist (manuell Gepflegtes wird nicht überschrieben).
|
||
Setzt plural_value + accept. Rückgabe: Anzahl vorbelegter Plurale.
|
||
|
||
Einträge, die durch eine angehakte Zusammenführung ohnehin verschwinden,
|
||
werden übersprungen — ein Plural darauf liefe ins Leere.
|
||
"""
|
||
verschwindet = {
|
||
(a["kind"], a["source_id"]) for a in actions
|
||
if a["action"] == "merge" and a.get("accept") and isinstance(a.get("source_id"), int)
|
||
}
|
||
ziele = [a for a in actions if a["action"] == "set_plural"
|
||
and (a["kind"], a["source_id"]) not in verschwindet]
|
||
if not alle:
|
||
ziele = [a for a in ziele if not (a.get("plural_before_scan") or "").strip()]
|
||
if not ziele:
|
||
return 0
|
||
|
||
system = (
|
||
"Du bist Experte für deutsche Grammatik und Lebensmittel. Gib zu jedem "
|
||
"Namen den korrekten Nominativ Plural. Für Maßeinheiten und nicht "
|
||
"zählbare Stoffe (z. B. Mehl, Wasser, Öl, Salz, Milliliter, Gramm) ist "
|
||
"der Plural meist gleich der Einzahl — gib dann den Namen unverändert "
|
||
"zurück. Behalte Groß-/Kleinschreibung und Zusätze bei. Antworte "
|
||
"ausschließlich mit JSON."
|
||
)
|
||
gefuellt = 0
|
||
for start in range(0, len(ziele), PLURAL_BATCH):
|
||
batch = ziele[start:start + PLURAL_BATCH]
|
||
namen = [{"id": a["aid"],
|
||
"art": _KIND_SINGULAR.get(a["kind"], a["kind"]),
|
||
"name": a["source_name"]} for a in batch]
|
||
user = (
|
||
"Gib ein JSON-Objekt zurück: Schlüssel ist die „id“, Wert ist der "
|
||
"Plural als String.\n\nEinträge:\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 a in batch:
|
||
pl = antwort.get(a["aid"])
|
||
if isinstance(pl, str) and pl.strip():
|
||
a["plural_value"] = pl.strip()[:120]
|
||
a["ai"] = True
|
||
# Nur anhaken, wenn es sich vom Ist-Stand unterscheidet.
|
||
if a["plural_value"] != (a.get("plural_before_scan") or "").strip():
|
||
a["accept"] = True
|
||
gefuellt += 1
|
||
return gefuellt
|
||
|
||
|
||
def vorbelegen(args: argparse.Namespace) -> int:
|
||
"""KI schlägt Plurale für Zutaten und Einheiten vor (nur Vorbelegung)."""
|
||
file = Path(args.plan).expanduser().resolve()
|
||
if not file.is_file():
|
||
raise SystemExit(f"Plandatei nicht gefunden: {file}")
|
||
plan = json.loads(file.read_text(encoding="utf-8"))
|
||
actions = plan.get("actions", [])
|
||
|
||
out(f"KI-Plurale mit Modell {args.model} …")
|
||
gefuellt = _fill_plurals(actions, args.model, alle=args.alle_plurale)
|
||
out(f" {gefuellt} Plurale vorgeschlagen"
|
||
+ (" (auch bestehende)" if args.alle_plurale else " (nur fehlende)"))
|
||
|
||
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()
|
||
out(f"Plan aktualisiert: {file}")
|
||
out("Die Vorschläge bitte prüfen, bevor du ausführst.")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Stammdaten aufräumen")
|
||
parser.add_argument("--base-url", default=None)
|
||
parser.add_argument("--token", default=None)
|
||
parser.add_argument("--auth-scheme", default=None)
|
||
parser.add_argument("--timeout", type=float, default=None)
|
||
parser.add_argument("--insecure", action="store_true")
|
||
sub = parser.add_subparsers(dest="command", required=True)
|
||
|
||
p = sub.add_parser("pruefen", help="Dubletten und Unbenutztes suchen")
|
||
p.add_argument("--arten", default="", help="food,unit,keyword — leer = alle")
|
||
p.add_argument("--ki-filter", action="store_true",
|
||
help="ähnliche Paare per KI vorfiltern (definitiv verschiedene raus)")
|
||
p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5"))
|
||
|
||
p = sub.add_parser("ausfuehren", help="Plan abarbeiten")
|
||
p.add_argument("--plan", required=True)
|
||
p.add_argument("--apply", action="store_true", help="wirklich ausführen")
|
||
p.add_argument("--ohne-rettung", action="store_true",
|
||
help="Nährwerte der Quelle NICHT vorher ans Ziel retten")
|
||
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, help="Ordnername unter laeufe/")
|
||
p.add_argument("--apply", action="store_true", help="wirklich zurückspielen")
|
||
p.add_argument("--force", action="store_true",
|
||
help="auch bei zwischenzeitlichen Änderungen oder anderem Tandoor")
|
||
|
||
p = sub.add_parser("vorbelegen", help="KI trifft eine Vorauswahl (ChatGPT)")
|
||
p.add_argument("--plan", required=True)
|
||
p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5"))
|
||
p.add_argument("--alle-plurale", action="store_true",
|
||
help="auch bestehende Plurale neu vorschlagen (Vorsicht: überschreibt)")
|
||
|
||
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()
|
||
|
||
# Diese Befehle brauchen kein Tandoor — nur OpenAI bzw. eine Plandatei.
|
||
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 == "vorbelegen":
|
||
if not os.environ.get("OPENAI_API_KEY"):
|
||
out("OPENAI_API_KEY fehlt — siehe Einstellungen.")
|
||
return 2
|
||
return vorbelegen(args)
|
||
|
||
try:
|
||
client = TandoorClient.from_env(
|
||
base_url=args.base_url, token=args.token, auth_scheme=args.auth_scheme,
|
||
timeout=args.timeout, verify=False if args.insecure else None,
|
||
)
|
||
except TandoorError as exc:
|
||
out(f"Tandoor-Zugang fehlt: {exc}")
|
||
return 2
|
||
|
||
try:
|
||
if args.command == "pruefen":
|
||
plan = scan(client, args)
|
||
target = data_dir() / "plaene" / f"{stamp()}.json"
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_text(
|
||
json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
print_scan(plan)
|
||
out()
|
||
out(f"Plan: {target}")
|
||
out("Es wurde nichts verändert.")
|
||
return 0
|
||
|
||
if args.command == "ausfuehren":
|
||
return execute(client, args)
|
||
|
||
if args.command == "zurueck":
|
||
return restore(client, args)
|
||
except TandoorError as exc:
|
||
out(f"Tandoor meldet: {exc}")
|
||
return 1
|
||
except KeyboardInterrupt:
|
||
out("Abgebrochen.")
|
||
return 130
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|