Files
boehmitools-main/plugins/tandoor-cleanup/backend.py
T
2026-07-24 21:37:03 +02:00

276 lines
10 KiB
Python
Raw 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 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