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
@@ -0,0 +1,53 @@
# Einheiten-Umrechnungen (nach Gramm)
Sorgt dafür, dass jede Zutat, die in Rezepten mit einer Nicht-Gramm-Einheit
vorkommt (z. B. „Stück“, „EL“, „ml“, „Prise“), eine Umrechnung **nach Gramm**
bekommt. Gramm ist die gemeinsame Basis — steht die einmal, kann Tandoor
zwischen allen Einheiten hin- und herrechnen und Mengen korrekt skalieren und
auf die Einkaufsliste bringen.
## Ablauf
1. **Prüfen** sucht die Gramm-Einheit („g“), liest die vorhandenen Umrechnungen
und alle Rezepte und ermittelt, welche Zutat mit welchen Einheiten benutzt
wird. Geprüft wird, ob eine Einheit — auch mehrstufig über vorhandene
Umrechnungen — bei Gramm ankommt. Kandidat wird, was das NICHT tut:
* Einheiten, die in Rezepten vorkommen und keinen Weg zu Gramm haben.
* Einheiten aus bereits vorhandenen Umrechnungen, deren „Kette“ nirgends bei
Gramm endet (z. B. „1 Stück = 3 EL“, aber weder Stück noch EL kennt Gramm) —
diese werden ebenfalls an Gramm angebunden.
Kann eine Einheit Gramm schon erreichen (direkt oder über eine Kette), wird
nichts doppelt angelegt. Reine Gewichtseinheiten (kg, mg, Pfund …) werden
übersprungen — die rechnet Tandoor ohnehin um.
2. **Werte vorschlagen** (ChatGPT) schätzt, wie viel Gramm eine typische Menge
wiegt (z. B. „1 Stück Zwiebel ≈ 110 g“, „1 EL Öl ≈ 9 g“).
3. Du prüfst und passt an — Ausgangsmenge und Gramm-Gewicht sind editierbar.
4. **Anlegen** legt die Umrechnungen in Tandoor an. Über **Sicherungen** lässt
sich jeder Lauf zurückspielen (die angelegten Umrechnungen werden gelöscht).
## Wichtig: Schätzwerte
Die KI-Werte sind **Schätzungen** für typische Größen. Tandoor rechnet damit
Rezepte und Einkaufslisten hoch — falsche Werte pflanzen sich fort. Deshalb
immer prüfen, bevor du sie anlegst.
## Voraussetzung
In Tandoor muss es eine Einheit **„g“** geben. Fehlt sie, meldet das Werkzeug
das und legt nichts an — dann zuerst in Tandoor eine Gramm-Einheit anlegen.
## Sicherheit
* Zutat und Einheit stehen fest (immer nach Gramm) — die Oberfläche kann nichts
Fremdes unterschieben; editierbar sind nur die beiden Mengen.
* Angelegte Umrechnungen sind über die Sicherungen wieder löschbar.
## Kommandozeile
```
conversions.py pruefen
conversions.py vorschlagen --plan <datei>
conversions.py anwenden --plan <datei> [--apply]
conversions.py zurueck --lauf <ordner> [--apply]
conversions.py probe
```
+194
View File
@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""
Plugin-Adapter für „Einheiten-Umrechnungen“.
Alles Verändernde läuft über tool/conversions.py im Job-Runner. Die Auswahl
steht vorher in einer Plandatei. Angelegte Umrechnungen sind über die
Sicherungen wieder löschbar.
"""
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 = "conversions.py"
class EditRequest(BaseModel):
accepted: list[dict[str, Any]]
class PrefillRequest(BaseModel):
plan: str
model: str | None = None
class ApplyRequest(BaseModel):
plan: str
apply: bool = True
class ProbeRequest(BaseModel):
model: str | None = None
def _num(value: Any) -> float | None:
try:
f = float(value)
return f if f > 0 else None
except (TypeError, ValueError):
return 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. Eng gehalten: je Kandidat lassen sich nur
`accept`, die Ausgangsmenge (`base_amount`) und das Gramm-Gewicht
(`grams`) ändern. Einheit und Zutat stehen fest (immer nach Gramm), die
Oberfläche kann daran nichts drehen.
"""
file = resolve_plan(name)
plan = json.loads(file.read_text(encoding="utf-8"))
by_aid = {k.get("aid"): k for k in plan.get("candidates", []) if k.get("aid")}
gesetzt = 0
for wish in request.accepted:
k = by_aid.get(wish.get("aid"))
if k is None:
continue
if "base_amount" in wish:
v = _num(wish["base_amount"])
if v is not None:
k["base_amount"] = v
if "grams" in wish:
v = _num(wish["grams"])
if v is not None:
k["grams"] = v
k["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 k in plan["candidates"]
if k.get("accept") and k.get("grams") and k.get("base_amount"))
return {"ok": True, "gesetzt": gesetzt, "umrechnungen": 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="Umrechnungen 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]
job = await ctx.jobs.start(
plugin=ctx.id, label=f"KI schlägt vor: {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=("Umrechnungen anlegen" 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
+18
View File
@@ -0,0 +1,18 @@
{
"id": "tandoor-conversions",
"name": "Einheiten-Umrechnungen",
"summary": "Fehlende Umrechnungen finden und per KI-Schätzung eintragen",
"description": "Findet Zutaten, die in Rezepten mit mehreren, nicht ohne Weiteres umrechenbaren Einheiten vorkommen (z. B. Stück und g) und wofür noch keine Umrechnung hinterlegt ist. ChatGPT schlägt sinnvolle Werte vor — diese sind Schätzungen und vor dem Eintragen zu prüfen. Angelegte Umrechnungen sind über die Sicherungen wieder löschbar.",
"icon": "🔁",
"category": "Tandoor",
"version": "1.0.0",
"entrypoint": "backend:create_app",
"order": 49,
"requires": ["tandoor"],
"features": [
"Findet fehlende, sinnvolle Umrechnungen aus der Rezeptnutzung",
"KI schlägt Werte vor — als Schätzung markiert",
"Angelegte Umrechnungen sind löschbar (rückspielbar)"
],
"docs": "TOOL-README.md"
}
@@ -0,0 +1,343 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einheiten-Umrechnungen</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>
.ucrow { display: grid; grid-template-columns: 24px 1fr; gap: 6px 10px;
padding: 9px 11px; border-bottom: 1px solid var(--bt-line-soft); align-items: center; }
.ucrow:last-child { border-bottom: none; }
.ucrow .head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.ucrow .conv { grid-column: 2; display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
font-size: 12.5px; }
.ucrow input[type=number] { width: 78px; padding: 4px 6px; font-size: 12.5px;
border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); color: var(--bt-ink); }
.ucrow .grund { grid-column: 2; font-size: 11.5px; color: var(--bt-muted); }
.ucfilter { 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; 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); }
.estimate { border-left: 3px solid color-mix(in srgb, var(--bt-accent) 60%, transparent);
background: color-mix(in srgb, var(--bt-accent) 8%, transparent);
padding: 10px 12px; border-radius: var(--bt-r-sm); margin: 0 0 12px; font-size: 12.5px; }
.list { border: 1px solid var(--bt-line); border-radius: var(--bt-r); overflow: hidden; }
</style>
</head>
<body>
<main class="bt-main wide">
<div class="bt-pagehead">
<h1>🔁 Einheiten-Umrechnungen</h1>
<p>Sorgt dafür, dass jede Zutat, die mit einer Nicht-Gramm-Einheit vorkommt,
eine Umrechnung nach Gramm bekommt. ChatGPT schätzt die Werte — du prüfst.</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 · Anlegen</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 Rezepte und vorhandene
Umrechnungen und prüft, welche Einheit noch nicht bei Gramm ankommt.
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>Werte per KI <small style="color:var(--bt-muted);font-weight:normal">(ChatGPT)</small></h2>
<p class="hint">ChatGPT schätzt, wie viel Gramm eine typische Menge wiegt
(z. B. „1 Stück Zwiebel ≈ 110 g“). Prüfen und freigeben unter „Anlegen“.</p>
<div class="bt-row" style="margin-top:12px;align-items:center">
<button class="primary" id="prefill">Werte vorschlagen</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 Anlegen -->
<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 Umrechnungen</span>
<span class="bt-spacer"></span>
<button id="dry">Trockenübung</button>
<button class="primary" id="apply">Anlegen</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: `Angelegte Umrechnungen werden beim Zurückspielen wieder gelöscht.`,
});
$("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 });
});
$("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);
$("box").addEventListener("input", updateCount);
function renderSummary() {
if (!plan) { $("summary").innerHTML = `<div class="bt-empty">Noch nicht geprüft. Oben auf „Jetzt prüfen“.</div>`; return; }
const n = (plan.candidates || []).length;
const nogram = !plan.gram_id;
$("summary").innerHTML = `
<div class="bt-card">
<div class="bt-row" style="gap:16px;flex-wrap:wrap">
<span class="bt-badge ${n ? "warn" : "ok"}">${n} ohne Weg zu Gramm</span>
<span class="bt-badge">${plan.existing_count} vorhandene Umrechnungen</span>
</div>
<p class="hint" style="margin:10px 0 0">${nogram
? `In Tandoor gibt es keine Einheit „g“. Bitte zuerst eine Gramm-Einheit anlegen.`
: n ? `Weiter zu <b>Vorschlagen</b> (KI schätzt die Gramm-Werte) oder direkt zu <b>Anlegen</b>.`
: `Alles hat bereits einen Weg zu Gramm.`}</p>
</div>`;
}
function render() {
renderSummary();
if (!plan) { $("box").innerHTML = `<div class="bt-empty">Erst prüfen (Tab 1).</div>`; $("runCard").style.display = "none"; return; }
const cs = plan.candidates || [];
const g = plan.gram_name || "g";
const nogram = !plan.gram_id;
$("box").innerHTML = `
<div class="bt-card">
<h2>Umrechnungen nach ${BT.escape(g)} <small style="color:var(--bt-muted);font-weight:normal">
(${cs.length} offen · ${plan.existing_count} vorhanden)</small></h2>
${nogram ? `<div class="estimate">In Tandoor gibt es keine Einheit „g“.
Bitte zuerst eine Gramm-Einheit anlegen — sonst kann nichts nach Gramm
umgerechnet werden.</div>` : `<div class="estimate">⚠️ Die KI-Werte sind
<b>Schätzungen</b> für typische Größen (z. B. „1 Zwiebel ≈ 110 g“).
Tandoor rechnet damit Rezepte und Einkaufslisten hoch — bitte vor dem
Anlegen prüfen und anpassen.</div>`}
<input type="text" class="ucfilter" id="ucfilter" placeholder="filtern nach Zutat …">
<div class="list" id="uclist">${cs.map((k) => {
const ba = k.base_amount ?? 1;
const gr = k.grams ?? "";
return `
<div class="ucrow" data-name="${BT.escape((k.food_name||"").toLowerCase())}">
<input type="checkbox" data-aid="${k.aid}" data-role="accept" ${k.accept ? "checked" : ""}>
<div class="head"><b>${BT.escape(k.food_name)}</b>
<span class="bt-muted" style="font-size:11.5px">[${k.food_id}]</span>
${k.ai ? `<span class="aibadge">KI</span>` : ""}
${k.quelle === "umrechnung" ? `<span class="bt-muted" style="font-size:11px">· aus vorhandener Umrechnung</span>` : ""}</div>
<div class="conv">
<input type="number" step="any" min="0" data-aid="${k.aid}" data-role="ba" value="${ba}">
<span>${BT.escape(k.unit.name)}</span>
<span>=</span>
<input type="number" step="any" min="0" data-aid="${k.aid}" data-role="grams" value="${gr}" placeholder="?">
<span>${BT.escape(g)}</span>
</div>
${k.reason ? `<div class="grund">KI: ${BT.escape(k.reason)}</div>` : ""}
</div>`;
}).join("")}</div>
</div>`;
$("ucfilter").addEventListener("input", (e) => {
const q = e.target.value.trim().toLowerCase();
$("uclist").querySelectorAll(".ucrow").forEach((row) => {
row.style.display = (!q || (row.dataset.name || "").includes(q)) ? "" : "none";
});
});
$("uclist").addEventListener("change", (e) => {
const t = e.target; if (!t.dataset) return;
if (["ba", "grams"].includes(t.dataset.role)) {
const acc = $("uclist").querySelector(`[data-aid="${t.dataset.aid}"][data-role="accept"]`);
if (acc) acc.checked = true;
}
});
$("runCard").style.display = "";
updateCount();
}
function collect() {
const rows = {};
document.querySelectorAll("#uclist [data-aid]").forEach((el) => {
const aid = el.dataset.aid;
rows[aid] = rows[aid] || { aid };
const r = el.dataset.role;
if (r === "accept") rows[aid].accept = el.checked;
if (r === "ba") rows[aid].base_amount = el.value;
if (r === "grams") rows[aid].grams = el.value;
});
return Object.values(rows);
}
function updateCount() {
if (!plan) return;
const n = collect().filter((r) =>
r.accept && Number(r.base_amount) > 0 && Number(r.grams) > 0).length;
$("count").textContent = `${n} Umrechnung${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.umrechnungen) { BT.toast("Nichts anzulegen.", "err"); return; }
if (apply && !confirm(`${saved.umrechnungen} Umrechnungen anlegen?\n\n` +
`Die Werte sind Schätzungen. Über „Sicherungen“ wieder löschbar.`)) 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("Umrechnungen prüfen") ? scanRunner
: lbl.startsWith("KI schlägt") ? 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,501 @@
# -*- coding: utf-8 -*-
"""
Einheiten-Umrechnungen — fehlende, sinnvolle Umrechnungen finden und eintragen.
Sinnvoll ist eine Umrechnung vor allem dort, wo eine Zutat in Rezepten mit
mehreren, nicht ohne Weiteres ineinander umrechenbaren Einheiten vorkommt
(z. B. „Stück“ und „g“) und dafür noch keine Umrechnung hinterlegt ist. Ohne
sie kann Tandoor solche Mengen nicht skalieren oder auf die Einkaufsliste
bringen.
WICHTIG: Die von der KI gelieferten Werte (z. B. „1 Zwiebel ≈ 110 g“) sind
SCHÄTZUNGEN. Sie sind vor dem Eintragen zu prüfen.
Befehle:
pruefen Rezepte, Einheiten und vorhandene Umrechnungen lesen
vorschlagen ChatGPT schlägt Werte für die fehlenden Umrechnungen vor
anwenden Freigegebene Umrechnungen anlegen
zurueck Einen Lauf zurückspielen (löscht die angelegten Umrechnungen)
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 = 30
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-conversions"
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)
def _unit_of(ing: dict) -> tuple[int, str] | None:
u = ing.get("unit")
if isinstance(u, dict) and isinstance(u.get("id"), int):
return u["id"], (u.get("name") or "")
return None
# ------------------------------------------------------------------ Prüfen
# Einheiten, die bereits „gewichtsartig“ und generisch nach Gramm umrechenbar
# sind — dafür braucht es keine food-spezifische Umrechnung.
_GRAM_COMPATIBLE = {
"g", "gramm", "gram", "gramme", "kg", "kilo", "kilogramm", "kilogram",
"mg", "milligramm", "dag", "dkg", "dekagramm", "pfund",
}
def _norm(name: str) -> str:
return (name or "").strip().casefold()
def _find_gram_unit(units: list[dict]) -> dict | None:
# Bevorzugt exakt „g“, sonst „gramm“/„gram“.
for wanted in ("g",):
for u in units:
if _norm(u.get("name")) == wanted:
return u
for u in units:
if _norm(u.get("name")) in ("gramm", "gram", "gramme"):
return u
return None
def scan(client: TandoorClient) -> dict[str, Any]:
out("Einheiten werden gelesen …")
units = client.list_objects("unit")
unit_name = {u["id"]: (u.get("name") or "") for u in units}
gram = _find_gram_unit(units)
if not gram:
out(" ⚠ Keine Gramm-Einheit („g“) in Tandoor gefunden — bitte zuerst "
"eine Einheit „g“ anlegen. Es kann nichts nach Gramm umgerechnet werden.")
gram_id = gram["id"] if gram else None
gram_name = gram.get("name") if gram else "g"
# Alle Einheiten, die ohne food-spezifisches Wissen bei Gramm ankommen
# (Gramm selbst + generische Gewichtseinheiten).
gram_seed = {gram_id} if gram_id else set()
for u in units:
if _norm(u.get("name")) in _GRAM_COMPATIBLE:
gram_seed.add(u["id"])
out("Vorhandene Umrechnungen werden gelesen …")
existing = client.list_objects("unit-conversion")
# Kanten (welche Einheiten sind durch eine Umrechnung verbunden) je Zutat,
# plus globale Umrechnungen (food=None), die für alle Zutaten gelten.
edges: dict[int | None, list[tuple[int, int]]] = {}
conv_units: dict[int, set[int]] = {} # food_id -> Einheiten aus Umrechnungen
conv_food_name: dict[int, str] = {}
for uc in existing:
food = uc.get("food") or {}
fid = food.get("id") if isinstance(food, dict) else None
bu = (uc.get("base_unit") or {}).get("id")
cu = (uc.get("converted_unit") or {}).get("id")
if not (bu and cu):
continue
edges.setdefault(fid, []).append((bu, cu))
if isinstance(fid, int):
conv_units.setdefault(fid, set()).update({bu, cu})
if food.get("name"):
conv_food_name[fid] = food["name"]
out(f" {len(existing)} Umrechnungen vorhanden.")
out("Rezepte werden gelesen (welche Zutat mit welchen Einheiten) …")
overview = client.list_objects("recipe")
used: dict[int, set[int]] = {}
food_name: dict[int, str] = {}
for i, entry in enumerate(overview, start=1):
try:
recipe = client.get_json(f"api/recipe/{entry['id']}/")
except TandoorError:
continue
for step in recipe.get("steps") or []:
for ing in step.get("ingredients") or []:
food = ing.get("food") or {}
fid = food.get("id")
unit = _unit_of(ing)
if isinstance(fid, int) and unit:
used.setdefault(fid, set()).add(unit[0])
unit_name.setdefault(unit[0], unit[1])
food_name.setdefault(fid, food.get("name") or "")
if i % 25 == 0 or i == len(overview):
out(f" {i}/{len(overview)}")
def reaches_gram(food_id: int) -> set[int]:
"""Alle Einheiten, die für diese Zutat (transitiv über vorhandene
Umrechnungen + globale) bei Gramm ankommen."""
reached = set(gram_seed)
kanten = edges.get(food_id, []) + edges.get(None, [])
changed = True
while changed:
changed = False
for a, b in kanten:
if a in reached and b not in reached:
reached.add(b); changed = True
elif b in reached and a not in reached:
reached.add(a); changed = True
return reached
counter = {"n": 0}
def new_aid() -> str:
counter["n"] += 1
return f"u{counter['n']:04d}-{secrets.token_hex(2)}"
# Relevante Zutaten: kommen in Rezepten vor ODER haben bereits Umrechnungen.
alle_foods = set(used) | set(conv_units)
kandidaten = []
ohne_gramm_einheit = False
for fid in alle_foods:
name = food_name.get(fid) or conv_food_name.get(fid) or f"#{fid}"
relevant = set(used.get(fid, set())) | set(conv_units.get(fid, set()))
reached = reaches_gram(fid) if gram_id else set()
for uid in sorted(relevant, key=lambda x: (unit_name.get(x, "") or "").casefold()):
uname = unit_name.get(uid, str(uid))
if not gram_id:
ohne_gramm_einheit = True
continue
if uid == gram_id or _norm(uname) in _GRAM_COMPATIBLE:
continue # schon gewichtsartig / Gramm
if uid in reached:
continue # erreicht Gramm bereits (auch mehrstufig)
in_recipe = uid in used.get(fid, set())
kandidaten.append({
"aid": new_aid(),
"food_id": fid,
"food_name": name,
"unit": {"id": uid, "name": uname},
"gram_id": gram_id,
"gram_name": gram_name,
"quelle": "rezept" if in_recipe else "umrechnung",
"base_amount": 1,
"grams": None,
"reason": "",
"ai": False,
"accept": False,
})
kandidaten.sort(key=lambda k: ((k["food_name"] or "").casefold(), k["unit"]["name"]))
aus_umr = sum(1 for k in kandidaten if k["quelle"] == "umrechnung")
out(f" {len(kandidaten)} Einheiten ohne Weg zu Gramm "
f"({len(kandidaten) - aus_umr} aus Rezepten, {aus_umr} aus vorhandenen Umrechnungen).")
return {
"created_at": datetime.now(timezone.utc).isoformat(),
"tandoor": client.base_url,
"existing_count": len(existing),
"gram_id": gram_id,
"gram_name": gram_name,
"candidates": kandidaten,
}
def print_scan(plan: dict[str, Any]) -> None:
out()
out("" * 60)
out(f" {plan['existing_count']} vorhandene Umrechnungen · "
f"{len(plan['candidates'])} ohne Weg zu Gramm")
out("" * 60)
for k in plan["candidates"][:20]:
marke = "" if k.get("quelle") == "rezept" else " (aus Umrechnung)"
out(f" {k['food_name']} [{k['food_id']}]: "
f"1 {k['unit']['name']} = ? {plan.get('gram_name', 'g')}{marke}")
if len(plan["candidates"]) > 20:
out(f" … und {len(plan['candidates']) - 20} weitere")
# ------------------------------------------------------------- KI-Vorschlag
def vorschlagen(plan: dict[str, Any], model: str) -> int:
gram_name = plan.get("gram_name", "g")
ziele = [k for k in plan["candidates"] if k.get("grams") is None]
if not ziele:
out("Nichts offen.")
return 0
system = (
"Du bist Experte für Lebensmittel und Küchenmengen. Für jede Zutat und "
"ihre Einheit gibst du an, wie viel GRAMM eine typische Menge dieser "
"Einheit wiegt (z. B. „1 Stück Zwiebel ≈ 110 g“, „1 EL Öl ≈ 9 g“, "
"„1 Prise Salz ≈ 0,4 g“). Die Werte sind Schätzungen für übliche Größen. "
"Gib pro Eintrag zusätzlich an, auf welche Ausgangsmenge (base_amount) "
"sich das Gramm-Gewicht bezieht — meist 1, bei sehr kleinen Mengen darf "
"es auch 100 sein (z. B. 100 ml). Kannst du eine Zutat/Einheit gar nicht "
"sinnvoll schätzen, setze grams auf null. Antworte nur mit JSON."
)
gesetzt = 0
for start in range(0, len(ziele), BATCH):
teil = ziele[start:start + BATCH]
anfrage = [{"id": k["aid"], "zutat": k["food_name"], "einheit": k["unit"]["name"]}
for k in teil]
user = (
f"Zieleinheit ist immer „{gram_name}“ (Gramm). Gib ein JSON-Objekt "
"zurück: Schlüssel ist die „id“, Wert ist "
'{"base_amount": Zahl (meist 1), "grams": Gramm für diese '
'base_amount (oder null), "grund": "kurz"}.\n\nEinträge:\n'
+ json.dumps(anfrage, ensure_ascii=False)
)
antwort = ai.chat_json(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
model,
)
if not isinstance(antwort, dict):
continue
for k in teil:
d = antwort.get(k["aid"])
k["ai"] = True
if not isinstance(d, dict):
continue
k["reason"] = str(d.get("grund") or "")[:200]
try:
grams = float(d["grams"])
base = float(d.get("base_amount") or 1)
except (KeyError, TypeError, ValueError):
continue
if grams <= 0 or base <= 0:
continue
k["base_amount"] = base
k["grams"] = grams
k["accept"] = True
gesetzt += 1
return gesetzt
# --------------------------------------------------------------- Anwenden
def anwenden(client: TandoorClient, plan: dict[str, Any], args: argparse.Namespace) -> int:
gram_id = plan.get("gram_id")
gram_name = plan.get("gram_name", "g")
if not gram_id:
out("Keine Gramm-Einheit in Tandoor — bitte zuerst „g“ anlegen. Abbruch.")
return 1
ziele = [k for k in plan["candidates"]
if k.get("accept") and k.get("grams") and k.get("base_amount")]
if not ziele:
out("Nichts angehakt (oder ohne Wert).")
return 0
out(f"Modus: {'ANWENDEN' if args.apply else 'TROCKENÜBUNG'}")
out(f"Tandoor: {client.base_url}")
out(f"{len(ziele)} Umrechnungen nach {gram_name}")
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-conversions",
"label": "Umrechnungen anlegen",
"tandoor": client.base_url,
"mode": "apply" if args.apply else "dry",
"steps": [],
}
done, failed = 0, 0
for nummer, k in enumerate(ziele, start=1):
text = (f"{k['base_amount']:g} {k['unit']['name']} "
f"= {k['grams']:g} {gram_name}")
prefix = f"[{nummer}/{len(ziele)}] „{k['food_name']}“: {text}"
payload = {
"food": {"id": k["food_id"], "name": k["food_name"]},
"base_amount": k["base_amount"],
"base_unit": {"id": k["unit"]["id"], "name": k["unit"]["name"]},
"converted_amount": k["grams"],
"converted_unit": {"id": gram_id, "name": gram_name},
}
if not args.apply:
out(f"{prefix}: würde anlegen")
done += 1
continue
try:
created = client.post_json("api/unit-conversion/", payload)
except TandoorError as exc:
out(f"{prefix}: FEHLER — {exc}")
failed += 1
if not args.continue_on_error:
return 1
continue
new_id = created.get("id") if isinstance(created, dict) else None
manifest["steps"].append({
"n": nummer,
"action": "create_conversion",
"conversion_id": new_id,
"food_name": k["food_name"],
"text": text,
"restore_level": "voll" if new_id else "nein",
"status": "done",
})
backups.write_manifest(run, manifest)
out(f"{prefix}: ✓ angelegt (id {new_id})")
done += 1
out()
out("" * 60)
verb = "angelegt" if args.apply else "würden angelegt"
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:
run = backups.resolve_run(data_dir() / "laeufe", 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") != "create_conversion":
continue
cid = step.get("conversion_id")
prefix = f"[{step['n']}] „{step['food_name']}“: {step.get('text','')}"
if not cid:
out(f"{prefix}: keine ID gespeichert — nicht löschbar")
continue
if not args.apply:
out(f"{prefix}: würde Umrechnung {cid} löschen")
zurueck += 1
continue
try:
client.delete(f"api/unit-conversion/{cid}/")
out(f"{prefix}: ✓ gelöscht")
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} gelöscht · {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="Einheiten-Umrechnungen")
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="fehlende Umrechnungen finden")
p = sub.add_parser("vorschlagen", help="ChatGPT schlägt Werte vor")
p.add_argument("--plan", required=True)
p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5"))
p = sub.add_parser("anwenden", help="freigegebene Umrechnungen anlegen")
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 schlägt Umrechnungen vor (Modell {args.model}) …")
n = vorschlagen(plan, args.model)
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} Umrechnungen vorgeschlagen. Werte sind SCHÄTZUNGEN — bitte prüfen.")
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())