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