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