chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 45bc449ea0
53 changed files with 12329 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# Zutaten-Kategorien
Ordnet Zutaten ohne **Supermarkt-Kategorie** einer der in Tandoor bereits
vorhandenen Kategorien zu. Neue Kategorien werden nicht angelegt.
## Ablauf
1. **Prüfen** liest die vorhandenen Kategorien und alle Zutaten. Zutaten ohne
Kategorie stehen oben und sind hervorgehoben.
2. **Einordnen lassen** (ChatGPT, optional) weist jeder noch nicht eingeordneten
Zutat die am besten passende vorhandene Kategorie zu und hakt sie an. Passt
keine, bleibt die Zutat offen. Das Modell kommt aus den Einstellungen
(`OPENAI_MODEL`), der Schlüssel aus `OPENAI_API_KEY`.
3. Du prüfst, korrigierst per Auswahlfeld und hakst an, was geschrieben werden
soll. Geschrieben wird nur, was sich vom Ist-Stand unterscheidet.
4. **Zuweisen** schreibt die Kategorien nach Tandoor. Vorher sichern, per
Trockenübung testen. Über **Sicherungen** lässt sich jeder Lauf exakt
zurückspielen.
## Sicherheit
* Es werden nur Kategorien vergeben, die es in Tandoor schon gibt — die
Oberfläche kann keine fremde ID unterschieben (das Backend prüft gegen die
im Plan bekannten Kategorien).
* Kategorie-Zuweisungen sind nicht destruktiv und voll rückspielbar.
* Wie bei den Einheiten wird robust geschrieben: zuerst per PATCH, bei einem
Server-500 per vollständigem PUT.
## Kommandozeile
```
categories.py pruefen
categories.py vorschlagen --plan <datei> [--alle]
categories.py anwenden --plan <datei> [--apply]
categories.py zurueck --lauf <ordner> [--apply]
categories.py probe
```
+194
View File
@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""
Plugin-Adapter für „Zutaten-Kategorien“.
Alles Verändernde läuft über tool/categories.py im Job-Runner, nie direkt aus einer
Anfrage. Die Auswahl steht vorher in einer Plandatei, die sich ansehen, ändern
und im Zweifel einfach nicht anwenden lässt.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from core.jobs import job_router
from core.backups import backup_router
TOOL = "categories.py"
class EditRequest(BaseModel):
accepted: list[dict[str, Any]]
class PlanRef(BaseModel):
plan: str
class ApplyRequest(BaseModel):
plan: str
apply: bool = True
class PrefillRequest(BaseModel):
plan: str
model: str | None = None
alle: bool = False
class ProbeRequest(BaseModel):
model: str | None = None
def create_app(ctx):
tool = ctx.path("tool", TOOL)
plans_dir = ctx.data_dir / "plaene"
plans_dir.mkdir(parents=True, exist_ok=True)
def tool_env() -> dict:
env = ctx.settings.tool_env()
env["DATA_DIR"] = str(ctx.data_dir)
return env
def resolve_plan(name: str) -> Path:
candidate = plans_dir / Path(name).name
if candidate.suffix != ".json" or not candidate.is_file():
raise HTTPException(404, f"Plandatei „{name}“ gibt es nicht.")
return candidate
app = FastAPI(title=ctx.meta.name, docs_url="/api/docs", redoc_url=None)
@app.get("/", include_in_schema=False)
def index():
return FileResponse(ctx.path("static", "index.html"))
@app.get("/api/state")
def state() -> dict[str, Any]:
running = [j.info() for j in ctx.jobs.running(ctx.id)]
return {
"plans": sorted((p.name for p in plans_dir.glob("*.json")), reverse=True),
"tandoor": ctx.settings.status()["tandoor"],
"openai": ctx.settings.status()["openai"],
"running": running[0] if running else None,
}
@app.get("/api/plans/{name}")
def read_plan(name: str) -> dict[str, Any]:
return json.loads(resolve_plan(name).read_text(encoding="utf-8"))
@app.post("/api/plans/{name}")
def save_plan(name: str, request: EditRequest) -> dict[str, Any]:
"""
Übernimmt die Auswahl aus der Oberfläche. Eng gehalten: je Zutat lassen
sich nur `accept` und `proposed_id` ändern, und `proposed_id` muss eine
der im Plan bekannten Kategorien sein (oder null). So kann die Oberfläche
keine fremde ID unterschieben.
"""
file = resolve_plan(name)
plan = json.loads(file.read_text(encoding="utf-8"))
valid = {c["id"]: c["name"] for c in plan.get("categories", [])}
by_aid = {f.get("aid"): f for f in plan.get("foods", []) if f.get("aid")}
gesetzt = 0
for wish in request.accepted:
food = by_aid.get(wish.get("aid"))
if food is None:
continue
if "proposed_id" in wish:
pid = wish.get("proposed_id")
if pid is None:
food["proposed_id"] = None
food["proposed_name"] = ""
elif pid in valid:
food["proposed_id"] = pid
food["proposed_name"] = valid[pid]
# unbekannte ID wird ignoriert
food["accept"] = bool(wish.get("accept"))
gesetzt += 1
file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
offen = sum(1 for f in plan["foods"]
if f.get("accept") and f.get("proposed_id") != f.get("current_id"))
return {"ok": True, "gesetzt": gesetzt, "zuweisungen": offen}
# ---------------------------------------------------------------- Läufe
@app.post("/api/run/pruefen")
async def run_scan() -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas.")
if not ctx.settings.status()["tandoor"]:
raise HTTPException(400, "Tandoor-URL und Token fehlen.")
job = await ctx.jobs.start(
plugin=ctx.id, label="Kategorien prüfen",
argv=[sys.executable, str(tool), "pruefen"],
cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
@app.post("/api/run/vorschlagen")
async def run_prefill(request: PrefillRequest) -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas.")
if not ctx.settings.status()["openai"]:
raise HTTPException(400, "OpenAI-Key fehlt siehe Einstellungen.")
file = resolve_plan(request.plan)
argv = [sys.executable, str(tool), "vorschlagen", "--plan", str(file)]
if request.model:
argv += ["--model", request.model]
if request.alle:
argv.append("--alle")
job = await ctx.jobs.start(
plugin=ctx.id, label=f"KI ordnet ein: {file.stem}",
argv=argv, cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
@app.post("/api/run/anwenden")
async def run_apply(request: ApplyRequest) -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas.")
if not ctx.settings.status()["tandoor"]:
raise HTTPException(400, "Tandoor-URL und Token fehlen.")
file = resolve_plan(request.plan)
argv = [sys.executable, str(tool), "anwenden", "--plan", str(file),
"--continue-on-error"]
if request.apply:
argv.append("--apply")
job = await ctx.jobs.start(
plugin=ctx.id, label=("Kategorien zuweisen" if request.apply else "Trockenübung")
+ f": {file.stem}",
argv=argv, cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
@app.post("/api/run/probe")
async def run_probe(request: ProbeRequest) -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas.")
if not ctx.settings.status()["openai"]:
raise HTTPException(400, "OpenAI-Key fehlt siehe Einstellungen.")
argv = [sys.executable, str(tool), "probe"]
if request.model:
argv += ["--model", request.model]
job = await ctx.jobs.start(
plugin=ctx.id, label="OpenAI-Verbindung testen",
argv=argv, cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
def restore_argv(run: str, apply: bool, force: bool) -> list[str]:
argv = [sys.executable, str(tool), "zurueck", "--lauf", run]
if apply:
argv.append("--apply")
return argv
app.include_router(backup_router(ctx, "laeufe", restore_argv))
app.include_router(job_router(ctx.jobs, ctx.id))
return app
+20
View File
@@ -0,0 +1,20 @@
{
"id": "tandoor-categories",
"name": "Zutaten-Kategorien",
"summary": "Fehlende Supermarkt-Kategorien per KI zuweisen",
"description": "Liest die vorhandenen Kategorien und die Zutaten und lässt ChatGPT die noch nicht eingeordneten Zutaten einer passenden vorhandenen Kategorie zuordnen. Nichts wird automatisch geschrieben; jede Zuweisung ist freizugeben, gesichert und rückspielbar.",
"icon": "🏷",
"category": "Tandoor",
"version": "1.0.0",
"entrypoint": "backend:create_app",
"order": 48,
"requires": [
"tandoor"
],
"features": [
"Ordnet nur in bereits vorhandene Kategorien ein",
"KI schlägt vor, du gibst frei",
"Nicht destruktiv und voll rückspielbar"
],
"docs": "TOOL-README.md"
}
@@ -0,0 +1,330 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zutaten-Kategorien</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<script src="/shared/boehmi-runner.js"></script>
<script src="/shared/boehmi-backups.js"></script>
<style>
.catrow { display: grid; grid-template-columns: 24px 1fr 200px; gap: 10px; align-items: center;
padding: 7px 11px; border-bottom: 1px solid var(--bt-line-soft); }
.catrow:last-child { border-bottom: none; }
.catrow.nocat { background: color-mix(in srgb, var(--bt-accent) 7%, transparent); }
.catrow select { width: 100%; padding: 5px 8px; font-size: 12.5px;
border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); color: var(--bt-ink); }
.catrow .cur { font-size: 11.5px; color: var(--bt-muted); }
.catfilter { width: 100%; box-sizing: border-box; margin-bottom: 10px; padding: 7px 10px;
font-size: 12.5px; border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); color: var(--bt-ink); }
.aibadge { display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 10.5px;
font-weight: 600; border-radius: 999px; color: var(--bt-accent);
background: color-mix(in srgb, var(--bt-accent) 14%, transparent); }
.list { border: 1px solid var(--bt-line); border-radius: var(--bt-r); overflow: hidden; }
@media (max-width: 640px) { .catrow { grid-template-columns: 24px 1fr; } .catrow select { grid-column: 2; } }
</style>
</head>
<body>
<main class="bt-main wide">
<div class="bt-pagehead">
<h1>🏷 Zutaten-Kategorien</h1>
<p>Ordnet Zutaten ohne Supermarkt-Kategorie einer der vorhandenen
Kategorien zu — auf Wunsch per ChatGPT vorgeschlagen. Du gibst frei,
nichts wird automatisch geschrieben.</p>
</div>
<div id="warn" class="bt-notice err bt-hidden"></div>
<div class="bt-tabs" id="tabs">
<button class="bt-tab active" data-tab="pruefen">1 · Prüfen</button>
<button class="bt-tab" data-tab="vorschlagen">2 · Vorschlagen</button>
<button class="bt-tab" data-tab="uebernehmen">3 · Übernehmen</button>
<button class="bt-tab" data-tab="sicherungen">Sicherungen</button>
</div>
<!-- ═══════════════════════════════════════════════════ 1 Prüfen -->
<section id="tab-pruefen" class="tab">
<div class="bt-card">
<div class="bt-row" style="align-items:center">
<div style="flex:1">
<h2 style="margin:0">Bestandsaufnahme</h2>
<p class="hint" style="margin:4px 0 0">Liest die vorhandenen Kategorien
und alle Zutaten. Verändert nichts.</p>
</div>
<button class="primary" id="scan">Jetzt prüfen</button>
</div>
<div class="bt-row" style="align-items:center;margin-top:12px">
<label class="bt-inline">Plan: <select id="planFile"></select></label>
<button class="ghost" id="reload">Neu laden</button>
</div>
<div id="scanStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="scanLog" class="bt-log bt-hidden"></div>
</div>
<div id="summary" class="bt-empty">Noch nicht geprüft. Oben auf „Jetzt prüfen“.</div>
</section>
<!-- ══════════════════════════════════════════════ 2 Vorschlagen -->
<section id="tab-vorschlagen" class="tab bt-hidden">
<div class="bt-card">
<h2>Einordnen per KI <small style="color:var(--bt-muted);font-weight:normal">(ChatGPT)</small></h2>
<p class="hint">Lässt ChatGPT jede noch nicht eingeordnete Zutat einer der
<b>vorhandenen</b> Kategorien zuordnen. Passt keine, bleibt die Zutat
offen. Nur ein Vorschlag — prüfen und freigeben unter „Übernehmen“.</p>
<label class="bt-check">
<input type="checkbox" id="alle"> auch bereits eingeordnete neu vorschlagen
</label>
<div class="bt-row" style="margin-top:12px;align-items:center">
<button class="primary" id="prefill">Einordnen lassen</button>
<button class="ghost" id="probe">OpenAI testen</button>
<span class="bt-spacer"></span>
<span class="bt-badge" id="aiHint"></span>
</div>
<div id="aiStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="aiLog" class="bt-log bt-hidden"></div>
</div>
</section>
<!-- ═══════════════════════════════════════════════ 3 Übernehmen -->
<section id="tab-uebernehmen" class="tab bt-hidden">
<div id="box"><div class="bt-empty">Erst prüfen (Tab 1).</div></div>
<div class="bt-card" id="runCard" style="display:none">
<div class="bt-row" style="align-items:center">
<span id="count" class="bt-badge">0 Zuweisungen</span>
<span class="bt-spacer"></span>
<button id="dry">Trockenübung</button>
<button class="primary" id="apply">Zuweisen</button>
</div>
<div id="runStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="runLog" class="bt-log bt-hidden"></div>
</div>
</section>
<!-- ═════════════════════════════════════════════ Sicherungen -->
<section id="tab-sicherungen" class="tab bt-hidden"></section>
</main>
<script>
const $ = (id) => document.getElementById(id);
let state = { plans: [], tandoor: false, openai: false };
let plan = null;
/* ------------------------------------------------------------ Reiter */
$("tabs").addEventListener("click", (e) => {
const b = e.target.closest("button[data-tab]");
if (!b) return;
document.querySelectorAll("#tabs button").forEach((x) => x.classList.remove("active"));
document.querySelectorAll("main > .tab").forEach((x) => x.classList.add("bt-hidden"));
b.classList.add("active");
$(`tab-${b.dataset.tab}`).classList.remove("bt-hidden");
if (b.dataset.tab === "sicherungen") backupsView.reload();
});
function goTab(name) {
const b = document.querySelector(`#tabs button[data-tab="${name}"]`);
if (b) b.click();
}
function makeRunner(endpoint, logId, statusId, after) {
return BT.Runner({
endpoint, log: $(logId), status: $(statusId),
onStart: () => { $(logId).classList.remove("bt-hidden"); $(statusId).classList.remove("bt-hidden"); },
onFinish: (info) => {
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
if (after) after(info);
},
});
}
const scanRunner = makeRunner("/api/run/pruefen", "scanLog", "scanStatus",
() => load(true).then(() => goTab("uebernehmen")));
let lastApply = false;
const runRunner = makeRunner("/api/run/anwenden", "runLog", "runStatus", () => {
if (lastApply) { lastApply = false; scanRunner.start({}); } else load();
});
const prefillRunner = makeRunner("/api/run/vorschlagen", "aiLog", "aiStatus", () => {
const cur = $("planFile").value; if (cur) loadPlan(cur).then(() => goTab("uebernehmen"));
});
const probeRunner = makeRunner("/api/run/probe", "aiLog", "aiStatus");
const restoreRunner = BT.Runner({
endpoint: "/api/run/restore",
onFinish: (info) => {
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
backupsView.reload(); load(true);
},
});
const backupsView = BT.Backups({
mount: $("tab-sicherungen"), runner: restoreRunner, detail: true,
note: `Kategorie-Zuweisungen sind nicht destruktiv und werden beim
Zurückspielen exakt auf den vorherigen Stand zurückgesetzt.`,
});
$("scan").addEventListener("click", () => scanRunner.start({}));
$("reload").addEventListener("click", () => load());
$("planFile").addEventListener("change", (e) => loadPlan(e.target.value));
$("prefill").addEventListener("click", () => {
const p = $("planFile").value;
if (!p) { BT.toast("Erst prüfen.", "err"); return; }
$("aiStatus").classList.remove("bt-hidden"); $("aiLog").classList.remove("bt-hidden");
prefillRunner.start({ plan: p, alle: $("alle").checked });
});
$("probe").addEventListener("click", () => {
$("aiStatus").classList.remove("bt-hidden"); $("aiLog").classList.remove("bt-hidden");
probeRunner.start({});
});
$("dry").addEventListener("click", () => run(false));
$("apply").addEventListener("click", () => run(true));
$("box").addEventListener("change", updateCount);
function renderSummary() {
if (!plan) { $("summary").innerHTML = `<div class="bt-empty">Noch nicht geprüft. Oben auf „Jetzt prüfen“.</div>`; return; }
const ohne = plan.foods.filter((f) => !f.current_id).length;
$("summary").innerHTML = `
<div class="bt-card">
<div class="bt-row" style="gap:16px;flex-wrap:wrap">
<span class="bt-badge">${plan.foods.length} Zutaten</span>
<span class="bt-badge ${ohne ? "warn" : "ok"}">${ohne} ohne Kategorie</span>
<span class="bt-badge">${plan.categories.length} Kategorien</span>
</div>
<p class="hint" style="margin:10px 0 0">${ohne
? `Weiter zu <b>Vorschlagen</b> (KI ordnet ein) oder direkt zu <b>Übernehmen</b>.`
: `Alle Zutaten haben bereits eine Kategorie.`}</p>
</div>`;
}
function render() {
renderSummary();
const boxEmpty = `<div class="bt-empty">Erst prüfen (Tab 1).</div>`;
if (!plan) { $("box").innerHTML = boxEmpty; $("runCard").style.display = "none"; return; }
const cats = plan.categories || [];
const ohne = plan.foods.filter((f) => !f.current_id).length;
const opts = (sel) => `<option value=""${sel ? "" : " selected"}>— keine —</option>` +
cats.map((c) => `<option value="${c.id}"${sel === c.id ? " selected" : ""}>${BT.escape(c.name)}</option>`).join("");
$("box").innerHTML = `
<div class="bt-card">
<h2>Zutaten <small style="color:var(--bt-muted);font-weight:normal">
(${ohne} ohne Kategorie von ${plan.foods.length})</small></h2>
<p class="hint">Wähle je Zutat eine Kategorie und hake sie an. Geschrieben
wird nur, was du anhakst und was sich vom Ist-Stand unterscheidet.</p>
<input type="text" class="catfilter" id="catfilter" placeholder="filtern nach Name …">
<div class="list" id="catlist">${plan.foods.map((f) => `
<div class="catrow${f.current_id ? "" : " nocat"}" data-name="${BT.escape((f.name||"").toLowerCase())}">
<input type="checkbox" data-aid="${f.aid}" data-role="accept" ${f.accept ? "checked" : ""}>
<div>
<div>${BT.escape(f.name)} <span class="cur">[${f.id}]</span>
${f.ai ? `<span class="aibadge">KI</span>` : ""}</div>
<div class="cur">aktuell: ${f.current_name ? BT.escape(f.current_name) : "—"}</div>
</div>
<select data-aid="${f.aid}" data-role="cat">${opts(f.proposed_id)}</select>
</div>`).join("")}</div>
</div>`;
$("catfilter").addEventListener("input", (e) => {
const q = e.target.value.trim().toLowerCase();
$("catlist").querySelectorAll(".catrow").forEach((row) => {
row.style.display = (!q || (row.dataset.name || "").includes(q)) ? "" : "none";
});
});
$("catlist").addEventListener("change", (e) => {
if (e.target.dataset && e.target.dataset.role === "cat") {
const acc = $("catlist").querySelector(`[data-aid="${e.target.dataset.aid}"][data-role="accept"]`);
if (acc) acc.checked = true;
}
});
$("runCard").style.display = "";
updateCount();
}
function collect() {
const rows = {};
document.querySelectorAll("#catlist [data-aid]").forEach((el) => {
const aid = el.dataset.aid;
rows[aid] = rows[aid] || { aid };
if (el.dataset.role === "accept") rows[aid].accept = el.checked;
if (el.dataset.role === "cat") rows[aid].proposed_id = el.value ? Number(el.value) : null;
});
return Object.values(rows);
}
function updateCount() {
if (!plan) return;
const byAid = Object.fromEntries(plan.foods.map((f) => [f.aid, f]));
const n = collect().filter((r) => {
const f = byAid[r.aid]; if (!f) return false;
return r.accept && (r.proposed_id ?? null) !== (f.current_id ?? null);
}).length;
$("count").textContent = `${n} Zuweisung${n === 1 ? "" : "en"}`;
$("apply").disabled = !n || !state.tandoor;
$("dry").disabled = !n;
}
async function save() {
const name = $("planFile").value;
if (!name) throw new Error("Kein Plan geladen.");
return BT.api(`/api/plans/${encodeURIComponent(name)}`, {
method: "POST", body: JSON.stringify({ accepted: collect() }),
});
}
async function run(apply) {
try {
const saved = await save();
if (!saved.zuweisungen) { BT.toast("Nichts anzuwenden.", "err"); return; }
if (apply && !confirm(`${saved.zuweisungen} Kategorie-Zuweisungen schreiben?\n\n` +
`Nicht destruktiv und über „Sicherungen“ rückspielbar.`)) return;
lastApply = apply;
runRunner.start({ plan: $("planFile").value, apply });
} catch (err) { BT.toast(err.message, "err"); }
}
async function loadPlan(name) {
if (!name) { plan = null; render(); return; }
plan = await BT.api(`/api/plans/${encodeURIComponent(name)}`);
render();
}
async function load(selectNewest = false) {
state = await BT.api("/api/state");
$("warn").classList.toggle("bt-hidden", state.tandoor);
if (!state.tandoor) {
$("warn").innerHTML = `Tandoor-URL und Token fehlen. Bitte in den
<a href="/settings">Einstellungen</a> hinterlegen.`;
}
$("scan").disabled = !state.tandoor;
const sel = $("planFile");
const previous = sel.value;
sel.innerHTML = state.plans.map((f) =>
`<option value="${BT.escape(f)}">${BT.escape(f.replace(".json", ""))}</option>`).join("");
if (state.plans.length) {
sel.value = (!selectNewest && state.plans.includes(previous)) ? previous : state.plans[0];
await loadPlan(sel.value);
} else { plan = null; render(); }
$("prefill").disabled = !state.openai;
$("aiHint").textContent = state.openai ? "" : "OpenAI-Key fehlt (Einstellungen)";
if (state.running) {
const lbl = state.running.label || "";
const r = lbl.startsWith("Kategorien prüfen") ? scanRunner
: lbl.startsWith("KI ordnet") ? prefillRunner
: lbl.startsWith("OpenAI") ? probeRunner
: lbl.includes("zurück") ? restoreRunner
: runRunner;
if (r === prefillRunner || r === probeRunner) {
$("aiStatus").classList.remove("bt-hidden"); $("aiLog").classList.remove("bt-hidden");
}
r.attach(state.running.id);
}
backupsView.reload();
}
load();
</script>
</body>
</html>
@@ -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())