# -*- 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