Keep KANSHO_ENV=production and require an explicit operator flag instead of treating Prod as Development. Promote to Prod only via merge commit on main. Co-authored-by: Cursor <cursoragent@cursor.com>
349 lines
12 KiB
Python
349 lines
12 KiB
Python
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.get("/providers/models")
|
|
def admin_provider_models(session: dict = Depends(require_admin_dep)):
|
|
from model_catalog import list_provider_models
|
|
from providers import detect_provider, generate_provider
|
|
|
|
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,
|
|
)
|
|
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}
|