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
+70
View File
@@ -0,0 +1,70 @@
# Nährwerte vervollständigen
Findet Zutaten, bei denen Eigenschaften fehlen, lässt die Lücken schätzen und
schreibt sie nach Prüfung zurück.
## Die drei Schritte
**1 · Prüfen** liest alle Zutaten und Eigenschaften und zeigt, wo was fehlt.
Sortiert nach Rezeptzahl — eine Lücke bei „Zwiebel“ (18 Rezepte) wiegt
schwerer als bei „Yuzukosho“ (0 Rezepte). Verändert nichts.
**2 · Vorschlagen** lässt ein Sprachmodell die fehlenden Werte je 100 g
schätzen. Geht in Blöcken zu 20 Zutaten an OpenAI. Schreibt eine
Vorschlagsdatei, in Tandoor passiert nichts.
**3 · Übernehmen** zeigt jeden Vorschlag mit Häkchen und änderbarem Wert.
Erst „Trockenübung“, dann „Übernehmen“. Von jeder geänderten Zutat wird der
Zustand vorher und nachher gesichert.
## Was die Zahlen sind — und was nicht
Schätzungen eines Sprachmodells. Für Hausgebrauch und Größenordnung gedacht,
keine Laboranalyse. Wer Nährwerte medizinisch braucht, prüft sie besser gegen
eine echte Datenbank. Deshalb ist jeder Wert vor dem Schreiben editierbar.
## Wie es mit Tandoor umgeht
* Vorhandene Werte werden **nie** überschrieben (außer man hakt es ausdrücklich an).
* Tandoor ersetzt beim `PATCH` die komplette `properties`-Liste. Das Tool
schickt deshalb immer den vollständigen Bestand mit — sonst fiele weg, was
schon da war.
* Zutaten ohne Bezugsmenge bekommen sie mitgesetzt (Vorgabe: 100 g). Ohne die
kann Tandoor keine Rezeptwerte rechnen.
## Ohne Suite
export TANDOOR_URL=https://kitchen.example.de
export TANDOOR_TOKEN=...
export OPENAI_API_KEY=...
python3 plugins/tandoor-nutrition/tool/nutrition.py pruefen
python3 plugins/tandoor-nutrition/tool/nutrition.py vorschlagen --limit 20
python3 plugins/tandoor-nutrition/tool/nutrition.py uebernehmen \
--vorschlag data/tandoor-nutrition/vorschlaege/<datei>.json --apply
## Dateien
data/tandoor-nutrition/bericht/bericht.json letzte Prüfung
data/tandoor-nutrition/bericht/luecken.csv dieselbe als Tabelle
data/tandoor-nutrition/vorschlaege/<zeit>.json Vorschläge
data/tandoor-nutrition/laeufe/<zeit>/ Sicherungen je Übernahme
## Wenn „Vorschlagen“ nur „ohne Ergebnis“ liefert
Kommt ein Lauf mit 0 Vorschlägen und „N ohne Ergebnis“ zurück, ist meist die
Modell-Abfrage grundsätzlich gescheitert, nicht an einzelnen Zutaten. Der erste
Fehler steht jetzt in der Vorschlagsdatei (`error`) und im Übernehmen-Reiter.
Schnellster Weg zur Ursache: im Reiter „2 · Vorschlagen“ auf **Verbindung
testen** (oder `nutrition.py probe`). Das macht eine einzelne Beispiel-Abfrage
und zeigt den vollständigen Fehler. Häufige Ursachen:
* **Modellname** nicht vorhanden oder nicht freigeschaltet → in den
Einstellungen ein anderes Modell setzen (`OPENAI_MODEL`).
* **API-Key** wird abgelehnt → `OPENAI_API_KEY` prüfen.
* **Guthaben/Kontingent** erschöpft.
* **Aufruf-Parameter**: Neuere Modelle lehnen `temperature` ungleich dem
Standard oder `response_format` ab. Das Tool probiert solche Fälle inzwischen
selbst mildere Varianten durch und merkt sich die funktionierende — hier ist
also normalerweise nichts mehr zu tun.
+245
View File
@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
"""
Plugin-Adapter für „Nährwerte vervollständigen“.
Das Backend hält selbst keine Logik: Es startet tool/nutrition.py über den
Job-Runner und liest die Dateien, die das Skript hinterlässt. Dadurch ist
dasselbe Werkzeug auch ohne Suite benutzbar und die Oberfläche zeigt
zwangsläufig das, was auch geschrieben würde.
"""
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, Field
from core.jobs import job_router
from core.backups import backup_router
TOOL = "nutrition.py"
class ScanRequest(BaseModel):
insecure: bool = False
class ProposeRequest(BaseModel):
foods: list[int] = Field(default_factory=list)
properties: list[int] = Field(default_factory=list)
limit: int = Field(default=0, ge=0, le=2000)
overwrite: bool = False
model: str | None = None
insecure: bool = False
class ProbeRequest(BaseModel):
model: str | None = None
class ApplyRequest(BaseModel):
proposal: str
apply: bool = False
overwrite: bool = False
continue_on_error: bool = True
base_amount: float = Field(default=100.0, gt=0, le=10000)
base_unit: str = Field(default="g", min_length=1, max_length=40)
insecure: bool = False
class EditRequest(BaseModel):
"""Die in der Oberfläche geprüften Werte zurückschreiben."""
proposals: list[dict[str, Any]]
def create_app(ctx):
tool = ctx.path("tool", TOOL)
report_file = ctx.data_dir / "bericht" / "bericht.json"
proposals_dir = ctx.data_dir / "vorschlaege"
proposals_dir.mkdir(parents=True, exist_ok=True)
def tool_env() -> dict:
"""
Umgebung für den Subprozess.
Wichtig: DATA_DIR ausdrücklich mitgeben. Ohne das schreibt das Skript
seinen Rückfallpfad neben die Anwendung — im Container ist der
schreibgeschützt, und die Oberfläche würde den Bericht nie finden.
"""
env = ctx.settings.tool_env()
env["DATA_DIR"] = str(ctx.data_dir)
return env
def base_argv(insecure: bool) -> list[str]:
argv = [sys.executable, str(tool)]
if insecure:
argv.append("--insecure")
return argv
def resolve_proposal(name: str) -> Path:
candidate = proposals_dir / Path(name).name
if candidate.suffix != ".json" or not candidate.is_file():
raise HTTPException(404, f"Vorschlagsdatei „{name}“ gibt es nicht.")
return candidate
def guard() -> None:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Es läuft bereits etwas. Bitte abwarten.")
if not ctx.settings.status()["tandoor"]:
raise HTTPException(400, "Tandoor-URL und Token fehlen siehe Einstellungen.")
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]:
report = None
if report_file.is_file():
try:
report = json.loads(report_file.read_text(encoding="utf-8"))
except Exception:
report = None
running = [j.info() for j in ctx.jobs.running(ctx.id)]
files = sorted(
(p.name for p in proposals_dir.glob("*.json")), reverse=True
)
return {
"report": report,
"proposals": files,
"tandoor": ctx.settings.status()["tandoor"],
"openai": ctx.settings.status()["openai"],
"model": ctx.settings.get("OPENAI_MODEL") or "gpt-5.5",
"running": running[0] if running else None,
"data_dir": str(ctx.data_dir),
}
@app.get("/api/proposals/{name}")
def read_proposal(name: str) -> dict[str, Any]:
return json.loads(resolve_proposal(name).read_text(encoding="utf-8"))
@app.post("/api/proposals/{name}")
def save_proposal(name: str, request: EditRequest) -> dict[str, Any]:
"""
Speichert die in der Oberfläche geprüften und ggf. korrigierten Werte.
Es werden nur Häkchen und Zahlen übernommen — welche Zutat und welche
Eigenschaft gemeint ist, bleibt das, was das Skript geschrieben hat.
"""
file = resolve_proposal(name)
payload = json.loads(file.read_text(encoding="utf-8"))
by_id = {p["food_id"]: p for p in payload.get("proposals", [])}
for edited in request.proposals:
original = by_id.get(edited.get("food_id"))
if original is None:
continue
original["accept"] = bool(edited.get("accept", True))
werte = edited.get("values") or {}
clean: dict[str, float] = {}
for key, value in werte.items():
if key in original["values"]:
try:
number = float(value)
except (TypeError, ValueError):
continue
if 0 <= number <= 100000:
clean[key] = round(number, 2)
if clean:
original["values"] = clean
file.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
angehakt = sum(1 for p in payload["proposals"] if p.get("accept", True))
return {"ok": True, "accepted": angehakt, "total": len(payload["proposals"])}
@app.post("/api/run/scan")
async def run_scan(request: ScanRequest) -> dict[str, Any]:
guard()
job = await ctx.jobs.start(
plugin=ctx.id, label="Prüfen: fehlende Nährwerte",
argv=base_argv(request.insecure) + ["pruefen"],
cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
@app.post("/api/run/propose")
async def run_propose(request: ProposeRequest) -> dict[str, Any]:
guard()
if not ctx.settings.status()["openai"]:
raise HTTPException(400, "OpenAI-Key fehlt siehe Einstellungen.")
argv = base_argv(request.insecure) + ["vorschlagen"]
if request.foods:
argv += ["--foods", ",".join(str(int(i)) for i in request.foods)]
if request.properties:
argv += ["--properties", ",".join(str(int(i)) for i in request.properties)]
if request.limit:
argv += ["--limit", str(request.limit)]
if request.overwrite:
argv.append("--overwrite")
if request.model:
argv += ["--model", request.model]
scope = f"{len(request.foods)} Zutaten" if request.foods else "alle Lücken"
job = await ctx.jobs.start(
plugin=ctx.id, label=f"Vorschlagen: {scope}", 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]:
# Kein Tandoor nötig — nur OpenAI. Eine einzelne Testabfrage.
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()
@app.post("/api/run/apply")
async def run_apply(request: ApplyRequest) -> dict[str, Any]:
guard()
file = resolve_proposal(request.proposal)
argv = base_argv(request.insecure) + ["uebernehmen", "--vorschlag", str(file)]
if request.apply:
argv.append("--apply")
if request.overwrite:
argv.append("--overwrite")
if request.continue_on_error:
argv.append("--continue-on-error")
argv += ["--base-amount", str(request.base_amount),
"--base-unit", request.base_unit]
job = await ctx.jobs.start(
plugin=ctx.id,
label=("Übernehmen" if request.apply else "Trockenübung") + f": {file.stem}",
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")
if force:
argv.append("--force")
return argv
app.include_router(backup_router(ctx, "laeufe", restore_argv))
app.include_router(job_router(ctx.jobs, ctx.id))
return app
+19
View File
@@ -0,0 +1,19 @@
{
"id": "tandoor-nutrition",
"name": "Nährwerte vervollständigen",
"summary": "Fehlende Zutaten-Eigenschaften finden, schätzen, übernehmen",
"description": "Zeigt, bei welchen Zutaten Ballaststoffe, Proteine, Fett, Kohlenhydrate, Kalorien oder Zucker fehlen, lässt die Lücken je 100 g schätzen und schreibt sie nach Prüfung zurück. Rezept-Nährwerte rechnet Tandoor danach selbst.",
"icon": "🥑",
"category": "Tandoor",
"version": "1.0.0",
"entrypoint": "backend:create_app",
"order": 50,
"requires": ["tandoor", "openai"],
"features": [
"Abdeckung je Eigenschaft auf einen Blick",
"Lücken zuerst dort, wo viele Rezepte dranhängen",
"Jeder Wert vor dem Schreiben prüf- und änderbar",
"Setzt fehlende Bezugsmengen gleich mit"
],
"docs": "TOOL-README.md"
}
+565
View File
@@ -0,0 +1,565 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nährwerte vervollständigen</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>
/* Nur Layout alles Optische kommt aus /shared/boehmi.css */
.facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; }
.fact { background: var(--bt-surface); border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg); padding: 13px 14px; box-shadow: var(--bt-shadow); }
.fact b { display: block; font-size: 24px; font-weight: 850; letter-spacing: -.03em; }
.fact span { font-size: 10.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--bt-muted); }
.fact.warn b { color: var(--bt-warn); }
.fact.ok b { color: var(--bt-ok); }
.coverage { display: grid; gap: 9px; }
.cov { display: grid; grid-template-columns: 130px 1fr 92px; gap: 10px; align-items: center; font-size: 13px; }
.cov .nm { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track { height: 9px; border-radius: 999px; background: var(--bt-bg-2); border: 1px solid var(--bt-line-soft); overflow: hidden; }
.fill { height: 100%; background: var(--bt-accent); border-radius: 999px; transition: width .4s ease; }
.fill.low { background: var(--bt-err); }
.fill.mid { background: var(--bt-warn); }
.cov .n { text-align: right; font-family: var(--bt-mono); font-size: 11.5px; color: var(--bt-muted); }
.matrix { overflow: auto; max-height: 62vh; border: 1px solid var(--bt-line); border-radius: var(--bt-r); }
table.mx { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
table.mx th { position: sticky; top: 0; z-index: 2; background: var(--bt-surface-2); padding: 8px 7px; text-align: left; font-size: 10.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--bt-muted); border-bottom: 1px solid var(--bt-line); white-space: nowrap; }
table.mx td { padding: 6px 7px; border-bottom: 1px solid var(--bt-line-soft); white-space: nowrap; }
table.mx tr:hover td { background: var(--bt-surface-2); }
td.v { text-align: right; font-family: var(--bt-mono); font-size: 11.5px; }
td.v.gap { background: var(--bt-err-soft); color: var(--bt-err); font-weight: 700; }
td.v.gap::after { content: "fehlt"; font-size: 9.5px; }
.rname { font-weight: 600; max-width: 230px; overflow: hidden; text-overflow: ellipsis; }
.rn { text-align: right; font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
.prop { display: grid; grid-template-columns: 26px 1fr auto; gap: 9px; align-items: center; padding: 9px 10px; border-bottom: 1px solid var(--bt-line-soft); }
.prop:last-child { border-bottom: none; }
.prop .pv { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
.pv label { display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--bt-muted); }
.pv input { width: 74px; padding: 4px 6px; font-size: 11.5px; font-family: var(--bt-mono); text-align: right; }
.plist { max-height: 54vh; overflow: auto; border: 1px solid var(--bt-line); border-radius: var(--bt-r); }
.runbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.chips { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px; }
.chip { cursor: pointer; user-select: none; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--bt-line); background: var(--bt-surface); font-size: 12px; font-weight: 650; }
.chip.on { background: var(--bt-accent-soft); border-color: var(--bt-accent); color: var(--bt-accent); }
.chip input { display: none; }
@media (max-width: 900px) { .facts { grid-template-columns: repeat(2, 1fr); } .cov { grid-template-columns: 96px 1fr 72px; } }
</style>
</head>
<body data-bt-title="Nährwerte" data-bt-icon="🥑">
<main class="bt-main wide">
<div class="bt-pagehead">
<h1>Nährwerte vervollständigen</h1>
<p>Findet Zutaten, bei denen Eigenschaften fehlen, lässt die Lücken
schätzen und schreibt sie nach Prüfung zurück. Rezept-Nährwerte rechnet
Tandoor danach selbst aus.</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 alle Zutaten und Eigenschaften. Verändert nichts.</p>
</div>
<button class="primary" id="scan">Jetzt prüfen</button>
</div>
<div id="scanStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="scanLog" class="bt-log bt-hidden"></div>
</div>
<div id="reportBox" 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>Was soll geschätzt werden?</h2>
<p class="hint">Ein Sprachmodell schätzt die fehlenden Werte je 100 g.
Nach Tandoor geschrieben wird hier noch nichts.</p>
<label>Eigenschaften</label>
<div class="chips" id="propChips"></div>
<label>Zutaten</label>
<div class="chips">
<span class="chip on" id="modeGaps" data-mode="gaps">nur Lücken</span>
<span class="chip" id="modeUsed" data-mode="used">nur Lücken in benutzten Zutaten</span>
<span class="chip" id="modeSel" data-mode="sel">Auswahl unten</span>
</div>
<div class="bt-grid2">
<div>
<label for="limit">Höchstens so viele Zutaten (0 = alle)</label>
<input type="number" id="limit" value="0" min="0" max="2000">
</div>
<div>
<label for="model">Modell</label>
<input type="text" id="model" value="">
</div>
</div>
<label class="bt-check" style="margin-top:8px">
<input type="checkbox" id="overwrite"> Auch vorhandene Werte neu schätzen
</label>
<div class="runbar" style="margin-top:12px">
<button class="primary" id="propose">Vorschläge holen</button>
<button class="ghost" id="probe">Verbindung testen</button>
<span class="bt-spacer"></span>
<span class="bt-badge" id="costHint"></span>
</div>
<p class="hint" style="margin:8px 0 0">„Verbindung testen“ macht eine
einzelne Beispiel-Abfrage und zeigt, ob das Modell erreichbar ist und
antwortet — nützlich, wenn ein Lauf „ohne Ergebnis“ zurückkommt.</p>
<div id="propStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="propLog" class="bt-log bt-hidden"></div>
</div>
<div class="bt-card">
<h2>Zutaten auswählen</h2>
<div class="listhead bt-row" style="margin-bottom:9px">
<input type="search" id="foodSearch" placeholder="Zutat suchen …" style="flex:1">
<button class="mini ghost" id="selAllGaps">Alle mit Lücken</button>
<button class="mini ghost" id="selNone">Keine</button>
<span class="bt-badge accent" id="selCount">0</span>
</div>
<div class="matrix" id="foodPick"></div>
</div>
</section>
<!-- ══════════════════════════════════════════ 3 Übernehmen -->
<section id="tab-uebernehmen" class="tab bt-hidden">
<div class="bt-card">
<div class="bt-row" style="align-items:flex-end">
<div style="flex:1">
<label for="proposalFile">Vorschlagsdatei</label>
<select id="proposalFile"></select>
</div>
<button class="ghost" id="reload">Neu laden</button>
</div>
<p class="hint" id="proposalMeta" style="margin:8px 0 0"></p>
</div>
<div class="bt-notice warn">
Die Werte sind <b>Schätzungen eines Sprachmodells</b>, keine Laboranalysen.
Für Hausgebrauch und Größenordnung gedacht. Bitte vor dem Übernehmen
durchsehen — falsche Zahlen fallen später kaum noch auf.
</div>
<div class="bt-card">
<div class="bt-row" style="align-items:center;margin-bottom:8px">
<h2 style="margin:0;flex:1">Vorschläge prüfen</h2>
<button class="mini ghost" id="ackAll">Alle anhaken</button>
<button class="mini ghost" id="ackNone">Keine</button>
<span class="bt-badge accent" id="ackCount">0</span>
</div>
<div class="plist" id="proposalList"></div>
<div class="bt-grid2" style="margin-top:12px">
<div>
<label for="baseAmount">Bezugsmenge</label>
<input type="number" id="baseAmount" value="100" min="1" step="1">
</div>
<div>
<label for="baseUnit">Bezugseinheit (muss es in Tandoor geben)</label>
<input type="text" id="baseUnit" value="g">
</div>
</div>
<p class="hint">Wird nur bei Zutaten gesetzt, die noch keine Bezugsmenge haben.</p>
<div class="runbar" style="margin-top:12px">
<button id="dry">Trockenübung</button>
<button class="primary" id="apply">Werte übernehmen</button>
<span class="bt-spacer"></span>
<button class="ghost mini" id="cancel" disabled>Abbrechen</button>
</div>
<div id="applyStatus" class="bt-status">Bereit.</div>
<div id="applyLog" class="bt-log"></div>
</div>
</section>
<!-- ══════════════════════════════════════════ Sicherungen -->
<section id="tab-sicherungen" class="tab bt-hidden"></section>
</main>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
let state = {};
let report = null;
let proposal = null;
const picked = new Set();
const activeProps = new Set();
let mode = "gaps";
/* ------------------------------------------------------------ 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();
});
/* ------------------------------------------------- Läufe (3 Bereiche) */
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/scan", "scanLog", "scanStatus", () => load());
const propRunner = makeRunner("/api/run/propose", "propLog", "propStatus", () => load());
const probeRunner = makeRunner("/api/run/probe", "propLog", "propStatus");
const applyRunner = makeRunner("/api/run/apply", "applyLog", "applyStatus", () => { $("cancel").disabled = true; });
/* ------------------------------------------------------ Sicherungen */
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();
},
});
const backupsView = BT.Backups({
mount: $("tab-sicherungen"),
runner: restoreRunner,
detail: true,
note: `Zurückspielen setzt die Nährwerte auf den Stand vor dem Lauf —
genau die Werte, die dieser Lauf gesetzt hat, verschwinden wieder.
Was du seither von Hand geändert hast, wird erkannt und übersprungen.`,
});
/* ------------------------------------------------------- 1 · Bericht */
function renderReport() {
if (!report) return;
const s = report.summary;
const types = report.types;
$("reportBox").className = "";
$("reportBox").innerHTML = `
<div class="facts">
<div class="fact"><b>${s.foods}</b><span>Zutaten</span></div>
<div class="fact ok"><b>${s.percent_complete}%</b><span>vollständig</span></div>
<div class="fact warn"><b>${s.incomplete}</b><span>mit Lücken</span></div>
<div class="fact warn"><b>${s.incomplete_used_in_recipes}</b><span>Lücken in benutzten Zutaten</span></div>
</div>
<section class="bt-card">
<h2>Abdeckung je Eigenschaft</h2>
<div class="coverage">${report.per_type.map((t) => {
const cls = t.percent < 34 ? "low" : t.percent < 67 ? "mid" : "";
return `<div class="cov">
<span class="nm" title="${BT.escape(t.name)}">${BT.escape(t.name)}</span>
<span class="track"><span class="fill ${cls}" style="width:${t.percent}%"></span></span>
<span class="n">${t.percent}% · ${t.missing} offen</span>
</div>`;
}).join("")}</div>
${s.without_base_amount ? `<p class="hint" style="margin-top:10px">
${s.without_base_amount} Zutaten haben keine Bezugsmenge. Ohne die
(z. B. „pro 100 g“) kann Tandoor keine Rezeptwerte rechnen — Schritt 3
setzt sie mit.</p>` : ""}
</section>
<section class="bt-card">
<div class="bt-row" style="align-items:center;margin-bottom:9px">
<h2 style="margin:0;flex:1">Wo genau fehlt was</h2>
<input type="search" id="mxSearch" placeholder="filtern …" style="max-width:220px">
<label class="bt-check"><input type="checkbox" id="onlyGaps" checked> nur Lücken</label>
</div>
<div class="matrix">
<table class="mx">
<thead><tr>
<th>Zutat</th><th style="text-align:right">Rezepte</th>
${types.map((t) => `<th style="text-align:right">${BT.escape(t.name)}${t.unit ? ` <span style="opacity:.6">${BT.escape(t.unit)}</span>` : ""}</th>`).join("")}
</tr></thead>
<tbody id="mxBody"></tbody>
</table>
</div>
</section>`;
const draw = () => {
const q = ($("mxSearch").value || "").toLowerCase();
const only = $("onlyGaps").checked;
const rows = report.foods.filter((r) =>
(!only || !r.complete) && (!q || r.name.toLowerCase().includes(q)));
$("mxBody").innerHTML = rows.slice(0, 400).map((r) => `
<tr>
<td class="rname" title="${BT.escape(r.name)}">${BT.escape(r.name)}</td>
<td class="rn">${r.numrecipe}</td>
${types.map((t) => {
const v = r.values[String(t.id)];
return v === null || v === undefined
? `<td class="v gap"></td>`
: `<td class="v">${Number(v).toLocaleString("de-DE")}</td>`;
}).join("")}
</tr>`).join("") || `<tr><td colspan="${types.length + 2}" style="padding:22px;text-align:center;color:var(--bt-muted)">Nichts gefunden.</td></tr>`;
};
$("mxSearch").addEventListener("input", draw);
$("onlyGaps").addEventListener("change", draw);
draw();
}
/* --------------------------------------------------- 2 · Vorschlagen */
function renderPropChips() {
if (!report) { $("propChips").innerHTML = `<span class="hint">Erst prüfen.</span>`; return; }
if (!activeProps.size) report.types.forEach((t) => activeProps.add(t.id));
$("propChips").innerHTML = report.types.map((t) =>
`<span class="chip ${activeProps.has(t.id) ? "on" : ""}" data-prop="${t.id}">${BT.escape(t.name)}</span>`
).join("");
}
$("propChips").addEventListener("click", (e) => {
const c = e.target.closest("[data-prop]");
if (!c) return;
const id = Number(c.dataset.prop);
activeProps.has(id) ? activeProps.delete(id) : activeProps.add(id);
renderPropChips(); updateCost();
});
document.querySelectorAll("[data-mode]").forEach((c) => c.addEventListener("click", () => {
mode = c.dataset.mode;
document.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x === c));
updateCost();
}));
function targetFoods() {
if (!report) return [];
if (mode === "sel") return report.foods.filter((f) => picked.has(f.id));
if (mode === "used") return report.foods.filter((f) => !f.complete && f.numrecipe > 0);
return report.foods.filter((f) => !f.complete);
}
function updateCost() {
const n = targetFoods().length;
const limit = Number($("limit").value) || 0;
const eff = limit ? Math.min(n, limit) : n;
$("costHint").textContent = eff
? `${eff} Zutaten · etwa ${Math.ceil(eff / 20)} Anfragen`
: "nichts ausgewählt";
$("propose").disabled = !eff || !state.openai;
}
$("limit").addEventListener("input", updateCost);
function renderFoodPick() {
if (!report) return;
const q = ($("foodSearch").value || "").toLowerCase();
const rows = report.foods.filter((f) => !q || f.name.toLowerCase().includes(q));
$("foodPick").innerHTML = `<table class="mx"><thead><tr>
<th style="width:30px"></th><th>Zutat</th><th style="text-align:right">Rezepte</th><th style="text-align:right">fehlt</th>
</tr></thead><tbody>${rows.slice(0, 400).map((f) => `
<tr>
<td><input type="checkbox" data-food="${f.id}" ${picked.has(f.id) ? "checked" : ""}></td>
<td class="rname">${BT.escape(f.name)}</td>
<td class="rn">${f.numrecipe}</td>
<td class="rn">${f.missing.length ? f.missing.length + " von " + report.types.length : "—"}</td>
</tr>`).join("")}</tbody></table>`;
$("selCount").textContent = picked.size;
}
$("foodPick").addEventListener("change", (e) => {
const b = e.target.closest("[data-food]");
if (!b) return;
const id = Number(b.dataset.food);
b.checked ? picked.add(id) : picked.delete(id);
$("selCount").textContent = picked.size;
updateCost();
});
$("foodSearch").addEventListener("input", renderFoodPick);
$("selAllGaps").addEventListener("click", () => {
report.foods.filter((f) => !f.complete).forEach((f) => picked.add(f.id));
mode = "sel";
document.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x.dataset.mode === "sel"));
renderFoodPick(); updateCost();
});
$("selNone").addEventListener("click", () => { picked.clear(); renderFoodPick(); updateCost(); });
$("scan").addEventListener("click", () => scanRunner.start({}));
$("propose").addEventListener("click", () => {
const foods = mode === "sel" ? [...picked] : [];
propRunner.start({
foods,
properties: [...activeProps],
limit: Number($("limit").value) || 0,
overwrite: $("overwrite").checked,
model: $("model").value.trim() || null,
});
});
$("probe").addEventListener("click", () => {
$("propLog").classList.remove("bt-hidden");
$("propStatus").classList.remove("bt-hidden");
probeRunner.start({ model: $("model").value.trim() || null });
});
/* ---------------------------------------------------- 3 · Übernehmen */
async function loadProposal(name) {
if (!name) { proposal = null; $("proposalList").innerHTML = ""; $("proposalMeta").textContent = ""; return; }
proposal = await BT.api(`/api/proposals/${encodeURIComponent(name)}`);
const d = new Date(proposal.created_at);
$("proposalMeta").textContent =
`${proposal.proposals.length} Vorschläge · Modell ${proposal.model} · ${d.toLocaleString("de-DE")}` +
(proposal.failed?.length ? ` · ${proposal.failed.length} ohne Ergebnis` : "");
renderProposal();
}
function emptyProposalNotice() {
const n = proposal?.failed?.length || 0;
const err = proposal?.error;
if (!n && !err) {
return `<div class="bt-empty" style="border:none">Diese Datei enthält keine Vorschläge.</div>`;
}
return `<div class="bt-notice warn" style="margin:0">
<b>Diese Datei enthält keine übernehmbaren Vorschläge${n ? ` (${n} ohne Ergebnis)` : ""}.</b>
Das deutet auf ein grundsätzliches Problem mit der Modell-Abfrage hin, nicht
auf einzelne Zutaten.
${err ? `<br><br>Erster Fehler der API:<br><code>${BT.escape(err)}</code>` : ""}
<br><br>Prüf über <b>2 · Vorschlagen → „Verbindung testen“</b>, ob das Modell
erreichbar ist und antwortet. Häufige Ursachen: falscher oder nicht
freigeschalteter Modellname, fehlendes OpenAI-Guthaben, oder ein Modell, das
bestimmte Aufruf-Parameter ablehnt.</div>`;
}
function renderProposal() {
if (!proposal) return;
const names = Object.fromEntries(proposal.types.map((t) => [String(t.id), t]));
$("proposalList").innerHTML = proposal.proposals.map((p) => `
<div class="prop" data-row="${p.food_id}">
<input type="checkbox" data-ack="${p.food_id}" ${p.accept !== false ? "checked" : ""}>
<div>
<b style="font-size:13px">${BT.escape(p.food_name)}</b>
<span class="bt-muted" style="font-size:11px"> · ${p.numrecipe} Rezepte</span>
</div>
<div class="pv">
${Object.entries(p.values).map(([tid, v]) => `
<label>${BT.escape(names[tid]?.name || tid)}
<input type="number" step="0.01" min="0" data-food="${p.food_id}" data-type="${tid}" value="${v}">
<span>${BT.escape(names[tid]?.unit || "")}</span>
</label>`).join("")}
</div>
</div>`).join("") || emptyProposalNotice();
updateAck();
}
function updateAck() {
const n = document.querySelectorAll("[data-ack]:checked").length;
$("ackCount").textContent = `${n} angehakt`;
$("apply").disabled = !n || !state.tandoor;
$("dry").disabled = !n;
}
$("proposalList").addEventListener("change", updateAck);
$("ackAll").addEventListener("click", () => {
document.querySelectorAll("[data-ack]").forEach((c) => c.checked = true); updateAck();
});
$("ackNone").addEventListener("click", () => {
document.querySelectorAll("[data-ack]").forEach((c) => c.checked = false); updateAck();
});
$("proposalFile").addEventListener("change", (e) => loadProposal(e.target.value));
$("reload").addEventListener("click", () => load());
async function saveEdits() {
const rows = proposal.proposals.map((p) => {
const values = {};
document.querySelectorAll(`input[data-food="${p.food_id}"][data-type]`).forEach((i) => {
values[i.dataset.type] = i.value;
});
return {
food_id: p.food_id,
accept: $(`[data-ack="${p.food_id}"]`)?.checked ?? true,
values,
};
});
return BT.api(`/api/proposals/${encodeURIComponent($("proposalFile").value)}`,
{ method: "POST", body: JSON.stringify({ proposals: rows }) });
}
async function runApply(apply) {
try {
const saved = await saveEdits();
if (!saved.accepted) { BT.toast("Nichts angehakt.", "err"); return; }
if (apply && !confirm(
`${saved.accepted} Zutaten in Tandoor mit geschätzten Nährwerten füllen?\n\n` +
`Vorhandene Werte bleiben unangetastet. Von jeder geänderten Zutat wird ` +
`vorher eine Sicherung ins Laufverzeichnis geschrieben.`)) return;
$("cancel").disabled = false;
applyRunner.start({
proposal: $("proposalFile").value,
apply,
overwrite: false,
continue_on_error: true,
base_amount: Number($("baseAmount").value) || 100,
base_unit: $("baseUnit").value.trim() || "g",
});
} catch (err) { BT.toast(err.message, "err"); }
}
$("dry").addEventListener("click", () => runApply(false));
$("apply").addEventListener("click", () => runApply(true));
$("cancel").addEventListener("click", () => applyRunner.cancel());
/* ------------------------------------------------------------- Laden */
async function load() {
state = await BT.api("/api/state");
report = state.report;
const fehlt = [];
if (!state.tandoor) fehlt.push("Tandoor-URL und Token");
if (!state.openai) fehlt.push("OpenAI-Key");
$("warn").classList.toggle("bt-hidden", !fehlt.length);
if (fehlt.length) {
$("warn").innerHTML = `Es fehlt: ${fehlt.join(" und ")}. Bitte in den
<a href="/settings">Einstellungen</a> hinterlegen.`;
}
$("scan").disabled = !state.tandoor;
if (!$("model").value) $("model").value = state.model;
const sel = $("proposalFile");
const previous = sel.value;
sel.innerHTML = state.proposals.map((f) =>
`<option value="${BT.escape(f)}">${BT.escape(f.replace(".json", ""))}</option>`).join("");
if (state.proposals.length) {
sel.value = state.proposals.includes(previous) ? previous : state.proposals[0];
await loadProposal(sel.value);
} else {
$("proposalList").innerHTML = `<div class="bt-empty" style="border:none">Noch keine Vorschläge. Schritt 2.</div>`;
}
if (report) { renderReport(); renderPropChips(); renderFoodPick(); }
updateCost(); updateAck();
if (state.running) {
const lbl = state.running.label || "";
const r = lbl.startsWith("Prüfen") ? scanRunner
: lbl.startsWith("Vorschlagen") ? propRunner
: lbl.startsWith("OpenAI-Verbindung") ? probeRunner
: lbl.startsWith("Zurückspielen") || lbl.startsWith("Trockenübung Zurück") ? restoreRunner
: applyRunner;
if (r === probeRunner) { $("propLog").classList.remove("bt-hidden"); $("propStatus").classList.remove("bt-hidden"); }
r.attach(state.running.id);
}
}
load();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff