# -*- coding: utf-8 -*- """ Plugin-Adapter für den Stammdaten-Aufräumer. Verändernde Schritte laufen ausschließlich über tool/cleanup.py im Job-Runner, nie direkt aus einer Anfrage heraus. Was ausgeführt wird, steht vorher in einer Plandatei — die lässt sich ansehen, ändern und im Zweifel einfach nicht ausführen. """ 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 = "cleanup.py" KINDS = {"food", "unit", "keyword"} class ScanRequest(BaseModel): kinds: list[str] = Field(default_factory=list) ki_filter: bool = False model: str | None = None insecure: bool = False class ExecuteRequest(BaseModel): plan: str apply: bool = False rescue: bool = True continue_on_error: bool = True insecure: bool = False class EditRequest(BaseModel): """Nur die Häkchen aus der Oberfläche zurückschreiben.""" accepted: list[dict[str, Any]] class PrefillRequest(BaseModel): plan: str model: str | None = None alle_plurale: 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: """ 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 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 def clean_plural(value: Any) -> str: """Pluraltext aus der Oberfläche säubern und begrenzen.""" text = str(value or "").strip() return text[:120] 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, "data_dir": str(ctx.data_dir), } @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 in den Plan. Bewusst eng: Aktionen werden über ihre feste `aid` angesprochen, und je Aktionstyp lassen sich nur bestimmte Felder ändern. So kann die Oberfläche keine beliebige Zusammenführung unterschieben. Erlaubt ist: alle accept ähnlich-Merge source_id/target_id — aber NUR die beiden Paar-Mitglieder, und set_plural (nur Food) Dubletten-Merge set_plural (nur Food) set_plural plural_value (Text) """ file = resolve_plan(name) plan = json.loads(file.read_text(encoding="utf-8")) by_aid = {a.get("aid"): a for a in plan.get("actions", []) if a.get("aid")} for wish in request.accepted: action = by_aid.get(wish.get("aid")) if action is None: continue action["accept"] = bool(wish.get("accept")) if action["action"] == "merge" and action.get("origin") == "similar": # Richtung nur auf genau eines der beiden Paar-Mitglieder. paar = {action.get("left_id"), action.get("right_id")} src = wish.get("source_id") tgt = wish.get("target_id") if src in paar and tgt in paar and src != tgt: action["source_id"] = src action["source_name"] = (action["left_name"] if src == action.get("left_id") else action["right_name"]) action["target_id"] = tgt action["target_name"] = (action["left_name"] if tgt == action.get("left_id") else action["right_name"]) elif src is None and tgt is None: action["source_id"] = action["target_id"] = None action["source_name"] = action["target_name"] = None if action["kind"] == "food": action["set_plural"] = bool(wish.get("set_plural")) # Der Pluralwert kommt aus dem Scan, je nach Richtung — nicht # aus beliebiger Eingabe. if action.get("source_id") == action.get("left_id"): action["plural_value"] = action.get("plural_left_to_right", "") else: action["plural_value"] = action.get("plural_right_to_left", "") elif action["action"] == "merge" and action["kind"] == "food": action["set_plural"] = bool(wish.get("set_plural")) if wish.get("set_plural") and wish.get("plural_value") is not None: action["plural_value"] = clean_plural(wish.get("plural_value")) elif action["action"] == "set_plural": if wish.get("plural_value") is not None: action["plural_value"] = clean_plural(wish.get("plural_value")) file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") angehakt = [a for a in plan["actions"] if a.get("accept")] return { "ok": True, "merges": sum(1 for a in angehakt if a["action"] == "merge"), "set_plurals": sum(1 for a in angehakt if a["action"] == "set_plural"), "deletes": sum(1 for a in angehakt if a["action"] == "delete"), } @app.post("/api/run/scan") async def run_scan(request: ScanRequest) -> 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 – siehe Einstellungen.") bad = [k for k in request.kinds if k not in KINDS] if bad: raise HTTPException(400, f"Unbekannte Art: {bad}") argv = [sys.executable, str(tool)] if request.insecure: argv.append("--insecure") argv.append("pruefen") if request.kinds: argv += ["--arten", ",".join(request.kinds)] if request.ki_filter and ctx.settings.status()["openai"]: argv.append("--ki-filter") if request.model: argv += ["--model", request.model] label = "Stammdaten prüfen" + (" · KI-Vorfilter" if request.ki_filter and ctx.settings.status()["openai"] else "") job = await ctx.jobs.start( plugin=ctx.id, label=label, argv=argv, cwd=ctx.data_dir, env=tool_env(), ) return job.info() @app.post("/api/run/execute") async def run_execute(request: ExecuteRequest) -> 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)] if request.insecure: argv.append("--insecure") argv += ["ausfuehren", "--plan", str(file)] if request.apply: argv.append("--apply") if not request.rescue: argv.append("--ohne-rettung") if request.continue_on_error: argv.append("--continue-on-error") job = await ctx.jobs.start( plugin=ctx.id, label=("Aufräumen" 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/vorbelegen") 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), "vorbelegen", "--plan", str(file)] if request.model: argv += ["--model", request.model] if request.alle_plurale: argv.append("--alle-plurale") job = await ctx.jobs.start( plugin=ctx.id, label=f"KI-Vorauswahl: {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") 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