chore: initial import
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user