Kansho/backend/routers/admin.py

220 lines
7.6 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from auth import require_admin_dep
from db import get_db
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()
return {
"ok": True,
"service": "kansho",
"version": APP_VERSION,
"build_date": BUILD_DATE,
"modules": MODULE_VERSIONS,
"inventory": {
"users": users,
"prompts": prompts,
"features": features,
"dialogue": dialogue,
"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 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}