1143 lines
42 KiB
Python
1143 lines
42 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Nährwerte der Tandoor-Zutaten prüfen und vervollständigen.
|
|
|
|
Drei Schritte, jeder einzeln aufrufbar:
|
|
|
|
nutrition.py pruefen
|
|
Liest alle Foods und Eigenschaften und schreibt einen Bericht:
|
|
Wo fehlt was? Welche Lücken tun weh (= stecken in vielen Rezepten)?
|
|
Verändert nichts.
|
|
|
|
nutrition.py vorschlagen --foods 1,2,3
|
|
Lässt fehlende Werte von OpenAI je 100 g schätzen und schreibt sie in
|
|
eine Vorschlagsdatei. Verändert in Tandoor nichts.
|
|
|
|
nutrition.py uebernehmen --vorschlag <datei>
|
|
Schreibt die Werte aus der Vorschlagsdatei per PATCH nach Tandoor.
|
|
Ohne --apply nur eine Trockenübung.
|
|
|
|
nutrition.py selftest
|
|
Prüft lokal, dass numerische Nullwerte beim Lesen und Schreiben erhalten
|
|
bleiben.
|
|
|
|
Die Werte sind Schätzungen eines Sprachmodells, keine Laboranalysen. Sie sind
|
|
für Hausgebrauch und Größenordnung gedacht — wer Nährwerte medizinisch braucht,
|
|
sollte sie gegen eine echte Datenbank prüfen.
|
|
|
|
Standalone:
|
|
python3 plugins/tandoor-nutrition/tool/nutrition.py pruefen
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import math
|
|
import os
|
|
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 ( # noqa: E402
|
|
TandoorClient,
|
|
TandoorError,
|
|
)
|
|
from core import backups # noqa: E402
|
|
|
|
BATCH = 20
|
|
|
|
|
|
_MISSING = object()
|
|
|
|
|
|
def _number_or_none(value: Any) -> float | None:
|
|
"""
|
|
Liefert eine endliche, nichtnegative Zahl oder ``None``.
|
|
|
|
Wichtig: Numerische Null ist ein gültiger Nährwert und darf niemals über
|
|
eine Wahrheitsprüfung wie ``if value`` verworfen werden. Auch numerische
|
|
Strings aus APIs bzw. JSON-Dateien werden akzeptiert.
|
|
"""
|
|
if value is None or isinstance(value, bool):
|
|
return None
|
|
|
|
if isinstance(value, (int, float)):
|
|
number = float(value)
|
|
elif isinstance(value, str):
|
|
raw = value.strip()
|
|
if not raw:
|
|
return None
|
|
# Dezimalkomma tolerieren, sofern nicht bereits ein Punkt vorkommt.
|
|
if "," in raw and "." not in raw:
|
|
raw = raw.replace(",", ".")
|
|
try:
|
|
number = float(raw)
|
|
except ValueError:
|
|
return None
|
|
else:
|
|
return None
|
|
|
|
if not math.isfinite(number) or number < 0:
|
|
return None
|
|
return number
|
|
|
|
|
|
def _property_type_id(prop: dict[str, Any]) -> int | None:
|
|
raw_type = prop.get("property_type")
|
|
raw_id = raw_type.get("id") if isinstance(raw_type, dict) else raw_type
|
|
try:
|
|
return int(raw_id)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def food_property_map(food: dict[str, Any]) -> dict[int, float]:
|
|
"""Liest Tandoor-Eigenschaften nullwertsicher als ``Typ-ID → Wert``."""
|
|
values: dict[int, float] = {}
|
|
for prop in food.get("properties") or []:
|
|
if not isinstance(prop, dict):
|
|
continue
|
|
type_id = _property_type_id(prop)
|
|
amount = _number_or_none(prop.get("property_amount"))
|
|
if type_id is not None and amount is not None:
|
|
# 0.0 wird ausdrücklich übernommen und nicht als fehlend behandelt.
|
|
values[type_id] = amount
|
|
return values
|
|
|
|
|
|
def merged_properties_payload(
|
|
food: dict[str, Any],
|
|
updates: dict[int, float],
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Baut die vollständige Tandoor-Property-Liste ohne Truthy/Falsy-Fallen.
|
|
|
|
Vorhandene IDs und Property-Type-Namen bleiben erhalten. Leere bzw.
|
|
ungültige Alt-Einträge werden nicht erneut übertragen. Neue Werte von 0
|
|
werden als ``0.0`` in den Payload aufgenommen.
|
|
"""
|
|
normalised: dict[int, float] = {}
|
|
for raw_type_id, raw_amount in updates.items():
|
|
try:
|
|
type_id = int(raw_type_id)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"Ungültige Eigenschafts-ID: {raw_type_id!r}") from exc
|
|
amount = _number_or_none(raw_amount)
|
|
if amount is None:
|
|
raise ValueError(
|
|
f"Ungültiger Nährwert für Eigenschaft {type_id}: {raw_amount!r}"
|
|
)
|
|
normalised[type_id] = amount
|
|
|
|
result: list[dict[str, Any]] = []
|
|
seen: set[int] = set()
|
|
|
|
for prop in food.get("properties") or []:
|
|
if not isinstance(prop, dict):
|
|
continue
|
|
type_id = _property_type_id(prop)
|
|
if type_id is None or type_id in seen:
|
|
continue
|
|
|
|
if type_id in normalised:
|
|
amount = normalised[type_id]
|
|
else:
|
|
amount = _number_or_none(prop.get("property_amount"))
|
|
if amount is None:
|
|
continue
|
|
|
|
raw_type = prop.get("property_type")
|
|
type_payload: dict[str, Any] = {"id": type_id}
|
|
if isinstance(raw_type, dict):
|
|
name = str(raw_type.get("name") or "").strip()
|
|
if name:
|
|
type_payload["name"] = name
|
|
|
|
entry: dict[str, Any] = {
|
|
"property_amount": amount,
|
|
"property_type": type_payload,
|
|
}
|
|
try:
|
|
property_id = int(prop.get("id"))
|
|
except (TypeError, ValueError):
|
|
property_id = None
|
|
if property_id is not None:
|
|
entry["id"] = property_id
|
|
|
|
result.append(entry)
|
|
seen.add(type_id)
|
|
|
|
for type_id, amount in normalised.items():
|
|
if type_id in seen:
|
|
continue
|
|
result.append({
|
|
"property_amount": amount,
|
|
"property_type": {"id": type_id},
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
def _normalise_proposal_values(
|
|
values: Any,
|
|
) -> tuple[dict[int, float], list[str]]:
|
|
"""Validiert Vorschlagswerte. Null bleibt erhalten, nur echte Lücken fallen weg."""
|
|
valid: dict[int, float] = {}
|
|
invalid: list[str] = []
|
|
if not isinstance(values, dict):
|
|
return valid, ["values ist kein JSON-Objekt"]
|
|
|
|
for raw_key, raw_value in values.items():
|
|
try:
|
|
type_id = int(raw_key)
|
|
except (TypeError, ValueError):
|
|
invalid.append(f"ungültige ID {raw_key!r}")
|
|
continue
|
|
amount = _number_or_none(raw_value)
|
|
if amount is None:
|
|
invalid.append(f"{type_id}={raw_value!r}")
|
|
continue
|
|
valid[type_id] = amount
|
|
return valid, invalid
|
|
|
|
|
|
def _property_type_names(types: list[dict[str, Any]]) -> dict[int, str]:
|
|
"""Tandoor-Eigenschaftstypen als ID→Name abbilden."""
|
|
names: dict[int, str] = {}
|
|
for item in types:
|
|
try:
|
|
type_id = int(item.get("id"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
name = str(item.get("name") or "").strip()
|
|
if name:
|
|
names[type_id] = name
|
|
return names
|
|
|
|
|
|
def _complete_property_type_names(
|
|
properties: list[dict[str, Any]],
|
|
names: dict[int, str],
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Ergänzt in Tandoor-Property-Payloads den Namen des Property-Typs.
|
|
|
|
Tandoors verschachtelter PropertyTypeSerializer verlangt beim Schreiben
|
|
neben der ID auch ``name``. GET-Antworten enthalten ihn bereits, neu von
|
|
``merged_properties_payload`` erzeugte Einträge können jedoch nur die ID
|
|
enthalten und werden dann mit HTTP 400 abgelehnt.
|
|
"""
|
|
completed: list[dict[str, Any]] = []
|
|
for prop in properties:
|
|
entry = dict(prop)
|
|
raw_type = entry.get("property_type")
|
|
if isinstance(raw_type, dict):
|
|
property_type = dict(raw_type)
|
|
try:
|
|
type_id = int(property_type.get("id"))
|
|
except (TypeError, ValueError):
|
|
type_id = None
|
|
if not str(property_type.get("name") or "").strip() and type_id is not None:
|
|
if name := names.get(type_id):
|
|
property_type["name"] = name
|
|
entry["property_type"] = property_type
|
|
completed.append(entry)
|
|
return completed
|
|
|
|
|
|
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-nutrition"
|
|
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 write_json(path: Path, payload: Any) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
temporary.write_text(
|
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
temporary.replace(path)
|
|
return path
|
|
|
|
|
|
# ---------------------------------------------------------------- Bestand
|
|
|
|
def collect(client: TandoorClient) -> dict[str, Any]:
|
|
out("Eigenschaften werden gelesen …")
|
|
types = sorted(
|
|
client.list_objects("property-type"),
|
|
key=lambda t: (t.get("order") or 0, (t.get("name") or "").casefold()),
|
|
)
|
|
out(f" {len(types)} Eigenschaften: " + ", ".join(t.get("name", "?") for t in types))
|
|
|
|
out("Zutaten werden gelesen …")
|
|
foods = client.list_objects("food")
|
|
out(f" {len(foods)} Zutaten")
|
|
return {"types": types, "foods": foods}
|
|
|
|
|
|
def analyse(types: list[dict[str, Any]], foods: list[dict[str, Any]]) -> dict[str, Any]:
|
|
type_ids = [t["id"] for t in types]
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
for food in foods:
|
|
values = food_property_map(food)
|
|
missing = [tid for tid in type_ids if values.get(tid) is None]
|
|
rows.append({
|
|
"id": food.get("id"),
|
|
"name": food.get("name") or "",
|
|
"plural_name": food.get("plural_name") or "",
|
|
"numrecipe": food.get("numrecipe") or 0,
|
|
"base_amount": food.get("properties_food_amount"),
|
|
"base_unit": (food.get("properties_food_unit") or {}).get("name")
|
|
if isinstance(food.get("properties_food_unit"), dict) else None,
|
|
"values": {str(tid): values.get(tid) for tid in type_ids},
|
|
"missing": [str(tid) for tid in missing],
|
|
"complete": not missing,
|
|
})
|
|
|
|
rows.sort(key=lambda r: (r["complete"], -r["numrecipe"], r["name"].casefold()))
|
|
|
|
per_type = []
|
|
for t in types:
|
|
have = sum(1 for r in rows if r["values"].get(str(t["id"])) is not None)
|
|
per_type.append({
|
|
"id": t["id"],
|
|
"name": t.get("name") or "",
|
|
"unit": t.get("unit") or "",
|
|
"have": have,
|
|
"missing": len(rows) - have,
|
|
"percent": round(have / len(rows) * 100, 1) if rows else 0.0,
|
|
})
|
|
|
|
complete = sum(1 for r in rows if r["complete"])
|
|
incomplete_used = [r for r in rows if not r["complete"] and r["numrecipe"] > 0]
|
|
no_base = [r for r in rows if not r["complete"] and not r["base_amount"]]
|
|
|
|
return {
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"types": [{"id": t["id"], "name": t.get("name"), "unit": t.get("unit")} for t in types],
|
|
"summary": {
|
|
"foods": len(rows),
|
|
"complete": complete,
|
|
"incomplete": len(rows) - complete,
|
|
"incomplete_used_in_recipes": len(incomplete_used),
|
|
"unused": sum(1 for r in rows if r["numrecipe"] == 0),
|
|
"percent_complete": round(complete / len(rows) * 100, 1) if rows else 0.0,
|
|
"without_base_amount": len(no_base),
|
|
},
|
|
"per_type": per_type,
|
|
"foods": rows,
|
|
}
|
|
|
|
|
|
def write_report(report: dict[str, Any], target: Path) -> None:
|
|
write_json(target / "bericht.json", report)
|
|
|
|
names = {str(t["id"]): t["name"] for t in report["types"]}
|
|
with (target / "luecken.csv").open("w", encoding="utf-8", newline="") as handle:
|
|
writer = csv.writer(handle, delimiter=";")
|
|
writer.writerow(["id", "Zutat", "Rezepte", "Basis"] +
|
|
[f"{t['name']} ({t['unit']})" for t in report["types"]] + ["fehlt"])
|
|
for row in report["foods"]:
|
|
base = (f"{row['base_amount']:g} {row['base_unit']}"
|
|
if row["base_amount"] and row["base_unit"] else "")
|
|
writer.writerow(
|
|
[row["id"], row["name"], row["numrecipe"], base]
|
|
+ [("" if row["values"][str(t["id"])] is None else row["values"][str(t["id"])])
|
|
for t in report["types"]]
|
|
+ [", ".join(names[m] for m in row["missing"])]
|
|
)
|
|
|
|
|
|
def print_report(report: dict[str, Any]) -> None:
|
|
s = report["summary"]
|
|
out()
|
|
out("─" * 62)
|
|
out(f" Zutaten gesamt {s['foods']}")
|
|
out(f" davon vollständig {s['complete']} ({s['percent_complete']} %)")
|
|
out(f" davon unvollständig {s['incomplete']}")
|
|
out(f" … und in Rezepten benutzt {s['incomplete_used_in_recipes']} ← die tun weh")
|
|
out(f" ohne Bezugsmenge {s['without_base_amount']}")
|
|
out("─" * 62)
|
|
out()
|
|
out(" Je Eigenschaft:")
|
|
for t in report["per_type"]:
|
|
bar = "█" * round(t["percent"] / 5) + "·" * (20 - round(t["percent"] / 5))
|
|
out(f" {t['name']:<16} {bar} {t['percent']:5.1f} % fehlt bei {t['missing']}")
|
|
out()
|
|
worst = [r for r in report["foods"] if not r["complete"]][:12]
|
|
if worst:
|
|
out(" Größte Lücken zuerst (nach Rezeptzahl):")
|
|
for row in worst:
|
|
fehlt = ", ".join(
|
|
next(t["name"] for t in report["types"] if str(t["id"]) == m)
|
|
for m in row["missing"]
|
|
)
|
|
out(f" {row['numrecipe']:>3} Rezepte · {row['name']:<28} fehlt: {fehlt}")
|
|
|
|
|
|
# ------------------------------------------------------------- Vorschläge
|
|
|
|
def _parse_json(text: str) -> dict[str, Any]:
|
|
"""Antworttext zu einem Dict machen, auch wenn das Modell schludert."""
|
|
text = (text or "").strip()
|
|
if text.startswith("```"):
|
|
text = text.strip("`")
|
|
if text[:4].lower() == "json":
|
|
text = text[4:]
|
|
text = text.strip()
|
|
data = json.loads(text)
|
|
# Manche Modelle verpacken die Antwort, z. B. {"lebensmittel": {…}}.
|
|
# Nur auspacken, wenn der einzige Wert selbst eine Tabelle von Tabellen ist.
|
|
if isinstance(data, dict) and len(data) == 1:
|
|
(only,) = data.values()
|
|
if isinstance(only, dict) and only and all(isinstance(v, dict) for v in only.values()):
|
|
return only
|
|
return data
|
|
|
|
|
|
# Merkt sich, welche Aufruf-Variante das Modell akzeptiert, damit nicht jede der
|
|
# vielen Anfragen erneut durchprobiert wird.
|
|
_CHAT_VARIANT: dict[str, Any] | None = None
|
|
|
|
|
|
def _chat_completion(client, model: str, messages: list[dict[str, str]]):
|
|
"""
|
|
Chat-Abfrage mit sanftem Rückfall.
|
|
|
|
Neuere Modelle (o-Reihe, GPT-5-Reihe) lehnen `temperature` ungleich dem
|
|
Standard und teils `response_format` ab. Statt daran zu scheitern, wird von
|
|
der genausten zur schlichtesten Variante durchprobiert — aber nur bei
|
|
Parameter-Fehlern. Echte Fehler (falsches Modell, Auth, Netz) werden sofort
|
|
durchgereicht, damit man sie sieht.
|
|
"""
|
|
global _CHAT_VARIANT
|
|
varianten = [
|
|
{"response_format": {"type": "json_object"}, "temperature": 0},
|
|
{"response_format": {"type": "json_object"}},
|
|
{"temperature": 0},
|
|
{},
|
|
]
|
|
if _CHAT_VARIANT is not None:
|
|
varianten = [_CHAT_VARIANT]
|
|
|
|
letzter = None
|
|
for extra in varianten:
|
|
try:
|
|
antwort = client.chat.completions.create(model=model, messages=messages, **extra)
|
|
_CHAT_VARIANT = extra
|
|
return antwort
|
|
except Exception as exc: # noqa: BLE001
|
|
letzter = exc
|
|
text = str(exc).lower()
|
|
parameterfehler = any(w in text for w in (
|
|
"temperature", "response_format", "unsupported", "not supported",
|
|
"unknown_parameter", "invalid_request", "unexpected keyword",
|
|
))
|
|
if not parameterfehler:
|
|
raise
|
|
raise letzter
|
|
|
|
|
|
def ask_openai(
|
|
foods: list[dict[str, Any]],
|
|
types: list[dict[str, Any]],
|
|
model: str,
|
|
) -> dict[str, dict[str, float | None]]:
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI()
|
|
liste = "\n".join(f"- {f['name']}" for f in foods)
|
|
eigenschaften = "\n".join(f"- {t['name']} (Einheit: {t['unit'] or '?'})" for t in types)
|
|
|
|
system = (
|
|
"Du bist Ernährungswissenschaftler und lieferst Nährwerte für "
|
|
"Lebensmittel. Antworte ausschließlich mit JSON, ohne Fließtext und "
|
|
"ohne Markdown."
|
|
)
|
|
user = f"""Gib für jedes Lebensmittel die Nährwerte **je 100 g** an (bei
|
|
Flüssigkeiten je 100 ml). Beziehe dich auf das rohe, unverarbeitete
|
|
Lebensmittel im handelsüblichen Zustand.
|
|
|
|
Lebensmittel:
|
|
{liste}
|
|
|
|
Benötigte Werte je Lebensmittel:
|
|
{eigenschaften}
|
|
|
|
Regeln:
|
|
- Zahlen als Dezimalzahl mit Punkt, ohne Einheit.
|
|
- Kalorien in kcal je 100 g.
|
|
- Wenn ein Wert sachlich null ist (z. B. Fett in Salz), gib 0 an.
|
|
- Nur wenn du einen Wert wirklich nicht sinnvoll angeben kannst: null.
|
|
- Für Gewürze und Kräuter gilt derselbe 100-g-Bezug, auch wenn nie 100 g
|
|
verwendet werden.
|
|
|
|
Antworte als JSON-Objekt mit genau dieser Form:
|
|
{{"Lebensmittelname": {{"Eigenschaftsname": Zahl oder null}}}}
|
|
Benutze exakt die oben genannten Namen als Schlüssel."""
|
|
|
|
response = _chat_completion(
|
|
client, model,
|
|
[{"role": "system", "content": system},
|
|
{"role": "user", "content": user}],
|
|
)
|
|
text = response.choices[0].message.content or "{}"
|
|
return _parse_json(text)
|
|
|
|
|
|
def probe(args: argparse.Namespace) -> int:
|
|
"""Eine einzelne Testabfrage — zeigt den echten Fehler bzw. Rohtext."""
|
|
demo_foods = [{"name": "Apfel"}, {"name": "Kochschinken"}]
|
|
demo_types = [{"name": "Kalorien", "unit": "kcal"}, {"name": "Proteine", "unit": "g"}]
|
|
out(f"Modell: {args.model}")
|
|
out("Sende eine einzelne Testabfrage an OpenAI …")
|
|
out()
|
|
try:
|
|
ergebnis = ask_openai(demo_foods, demo_types, args.model)
|
|
except Exception as exc: # noqa: BLE001
|
|
import traceback
|
|
out("FEHLGESCHLAGEN. Das ist der vollständige Fehler:")
|
|
out()
|
|
out(traceback.format_exc().strip())
|
|
out()
|
|
low = str(exc).lower()
|
|
if "model" in low and ("not" in low or "exist" in low or "unknown" in low):
|
|
out("→ Das Modell scheint nicht zu existieren oder nicht freigeschaltet zu sein.")
|
|
out(" In den Einstellungen ein anderes Modell setzen (OPENAI_MODEL).")
|
|
elif "api key" in low or "authentication" in low or "401" in low:
|
|
out("→ Der API-Schlüssel wird nicht akzeptiert. OPENAI_API_KEY prüfen.")
|
|
elif "quota" in low or "insufficient" in low or "429" in low:
|
|
out("→ Kontingent erschöpft oder zu viele Anfragen. Bei OpenAI das Guthaben prüfen.")
|
|
return 1
|
|
|
|
out("Erfolg. Die API antwortet und die Antwort ließ sich lesen:")
|
|
out()
|
|
out(json.dumps(ergebnis, ensure_ascii=False, indent=2))
|
|
out()
|
|
out(f"Verwendete Aufruf-Variante: {_CHAT_VARIANT}")
|
|
out("Damit sollte „Vorschlagen“ funktionieren.")
|
|
return 0
|
|
|
|
|
|
def propose(
|
|
client: TandoorClient,
|
|
args: argparse.Namespace,
|
|
bestand: dict[str, Any],
|
|
) -> Path:
|
|
types = bestand["types"]
|
|
foods = bestand["foods"]
|
|
by_id = {f["id"]: f for f in foods}
|
|
|
|
if args.foods:
|
|
wanted = [int(x) for x in args.foods.split(",") if x.strip()]
|
|
unknown = [i for i in wanted if i not in by_id]
|
|
if unknown:
|
|
raise SystemExit(f"Unbekannte Zutaten-IDs: {unknown}")
|
|
selected = [by_id[i] for i in wanted]
|
|
else:
|
|
selected = foods
|
|
|
|
type_filter = ([int(x) for x in args.properties.split(",") if x.strip()]
|
|
if args.properties else [t["id"] for t in types])
|
|
active_types = [t for t in types if t["id"] in type_filter]
|
|
if not active_types:
|
|
raise SystemExit("Keine gültige Eigenschaft ausgewählt.")
|
|
|
|
todo = []
|
|
for food in selected:
|
|
values = food_property_map(food)
|
|
missing = [t for t in active_types
|
|
if args.overwrite or values.get(t["id"]) is None]
|
|
if missing:
|
|
todo.append((food, missing))
|
|
|
|
if not todo:
|
|
out("Nichts zu tun: Für die Auswahl ist bereits alles gefüllt.")
|
|
raise SystemExit(0)
|
|
|
|
if args.limit:
|
|
todo = todo[:args.limit]
|
|
|
|
out(f"Modell: {args.model}")
|
|
out(f"{len(todo)} Zutaten mit Lücken, {len(active_types)} Eigenschaften.")
|
|
out(f"Das sind etwa {(len(todo) + BATCH - 1) // BATCH} Anfragen an OpenAI.")
|
|
out()
|
|
|
|
proposals: list[dict[str, Any]] = []
|
|
failed: list[str] = []
|
|
unresolved: list[dict[str, Any]] = []
|
|
first_error: str | None = None
|
|
|
|
for start in range(0, len(todo), BATCH):
|
|
chunk = todo[start:start + BATCH]
|
|
out(f" Anfrage {start // BATCH + 1}: {', '.join(f['name'] for f, _ in chunk)}")
|
|
try:
|
|
answer = ask_openai([f for f, _ in chunk], active_types, args.model)
|
|
except Exception as exc:
|
|
out(f" Fehler: {exc}")
|
|
if first_error is None:
|
|
first_error = str(exc)
|
|
failed.extend(f["name"] for f, _ in chunk)
|
|
continue
|
|
|
|
lookup = {str(k).strip().casefold(): v for k, v in answer.items()
|
|
if isinstance(v, dict)}
|
|
for food, missing in chunk:
|
|
got = lookup.get(food["name"].strip().casefold())
|
|
if got is None:
|
|
out(f" ohne Antwort: {food['name']}")
|
|
failed.append(food["name"])
|
|
continue
|
|
werte: dict[str, float] = {}
|
|
offene_felder: list[str] = []
|
|
for t in missing:
|
|
raw = got.get(t["name"], _MISSING)
|
|
number = None if raw is _MISSING else _number_or_none(raw)
|
|
if number is not None:
|
|
# Auch number == 0 wird als vorhandener Wert gespeichert.
|
|
werte[str(t["id"])] = round(number, 2)
|
|
else:
|
|
offene_felder.append(t["name"])
|
|
if werte:
|
|
proposals.append({
|
|
"food_id": food["id"],
|
|
"food_name": food["name"],
|
|
"numrecipe": food.get("numrecipe") or 0,
|
|
"current": {str(k): v for k, v in food_property_map(food).items()},
|
|
"values": werte,
|
|
"accept": True,
|
|
})
|
|
out(f" {food['name']}: " + ", ".join(
|
|
f"{t['name']} {werte[str(t['id'])]:g}{t['unit'] or ''}"
|
|
for t in missing if str(t["id"]) in werte))
|
|
if offene_felder:
|
|
unresolved.append({
|
|
"food_id": food["id"],
|
|
"food_name": food["name"],
|
|
"properties": offene_felder,
|
|
})
|
|
out(f" weiterhin offen: {', '.join(offene_felder)}")
|
|
else:
|
|
failed.append(food["name"])
|
|
if offene_felder:
|
|
unresolved.append({
|
|
"food_id": food["id"],
|
|
"food_name": food["name"],
|
|
"properties": offene_felder,
|
|
})
|
|
|
|
target = data_dir() / "vorschlaege" / f"{stamp()}.json"
|
|
write_json(target, {
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"model": args.model,
|
|
"base_amount": args.base_amount,
|
|
"base_unit": args.base_unit,
|
|
"types": [{"id": t["id"], "name": t["name"], "unit": t.get("unit")} for t in active_types],
|
|
"proposals": proposals,
|
|
"failed": failed,
|
|
"unresolved": unresolved,
|
|
"error": first_error,
|
|
})
|
|
out()
|
|
out(f"{len(proposals)} Vorschläge geschrieben, {len(failed)} ohne Ergebnis.")
|
|
if unresolved:
|
|
out(f"{len(unresolved)} Zutaten enthalten noch einzelne offene Werte.")
|
|
out(f"Vorschlagsdatei: {target}")
|
|
out()
|
|
if not proposals and failed:
|
|
out("Kein einziger Vorschlag — das deutet auf ein grundsätzliches Problem hin,")
|
|
out("nicht auf einzelne Zutaten.")
|
|
if first_error:
|
|
out(f"Erster Fehler der API: {first_error}")
|
|
else:
|
|
out("Die API hat geantwortet, aber die Namen passten nicht zusammen.")
|
|
out("Zur genauen Ursache: `nutrition.py probe` macht eine einzelne")
|
|
out("Testabfrage und zeigt den vollständigen Fehler.")
|
|
out()
|
|
out("Die Werte sind Schätzungen des Sprachmodells. Vor dem Übernehmen")
|
|
out("bitte in der Oberfläche durchsehen.")
|
|
return target
|
|
|
|
|
|
# ------------------------------------------------------------ Übernehmen
|
|
|
|
def apply_proposal(client: TandoorClient, args: argparse.Namespace) -> int:
|
|
file = Path(args.vorschlag).expanduser().resolve()
|
|
if not file.is_file():
|
|
raise SystemExit(f"Vorschlagsdatei nicht gefunden: {file}")
|
|
payload = json.loads(file.read_text(encoding="utf-8"))
|
|
proposals = [p for p in payload.get("proposals", []) if p.get("accept", True)]
|
|
if not proposals:
|
|
out("In der Vorschlagsdatei ist nichts angehakt.")
|
|
return 0
|
|
|
|
base_amount = payload.get("base_amount") or args.base_amount
|
|
base_unit_name = payload.get("base_unit") or args.base_unit
|
|
|
|
# Die Vorschlagsdatei enthält bereits die Namen der bearbeiteten Typen.
|
|
# Beim echten Schreiben ergänzen wir die vollständige, aktuelle Liste aus
|
|
# Tandoor, damit auch bereits vorhandene Eigenschaften sicher abgedeckt sind.
|
|
property_type_names = _property_type_names(payload.get("types") or [])
|
|
if args.apply:
|
|
property_type_names.update(
|
|
_property_type_names(client.list_objects("property-type"))
|
|
)
|
|
|
|
out(f"Modus: {'ÜBERNEHMEN' if args.apply else 'TROCKENÜBUNG'}")
|
|
out(f"Tandoor: {client.base_url}")
|
|
out(f"Zutaten: {len(proposals)}")
|
|
out(f"Bezug: {base_amount:g} {base_unit_name}")
|
|
out()
|
|
|
|
base_unit = None
|
|
if args.apply:
|
|
units = client.list_objects("unit")
|
|
base_unit = next(
|
|
(u for u in units
|
|
if (u.get("name") or "").strip().casefold() == base_unit_name.casefold()),
|
|
None,
|
|
)
|
|
if base_unit is None:
|
|
out(f"Einheit „{base_unit_name}“ gibt es in Tandoor nicht.")
|
|
out("Bitte anlegen oder eine andere Bezugseinheit wählen.")
|
|
return 2
|
|
|
|
run = data_dir() / "laeufe" / stamp()
|
|
run.mkdir(parents=True, exist_ok=True)
|
|
manifest: dict[str, Any] = {
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"plugin": "tandoor-nutrition",
|
|
"label": f"Nährwerte übernehmen ({file.stem})",
|
|
"tandoor": client.base_url,
|
|
"mode": "apply" if args.apply else "dry",
|
|
"proposal": file.name,
|
|
"steps": [],
|
|
}
|
|
written, skipped, errors = 0, 0, 0
|
|
|
|
for index, item in enumerate(proposals, start=1):
|
|
food_id = item["food_id"]
|
|
prefix = f"[{index}/{len(proposals)}] {item['food_name']} [{food_id}]"
|
|
try:
|
|
food = client.get_json(f"api/food/{food_id}/")
|
|
except TandoorError as exc:
|
|
out(f"{prefix}: nicht lesbar — {exc}")
|
|
errors += 1
|
|
if not args.continue_on_error:
|
|
return 1
|
|
continue
|
|
|
|
updates, invalid_values = _normalise_proposal_values(item.get("values"))
|
|
if invalid_values:
|
|
out(f"{prefix}: ungültige Vorschlagswerte ignoriert: {', '.join(invalid_values)}")
|
|
if not args.overwrite:
|
|
vorhanden = food_property_map(food)
|
|
updates = {k: v for k, v in updates.items() if vorhanden.get(k) is None}
|
|
if not updates:
|
|
out(f"{prefix}: inzwischen schon gefüllt, übersprungen")
|
|
skipped += 1
|
|
continue
|
|
|
|
merged_properties = merged_properties_payload(food, updates)
|
|
body: dict[str, Any] = {
|
|
"properties": _complete_property_type_names(
|
|
merged_properties, property_type_names
|
|
)
|
|
}
|
|
if food.get("properties_food_amount") is None:
|
|
body["properties_food_amount"] = base_amount
|
|
if not food.get("properties_food_unit") and base_unit:
|
|
body["properties_food_unit"] = {"id": base_unit["id"], "name": base_unit["name"]}
|
|
|
|
werte = ", ".join(f"{k}={v:g}" for k, v in updates.items())
|
|
if not args.apply:
|
|
out(f"{prefix}: würde setzen → {werte}")
|
|
written += 1
|
|
continue
|
|
|
|
(run / f"vorher-{food_id:05d}.json").write_text(
|
|
json.dumps(food, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
# Der Schritt wird VOR dem Schreiben vermerkt. Bricht der Prozess
|
|
# mittendrin ab, weiß das Rückspielen trotzdem, was angefasst wurde.
|
|
step: dict[str, Any] = {
|
|
"n": index,
|
|
"action": "properties",
|
|
"food_id": food_id,
|
|
"food_name": item["food_name"],
|
|
"before": {
|
|
"properties": food.get("properties") or [],
|
|
"properties_food_amount": food.get("properties_food_amount"),
|
|
"properties_food_unit": food.get("properties_food_unit"),
|
|
},
|
|
"written": {str(k): v for k, v in updates.items()},
|
|
"restore_level": "voll",
|
|
"status": "angefangen",
|
|
}
|
|
manifest["steps"].append(step)
|
|
backups.write_manifest(run, manifest)
|
|
|
|
try:
|
|
result = client.patch_json(f"api/food/{food_id}/", body)
|
|
except TandoorError as exc:
|
|
out(f"{prefix}: FEHLER — {exc}")
|
|
step["status"] = "fehler"
|
|
step["error"] = str(exc)
|
|
backups.write_manifest(run, manifest)
|
|
errors += 1
|
|
if not args.continue_on_error:
|
|
out("Abbruch. Die bereits geschriebenen Zutaten bleiben gesetzt;")
|
|
out(f"die Sicherungen liegen in {run}")
|
|
return 1
|
|
continue
|
|
|
|
step["status"] = "done"
|
|
backups.write_manifest(run, manifest)
|
|
(run / f"nachher-{food_id:05d}.json").write_text(
|
|
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
# Nicht nur der PATCH-Antwort vertrauen: frisch aus Tandoor lesen und
|
|
# ausdrücklich prüfen, ob auch 0-Werte wirklich gespeichert wurden.
|
|
try:
|
|
verified_food = client.get_json(f"api/food/{food_id}/")
|
|
except TandoorError as exc:
|
|
out(f"{prefix}: geschrieben, aber Rückprüfung nicht möglich — {exc}")
|
|
verified_food = result
|
|
|
|
nachher = food_property_map(verified_food)
|
|
fehlend = [k for k in updates if nachher.get(k) is None]
|
|
abweichend = [
|
|
k for k, expected in updates.items()
|
|
if nachher.get(k) is not None
|
|
and abs(nachher[k] - expected) > 1e-6
|
|
]
|
|
if fehlend or abweichend:
|
|
details = []
|
|
if fehlend:
|
|
details.append(f"fehlend: {fehlend}")
|
|
if abweichend:
|
|
details.append(
|
|
"abweichend: " + ", ".join(
|
|
f"{k} erwartet {updates[k]:g}, erhalten {nachher.get(k)!r}"
|
|
for k in abweichend
|
|
)
|
|
)
|
|
out(f"{prefix}: geschrieben, aber Rückprüfung fehlgeschlagen ({'; '.join(details)})")
|
|
errors += 1
|
|
else:
|
|
out(f"{prefix}: ✓ gesetzt und rückgeprüft → {werte}")
|
|
written += 1
|
|
|
|
out()
|
|
out("─" * 62)
|
|
verb = "gesetzt" if args.apply else "würden gesetzt"
|
|
out(f" {written} {verb} · {skipped} übersprungen · {errors} Fehler")
|
|
if args.apply:
|
|
out(f" Sicherungen: {run}")
|
|
out(f" Zurückspielen: Reiter „Sicherungen“ oder")
|
|
out(f" nutrition.py zurueck --lauf {run.name} --apply")
|
|
else:
|
|
out(" Nichts verändert. Mit --apply wird geschrieben.")
|
|
out("─" * 62)
|
|
return 1 if errors else 0
|
|
|
|
|
|
# ---------------------------------------------------------- Zurückspielen
|
|
|
|
def restore(client: TandoorClient, args: argparse.Namespace) -> int:
|
|
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 geschrieben — 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)} Zutaten")
|
|
out()
|
|
|
|
property_type_names: dict[int, str] = {}
|
|
if args.apply:
|
|
property_type_names = _property_type_names(
|
|
client.list_objects("property-type")
|
|
)
|
|
|
|
zurueck, uebersprungen, fehler = 0, 0, 0
|
|
for step in steps:
|
|
food_id = step["food_id"]
|
|
prefix = f"[{step['n']}] {step['food_name']} [{food_id}]"
|
|
try:
|
|
jetzt = client.get_json(f"api/food/{food_id}/")
|
|
except TandoorError as exc:
|
|
out(f"{prefix}: nicht lesbar — {exc}")
|
|
fehler += 1
|
|
continue
|
|
|
|
before = step["before"]
|
|
# Schutz: Wurde die Zutat seit dem Lauf von Hand angefasst?
|
|
aktuell = food_property_map(jetzt)
|
|
geschrieben = {int(k): v for k, v in step["written"].items()}
|
|
abweichung = [
|
|
k for k, v in geschrieben.items()
|
|
if aktuell.get(k) is not None and abs(aktuell[k] - float(v)) > 1e-6
|
|
]
|
|
if abweichung and not args.force:
|
|
out(f"{prefix}: übersprungen — Werte wurden zwischenzeitlich geändert. "
|
|
f"Mit --force trotzdem.")
|
|
uebersprungen += 1
|
|
continue
|
|
|
|
# Die alte properties-Liste eins zu eins zurückschreiben. Da Tandoor die
|
|
# Liste komplett ersetzt, verschwindet damit genau das, was dieser Lauf
|
|
# hinzugefügt hat.
|
|
alt = []
|
|
for prop in before.get("properties") or []:
|
|
raw_type = prop.get("property_type") or {}
|
|
ptype = raw_type.get("id")
|
|
if not isinstance(ptype, int):
|
|
continue
|
|
type_payload: dict[str, Any] = {"id": ptype}
|
|
if name := str(raw_type.get("name") or "").strip():
|
|
type_payload["name"] = name
|
|
eintrag = {"property_amount": prop.get("property_amount"),
|
|
"property_type": type_payload}
|
|
if isinstance(prop.get("id"), int):
|
|
eintrag["id"] = prop["id"]
|
|
alt.append(eintrag)
|
|
|
|
body: dict[str, Any] = {
|
|
"properties": _complete_property_type_names(alt, property_type_names)
|
|
}
|
|
if before.get("properties_food_amount") is None:
|
|
body["properties_food_amount"] = None
|
|
if before.get("properties_food_unit") is None:
|
|
body["properties_food_unit"] = None
|
|
|
|
namen = ", ".join(str(k) for k in geschrieben)
|
|
if not args.apply:
|
|
out(f"{prefix}: würde zurücksetzen auf {len(alt)} Eigenschaften "
|
|
f"(entfernt: {namen})")
|
|
zurueck += 1
|
|
continue
|
|
|
|
try:
|
|
ergebnis = client.patch_json(f"api/food/{food_id}/", body)
|
|
except TandoorError as exc:
|
|
out(f"{prefix}: FEHLER — {exc}")
|
|
fehler += 1
|
|
continue
|
|
|
|
nachher = food_property_map(ergebnis)
|
|
rest = [k for k in geschrieben if nachher.get(k) is not None
|
|
and food_property_map({"properties": before.get("properties") or []}).get(k) is None]
|
|
if rest:
|
|
out(f"{prefix}: zurückgeschrieben, aber {rest} sind noch gesetzt")
|
|
fehler += 1
|
|
else:
|
|
out(f"{prefix}: ✓ auf den Stand vor dem Lauf zurückgesetzt")
|
|
zurueck += 1
|
|
|
|
out()
|
|
out("─" * 62)
|
|
verb = "zurückgespielt" if args.apply else "würden zurückgespielt"
|
|
out(f" {zurueck} {verb} · {uebersprungen} übersprungen · {fehler} Fehler")
|
|
if args.apply:
|
|
backups.mark_restored(run, {"restored": zurueck, "skipped": uebersprungen,
|
|
"errors": fehler})
|
|
out(" Der Lauf ist als zurückgespielt vermerkt.")
|
|
else:
|
|
out(" Nichts verändert. Mit --apply wird zurückgeschrieben.")
|
|
out("─" * 62)
|
|
return 1 if fehler else 0
|
|
|
|
|
|
# ---------------------------------------------------------------- Selbsttest
|
|
|
|
def selftest() -> int:
|
|
"""Prüft lokal, dass 0 weder beim Lesen noch beim Payload-Bau verloren geht."""
|
|
food = {
|
|
"properties": [
|
|
{
|
|
"id": 11,
|
|
"property_amount": 0,
|
|
"property_type": {"id": 1, "name": "Fett"},
|
|
},
|
|
{
|
|
"id": 12,
|
|
"property_amount": "0.00",
|
|
"property_type": {"id": 2, "name": "Zucker"},
|
|
},
|
|
{
|
|
"id": 13,
|
|
"property_amount": 4.2,
|
|
"property_type": {"id": 3, "name": "Protein"},
|
|
},
|
|
]
|
|
}
|
|
|
|
values = food_property_map(food)
|
|
assert values == {1: 0.0, 2: 0.0, 3: 4.2}, values
|
|
|
|
payload = merged_properties_payload(food, {1: 0, 2: "0", 4: 0.0})
|
|
payload_values = food_property_map({"properties": payload})
|
|
assert payload_values == {1: 0.0, 2: 0.0, 3: 4.2, 4: 0.0}, payload_values
|
|
|
|
proposal_values, invalid = _normalise_proposal_values({"1": 0, "2": "0.00"})
|
|
assert proposal_values == {1: 0.0, 2: 0.0}, proposal_values
|
|
assert invalid == [], invalid
|
|
|
|
out("Selbsttest erfolgreich: numerische 0 bleibt in allen Verarbeitungsschritten erhalten.")
|
|
return 0
|
|
|
|
|
|
# ----------------------------------------------------------------- Aufruf
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n")[1])
|
|
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="Bericht über fehlende Nährwerte")
|
|
p.add_argument("--json", action="store_true", help="Bericht auf stdout ausgeben")
|
|
|
|
p = sub.add_parser("vorschlagen", help="Fehlende Werte von OpenAI schätzen lassen")
|
|
p.add_argument("--foods", default="", help="IDs, kommagetrennt. Leer = alle")
|
|
p.add_argument("--properties", default="", help="Eigenschafts-IDs. Leer = alle")
|
|
p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5"))
|
|
p.add_argument("--limit", type=int, default=0, help="höchstens so viele Zutaten")
|
|
p.add_argument("--overwrite", action="store_true", help="auch vorhandene Werte neu schätzen")
|
|
p.add_argument("--base-amount", type=float, default=100.0)
|
|
p.add_argument("--base-unit", default="g")
|
|
|
|
p = sub.add_parser("probe", help="Eine einzelne Testabfrage an OpenAI")
|
|
p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5"))
|
|
|
|
sub.add_parser("selftest", help="Nullwert-Behandlung lokal prüfen")
|
|
|
|
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ückschreiben")
|
|
p.add_argument("--force", action="store_true",
|
|
help="auch bei zwischenzeitlichen Änderungen oder anderem Tandoor")
|
|
|
|
p = sub.add_parser("uebernehmen", help="Vorschlagsdatei nach Tandoor schreiben")
|
|
p.add_argument("--vorschlag", required=True)
|
|
p.add_argument("--apply", action="store_true", help="wirklich schreiben")
|
|
p.add_argument("--overwrite", action="store_true")
|
|
p.add_argument("--continue-on-error", action="store_true")
|
|
p.add_argument("--base-amount", type=float, default=100.0)
|
|
p.add_argument("--base-unit", default="g")
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
|
|
if args.command == "selftest":
|
|
return selftest()
|
|
|
|
# Der Probe-Befehl braucht kein Tandoor, nur OpenAI.
|
|
if args.command == "probe":
|
|
if not os.environ.get("OPENAI_API_KEY"):
|
|
out("OPENAI_API_KEY fehlt — siehe Einstellungen.")
|
|
return 2
|
|
return probe(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":
|
|
bestand = collect(client)
|
|
report = analyse(bestand["types"], bestand["foods"])
|
|
target = data_dir() / "bericht"
|
|
write_report(report, target)
|
|
print_report(report)
|
|
out()
|
|
out(f"Bericht: {target / 'bericht.json'}")
|
|
out(f"Tabelle: {target / 'luecken.csv'}")
|
|
if args.json:
|
|
out(json.dumps(report, ensure_ascii=False))
|
|
return 0
|
|
|
|
if args.command == "vorschlagen":
|
|
if not os.environ.get("OPENAI_API_KEY"):
|
|
out("OPENAI_API_KEY fehlt — siehe Einstellungen.")
|
|
return 2
|
|
bestand = collect(client)
|
|
propose(client, args, bestand)
|
|
return 0
|
|
|
|
if args.command == "uebernehmen":
|
|
return apply_proposal(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())
|