Onyscribe Admin: Initialer Stand
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>
This commit is contained in:
125
app/services/contract.py
Normal file
125
app/services/contract.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user