246 lines
8.8 KiB
Python
246 lines
8.8 KiB
Python
# -*- 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
|