# -*- coding: utf-8 -*- """ Plugin-Adapter für „Zutaten-Kategorien“. Alles Verändernde läuft über tool/categories.py im Job-Runner, nie direkt aus einer Anfrage. Die Auswahl steht vorher in einer Plandatei, die sich ansehen, ändern und im Zweifel einfach nicht anwenden lässt. """ 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 from core.jobs import job_router from core.backups import backup_router TOOL = "categories.py" class EditRequest(BaseModel): accepted: list[dict[str, Any]] class PlanRef(BaseModel): plan: str class ApplyRequest(BaseModel): plan: str apply: bool = True class PrefillRequest(BaseModel): plan: str model: str | None = None alle: bool = False class ProbeRequest(BaseModel): model: str | None = None def create_app(ctx): tool = ctx.path("tool", TOOL) plans_dir = ctx.data_dir / "plaene" plans_dir.mkdir(parents=True, exist_ok=True) def tool_env() -> dict: env = ctx.settings.tool_env() env["DATA_DIR"] = str(ctx.data_dir) return env def resolve_plan(name: str) -> Path: candidate = plans_dir / Path(name).name if candidate.suffix != ".json" or not candidate.is_file(): raise HTTPException(404, f"Plandatei „{name}“ gibt es nicht.") return candidate 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]: running = [j.info() for j in ctx.jobs.running(ctx.id)] return { "plans": sorted((p.name for p in plans_dir.glob("*.json")), reverse=True), "tandoor": ctx.settings.status()["tandoor"], "openai": ctx.settings.status()["openai"], "running": running[0] if running else None, } @app.get("/api/plans/{name}") def read_plan(name: str) -> dict[str, Any]: return json.loads(resolve_plan(name).read_text(encoding="utf-8")) @app.post("/api/plans/{name}") def save_plan(name: str, request: EditRequest) -> dict[str, Any]: """ Übernimmt die Auswahl aus der Oberfläche. Eng gehalten: je Zutat lassen sich nur `accept` und `proposed_id` ändern, und `proposed_id` muss eine der im Plan bekannten Kategorien sein (oder null). So kann die Oberfläche keine fremde ID unterschieben. """ file = resolve_plan(name) plan = json.loads(file.read_text(encoding="utf-8")) valid = {c["id"]: c["name"] for c in plan.get("categories", [])} by_aid = {f.get("aid"): f for f in plan.get("foods", []) if f.get("aid")} gesetzt = 0 for wish in request.accepted: food = by_aid.get(wish.get("aid")) if food is None: continue if "proposed_id" in wish: pid = wish.get("proposed_id") if pid is None: food["proposed_id"] = None food["proposed_name"] = "" elif pid in valid: food["proposed_id"] = pid food["proposed_name"] = valid[pid] # unbekannte ID wird ignoriert food["accept"] = bool(wish.get("accept")) gesetzt += 1 file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") offen = sum(1 for f in plan["foods"] if f.get("accept") and f.get("proposed_id") != f.get("current_id")) return {"ok": True, "gesetzt": gesetzt, "zuweisungen": offen} # ---------------------------------------------------------------- Läufe @app.post("/api/run/pruefen") async def run_scan() -> dict[str, Any]: if ctx.jobs.running(ctx.id): raise HTTPException(409, "Es läuft bereits etwas.") if not ctx.settings.status()["tandoor"]: raise HTTPException(400, "Tandoor-URL und Token fehlen.") job = await ctx.jobs.start( plugin=ctx.id, label="Kategorien prüfen", argv=[sys.executable, str(tool), "pruefen"], cwd=ctx.data_dir, env=tool_env(), ) return job.info() @app.post("/api/run/vorschlagen") async def run_prefill(request: PrefillRequest) -> dict[str, Any]: 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.") file = resolve_plan(request.plan) argv = [sys.executable, str(tool), "vorschlagen", "--plan", str(file)] if request.model: argv += ["--model", request.model] if request.alle: argv.append("--alle") job = await ctx.jobs.start( plugin=ctx.id, label=f"KI ordnet ein: {file.stem}", argv=argv, cwd=ctx.data_dir, env=tool_env(), ) return job.info() @app.post("/api/run/anwenden") async def run_apply(request: ApplyRequest) -> dict[str, Any]: if ctx.jobs.running(ctx.id): raise HTTPException(409, "Es läuft bereits etwas.") if not ctx.settings.status()["tandoor"]: raise HTTPException(400, "Tandoor-URL und Token fehlen.") file = resolve_plan(request.plan) argv = [sys.executable, str(tool), "anwenden", "--plan", str(file), "--continue-on-error"] if request.apply: argv.append("--apply") job = await ctx.jobs.start( plugin=ctx.id, label=("Kategorien zuweisen" if request.apply else "Trockenübung") + f": {file.stem}", 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]: 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() def restore_argv(run: str, apply: bool, force: bool) -> list[str]: argv = [sys.executable, str(tool), "zurueck", "--lauf", run] if apply: argv.append("--apply") return argv app.include_router(backup_router(ctx, "laeufe", restore_argv)) app.include_router(job_router(ctx.jobs, ctx.id)) return app