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
+42
View File
@@ -0,0 +1,42 @@
# Rezept-Inventur
Geht die Sammlung durch und meldet, was fehlt oder schief steht.
**Dieses Werkzeug liest nur.** Es gibt keinen Schalter, der etwas verändert,
und keinen schreibenden Endpunkt. Ergebnis ist ein Bericht und eine CSV.
## Was geprüft wird
| Schwere | Prüfung | warum |
|---|---|---|
| Fehler | Keine Portionsangabe | ohne `servings` rechnet Tandoor nichts um |
| Fehler | Schritt ohne Anleitung | beim Kochen wertlos |
| Warnung | Keine Quelle | Herkunft nicht mehr nachvollziehbar |
| Warnung | Zutat ohne Einheit | Menge lässt sich nicht umrechnen |
| Warnung | Zutat ohne Menge | `amount` 0, ohne „keine Menge“ zu setzen |
| Warnung | Rezeptname doppelt | Verwechslungsgefahr |
| Hinweis | Kein Bild, kein Portionstext, keine Arbeitszeit, keine Schlagworte | |
| Hinweis | Zutat ohne Nährwerte | Rezeptwert bleibt unvollständig |
| Hinweis | Zutat / Einheit / Schlagwort ungenutzt | Karteileichen |
Befunde lassen sich in der Oberfläche nach Prüfung filtern und als CSV
herunterladen.
## Dauer
Je Rezept eine Abfrage — die Rezeptliste allein enthält keine Schritte. Bei
einigen hundert Rezepten dauert der Durchlauf entsprechend. Der Fortschritt
steht im Log.
## Ohne Suite
export TANDOOR_URL=https://kitchen.example.de
export TANDOOR_TOKEN=...
python3 plugins/tandoor-lint/tool/lint.py pruefen
python3 plugins/tandoor-lint/tool/lint.py pruefen --nur err,warn
python3 plugins/tandoor-lint/tool/lint.py pruefen --ohne bild,arbeitszeit
## Dateien
data/tandoor-lint/bericht/bericht.json
data/tandoor-lint/bericht/befunde.csv
+104
View File
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
Plugin-Adapter für die Rezept-Inventur.
Nur lesend: Es gibt hier bewusst keinen Endpunkt, der etwas verändern könnte.
"""
from __future__ import annotations
import json
import sys
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
TOOL = "lint.py"
SEVERITIES = {"err", "warn", "info"}
class ScanRequest(BaseModel):
only: list[str] = Field(default_factory=list)
skip: list[str] = Field(default_factory=list)
insecure: bool = False
def create_app(ctx):
tool = ctx.path("tool", TOOL)
report_file = ctx.data_dir / "bericht" / "bericht.json"
csv_file = ctx.data_dir / "bericht" / "befunde.csv"
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
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)]
return {
"report": report,
"tandoor": ctx.settings.status()["tandoor"],
"running": running[0] if running else None,
"has_csv": csv_file.is_file(),
}
@app.get("/api/export.csv")
def export():
if not csv_file.is_file():
raise HTTPException(404, "Noch kein Bericht vorhanden.")
return FileResponse(csv_file, media_type="text/csv", filename="befunde.csv")
@app.post("/api/run/scan")
async def run_scan(request: ScanRequest) -> dict[str, Any]:
if ctx.jobs.running(ctx.id):
raise HTTPException(409, "Die Inventur läuft bereits.")
if not ctx.settings.status()["tandoor"]:
raise HTTPException(400, "Tandoor-URL und Token fehlen siehe Einstellungen.")
argv = [sys.executable, str(tool)]
if request.insecure:
argv.append("--insecure")
argv.append("pruefen")
bad = [s for s in request.only if s not in SEVERITIES]
if bad:
raise HTTPException(400, f"Unbekannter Schweregrad: {bad}")
if request.only:
argv += ["--nur", ",".join(request.only)]
if request.skip:
# Die Schlüssel prüft das Skript selbst; hier nur grob absichern.
clean = [s for s in request.skip if s.replace("_", "").isalnum()]
if clean:
argv += ["--ohne", ",".join(clean)]
job = await ctx.jobs.start(
plugin=ctx.id, label="Inventur der Sammlung", argv=argv,
cwd=ctx.data_dir, env=tool_env(),
)
return job.info()
app.include_router(job_router(ctx.jobs, ctx.id))
return app
+19
View File
@@ -0,0 +1,19 @@
{
"id": "tandoor-lint",
"name": "Rezept-Inventur",
"summary": "Findet fehlende Quellen, Bilder, Portionen, Einheiten, Dubletten",
"description": "Geht die ganze Sammlung durch und meldet, was fehlt oder schief steht: Rezepte ohne Quelle oder Portionsangabe, Zutaten ohne Einheit, doppelte Rezeptnamen, verwaiste Stammdaten. Liest ausschließlich verändert nichts.",
"icon": "🔍",
"category": "Tandoor",
"version": "1.0.0",
"entrypoint": "backend:create_app",
"order": 15,
"requires": ["tandoor"],
"features": [
"14 Prüfungen, nach Fehler / Warnung / Hinweis sortiert",
"Nur lesend kein Schalter kann etwas verändern",
"Befunde filterbar, Export als CSV",
"Zeigt auch verwaiste Zutaten, Einheiten und Schlagworte"
],
"docs": "TOOL-README.md"
}
+200
View File
@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rezept-Inventur</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<script src="/shared/boehmi-runner.js"></script>
<style>
.facts { display: grid; grid-template-columns: repeat(5, 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.err b { color: var(--bt-err); }
.fact.warn b { color: var(--bt-warn); }
.fact.ok b { color: var(--bt-ok); }
.checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 7px; }
.ck { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: center;
padding: 8px 10px; border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); cursor: pointer; }
.ck:hover { background: var(--bt-surface-2); }
.ck .t { font-size: 12px; font-weight: 600; line-height: 1.25; }
.ck .c { font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
.ck.off { opacity: .45; }
.flist { border: 1px solid var(--bt-line); border-radius: var(--bt-r); overflow: hidden; }
.fitem { display: grid; grid-template-columns: 56px 1fr auto; gap: 10px; align-items: baseline;
padding: 9px 11px; border-bottom: 1px solid var(--bt-line-soft); font-size: 13px; }
.fitem:last-child { border-bottom: none; }
.fitem:hover { background: var(--bt-surface-2); }
.fitem .who { font-weight: 650; }
.fitem .det { color: var(--bt-muted); font-size: 12px; }
.fitem .id { font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
.sev { font-size: 9.5px; font-weight: 850; letter-spacing: .06em; text-transform: uppercase;
padding: 2px 6px; border-radius: 4px; text-align: center; }
.sev.err { color: var(--bt-err); background: var(--bt-err-soft); }
.sev.warn { color: var(--bt-warn); background: var(--bt-warn-soft); }
.sev.info { color: var(--bt-muted); background: var(--bt-bg-2); }
.grp { padding: 9px 11px; background: var(--bt-surface-2); border-bottom: 1px solid var(--bt-line);
font-size: 11px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--bt-muted); }
.grp .h { text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--bt-muted); font-size: 11.5px; }
@media (max-width: 900px) { .facts { grid-template-columns: repeat(2, 1fr); } .fitem { grid-template-columns: 52px 1fr; } .fitem .id { display: none; } }
</style>
</head>
<body data-bt-title="Rezept-Inventur" data-bt-icon="🔍">
<main class="bt-main wide">
<div class="bt-pagehead">
<h1>Rezept-Inventur</h1>
<p>Geht die Sammlung durch und meldet, was fehlt oder schief steht.
Dieses Werkzeug liest ausschließlich — es verändert in Tandoor nichts.</p>
</div>
<div id="warn" class="bt-notice err bt-hidden"></div>
<div class="bt-card">
<div class="bt-row" style="align-items:center">
<div style="flex:1">
<h2 style="margin:0">Durchsehen</h2>
<p class="hint" style="margin:4px 0 0">
Je Rezept eine Abfrage — bei vielen Rezepten dauert das einen Moment.</p>
</div>
<button class="primary" id="scan">Inventur starten</button>
<button class="ghost mini" id="cancel" disabled>Abbrechen</button>
</div>
<div id="status" class="bt-status bt-hidden">Bereit.</div>
<div id="log" class="bt-log bt-hidden"></div>
</div>
<div id="box" class="bt-empty">Noch keine Inventur gelaufen.</div>
</main>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
let state = {};
let report = null;
const hidden = new Set();
let search = "";
const runner = BT.Runner({
endpoint: "/api/run/scan",
log: $("log"), status: $("status"),
onStart: () => {
$("log").classList.remove("bt-hidden");
$("status").classList.remove("bt-hidden");
$("cancel").disabled = false;
},
onFinish: (info) => {
$("cancel").disabled = true;
BT.toast(info.status === "done" ? "Inventur fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
load();
},
});
$("scan").addEventListener("click", () => runner.start({ only: [], skip: [] }));
$("cancel").addEventListener("click", () => runner.cancel());
function render() {
if (!report) return;
const s = report.summary;
const aktive = report.checks.filter((c) => c.count > 0);
$("box").className = "";
$("box").innerHTML = `
<div class="facts">
<div class="fact"><b>${s.recipes_checked}</b><span>Rezepte geprüft</span></div>
<div class="fact ok"><b>${s.clean_recipes}</b><span>ohne Befund</span></div>
<div class="fact err"><b>${s.err}</b><span>Fehler</span></div>
<div class="fact warn"><b>${s.warn}</b><span>Warnungen</span></div>
<div class="fact"><b>${s.info}</b><span>Hinweise</span></div>
</div>
${s.unreadable?.length ? `<div class="bt-notice warn">
${s.unreadable.length} Rezepte konnten nicht gelesen werden und fehlen im Bericht.
</div>` : ""}
<section class="bt-card">
<h2>Was wurde gefunden</h2>
<p class="hint">Zum Ein- und Ausblenden anklicken.</p>
<div class="checks">${aktive.map((c) => `
<div class="ck ${hidden.has(c.key) ? "off" : ""}" data-ck="${c.key}" title="${BT.escape(c.hint)}">
<span class="sev ${c.severity}">${c.severity}</span>
<span class="t">${BT.escape(c.title)}</span>
<span class="c">${c.count}</span>
</div>`).join("") || `<span class="hint">Nichts gefunden — sauber.</span>`}</div>
</section>
<section class="bt-card">
<div class="bt-row" style="align-items:center;margin-bottom:9px">
<h2 style="margin:0;flex:1">Befunde</h2>
<input type="search" id="q" placeholder="Rezept oder Zutat …" style="max-width:230px" value="${BT.escape(search)}">
<a class="bt-btn mini ghost" href="${BT.url("/api/export.csv")}" download>CSV</a>
</div>
<div class="flist" id="list"></div>
<p class="hint" id="more" style="margin-top:8px"></p>
</section>`;
$("q").addEventListener("input", (e) => { search = e.target.value; draw(); });
document.querySelectorAll("[data-ck]").forEach((el) => el.addEventListener("click", () => {
const k = el.dataset.ck;
hidden.has(k) ? hidden.delete(k) : hidden.add(k);
el.classList.toggle("off", hidden.has(k));
draw();
}));
draw();
}
function draw() {
const q = search.toLowerCase();
const rows = report.findings.filter((f) =>
!hidden.has(f.check) &&
(!q || String(f.target).toLowerCase().includes(q) || f.detail.toLowerCase().includes(q)));
const byCheck = {};
rows.forEach((f) => (byCheck[f.check] ||= []).push(f));
let html = "";
let shown = 0;
for (const [key, items] of Object.entries(byCheck)) {
const meta = report.checks.find((c) => c.key === key);
html += `<div class="grp">${BT.escape(meta.title)} · ${items.length}
<span class="h">— ${BT.escape(meta.hint)}</span></div>`;
for (const f of items.slice(0, 60)) {
shown++;
html += `<div class="fitem">
<span class="sev ${f.severity}">${f.severity}</span>
<span><span class="who">${BT.escape(f.target)}</span>
<span class="det"> — ${BT.escape(f.detail)}</span></span>
<span class="id">${f.scope} ${f.target_id}</span>
</div>`;
}
if (items.length > 60) {
html += `<div class="fitem"><span></span>
<span class="det">… und ${items.length - 60} weitere. Die vollständige Liste steht in der CSV.</span>
<span></span></div>`;
}
}
$("list").innerHTML = html || `<div class="bt-empty" style="border:none">Nichts übrig — Filter zurücksetzen?</div>`;
$("more").textContent = `${rows.length} Befunde angezeigt, ${report.findings.length} insgesamt.`;
}
async function load() {
state = await BT.api("/api/state");
report = state.report;
$("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;
if (report) render();
if (state.running) runner.attach(state.running.id);
}
load();
</script>
</body>
</html>
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Rezept-Inventur: findet Lücken und Ungereimtheiten in der Sammlung.
Liest nur. Schreibt nichts nach Tandoor — weder jetzt noch mit irgendeinem
Schalter. Das Ergebnis ist ein Bericht und eine CSV-Tabelle.
lint.py pruefen [--nur err,warn] [--ohne bild,zeit]
Standalone:
python3 plugins/tandoor-lint/tool/lint.py pruefen
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SUITE_ROOT = Path(__file__).resolve().parents[3]
if str(SUITE_ROOT) not in sys.path:
sys.path.insert(0, str(SUITE_ROOT))
from core.tandoor import TandoorClient, TandoorError, food_property_map # noqa: E402
from core.foodmatch import comparable # noqa: E402
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-lint"
base.mkdir(parents=True, exist_ok=True)
return base
# Jede Prüfung: Schlüssel, Schweregrad, Überschrift, was man tun sollte.
CHECKS: dict[str, dict[str, str]] = {
"quelle": {"severity": "warn", "title": "Keine Quelle hinterlegt",
"hint": "source_url leer — Herkunft später nicht mehr nachvollziehbar."},
"bild": {"severity": "info", "title": "Kein Bild",
"hint": "Rezept ohne Bild geht in der Übersicht unter."},
"portionen": {"severity": "err", "title": "Keine Portionsangabe",
"hint": "Ohne servings rechnet Tandoor Mengen und Nährwerte nicht um."},
"portionstext": {"severity": "info", "title": "Kein Portionstext",
"hint": "servings_text leer — es steht dann nur eine nackte Zahl da."},
"arbeitszeit": {"severity": "info", "title": "Keine Arbeitszeit",
"hint": "working_time 0 — Filter nach Zeit findet das Rezept nicht."},
"schlagwort": {"severity": "info", "title": "Keine Schlagworte",
"hint": "Ohne Keywords über Filter praktisch unauffindbar."},
"schritt_leer": {"severity": "err", "title": "Schritt ohne Anleitung",
"hint": "Ein Schritt ohne Text ist beim Kochen wertlos."},
"zutat_einheit": {"severity": "warn", "title": "Zutat ohne Einheit",
"hint": "Menge ohne Einheit lässt sich nicht umrechnen."},
"zutat_menge": {"severity": "warn", "title": "Zutat ohne Menge",
"hint": "amount 0, ohne dass „keine Menge“ gesetzt ist."},
"zutat_naehr": {"severity": "info", "title": "Zutat ohne Nährwerte",
"hint": "Solange die fehlen, bleibt der Rezeptwert unvollständig."},
"name_doppelt": {"severity": "warn", "title": "Rezeptname doppelt",
"hint": "Zwei Rezepte gleichen Namens — Verwechslungsgefahr."},
"food_verwaist": {"severity": "info", "title": "Zutat in keinem Rezept",
"hint": "numrecipe 0 — Karteileiche im Stammdatenbestand."},
"unit_verwaist": {"severity": "info", "title": "Einheit ungenutzt",
"hint": "Taucht in keinem Rezept auf."},
"kw_verwaist": {"severity": "info", "title": "Schlagwort ungenutzt",
"hint": "Hängt an keinem Rezept."},
}
ORDER = {"err": 0, "warn": 1, "info": 2}
def finding(check: str, scope: str, target: str, target_id: Any, detail: str) -> dict[str, Any]:
meta = CHECKS[check]
return {
"check": check, "severity": meta["severity"], "title": meta["title"],
"hint": meta["hint"], "scope": scope, "target": target,
"target_id": target_id, "detail": detail,
}
def scan(client: TandoorClient, args: argparse.Namespace) -> dict[str, Any]:
skip = {s.strip() for s in (args.ohne or "").split(",") if s.strip()}
unknown = skip - set(CHECKS)
if unknown:
raise SystemExit(f"Unbekannte Prüfung in --ohne: {sorted(unknown)}")
out("Stammdaten werden gelesen …")
foods = client.list_objects("food")
units = client.list_objects("unit")
keywords = client.list_objects("keyword")
out(f" {len(foods)} Zutaten · {len(units)} Einheiten · {len(keywords)} Schlagworte")
out("Rezeptliste wird gelesen …")
overview = client.list_objects("recipe")
out(f" {len(overview)} Rezepte")
food_props = {f["id"]: food_property_map(f) for f in foods}
prop_type_count = len(client.list_objects("property-type"))
findings: list[dict[str, Any]] = []
# ------------------------------------------------ doppelte Rezeptnamen
if "name_doppelt" not in skip:
by_name: dict[str, list[dict]] = defaultdict(list)
for r in overview:
by_name[comparable(r.get("name"))].append(r)
for name, group in by_name.items():
if len(group) > 1:
ids = ", ".join(str(r["id"]) for r in group)
for r in group:
findings.append(finding(
"name_doppelt", "Rezept", r.get("name") or "?", r["id"],
f"{len(group)}× derselbe Name (IDs {ids})",
))
# ------------------------------------------------------ Rezept-Details
out("Rezepte werden einzeln geprüft …")
used_units: set[int] = set()
used_keywords: set[int] = set()
checked = 0
unreadable: list[str] = []
for index, entry in enumerate(overview, start=1):
rid = entry.get("id")
try:
recipe = client.get_json(f"api/recipe/{rid}/")
except TandoorError as exc:
unreadable.append(f"{rid}: {exc}")
continue
checked += 1
if index % 25 == 0 or index == len(overview):
out(f" {index}/{len(overview)}")
name = recipe.get("name") or f"Rezept {rid}"
if "quelle" not in skip and not (recipe.get("source_url") or "").strip():
findings.append(finding("quelle", "Rezept", name, rid, "source_url ist leer"))
if "bild" not in skip and not recipe.get("image"):
findings.append(finding("bild", "Rezept", name, rid, "kein Bild hinterlegt"))
if "portionen" not in skip and not (recipe.get("servings") or 0):
findings.append(finding("portionen", "Rezept", name, rid, "servings ist 0"))
if "portionstext" not in skip and not (recipe.get("servings_text") or "").strip():
findings.append(finding("portionstext", "Rezept", name, rid, "servings_text ist leer"))
if "arbeitszeit" not in skip and not (recipe.get("working_time") or 0):
findings.append(finding("arbeitszeit", "Rezept", name, rid, "working_time ist 0"))
kws = recipe.get("keywords") or []
for kw in kws:
if isinstance(kw, dict) and isinstance(kw.get("id"), int):
used_keywords.add(kw["id"])
if "schlagwort" not in skip and not kws:
findings.append(finding("schlagwort", "Rezept", name, rid, "keine Keywords"))
for step_no, step in enumerate(recipe.get("steps") or [], start=1):
if "schritt_leer" not in skip and not (step.get("instruction") or "").strip():
findings.append(finding(
"schritt_leer", "Rezept", name, rid, f"Schritt {step_no} hat keinen Text"))
for ing in step.get("ingredients") or []:
zutat = (ing.get("food") or {}).get("name") or "?"
food_id = (ing.get("food") or {}).get("id")
unit = ing.get("unit")
if isinstance(unit, dict) and isinstance(unit.get("id"), int):
used_units.add(unit["id"])
if ing.get("is_header"):
continue
no_amount = bool(ing.get("no_amount"))
if "zutat_einheit" not in skip and not unit and not no_amount:
findings.append(finding(
"zutat_einheit", "Rezept", name, rid,
f"Schritt {step_no}: „{zutat}“ hat keine Einheit"))
if "zutat_menge" not in skip and not (ing.get("amount") or 0) and not no_amount:
findings.append(finding(
"zutat_menge", "Rezept", name, rid,
f"Schritt {step_no}: „{zutat}“ hat keine Menge"))
if "zutat_naehr" not in skip and isinstance(food_id, int):
werte = food_props.get(food_id, {})
fehlen = prop_type_count - sum(1 for v in werte.values() if v is not None)
if fehlen:
findings.append(finding(
"zutat_naehr", "Rezept", name, rid,
f"{zutat}“ fehlen {fehlen} von {prop_type_count} Eigenschaften"))
# ---------------------------------------------------------- Verwaistes
if "food_verwaist" not in skip:
for f in foods:
if not (f.get("numrecipe") or 0):
findings.append(finding(
"food_verwaist", "Zutat", f.get("name") or "?", f.get("id"),
"in keinem Rezept verwendet"))
if "unit_verwaist" not in skip:
for u in units:
if u.get("id") not in used_units:
findings.append(finding(
"unit_verwaist", "Einheit", u.get("name") or "?", u.get("id"),
"in keinem geprüften Rezept verwendet"))
if "kw_verwaist" not in skip:
for k in keywords:
if k.get("id") not in used_keywords:
findings.append(finding(
"kw_verwaist", "Schlagwort", k.get("name") or "?", k.get("id"),
"an keinem geprüften Rezept"))
if args.nur:
wanted = {s.strip() for s in args.nur.split(",") if s.strip()}
findings = [f for f in findings if f["severity"] in wanted]
findings.sort(key=lambda f: (ORDER[f["severity"]], f["check"], str(f["target"]).casefold()))
per_check = Counter(f["check"] for f in findings)
per_severity = Counter(f["severity"] for f in findings)
betroffene = {(f["scope"], f["target_id"]) for f in findings}
return {
"created_at": datetime.now(timezone.utc).isoformat(),
"summary": {
"recipes": len(overview),
"recipes_checked": checked,
"foods": len(foods), "units": len(units), "keywords": len(keywords),
"findings": len(findings),
"err": per_severity.get("err", 0),
"warn": per_severity.get("warn", 0),
"info": per_severity.get("info", 0),
"affected": len(betroffene),
"clean_recipes": len(overview) - len({
f["target_id"] for f in findings if f["scope"] == "Rezept"}),
"unreadable": unreadable,
},
"checks": [
{"key": key, **meta, "count": per_check.get(key, 0), "skipped": key in skip}
for key, meta in CHECKS.items()
],
"findings": findings,
}
def write_report(report: dict[str, Any], target: Path) -> None:
target.mkdir(parents=True, exist_ok=True)
(target / "bericht.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
with (target / "befunde.csv").open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle, delimiter=";")
writer.writerow(["Schweregrad", "Prüfung", "Was", "Bereich", "Name", "ID", "Detail"])
for f in report["findings"]:
writer.writerow([f["severity"], f["check"], f["title"],
f["scope"], f["target"], f["target_id"], f["detail"]])
def print_report(report: dict[str, Any]) -> None:
s = report["summary"]
out()
out("" * 64)
out(f" Rezepte geprüft {s['recipes_checked']} von {s['recipes']}")
out(f" ohne jeden Befund {s['clean_recipes']}")
out(f" Befunde gesamt {s['findings']}")
out(f" Fehler {s['err']}")
out(f" Warnungen {s['warn']}")
out(f" Hinweise {s['info']}")
out("" * 64)
out()
for check in sorted(report["checks"], key=lambda c: (ORDER[c["severity"]], -c["count"])):
if check["skipped"]:
out(f" {'übersprungen':<12} {check['title']}")
elif check["count"]:
out(f" {check['severity']:<5} {check['count']:>4} × {check['title']}")
out()
hart = [f for f in report["findings"] if f["severity"] == "err"][:15]
if hart:
out(" Fehler zuerst:")
for f in hart:
out(f" {f['scope']}{f['target']}“ [{f['target_id']}]: {f['detail']}")
if s["unreadable"]:
out()
out(f" {len(s['unreadable'])} Rezepte waren nicht lesbar:")
for line in s["unreadable"][:5]:
out(f" {line[:110]}")
def main() -> int:
parser = argparse.ArgumentParser(description="Rezept-Inventur (nur lesend)")
parser.add_argument("--base-url", default=None)
parser.add_argument("--token", default=None)
parser.add_argument("--auth-scheme", default=None)
parser.add_argument("--timeout", type=float, default=None)
parser.add_argument("--insecure", action="store_true")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("pruefen", help="Sammlung durchsehen")
p.add_argument("--nur", default="", help="nur diese Schweregrade: err,warn,info")
p.add_argument("--ohne", default="", help="diese Prüfungen auslassen, kommagetrennt")
args = parser.parse_args()
try:
client = TandoorClient.from_env(
base_url=args.base_url, token=args.token, auth_scheme=args.auth_scheme,
timeout=args.timeout, verify=False if args.insecure else None,
)
except TandoorError as exc:
out(f"Tandoor-Zugang fehlt: {exc}")
return 2
try:
report = scan(client, args)
except TandoorError as exc:
out(f"Tandoor meldet: {exc}")
return 1
except KeyboardInterrupt:
out("Abgebrochen.")
return 130
target = data_dir() / "bericht"
write_report(report, target)
print_report(report)
out()
out(f"Bericht: {target / 'bericht.json'}")
out(f"Tabelle: {target / 'befunde.csv'}")
out()
out("Es wurde nichts verändert — dieses Werkzeug liest nur.")
return 0
if __name__ == "__main__":
raise SystemExit(main())