Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs), OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant), Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich. Runbook: docs/RUNBOOK_PARALLELTEST.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
126 lines
4.2 KiB
Python
126 lines
4.2 KiB
Python
"""Vertrag mit dem Core (siehe transkript-platform/docs/CONTRACT.md): Schema, Manifest, Build-Info, Env-Spezifikation.
|
||
|
||
Gelesen wird aus dem Core, auf dem die Admin-Plattform läuft (PLATFORM_DIR: im Container das Core-Image, lokal der vendored Stand).
|
||
`core/contract.py` ist import-sicher (nur Standardbibliothek) und wird per Dateipfad geladen – der Core-Code selbst wird NIE
|
||
in diesem Prozess importiert (er hält Prozess-Singletons); gerendert wird in der Sandbox.
|
||
Ältere Cores ohne Vertrag („legacy“) funktionieren weiter: Schema fehlt → feste Admin-Formulare und feste Env-Liste.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import json
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
|
||
from app.settings import PLATFORM_DIR
|
||
|
||
SUPPORTED_CONTRACT = {1}
|
||
|
||
# Rückfall für Cores ohne Schema (entspricht dem Stand vor dem Vertrag)
|
||
LEGACY_SHARED_ENV = {
|
||
"OIDC_ISSUER": "oidc_issuer", "AUTH_ENABLED": "auth_enabled", "APP_COOKIE_SECURE": "app_cookie_secure",
|
||
"MAX_FILE_SIZE_MB": "max_file_size_mb", "LLM_MODEL": "llm_model", "LLM_MAX_TOKENS": "llm_max_tokens",
|
||
"LLM_TEMPERATURE": "llm_temperature", "CORTECS_BASE_URL": "cortecs_base_url", "SMTP_HOST": "smtp_host",
|
||
"SMTP_PORT": "smtp_port", "SMTP_USER": "smtp_user", "SMTP_FROM": "smtp_from",
|
||
"MISTRAL_TRANSCRIBE_MODEL": "mistral_transcribe_model", "MISTRAL_DIARIZE": "mistral_diarize",
|
||
"MISTRAL_TIMEOUT": "mistral_timeout", "COST_API_BASE": "cost_api_base", "COST_TRACKING_ENABLED": "cost_tracking_enabled",
|
||
}
|
||
LEGACY_REQUIRED_SECRETS = ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "SESSION_SECRET"]
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def _module():
|
||
path = Path(PLATFORM_DIR) / "core" / "contract.py"
|
||
if not path.exists():
|
||
return None
|
||
spec = importlib.util.spec_from_file_location("onyscribe_core_contract", path)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mod)
|
||
return mod
|
||
|
||
|
||
def reload() -> None:
|
||
_module.cache_clear()
|
||
schema.cache_clear()
|
||
|
||
|
||
def available() -> bool:
|
||
return _module() is not None
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def schema() -> dict | None:
|
||
m = _module()
|
||
return m.load_schema() if m else None
|
||
|
||
|
||
def build_info() -> dict:
|
||
m = _module()
|
||
if m:
|
||
return m.build_info()
|
||
f = Path(PLATFORM_DIR) / "CORE_VERSION"
|
||
return {"sha": f.read_text().split()[0] if f.exists() else "unbekannt", "built_at": None, "source": "legacy"}
|
||
|
||
|
||
def manifest() -> dict:
|
||
m = _module()
|
||
if m:
|
||
return m.build_manifest()
|
||
return {"contract": 0, "core": build_info(), "legacy": True}
|
||
|
||
|
||
def contract_version() -> int:
|
||
return int(manifest().get("contract", 0))
|
||
|
||
|
||
def supported() -> bool:
|
||
return (not available()) or contract_version() in SUPPORTED_CONTRACT
|
||
|
||
|
||
def sha() -> str:
|
||
return build_info()["sha"]
|
||
|
||
|
||
def same_sha(a: str, b: str) -> bool:
|
||
a, b = (a or "").lower(), (b or "").lower()
|
||
return bool(a and b) and (a.startswith(b) or b.startswith(a)) and min(len(a), len(b)) >= 7
|
||
|
||
|
||
def check_tenant_json(raw: dict) -> list[str]:
|
||
m = _module()
|
||
return m.check_tenant_json(raw, schema()) if m else []
|
||
|
||
|
||
def field_paths() -> set[str]:
|
||
s = schema()
|
||
return {f["path"] for f in s["fields"]} if s else set()
|
||
|
||
|
||
def env_spec() -> list[dict]:
|
||
s = schema()
|
||
if s:
|
||
return s["env"]
|
||
return [{"name": n, "kind": "setting", "scope": "shared", "setting": k, "default": None, "label": n, "group": "Allgemein"} for n, k in LEGACY_SHARED_ENV.items()]
|
||
|
||
|
||
def required_secrets(tenant_cfg: dict | None = None) -> list[str]:
|
||
"""Pflicht-Secrets laut Schema (required, required_if wie „stt.provider!=cortecs“ gegen die Tenant-Config ausgewertet)."""
|
||
s = schema()
|
||
if not s:
|
||
return list(LEGACY_REQUIRED_SECRETS)
|
||
out = []
|
||
for e in s["env"]:
|
||
if e["kind"] != "secret":
|
||
continue
|
||
if e.get("required"):
|
||
out.append(e["name"])
|
||
elif e.get("required_if"):
|
||
path, _, rhs = e["required_if"].partition("!=")
|
||
cur = tenant_cfg or {}
|
||
for k in path.split("."):
|
||
cur = cur.get(k, {}) if isinstance(cur, dict) else {}
|
||
val = cur if isinstance(cur, str) else ""
|
||
if (val or "mistral") != rhs: # Provider-Standard ist mistral
|
||
out.append(e["name"])
|
||
return out
|