from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from pydantic import BaseModel, Field import json import os 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 detect_learning import list_senses 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, activate_profile, create_profile, delete_profile, list_profiles, public_status, set_role_key, update_profile, 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 profile_id: str | None = None save_as_title: str | None = None key: str | None = Field(default=None, description="Write-only. Never returned.") class LlmProfileWrite(BaseModel): title: str name: str = "" url: str model: str = "" zdr: bool = True no_train: bool = True class ActivateProfile(BaseModel): profile_id: str class DetectModeWrite(BaseModel): mode: str @router.put("/providers/detect-mode") def admin_set_detect_mode(body: DetectModeWrite, session: dict = Depends(require_admin_dep)): from detect_learning import set_detect_operating_mode from provider_settings import public_status as provider_public_status try: set_detect_operating_mode(body.mode) except ValueError: raise HTTPException( status_code=400, detail={ "code": "invalid_detect_operating_mode", "message": "Detect-Modus muss semantic oder learning sein.", }, ) return provider_public_status() @router.get("/providers") def admin_providers(session: dict = Depends(require_admin_dep)): return public_status() @router.get("/providers/models") def admin_provider_models(url: str | None = None, session: dict = Depends(require_admin_dep)): from model_catalog import list_models_for_url, list_provider_models from providers import detect_provider, generate_provider if url: key = "" from providers import is_local_url if not is_local_url(url): key = (os.environ.get("KANSHO_PROVIDER_KEY") or "").strip() return {"url": url, "models": list_models_for_url(url, key)} return { "roles": { "generate": list_provider_models(generate_provider()), "detect": list_provider_models(detect_provider()), } } @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, profile_id=body.profile_id, save_as_title=body.save_as_title, ) 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() @router.post("/providers/{role}/activate") def admin_activate_provider(role: str, body: ActivateProfile, 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: activate_profile(role, body.profile_id) except SettingsError as exc: raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc return public_status() @router.get("/llm-profiles") def admin_list_profiles(session: dict = Depends(require_admin_dep)): return {"profiles": list_profiles()} @router.post("/llm-profiles") def admin_create_profile(body: LlmProfileWrite, session: dict = Depends(require_admin_dep)): try: profile = create_profile( title=body.title, name=body.name, url=body.url, model=body.model, zdr=body.zdr, no_train=body.no_train, ) except SettingsError as exc: raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc return {"profile": profile, **public_status()} @router.put("/llm-profiles/{profile_id}") def admin_update_profile(profile_id: str, body: LlmProfileWrite, session: dict = Depends(require_admin_dep)): try: profile = update_profile( profile_id, title=body.title, name=body.name, url=body.url, model=body.model, zdr=body.zdr, no_train=body.no_train, ) except SettingsError as exc: raise HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message}) from exc return {"profile": profile, **public_status()} @router.delete("/llm-profiles/{profile_id}") def admin_delete_profile(profile_id: str, session: dict = Depends(require_admin_dep)): try: delete_profile(profile_id) 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), "senses": list_senses(profile_id), "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}