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