Files
2026-07-24 21:37:03 +02:00

246 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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