Kansho/backend/routers/admin.py
Lars bef422a429
Some checks failed
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Failing after 2m40s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 16s
Add selectable LLM profiles and treat LAN Ollama as local.
Saved presets keep URL and model per stage so detect can switch to Ollama without re-entering settings or sending plaintext to OpenRouter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 12:48:25 +02:00

443 lines
15 KiB
Python

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 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
@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),
"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}