from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from pydantic import BaseModel, Field import json from auth import require_admin_dep from db import get_db from debug_store import ( clear_runs, debug_tree, delete_run, export_document, export_filename, get_run, latest_run, list_runs, render_markdown, set_persist_enabled, settings_payload, ) from dialogue_store import StoreError, get_conversation, inventory, list_conversations, list_derived_for_conversation, list_messages from identity_store import ( confirm_identity, confirm_review_proposal, deactivate_identity, delete_identity, delete_review_proposal, dismiss_review_proposal, list_registry, list_review_proposals, update_identity, ) from provider_settings import ROLES, SettingsError, public_status, set_role_key, upsert_setting from version import APP_VERSION, BUILD_DATE, MODULE_VERSIONS router = APIRouter(prefix="/api/admin", tags=["admin"]) @router.get("/health") def admin_health(session: dict = Depends(require_admin_dep)): with get_db() as conn: users = conn.execute("SELECT COUNT(*) AS n FROM profiles").fetchone()["n"] prompts = conn.execute("SELECT COUNT(*) AS n FROM ai_prompts").fetchone()["n"] features = conn.execute("SELECT COUNT(*) AS n FROM features").fetchone()["n"] dialogue = inventory() providers = public_status() debug = settings_payload(session["profile_id"]) return { "ok": True, "service": "kansho", "version": APP_VERSION, "build_date": BUILD_DATE, "modules": MODULE_VERSIONS, "inventory": { "users": users, "prompts": prompts, "features": features, "dialogue": dialogue, "debug_runs": debug["run_count"], "debug_persist_enabled": debug["persist_enabled"], "providers": { role["role"]: {"ready": role["ready"], "key_present": role["key_present"]} for role in providers["roles"] }, }, "session": { "profile_id": session["profile_id"], "role": session["role"], }, } @router.get("/dialogue") def admin_dialogue(session: dict = Depends(require_admin_dep)): return { "instance": inventory(), "own_conversations": list_conversations(session["profile_id"]), "note": "Fremde Lived Experience ist kein Alltagspfad. Detailansicht nur für das eigene Profil.", } @router.get("/dialogue/conversations/{conversation_id}") def admin_conversation(conversation_id: str, session: dict = Depends(require_admin_dep)): try: conv = get_conversation(session["profile_id"], conversation_id) return { "conversation": conv, "original_messages": list_messages(session["profile_id"], conversation_id), "derived_records": list_derived_for_conversation(session["profile_id"], conversation_id), } except StoreError as exc: raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc class DebugToggle(BaseModel): persist_enabled: bool @router.get("/debug") def admin_debug(session: dict = Depends(require_admin_dep)): return settings_payload(session["profile_id"]) @router.put("/debug") def admin_update_debug(body: DebugToggle, session: dict = Depends(require_admin_dep)): payload = set_persist_enabled(body.persist_enabled) payload["run_count"] = settings_payload(session["profile_id"])["run_count"] return payload @router.get("/debug/tree") def admin_debug_tree(session: dict = Depends(require_admin_dep)): return debug_tree(session["profile_id"]) @router.get("/debug/runs") def admin_debug_runs(limit: int = Query(default=200, ge=1, le=500), session: dict = Depends(require_admin_dep)): return {"runs": list_runs(session["profile_id"], limit=limit)} @router.get("/debug/latest") def admin_debug_latest( journal_day_id: str | None = None, purpose: str | None = None, session: dict = Depends(require_admin_dep), ): return { "run": latest_run( session["profile_id"], journal_day_id=journal_day_id, purpose=purpose, ) } @router.get("/debug/export") def admin_debug_export( format: str = Query(default="json"), conversation_id: str | None = None, journal_day_id: str | None = None, purpose: str | None = None, session: dict = Depends(require_admin_dep), ): fmt = (format or "json").strip().lower() if fmt not in {"json", "markdown"}: raise HTTPException( status_code=400, detail={"code": "invalid_debug_export_format", "message": "Format ist json oder markdown."}, ) document = export_document( session["profile_id"], conversation_id=conversation_id, journal_day_id=journal_day_id, purpose=purpose, ) filename = export_filename(fmt, document) if fmt == "markdown": body = render_markdown(document) return Response( content=body.encode("utf-8"), media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) body = json.dumps(document, ensure_ascii=False, indent=2) return Response( content=body.encode("utf-8"), media_type="application/json; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/debug/runs/clear") def admin_clear_debug_runs(session: dict = Depends(require_admin_dep)): deleted = clear_runs(session["profile_id"]) return {"ok": True, "deleted": deleted} @router.get("/debug/runs/{run_id}") def admin_debug_run(run_id: str, session: dict = Depends(require_admin_dep)): item = get_run(session["profile_id"], run_id) if not item: raise HTTPException(status_code=404, detail={"code": "debug_run_missing", "message": "Debug-Lauf fehlt."}) return item @router.delete("/debug/runs/{run_id}") def admin_delete_debug_run(run_id: str, session: dict = Depends(require_admin_dep)): if not delete_run(session["profile_id"], run_id): raise HTTPException(status_code=404, detail={"code": "debug_run_missing", "message": "Debug-Lauf fehlt."}) return {"ok": True} class ProviderUpdate(BaseModel): name: str = "" url: str = "" model: str = "" zdr: bool = True no_train: bool = True key: str | None = Field(default=None, description="Write-only. Never returned.") @router.get("/providers") def admin_providers(session: dict = Depends(require_admin_dep)): return public_status() @router.put("/providers/{role}") def admin_update_provider(role: str, body: ProviderUpdate, session: dict = Depends(require_admin_dep)): if role not in ROLES: raise HTTPException(status_code=404, detail={"code": "unknown_provider_role", "message": "Unbekannte Provider-Rolle."}) try: upsert_setting( role, name=body.name, url=body.url, model=body.model, zdr=body.zdr, no_train=body.no_train, ) if body.key is not None and body.key.strip(): set_role_key(role, body.key) except SettingsError as exc: raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc return public_status() class IdentityWrite(BaseModel): canonical_label: str = "" entity_type: str = "PERSON" aliases: list[str] = Field(default_factory=list) token: str | None = None status: str | None = None class IdentityPatch(BaseModel): canonical_label: str | None = None entity_type: str | None = None aliases: list[str] | None = None status: str | None = None def _identity_error(exc: ValueError) -> HTTPException: code = str(exc) messages = { "empty_label": "Bezeichnung ist leer oder nicht maskierbar.", "invalid_entity_type": "Unzulässiger Entitätstyp.", "invalid_status": "Unzulässiger Status.", "identity_missing": "Registry-Eintrag fehlt.", "proposal_missing": "Vorschlag fehlt.", } return HTTPException(status_code=400, detail={"code": code, "message": messages.get(code, "Ungültige Identitätsangabe.")}) @router.get("/identities") def admin_identities(session: dict = Depends(require_admin_dep)): profile_id = session["profile_id"] return { "registry": list_registry(profile_id), "proposals": list_review_proposals(profile_id, include_dismissed=True), "note": "Nur lokale bestätigte Registry und unbestätigte Vorschläge. Kein externer Egress.", "backup": ( "Vor einer Bereinigung die lokale Datei backend/data/kansho.sqlite kopieren. " "Wiederherstellung: Anwendung beenden und die Kopie zurücklegen." ), } @router.post("/identities") def admin_create_identity(body: IdentityWrite, session: dict = Depends(require_admin_dep)): try: return confirm_identity( session["profile_id"], body.canonical_label, entity_type=body.entity_type, token=body.token, aliases=body.aliases, ) except ValueError as exc: raise _identity_error(exc) from exc @router.patch("/identities/{identity_id}") def admin_update_identity(identity_id: str, body: IdentityPatch, session: dict = Depends(require_admin_dep)): try: return update_identity( session["profile_id"], identity_id, canonical_label=body.canonical_label, entity_type=body.entity_type, aliases=body.aliases, status=body.status, ) except ValueError as exc: raise _identity_error(exc) from exc @router.post("/identities/{identity_id}/deactivate") def admin_deactivate_identity(identity_id: str, session: dict = Depends(require_admin_dep)): try: return deactivate_identity(session["profile_id"], identity_id) except ValueError as exc: raise _identity_error(exc) from exc @router.delete("/identities/{identity_id}") def admin_delete_identity(identity_id: str, session: dict = Depends(require_admin_dep)): delete_identity(session["profile_id"], identity_id) return {"ok": True} @router.post("/identities/proposals/{proposal_id}/confirm") def admin_confirm_proposal(proposal_id: str, body: IdentityPatch | None = None, session: dict = Depends(require_admin_dep)): payload = body or IdentityPatch() try: return confirm_review_proposal( session["profile_id"], proposal_id, canonical_label=payload.canonical_label, entity_type=payload.entity_type, aliases=payload.aliases, ) except ValueError as exc: raise _identity_error(exc) from exc @router.post("/identities/proposals/{proposal_id}/dismiss") def admin_dismiss_proposal(proposal_id: str, session: dict = Depends(require_admin_dep)): dismiss_review_proposal(session["profile_id"], proposal_id) return {"ok": True} @router.delete("/identities/proposals/{proposal_id}") def admin_delete_proposal(proposal_id: str, session: dict = Depends(require_admin_dep)): delete_review_proposal(session["profile_id"], proposal_id) return {"ok": True}