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:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

154
vendor/platform/core/config.py vendored Normal file
View File

@@ -0,0 +1,154 @@
"""Tenant configuration loading.
One process serves exactly one tenant, selected via the TENANT env var
(default "default"). The tenant folder lives in tenants/<id>/ and contains:
tenant.json branding, office info, feature flags, STT provider (data, not code)
incl. org_short/org_profile for the shared default prompt
logo.svg|png brand logo (web topbar + PDF header)
prompt.txt summary prompt (optional — falls back to core default)
protocol.html PDF layout template (optional — falls back to core default)
protocol.css PDF styling (optional — falls back to core default)
overrides.py code hooks (optional — see core/hooks.py)
Everything that differs per customer is *data* here. The core code never
changes per customer.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from core import bundle
ROOT = Path(__file__).resolve().parent.parent
# TENANTS_DIR (Env) nur für Tests/Entwicklung; Standard: eingebackene Tenants im Image
TENANTS_DIR = Path(os.getenv("TENANTS_DIR") or ROOT / "tenants")
CORE_DEFAULTS = ROOT / "core" / "defaults"
CORE_TEMPLATES = ROOT / "core" / "templates"
# Default theme — used for any colour a tenant does not override.
DEFAULT_COLORS = {
"accent": "#327F24",
"accent_dark": "#235A19",
"accent_mid": "#3E9130",
"accent_light": "#EAF4E7",
"accent_xlight": "#F3FAF1",
"bg": "#F1F5F0",
"surface": "#FFFFFF",
"surface2": "#F6FAF5",
"border": "#DCE8D9",
"border2": "#BDD1B8",
"text": "#444444",
"text_2": "#666666",
"text_3": "#9AA89A",
"red": "#DC2626",
"progress_to": "#8bbf43",
}
@dataclass
class TenantConfig:
id: str
dir: Path
name: str
title: str
description: str
colors: dict
office: dict
flags: dict
ai_detect_org: str
org_short: str = ""
org_profile: str = ""
stt: dict = field(default_factory=dict)
raw: dict = field(default_factory=dict)
# Herkunft der Tenant-Daten (siehe core/bundle.py): {source: git|admin|cache|baked, version, stale, error}
bundle: dict = field(default_factory=dict)
# ── resolved file paths (tenant override → core default) ──
def _pick(self, tenant_name: str, default_path: Path) -> Path:
candidate = self.dir / tenant_name
return candidate if candidate.exists() else default_path
@property
def prompt_path(self) -> Path:
return self._pick("prompt.txt", CORE_DEFAULTS / "prompt.txt")
@property
def pdf_template_path(self) -> Path:
return self._pick("protocol.html", CORE_TEMPLATES / "protocol.html")
@property
def pdf_css_path(self) -> Path:
return self._pick("protocol.css", CORE_TEMPLATES / "protocol.css")
@property
def logo_path(self) -> Path | None:
for name in ("logo.svg", "logo.png"):
p = self.dir / name
if p.exists():
return p
return None
@property
def docx_logo_path(self) -> Path | None:
"""Logo for the Word header — Word needs a raster image, no SVG.
A tenant may ship both (logo.svg for web/PDF, logo.png next to it);
this prefers the raster file where logo_path prefers the vector one.
"""
for name in ("logo.png", "logo.jpg", "logo.jpeg", "logo.svg"):
p = self.dir / name
if p.exists():
return p
return None
@property
def favicon_path(self) -> Path | None:
p = self.dir / "favicon.svg"
return p if p.exists() else None
def flag(self, key: str, default=None):
return self.flags.get(key, default)
def load_tenant(tenant_id: str | None = None) -> TenantConfig:
tenant_id = (tenant_id or os.getenv("TENANT", "default")).strip()
tenant_dir, bundle_info = bundle.resolve(tenant_id, TENANTS_DIR)
config_file = tenant_dir / "tenant.json"
if not config_file.exists():
raise RuntimeError(
f"Tenant '{tenant_id}' nicht gefunden: {config_file} fehlt. "
f"Verfügbar: {[p.name for p in TENANTS_DIR.iterdir() if p.is_dir()] if TENANTS_DIR.exists() else []}"
)
raw = json.loads(config_file.read_text(encoding="utf-8"))
colors = {**DEFAULT_COLORS, **(raw.get("colors") or {})}
name = raw.get("name", tenant_id)
return TenantConfig(
id=tenant_id,
dir=tenant_dir,
name=name,
title=raw.get("title", f"{name} – Gesprächsprotokoll"),
description=raw.get("description", f"Gesprächsprotokoll-Tool für {name}"),
colors=colors,
office=raw.get("office") or {},
flags=raw.get("flags") or {},
ai_detect_org=raw.get("ai_detect_org", name),
# How the tenant refers to itself inside the protocol text ("revier",
# "Onyva") and one optional sentence describing what it does — both are
# substituted into the shared default prompt.
org_short=raw.get("org_short", name),
org_profile=raw.get("org_profile", ""),
stt=raw.get("stt") or {},
raw=raw,
bundle=bundle_info,
)
# Singleton for the running process (TENANT is fixed per instance).
CFG: TenantConfig = load_tenant()