195 lines
6.9 KiB
Python
195 lines
6.9 KiB
Python
# -*- 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
|