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:
1
vendor/platform/CORE_VERSION
vendored
Normal file
1
vendor/platform/CORE_VERSION
vendored
Normal file
@@ -0,0 +1 @@
|
||||
13055b811ca0030841f41ce1f10e4f30cba1a7db admin-platform-integration
|
||||
1
vendor/platform/core/BUILD_INFO.json
vendored
Normal file
1
vendor/platform/core/BUILD_INFO.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"sha": "13055b811ca0030841f41ce1f10e4f30cba1a7db", "built_at": "2026-10-15T13:26:28Z"}
|
||||
0
vendor/platform/core/__init__.py
vendored
Normal file
0
vendor/platform/core/__init__.py
vendored
Normal file
1637
vendor/platform/core/app.py
vendored
Normal file
1637
vendor/platform/core/app.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
178
vendor/platform/core/bundle.py
vendored
Normal file
178
vendor/platform/core/bundle.py
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Tenant-Bundle-Loader: holt die Tenant-Daten zur Laufzeit von der Admin-Plattform (TENANT_SOURCE=admin).
|
||||
|
||||
git (Standard, wie bisher) Tenant-Ordner aus dem Image: tenants/<id>/
|
||||
admin Bundle von {ADMIN_URL}/api/tenants/<id>/bundle (Bearer ADMIN_BUNDLE_TOKEN), geprüft und lokal gecacht
|
||||
|
||||
Ausfallsicherheit bei admin: Admin nicht erreichbar → letzter gecachter Stand (/data/bundle) → eingebackener Tenant
|
||||
(nur solange BUNDLE_FALLBACK_BAKED nicht „false“ ist) → sonst Fehler beim Start. Die Admin-Plattform ist damit nur
|
||||
Kontrollebene, nicht Teil des Laufzeitpfads.
|
||||
|
||||
Sicherheit: Hashes aller Dateien werden gegen MANIFEST.json geprüft, nur normale Dateien, keine Pfad-Tricks, Größenlimits.
|
||||
Format siehe docs/CONTRACT.md. Nur Standardbibliothek – wird beim Import von core.config genutzt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
SUPPORTED_BUNDLE_FORMAT = 1
|
||||
MAX_FILES = 200
|
||||
MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
||||
KEEP_VERSIONS = 2
|
||||
|
||||
|
||||
class BundleError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _truthy(v: str | None, default: bool) -> bool:
|
||||
return default if v is None else v.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _http_fetch(url: str, token: str, etag: str | None, timeout: float = 20.0):
|
||||
"""→ (status, headers, body). 304 bei unverändertem ETag. Wirft OSError bei Netzwerkfehlern."""
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", **({"If-None-Match": etag} if etag else {})})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status, dict(r.headers), r.read(MAX_TOTAL_BYTES + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, dict(exc.headers or {}), b""
|
||||
|
||||
|
||||
def parse_bundle(data: bytes, tenant_id: str) -> tuple[dict, dict[str, bytes]]:
|
||||
"""Prüft ein Bundle und gibt (manifest, {pfad: inhalt}) zurück. Wirft BundleError bei jeder Auffälligkeit."""
|
||||
if len(data) > MAX_TOTAL_BYTES:
|
||||
raise BundleError("Bundle zu groß")
|
||||
files: dict[str, bytes] = {}
|
||||
total = 0
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
||||
members = tar.getmembers()
|
||||
if len(members) > MAX_FILES:
|
||||
raise BundleError(f"Bundle enthält mehr als {MAX_FILES} Dateien")
|
||||
for m in members:
|
||||
if not m.isreg():
|
||||
raise BundleError(f"Nicht erlaubter Eintrag im Bundle: {m.name!r}")
|
||||
p = PurePosixPath(m.name)
|
||||
if p.is_absolute() or ".." in p.parts or not p.parts or "\\" in m.name:
|
||||
raise BundleError(f"Unzulässiger Pfad im Bundle: {m.name!r}")
|
||||
total += m.size
|
||||
if total > MAX_TOTAL_BYTES:
|
||||
raise BundleError("Bundle entpackt zu groß")
|
||||
files[str(p)] = tar.extractfile(m).read()
|
||||
except (tarfile.TarError, EOFError, OSError) as exc:
|
||||
raise BundleError(f"Bundle nicht lesbar: {exc}") from exc
|
||||
if "MANIFEST.json" not in files or "tenant.json" not in files:
|
||||
raise BundleError("MANIFEST.json oder tenant.json fehlt im Bundle")
|
||||
try:
|
||||
manifest = json.loads(files.pop("MANIFEST.json"))
|
||||
cfg = json.loads(files["tenant.json"])
|
||||
except ValueError as exc:
|
||||
raise BundleError(f"MANIFEST.json/tenant.json ungültig: {exc}") from exc
|
||||
if int(manifest.get("bundle_format", 1)) > SUPPORTED_BUNDLE_FORMAT:
|
||||
raise BundleError(f"Bundle-Format {manifest.get('bundle_format')} wird von diesem Core nicht unterstützt")
|
||||
if manifest.get("tenant") != tenant_id or cfg.get("id") != tenant_id:
|
||||
raise BundleError(f"Bundle gehört nicht zu Tenant {tenant_id!r}")
|
||||
expected = manifest.get("files") or {}
|
||||
if set(expected) != set(files):
|
||||
raise BundleError("Dateiliste weicht vom MANIFEST ab")
|
||||
for path, sha in expected.items():
|
||||
if hashlib.sha256(files[path]).hexdigest() != sha:
|
||||
raise BundleError(f"Hash-Abweichung: {path}")
|
||||
return manifest, files
|
||||
|
||||
|
||||
def _install(cache: Path, version: int, manifest: dict, files: dict[str, bytes], etag: str | None) -> Path:
|
||||
"""Schreibt das Bundle in ein neues Versionsverzeichnis und schaltet `current` atomar um."""
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
target = cache / f"v{version}-{hashlib.sha256(json.dumps(manifest.get('files', {}), sort_keys=True).encode()).hexdigest()[:8]}"
|
||||
if not target.exists():
|
||||
tmp = cache / f".tmp-{os.getpid()}-{int(time.time() * 1000)}"
|
||||
for path, content in files.items():
|
||||
dest = tmp / path
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(content)
|
||||
(tmp / ".bundle.json").write_text(json.dumps({"version": version, "etag": etag, "fetched_at": time.time()}))
|
||||
os.replace(tmp, target)
|
||||
link = cache / "current"
|
||||
new_link = cache / f".current-{os.getpid()}"
|
||||
if new_link.is_symlink() or new_link.exists():
|
||||
new_link.unlink()
|
||||
os.symlink(target.name, new_link)
|
||||
os.replace(new_link, link)
|
||||
keep = {target.name}
|
||||
for d in sorted((p for p in cache.glob("v*") if p.is_dir()), key=lambda p: p.stat().st_mtime, reverse=True)[:KEEP_VERSIONS]:
|
||||
keep.add(d.name)
|
||||
for d in cache.glob("v*"):
|
||||
if d.is_dir() and d.name not in keep:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
return target
|
||||
|
||||
|
||||
def _cached(cache: Path) -> tuple[Path, dict] | None:
|
||||
link = cache / "current"
|
||||
if link.is_symlink() and (link / "tenant.json").exists():
|
||||
try:
|
||||
meta = json.loads((link / ".bundle.json").read_text())
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
return link, meta
|
||||
return None
|
||||
|
||||
|
||||
def resolve(tenant_id: str, tenants_dir: Path, env: dict | None = None, fetch=_http_fetch) -> tuple[Path, dict]:
|
||||
"""Liefert (Tenant-Verzeichnis, Info). Info: {source: git|admin|cache|baked, version, stale, error, fetched_at}."""
|
||||
env = os.environ if env is None else env
|
||||
source = (env.get("TENANT_SOURCE") or "git").strip().lower()
|
||||
baked = tenants_dir / tenant_id
|
||||
if source == "git":
|
||||
return baked, {"source": "git", "version": None, "stale": False, "error": None, "fetched_at": None}
|
||||
if source != "admin":
|
||||
raise BundleError(f"Unbekannte TENANT_SOURCE {source!r} (erlaubt: git, admin)")
|
||||
|
||||
base = (env.get("ADMIN_URL") or "").rstrip("/")
|
||||
token = env.get("ADMIN_BUNDLE_TOKEN") or ""
|
||||
cache = Path(env.get("BUNDLE_CACHE_DIR") or Path(env.get("DATA_DIR") or "/data") / "bundle") / tenant_id
|
||||
have = _cached(cache)
|
||||
error: str | None = None
|
||||
if not base or not token:
|
||||
error = "ADMIN_URL oder ADMIN_BUNDLE_TOKEN nicht gesetzt"
|
||||
else:
|
||||
for attempt in range(3): # kurze Wiederholung: Container und Admin starten oft gleichzeitig
|
||||
try:
|
||||
status, headers, body = fetch(f"{base}/api/tenants/{tenant_id}/bundle", token, (have[1].get("etag") if have else None))
|
||||
except OSError as exc:
|
||||
error, status = f"Admin nicht erreichbar: {exc}", None
|
||||
else:
|
||||
if status == 200:
|
||||
try:
|
||||
manifest, files = parse_bundle(body, tenant_id)
|
||||
version = int(manifest.get("version") or headers.get("X-Bundle-Version") or 0)
|
||||
etag = {k.lower(): v for k, v in headers.items()}.get("etag")
|
||||
d = _install(cache, version, manifest, files, etag)
|
||||
return d, {"source": "admin", "version": version, "stale": False, "error": None, "fetched_at": time.time()}
|
||||
except BundleError as exc:
|
||||
error = f"Ungültiges Bundle: {exc}"
|
||||
break # kein Retry: Inhalt ist kaputt, nicht das Netz
|
||||
elif status == 304 and have:
|
||||
return have[0], {"source": "admin", "version": have[1].get("version"), "stale": False, "error": None, "fetched_at": have[1].get("fetched_at")}
|
||||
elif status in (401, 403, 404):
|
||||
error = f"Admin verweigert das Bundle (HTTP {status})"
|
||||
break
|
||||
else:
|
||||
error = f"Admin-Antwort HTTP {status}"
|
||||
if attempt < 2:
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
if have:
|
||||
return have[0], {"source": "cache", "version": have[1].get("version"), "stale": True, "error": error, "fetched_at": have[1].get("fetched_at")}
|
||||
if _truthy(env.get("BUNDLE_FALLBACK_BAKED"), True) and (baked / "tenant.json").exists():
|
||||
return baked, {"source": "baked", "version": None, "stale": True, "error": error, "fetched_at": None}
|
||||
raise BundleError(f"Tenant-Bundle für {tenant_id!r} nicht verfügbar und kein Cache/Rückfall vorhanden: {error}")
|
||||
154
vendor/platform/core/config.py
vendored
Normal file
154
vendor/platform/core/config.py
vendored
Normal 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()
|
||||
111
vendor/platform/core/contract.py
vendored
Normal file
111
vendor/platform/core/contract.py
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Vertrag zwischen Core und Admin-Plattform (siehe docs/CONTRACT.md).
|
||||
|
||||
Der Core veröffentlicht bei jedem Release: Image, Manifest, Schema (`core/schema/tenant_schema.json`), Bundle-Format und
|
||||
`/health`. Die Admin-Plattform liest das – sie hält keine eigene Kopie der Tenant-Optionen.
|
||||
|
||||
python -m core.contract # Manifest als JSON auf stdout
|
||||
python -m core.contract --check # Schema gegen den Code prüfen (Exit-Code ≠ 0 bei Abweichung)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONTRACT_VERSION = 1 # Version dieses Vertrags (Schema-Format, Bundle-Format, Health-Felder)
|
||||
HOOK_API = 1 # Version der Hook-Schnittstelle (core/hooks.py); overrides.py kann `HOOK_API = 1` deklarieren
|
||||
BUNDLE_FORMAT = 1 # Format des Tenant-Bundles (tar.gz mit tenant.json + Dateien + MANIFEST.json)
|
||||
|
||||
CORE_DIR = Path(__file__).resolve().parent
|
||||
ROOT = CORE_DIR.parent
|
||||
SCHEMA_PATH = CORE_DIR / "schema" / "tenant_schema.json"
|
||||
BUILD_INFO_PATH = CORE_DIR / "BUILD_INFO.json" # schreibt das Dockerfile; lokal fehlt die Datei
|
||||
|
||||
# Env-Variablen, die der Code liest, die aber bewusst nicht im Schema stehen (interne/Legacy/Plattformvariablen)
|
||||
INTERNAL_ENV = {"TENANTS_DIR", "TEMP_DIR", "LOCALAPPDATA", "APP_SESSION_SECRET", "CORE_SHA", "CORE_BUILT_AT", "PYTHONPATH", "PATH", "HOME"}
|
||||
|
||||
|
||||
def build_info() -> dict:
|
||||
"""{sha, built_at, source}: BUILD_INFO.json (Image) > Env CORE_SHA > git > 'dev'."""
|
||||
try:
|
||||
data = json.loads(BUILD_INFO_PATH.read_text(encoding="utf-8"))
|
||||
if data.get("sha"):
|
||||
return {"sha": data["sha"], "built_at": data.get("built_at"), "source": "image"}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if os.getenv("CORE_SHA"):
|
||||
return {"sha": os.environ["CORE_SHA"], "built_at": os.getenv("CORE_BUILT_AT"), "source": "env"}
|
||||
try:
|
||||
sha = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5).stdout.strip()
|
||||
if sha:
|
||||
return {"sha": sha, "built_at": None, "source": "git"}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {"sha": "dev", "built_at": None, "source": "dev"}
|
||||
|
||||
|
||||
def load_schema() -> dict:
|
||||
return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str | None:
|
||||
try:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def build_manifest() -> dict:
|
||||
schema = load_schema()
|
||||
return {
|
||||
"contract": CONTRACT_VERSION, "hook_api": HOOK_API, "bundle_format": BUNDLE_FORMAT,
|
||||
"core": build_info(),
|
||||
"requirements_sha256": _sha256(ROOT / "requirements.txt"),
|
||||
"schema_sha256": _sha256(SCHEMA_PATH),
|
||||
"fields": len(schema["fields"]), "env": len(schema["env"]),
|
||||
}
|
||||
|
||||
|
||||
def schema_paths(schema: dict | None = None) -> set[str]:
|
||||
return {f["path"] for f in (schema or load_schema())["fields"]}
|
||||
|
||||
|
||||
def check_tenant_json(raw: dict, schema: dict | None = None) -> list[str]:
|
||||
"""Meldet unbekannte Schlüssel und falsche Typen in einer tenant.json (Tippfehler fallen sonst still durch)."""
|
||||
schema = schema or load_schema()
|
||||
fields = {f["path"]: f for f in schema["fields"]}
|
||||
ignored = set(schema.get("ignored_keys", []))
|
||||
problems: list[str] = []
|
||||
|
||||
def walk(prefix: str, obj: dict) -> None:
|
||||
for k, v in obj.items():
|
||||
path = f"{prefix}{k}"
|
||||
if not prefix and k in ignored or (not prefix and k == "id"):
|
||||
continue
|
||||
if path in fields:
|
||||
t = fields[path]["type"]
|
||||
ok = {"bool": isinstance(v, bool), "number": isinstance(v, (int, float)) and not isinstance(v, bool),
|
||||
"enum": v in (fields[path].get("options") or []) or v in (None, ""),
|
||||
"color": isinstance(v, str) and len(v) in (4, 7) and v.startswith("#")}.get(t, isinstance(v, str) or v is None)
|
||||
if not ok:
|
||||
problems.append(f"{path}: ungültiger Wert {v!r} (erwartet {t})")
|
||||
elif isinstance(v, dict) and any(p.startswith(path + ".") for p in fields):
|
||||
walk(path + ".", v)
|
||||
else:
|
||||
problems.append(f"{path}: unbekannte Option (nicht im Schema)")
|
||||
|
||||
walk("", raw)
|
||||
return problems
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--check" in sys.argv:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from tests.test_contract import drift_report # noqa: E402
|
||||
rep = drift_report()
|
||||
print("\n".join(rep) or "Schema und Code stimmen überein.")
|
||||
sys.exit(1 if rep else 0)
|
||||
print(json.dumps(build_manifest(), indent=2, ensure_ascii=False))
|
||||
146
vendor/platform/core/cost.py
vendored
Normal file
146
vendor/platform/core/cost.py
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Cost-/Usage-Tracking client for the central Onyva Dev Login.
|
||||
|
||||
Reports finished EUR amounts for LLM calls and audio STT to the central
|
||||
``/api/usage`` endpoint (see ``docs/cost.md``). Design goals:
|
||||
|
||||
* **Buffered + batched** — events are collected in memory and flushed by a
|
||||
background thread, not one HTTP request per event.
|
||||
* **Never blocks the user request** — ``track_usage`` only appends to a buffer;
|
||||
the network call happens on the daemon thread. All errors are swallowed
|
||||
(tracking is best effort and must never break the app flow).
|
||||
* **Idempotent** — callers pass a stable ``idempotency_key`` so retries / repeated
|
||||
status polls never double-count.
|
||||
|
||||
The ingest key (``usk_…``) comes from a **per-tenant Gitea secret**
|
||||
``COST_INGEST_KEY_<ID>`` and reaches the container as ``COST_INGEST_KEY`` — kept
|
||||
separate from the existing ``TENANT_SECRETS_<ID>`` blob (see ``infra/deploy.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
# ── config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Same base URL as the auth server. Defaults to OIDC_ISSUER, then the public login.
|
||||
COST_API_BASE = os.getenv(
|
||||
"COST_API_BASE",
|
||||
os.getenv("OIDC_ISSUER", "https://login.onyva.dev"),
|
||||
).strip().rstrip("/")
|
||||
|
||||
# Per-tenant ingest key (usk_…). Empty → tracking is inert (see _enabled()).
|
||||
COST_INGEST_KEY = os.getenv("COST_INGEST_KEY", "").strip()
|
||||
|
||||
COST_TRACKING_ENABLED = os.getenv("COST_TRACKING_ENABLED", "true").lower() == "true"
|
||||
|
||||
_FLUSH_INTERVAL = float(os.getenv("COST_FLUSH_INTERVAL", "5"))
|
||||
_HTTP_TIMEOUT = float(os.getenv("COST_HTTP_TIMEOUT", "5"))
|
||||
|
||||
_buffer: list[dict] = []
|
||||
_lock = threading.Lock()
|
||||
_worker_started = False
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
# No key → nothing to authenticate with → stay completely inert (also covers
|
||||
# local dev where the key is simply unset).
|
||||
return COST_TRACKING_ENABLED and bool(COST_INGEST_KEY)
|
||||
|
||||
|
||||
# ── pricing (EUR) ─────────────────────────────────────────────────────────────
|
||||
# The app reports finished EUR amounts; the server stores them 1:1. Rates are
|
||||
# overridable via env so they can be tuned without a redeploy.
|
||||
#
|
||||
# Defaults:
|
||||
# • LLM — claude-4-6-sonnet via Cortecs, billed by Cortecs directly in EUR:
|
||||
# €2.87 / €14.31 per 1M tokens (list price, 17 Jul 2026). No USD
|
||||
# conversion applies — unlike Bedrock, Cortecs quotes EUR natively.
|
||||
# • STT — Mistral Voxtral Mini Transcribe: $0.003/min → €0.002625/min
|
||||
# (converted at 1 USD ≈ €0.875, 24 Jun 2026). Läuft ein Tenant über
|
||||
# Cortecs statt direkt über Mistral, liefert core/app.py den dortigen
|
||||
# EUR-Tarif als `per_minute` mit (siehe _STT_PROVIDERS).
|
||||
# Re-tune via the COST_* env vars when tariffs move, or if LLM_MODEL is changed
|
||||
# to a different tier (Cortecs publishes per-model EUR rates on GET /v1/models).
|
||||
|
||||
def llm_cost_eur(model: str, input_tokens: int, output_tokens: int) -> float:
|
||||
"""EUR cost of one LLM call from token counts. Rates are €/1M tokens."""
|
||||
in_per_mtok = float(os.getenv("COST_LLM_INPUT_PER_MTOK", "2.87"))
|
||||
out_per_mtok = float(os.getenv("COST_LLM_OUTPUT_PER_MTOK", "14.31"))
|
||||
return (input_tokens / 1_000_000) * in_per_mtok + (output_tokens / 1_000_000) * out_per_mtok
|
||||
|
||||
|
||||
def stt_cost_eur(minutes: float, per_minute: float | None = None) -> float:
|
||||
"""EUR cost of an STT call from audio duration. Rate is €/minute.
|
||||
|
||||
The caller passes the rate of the tenant's STT provider (Mistral direkt vs.
|
||||
Cortecs sind unterschiedlich teuer); COST_STT_PER_MINUTE überschreibt sie."""
|
||||
default = "0.002625" if per_minute is None else str(per_minute)
|
||||
rate = float(os.getenv("COST_STT_PER_MINUTE") or default)
|
||||
return max(minutes, 0.0) * rate
|
||||
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def track_usage(
|
||||
cost_point: str,
|
||||
amount_eur: float,
|
||||
account: str | None = None,
|
||||
quantity: float | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
"""Non-blocking: append a usage event to the buffer. No-op when disabled."""
|
||||
if not _enabled():
|
||||
return
|
||||
ev: dict = {"cost_point": cost_point, "amount": round(float(amount_eur), 4)}
|
||||
if account:
|
||||
ev["account"] = account
|
||||
if quantity is not None:
|
||||
ev["quantity"] = quantity
|
||||
if idempotency_key:
|
||||
ev["idempotency_key"] = idempotency_key
|
||||
if metadata:
|
||||
ev["metadata"] = metadata
|
||||
with _lock:
|
||||
_buffer.append(ev)
|
||||
|
||||
|
||||
def flush_now() -> None:
|
||||
"""Send everything currently buffered. Safe to call from shutdown."""
|
||||
if not _enabled():
|
||||
return
|
||||
with _lock:
|
||||
if not _buffer:
|
||||
return
|
||||
batch, _buffer[:] = _buffer[:], []
|
||||
try:
|
||||
requests.post(
|
||||
f"{COST_API_BASE}/api/usage",
|
||||
headers={"Authorization": f"Bearer {COST_INGEST_KEY}"},
|
||||
json={"events": batch},
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
)
|
||||
except Exception as exc: # best effort — never propagate
|
||||
# Re-buffer so a transient outage doesn't silently drop events.
|
||||
with _lock:
|
||||
_buffer[:0] = batch
|
||||
print(f"[cost] flush failed ({len(batch)} events re-buffered): {exc}")
|
||||
|
||||
|
||||
def _loop() -> None:
|
||||
while True:
|
||||
time.sleep(_FLUSH_INTERVAL)
|
||||
flush_now()
|
||||
|
||||
|
||||
def start_worker() -> None:
|
||||
"""Start the background flush thread once. Inert when tracking is disabled."""
|
||||
global _worker_started
|
||||
if _worker_started or not _enabled():
|
||||
return
|
||||
_worker_started = True
|
||||
threading.Thread(target=_loop, daemon=True, name="cost-flush").start()
|
||||
print(f"[cost] tracking enabled → {COST_API_BASE}/api/usage")
|
||||
142
vendor/platform/core/defaults/prompt.txt
vendored
Normal file
142
vendor/platform/core/defaults/prompt.txt
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
Du bist der offizielle Protokollführer von @@ORG@@. @@ORG_PROFILE@@
|
||||
|
||||
Deine Aufgabe ist es, aus einem rohen Gesprächsprotokoll eines Kundentermins ein druckreifes Ergebnisprotokoll im Stil von @@ORG_SHORT@@ zu erstellen. Das Ergebnis ist ein internes wie externes Arbeitsdokument, das nach dem Termin an Kunde und Team geht und als verbindliche Grundlage für die weitere Zusammenarbeit dient.
|
||||
|
||||
Zentrale Zielsetzung:
|
||||
Erzeuge keine wortgetreue Wiedergabe des Gesprächs, sondern ein klar gegliedertes Ergebnisprotokoll. Im Mittelpunkt stehen Entscheidungen, Action Items mit Verantwortlichen und Fristen sowie offene Punkte. Diskussionen werden verdichtet, Smalltalk und Wiederholungen entfallen.
|
||||
|
||||
---
|
||||
|
||||
ABSOLUTE REGELN
|
||||
|
||||
1. Erfinde nichts. Füge keine Namen, Termine, Budgets, Tools, Plattformen, Domains, KPIs oder Zusagen hinzu, die nicht im Rohprotokoll enthalten oder daraus eindeutig ableitbar sind.
|
||||
2. Bleibe streng quellentreu. Verdichte, ordne und glätte sprachlich, aber ändere keine inhaltliche Aussage.
|
||||
3. Schreibe vollständig auf Deutsch.
|
||||
4. Verwende niemals Markdown. Also keine Sternchen, Doppelsternchen, Rauten, Backticks oder eckigen Klammern (außer in der genau vorgegebenen Action-Item-Notation, siehe unten).
|
||||
5. Gib ausschließlich den finalen Dokumenttext aus. Keine Vorbemerkung, keine Erklärung, keine Meta-Hinweise, keine Code-Blöcke.
|
||||
6. Sachlicher, professioneller Ton. Sie-Anrede an den Kunden, soweit Anrede überhaupt vorkommt. Keine Werbesprache, keine Füllphrasen, keine Bewertungen.
|
||||
7. Eigennamen, Zahlen, Geldbeträge, Datumsangaben, URLs und Tool-Bezeichnungen exakt bewahren.
|
||||
8. Wenn ein Detail im Gespräch unklar bleibt, nimm es als offenen Punkt auf, statt etwas zu ergänzen.
|
||||
|
||||
---
|
||||
|
||||
EINGABE
|
||||
|
||||
Du erhältst vom Nutzer vor dem Gesprächsprotokoll drei Angaben:
|
||||
|
||||
Termin-Art: entweder eine konkrete Bezeichnung (z.B. "Kick-off", "Jour fixe", "Konzept-Review", "Status-Update", "Workshop", "Briefing", "Abnahme", "Strategie-Meeting") oder leer bzw. "(aus Gespräch erkennen)". Ist die Termin-Art vorgegeben, übernimm sie unverändert. Ist sie nicht vorgegeben, leite sie aus dem Gespräch ab. Gelingt das nicht eindeutig, nutze "Kundengespräch".
|
||||
|
||||
Projektname: entweder eine Bezeichnung des Projekts oder leer bzw. "(keiner)". Ist ein Projektname angegeben, führe ihn im Projekt-Block auf.
|
||||
|
||||
Meeting-Titel: ein freier Titel, der dir Orientierung geben kann, aber nicht ins Dokument gehört.
|
||||
|
||||
---
|
||||
|
||||
AUSGABEFORMAT
|
||||
|
||||
Die Ausgabe besteht aus zwei Teilen:
|
||||
|
||||
TEIL 1 — METADATEN-BLÖCKE (immer am Anfang, in genau dieser Reihenfolge, mit den exakten Markern)
|
||||
|
||||
===PROJEKT===
|
||||
{Kunde / Unternehmen, z.B. "Müller Bau GmbH"}
|
||||
{ggf. Projektname, z.B. "Relaunch Website 2026", nur wenn vorhanden}
|
||||
===/PROJEKT===
|
||||
|
||||
===KONTAKT===
|
||||
{Vorname Nachname - Funktion, alles auf einer Zeile, z.B. "Markus Müller - Geschäftsführer"}
|
||||
{ggf. weitere Teilnehmer auf Kundenseite, je eine Zeile, gleiches Format "Vorname Nachname - Funktion"}
|
||||
===/KONTAKT===
|
||||
|
||||
Wenn die Funktion nicht bekannt ist, schreibe nur den Namen ohne Trennstrich. Setze keine Anrede ("Herr"/"Frau") vor den Namen — Vor- und Nachname genügen.
|
||||
|
||||
Wenn im Gespräch kein Ansprechpartner identifizierbar ist, gib den Block trotzdem aus, aber mit einer einzigen Zeile dazwischen:
|
||||
===KONTAKT===
|
||||
(keiner)
|
||||
===/KONTAKT===
|
||||
|
||||
===TERMINART===
|
||||
{Die verwendete Termin-Art, exakt so wie in der Eingabe vorgegeben oder aus dem Gespräch erkannt}
|
||||
===/TERMINART===
|
||||
|
||||
TEIL 2 — INHALTSABSCHNITTE (immer in dieser Reihenfolge, jeder Abschnitt mit seiner genauen Überschrift auf einer eigenen Zeile, gefolgt von einer Leerzeile und dem Abschnittsinhalt)
|
||||
|
||||
Anlass & Kontext
|
||||
|
||||
Besprochene Themen
|
||||
|
||||
Entscheidungen
|
||||
|
||||
Action Items
|
||||
|
||||
Offene Punkte
|
||||
|
||||
Nächste Schritte
|
||||
|
||||
---
|
||||
|
||||
INHALT DER EINZELNEN ABSCHNITTE
|
||||
|
||||
Abschnitt "Anlass & Kontext"
|
||||
|
||||
Knapper Absatz von 2 bis 4 Sätzen, der benennt:
|
||||
- Worum es in diesem Termin ging (z.B. Kick-off, Status, Abnahme)
|
||||
- Welcher Projekt- oder Phasenstand vorliegt (nur wenn aus Gespräch erkennbar)
|
||||
- Welche übergeordnete Zielsetzung im Raum steht
|
||||
Keine Aufzählung, sondern Fließtext. Smalltalk und Begrüßung gehören nicht hierhin.
|
||||
|
||||
Abschnitt "Besprochene Themen"
|
||||
|
||||
Bullet-Liste der inhaltlich besprochenen Themen. Jeder Punkt ist ein eigener Bullet, der mit "• " (Aufzählungspunkt + Leerzeichen) beginnt. Pro Thema 1 bis 3 Sätze, die das Wesentliche der Diskussion verdichten — also Position des Kunden, Hinweise von @@ORG_SHORT@@, Argumente, Optionen. Verzichte auf Wortlautwiedergabe und auf reine Wiederholung. Reihenfolge: thematisch sinnvoll, nicht zwingend chronologisch. Wenn mehrere Sub-Punkte zusammengehören, halte sie in einem Bullet.
|
||||
|
||||
Beispiel für Bullet-Stil:
|
||||
• Startseiten-Hero: Kunde wünscht stärkeren Fokus auf USPs. @@ORG_SHORT@@ schlägt zweispaltiges Layout mit Slider rechts vor; Entscheidung wird nach Wireframe getroffen.
|
||||
|
||||
Abschnitt "Entscheidungen"
|
||||
|
||||
Bullet-Liste der im Termin verbindlich getroffenen Entscheidungen. Jeder Punkt beginnt mit "• ". Nur Beschlüsse aufnehmen, die im Gespräch ausdrücklich getroffen oder bestätigt wurden — keine Vermutungen, keine Empfehlungen ohne Zustimmung. Wenn keine Entscheidungen getroffen wurden, gib einen einzigen Satz aus: "In diesem Termin wurden keine verbindlichen Entscheidungen getroffen."
|
||||
|
||||
Abschnitt "Action Items"
|
||||
|
||||
Bullet-Liste der vereinbarten Aufgaben. Jeder Punkt beginnt mit "• " und folgt diesem festen Schema:
|
||||
|
||||
• [Verantwortlicher · Frist] Aufgabenbeschreibung im Imperativ
|
||||
|
||||
Erläuterungen:
|
||||
- Verantwortlicher ist entweder "Kunde", "@@ORG_SHORT@@" oder ein konkreter Personenname, sofern im Gespräch genannt. Wenn unklar, nutze "offen".
|
||||
- Frist ist ein konkretes Datum (TT.MM.JJJJ), eine Kalenderwoche ("KW 19"), eine relative Angabe ("bis nächste Woche") oder "offen". Bewahre die Schreibweise wie im Gespräch.
|
||||
- Aufgabenbeschreibung ist ein konkreter Auftrag, kein Konzept. Schreibe "Logo-Dateien liefern" statt "Wir kümmern uns um die Logos".
|
||||
|
||||
Beispiele:
|
||||
• [Kunde · 02.05.2026] Logo-Dateien und Brand-Guidelines im Vektorformat liefern
|
||||
• [@@ORG_SHORT@@ · KW 19] Wireframes für Startseite und zwei Unterseiten erstellen
|
||||
• [Markus Müller · offen] Freigabe der Domain-Liste
|
||||
|
||||
Wenn keine Action Items vereinbart wurden, gib einen einzigen Satz aus: "In diesem Termin wurden keine Action Items vereinbart."
|
||||
|
||||
Abschnitt "Offene Punkte"
|
||||
|
||||
Bullet-Liste aller Themen, zu denen Klärungsbedarf besteht oder die explizit vertagt wurden. Jeder Punkt beginnt mit "• ". Halte den Punkt so konkret, dass beim Folgetermin direkt daran weitergearbeitet werden kann. Wenn keine offenen Punkte vorliegen, gib einen einzigen Satz aus: "Es bestehen keine offenen Punkte."
|
||||
|
||||
Abschnitt "Nächste Schritte"
|
||||
|
||||
Knapper Absatz oder bis zu drei Bullet-Punkte. Inhalt: Folgetermin (Datum, falls vereinbart), nächster Meilenstein, anstehende Phase. Wenn nichts Konkretes vereinbart wurde, gib einen einzigen Satz aus: "Ein Folgetermin wurde nicht vereinbart."
|
||||
|
||||
---
|
||||
|
||||
FORMATREGELN
|
||||
|
||||
- Jede Abschnittsüberschrift steht allein in einer Zeile, exakt in der oben genannten Schreibweise.
|
||||
- Nach jeder Überschrift folgt eine Leerzeile, dann der Abschnittsinhalt.
|
||||
- Fließtext als sauberer, gut lesbarer Absatz.
|
||||
- Bullet-Points beginnen mit "• " (Aufzählungspunkt + Leerzeichen). Keine "-", keine "*", keine Nummerierung.
|
||||
- Eckige Klammern sind ausschließlich in der Action-Item-Notation [Verantwortlicher · Frist] erlaubt, sonst nirgendwo.
|
||||
- Keine Tabellen, keine HTML-Ausgabe, kein Markdown.
|
||||
- Keine doppelten Leerzeilen zwischen Absätzen innerhalb eines Abschnitts (höchstens eine Leerzeile).
|
||||
- Zwischen zwei Abschnitten steht genau eine Leerzeile vor der nächsten Überschrift.
|
||||
|
||||
---
|
||||
|
||||
QUALITÄTSMASSSTAB
|
||||
|
||||
Das Ergebnis soll so wirken, als wäre es von einem erfahrenen Projektleiter von @@ORG_SHORT@@ direkt nach dem Termin redaktionell erstellt worden. Der Leser versteht innerhalb von 30 Sekunden: Worum ging es, was wurde entschieden, wer macht was bis wann, was ist offen. Keine Floskeln, keine Wiederholungen, keine erfundenen Details. Action Items sind als verbindliche Aufträge formuliert, nicht als Absichtserklärungen.
|
||||
238
vendor/platform/core/docx_export.py
vendored
Normal file
238
vendor/platform/core/docx_export.py
vendored
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Renders a Word (.docx) protocol from the shared PDF context.
|
||||
|
||||
The *content* is 100 % identical to the PDF export: both consume the structured
|
||||
dict produced by ``core.pdf.build_pdf_context`` (tenant-independent). This module
|
||||
supplies the Word *layout*, kept as close as reasonably possible to the PDF
|
||||
(``core/templates/protocol.html`` + ``protocol.css``):
|
||||
|
||||
* title + logo live in the page header (not a big body title),
|
||||
* office/footer note + page numbers live in the page footer,
|
||||
* section headings are filled accent bars,
|
||||
* the "Erstellt durch" metadata column is right-aligned,
|
||||
* body font approximates the PDF's Noto Sans / Arial stack.
|
||||
|
||||
Because it reuses the shared context, the Word download works for every tenant
|
||||
automatically, with no per-tenant flag or template.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
|
||||
def _norm_hex(value: str, fallback: str) -> str:
|
||||
v = str(value or "").lstrip("#")
|
||||
if len(v) == 3:
|
||||
v = "".join(c * 2 for c in v)
|
||||
if len(v) != 6:
|
||||
v = fallback.lstrip("#")
|
||||
try:
|
||||
int(v, 16)
|
||||
except ValueError:
|
||||
v = fallback.lstrip("#")
|
||||
return v.upper()
|
||||
|
||||
|
||||
def render_docx(context: dict, cfg) -> bytes:
|
||||
from docx import Document
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_TAB_ALIGNMENT
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Mm, Pt, RGBColor
|
||||
|
||||
colors = context.get("colors") or {}
|
||||
|
||||
def hx(key: str, fallback: str) -> str:
|
||||
return _norm_hex(colors.get(key, fallback), fallback)
|
||||
|
||||
accent_hex = hx("accent", "327F24")
|
||||
accent_dark_hex = hx("accent_dark", accent_hex)
|
||||
accent_light_hex = hx("accent_light", "E8F0E5")
|
||||
text2_hex = hx("text_2", "5A5A68")
|
||||
text3_hex = hx("text_3", "8E8E9E")
|
||||
|
||||
def rgb(h: str) -> "RGBColor":
|
||||
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||
|
||||
accent, accent_dark = rgb(accent_hex), rgb(accent_dark_hex)
|
||||
text2, text3 = rgb(text2_hex), rgb(text3_hex)
|
||||
|
||||
BODY_FONT = "Arial" # PDF stack is Noto Sans → DejaVu Sans → Arial
|
||||
CONTENT_W = Mm(166) # A4 (210) − 22mm left − 22mm right
|
||||
|
||||
doc = Document()
|
||||
|
||||
# ── page geometry (matches protocol.css @page margins) ──
|
||||
sec = doc.sections[0]
|
||||
sec.top_margin, sec.bottom_margin = Mm(26), Mm(24)
|
||||
sec.left_margin, sec.right_margin = Mm(22), Mm(22)
|
||||
|
||||
# ── base font ──
|
||||
normal = doc.styles["Normal"]
|
||||
normal.font.name = BODY_FONT
|
||||
normal.font.size = Pt(9.8)
|
||||
normal.paragraph_format.space_after = Pt(0)
|
||||
normal.paragraph_format.space_before = Pt(0)
|
||||
|
||||
office = context.get("office") or {}
|
||||
terminart = context.get("terminart", "")
|
||||
|
||||
def shade(paragraph, fill_hex: str) -> None:
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), fill_hex)
|
||||
paragraph._p.get_or_add_pPr().append(shd)
|
||||
|
||||
def field(paragraph, instr: str, size: float, italic: bool, color_hex: str) -> None:
|
||||
fld = OxmlElement("w:fldSimple")
|
||||
fld.set(qn("w:instr"), instr)
|
||||
r = OxmlElement("w:r")
|
||||
rpr = OxmlElement("w:rPr")
|
||||
sz = OxmlElement("w:sz"); sz.set(qn("w:val"), str(int(size * 2))); rpr.append(sz)
|
||||
if italic:
|
||||
rpr.append(OxmlElement("w:i"))
|
||||
col = OxmlElement("w:color"); col.set(qn("w:val"), color_hex); rpr.append(col)
|
||||
r.append(rpr)
|
||||
t = OxmlElement("w:t"); t.text = "1"; r.append(t)
|
||||
fld.append(r)
|
||||
paragraph._p.append(fld)
|
||||
|
||||
def right_tab_only(paragraph) -> None:
|
||||
"""Give the paragraph a single right tab stop at the content edge.
|
||||
|
||||
Word *merges* a paragraph's tab stops with those of its style, and the
|
||||
built-in Header/Footer styles carry a centre tab at half the US-Letter
|
||||
text width. Without clearing it the first "\\t" stops mid-page (the logo
|
||||
would look centred), so every inherited stop gets an explicit clear.
|
||||
"""
|
||||
pf = paragraph.paragraph_format
|
||||
for ts in list(paragraph.style.paragraph_format.tab_stops):
|
||||
pf.tab_stops.add_tab_stop(ts.position, WD_TAB_ALIGNMENT.CLEAR)
|
||||
pf.tab_stops.add_tab_stop(CONTENT_W, WD_TAB_ALIGNMENT.RIGHT)
|
||||
|
||||
# ── page header: "<Terminart> | Gesprächsprotokoll" + logo right ──
|
||||
hp = sec.header.paragraphs[0]
|
||||
right_tab_only(hp)
|
||||
hr = hp.add_run(f"{terminart} | Gesprächsprotokoll")
|
||||
hr.italic = True
|
||||
hr.font.size = Pt(9)
|
||||
hr.font.color.rgb = text2
|
||||
logo_path = getattr(cfg, "docx_logo_path", None) or getattr(cfg, "logo_path", None)
|
||||
if logo_path and logo_path.suffix.lower() in (".png", ".jpg", ".jpeg"):
|
||||
try:
|
||||
hp.add_run("\t")
|
||||
hp.add_run().add_picture(str(logo_path), height=Mm(11))
|
||||
except Exception:
|
||||
pass # a broken logo must never fail the whole export
|
||||
|
||||
# ── page footer: office / note (left, multi-line) + "Seite X / Y" (right) ──
|
||||
fp = sec.footer.paragraphs[0]
|
||||
right_tab_only(fp)
|
||||
f1 = fp.add_run(
|
||||
f"Gesprächsprotokoll · {terminart} · {context.get('date_de', '')} · "
|
||||
f"{context.get('project_display', '')}"
|
||||
)
|
||||
f1.font.size = Pt(7); f1.font.color.rgb = text2
|
||||
fp.add_run("\t")
|
||||
field(fp, "PAGE", 7.5, True, text3_hex)
|
||||
fpr = fp.add_run(" / "); fpr.font.size = Pt(7.5); fpr.italic = True; fpr.font.color.rgb = text3
|
||||
field(fp, "NUMPAGES", 7.5, True, text3_hex)
|
||||
|
||||
erstellt = "Erstellt durch: " + (office.get("name") or "")
|
||||
if office.get("subtitle"):
|
||||
erstellt += f" {office['subtitle']}"
|
||||
if office.get("tel"):
|
||||
erstellt += f" • {office['tel']}"
|
||||
if office.get("email"):
|
||||
erstellt += f" • {office['email']}"
|
||||
f2 = sec.footer.add_paragraph().add_run(erstellt)
|
||||
f2.font.size = Pt(7); f2.font.color.rgb = text2
|
||||
if context.get("footer_note"):
|
||||
f3 = sec.footer.add_paragraph().add_run(context["footer_note"])
|
||||
f3.font.size = Pt(7); f3.italic = True; f3.font.color.rgb = text2
|
||||
|
||||
if context.get("doc_title"):
|
||||
tp = doc.add_paragraph()
|
||||
tp.paragraph_format.space_after = Pt(6)
|
||||
tr = tp.add_run(context["doc_title"])
|
||||
tr.bold = True
|
||||
tr.font.size = Pt(16)
|
||||
tr.font.color.rgb = accent_dark
|
||||
|
||||
# ── metadata block: two columns (Projekt / Erstellt durch, right-aligned) ──
|
||||
table = doc.add_table(rows=1, cols=2)
|
||||
table.alignment = WD_TABLE_ALIGNMENT.LEFT
|
||||
table.allow_autofit = False
|
||||
left, right = table.rows[0].cells
|
||||
left.width = right.width = Mm(83)
|
||||
|
||||
def cell_line(cell, text, *, size, color=None, bold=False, italic=False,
|
||||
align=None, first=False, space_before=0.0):
|
||||
if first and not cell.paragraphs[0].runs:
|
||||
p = cell.paragraphs[0]
|
||||
else:
|
||||
p = cell.add_paragraph()
|
||||
p.paragraph_format.space_after = Pt(0)
|
||||
p.paragraph_format.space_before = Pt(space_before)
|
||||
if align is not None:
|
||||
p.alignment = align
|
||||
run = p.add_run(text)
|
||||
run.font.size = Pt(size)
|
||||
run.bold, run.italic = bold, italic
|
||||
if color is not None:
|
||||
run.font.color.rgb = color
|
||||
|
||||
# left column
|
||||
cell_line(left, context.get("projekt_label", "Projekt"), size=11, color=accent, italic=True, first=True)
|
||||
for line in context.get("projekt") or []:
|
||||
cell_line(left, line, size=10.2)
|
||||
if context.get("kontakt"):
|
||||
cell_line(left, "Ansprechpartner Kunde", size=9.5, color=accent, bold=True, space_before=3)
|
||||
for line in context["kontakt"]:
|
||||
cell_line(left, line, size=10.2)
|
||||
|
||||
# right column (right-aligned)
|
||||
R = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
cell_line(right, "Erstellt durch", size=11, color=accent, italic=True, align=R, first=True)
|
||||
if office.get("name"):
|
||||
name = office["name"] + (f" {office['subtitle']}" if office.get("subtitle") else "")
|
||||
cell_line(right, name, size=10.2, align=R)
|
||||
for key in ("address", "tel", "email", "website"):
|
||||
for line in str(office.get(key) or "").splitlines(): # multi-line address
|
||||
cell_line(right, line, size=10.2, align=R)
|
||||
|
||||
doc.add_paragraph().paragraph_format.space_after = Pt(6) # gap before sections
|
||||
|
||||
# ── content sections: filled accent bar + indented body ──
|
||||
for section in context.get("sections") or []:
|
||||
bar = doc.add_paragraph()
|
||||
bpf = bar.paragraph_format
|
||||
bpf.space_before = Pt(10)
|
||||
bpf.space_after = Pt(4)
|
||||
bpf.left_indent = Mm(1.5)
|
||||
bpf.right_indent = Mm(1.5)
|
||||
bpf.keep_with_next = True
|
||||
bpf.line_spacing = 1.3
|
||||
shade(bar, accent_light_hex)
|
||||
b_run = bar.add_run(section.get("heading", ""))
|
||||
b_run.bold = True
|
||||
b_run.font.size = Pt(10)
|
||||
b_run.font.color.rgb = accent_dark
|
||||
|
||||
for block in section.get("blocks") or []:
|
||||
if block.get("type") == "paragraph":
|
||||
p = doc.add_paragraph(block.get("text", ""))
|
||||
p.paragraph_format.left_indent = Mm(6)
|
||||
p.paragraph_format.space_after = Pt(5)
|
||||
p.paragraph_format.line_spacing = 1.4
|
||||
elif block.get("type") == "list":
|
||||
for item in block.get("items") or []:
|
||||
p = doc.add_paragraph(item, style="List Bullet")
|
||||
p.paragraph_format.left_indent = Mm(10)
|
||||
p.paragraph_format.space_after = Pt(2)
|
||||
p.paragraph_format.line_spacing = 1.4
|
||||
|
||||
buffer = io.BytesIO()
|
||||
doc.save(buffer)
|
||||
return buffer.getvalue()
|
||||
64
vendor/platform/core/hooks.py
vendored
Normal file
64
vendor/platform/core/hooks.py
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Per-tenant code hooks.
|
||||
|
||||
Most customers differ only in *data* (colours, logo, prompt, PDF template) and
|
||||
need no code here. For the few that need genuine behaviour changes, drop an
|
||||
`overrides.py` into their tenant folder defining a `Hooks(TenantHooks)` subclass
|
||||
that overrides only the methods it needs. Core stays untouched, so core updates
|
||||
keep flowing.
|
||||
|
||||
Example tenants/acme/overrides.py:
|
||||
|
||||
from core.hooks import TenantHooks
|
||||
|
||||
class Hooks(TenantHooks):
|
||||
def build_prompt(self, base, ctx):
|
||||
return base + "\\nBevorzuge Fachterminologie aus der Orthopädie."
|
||||
|
||||
def extra_routes(self, app):
|
||||
@app.get("/health/acme")
|
||||
def _h():
|
||||
return {"ok": True}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from core.contract import HOOK_API
|
||||
|
||||
|
||||
class TenantHooks:
|
||||
"""Default (no-op) behaviour. Override selectively per tenant."""
|
||||
|
||||
def build_prompt(self, base_prompt: str, ctx: dict) -> str:
|
||||
"""Adjust the system prompt sent to the LLM. ctx carries the form fields."""
|
||||
return base_prompt
|
||||
|
||||
def transform_transcript(self, transcript: str, ctx: dict) -> str:
|
||||
"""Post-process the transcript before summarisation/delivery."""
|
||||
return transcript
|
||||
|
||||
def pdf_context(self, context: dict) -> dict:
|
||||
"""Last chance to enrich/alter the data passed to the PDF template."""
|
||||
return context
|
||||
|
||||
def extra_routes(self, app) -> None:
|
||||
"""Register tenant-specific FastAPI routes."""
|
||||
return None
|
||||
|
||||
|
||||
def load_hooks(tenant_dir: Path) -> TenantHooks:
|
||||
override = tenant_dir / "overrides.py"
|
||||
if not override.exists():
|
||||
return TenantHooks()
|
||||
spec = importlib.util.spec_from_file_location(f"tenant_overrides_{tenant_dir.name}", override)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
declared = getattr(module, "HOOK_API", HOOK_API) # fehlt die Deklaration, gilt die aktuelle Version
|
||||
if declared != HOOK_API:
|
||||
raise RuntimeError(f"{override}: HOOK_API={declared} wird von diesem Core nicht unterstützt (Core: {HOOK_API})")
|
||||
hooks_cls = getattr(module, "Hooks", None)
|
||||
if hooks_cls is None:
|
||||
return TenantHooks()
|
||||
return hooks_cls()
|
||||
4
vendor/platform/core/pdf/__init__.py
vendored
Normal file
4
vendor/platform/core/pdf/__init__.py
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
from core.pdf.context import build_pdf_context
|
||||
from core.pdf.render import render_pdf
|
||||
|
||||
__all__ = ["build_pdf_context", "render_pdf"]
|
||||
92
vendor/platform/core/pdf/context.py
vendored
Normal file
92
vendor/platform/core/pdf/context.py
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Builds the structured data the PDF template renders.
|
||||
|
||||
This is the *content* half of the PDF — purely data, identical logic for every
|
||||
tenant. The *layout* half lives in the tenant's (or core's) protocol.html +
|
||||
protocol.css. Core guarantees the shape of this dict; templates may ignore any
|
||||
field they don't use, so adding fields here never breaks an old template.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from core.protocol import (
|
||||
group_section_blocks,
|
||||
normalize_protocol_text,
|
||||
parse_address_lines,
|
||||
parse_protocol_sections,
|
||||
project_label,
|
||||
)
|
||||
|
||||
|
||||
def _logo_data_uri(logo_path: Path | None) -> str | None:
|
||||
if not logo_path or not logo_path.exists():
|
||||
return None
|
||||
mime = "image/svg+xml" if logo_path.suffix.lower() == ".svg" else "image/png"
|
||||
data = base64.b64encode(logo_path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime};base64,{data}"
|
||||
|
||||
|
||||
def build_pdf_context(
|
||||
cfg,
|
||||
title: str,
|
||||
summary: str,
|
||||
insurance_type: str = "",
|
||||
customer_2: str = "",
|
||||
customer_name: str = "",
|
||||
customer_address: str = "",
|
||||
) -> dict:
|
||||
clean = normalize_protocol_text(summary)
|
||||
parsed = parse_protocol_sections(clean)
|
||||
|
||||
terminart = parsed["terminart"] or insurance_type.strip() or "Kundengespräch"
|
||||
|
||||
projekt: list[str] = []
|
||||
if parsed["projekt"]:
|
||||
projekt = parsed["projekt"]
|
||||
else:
|
||||
if customer_name.strip():
|
||||
projekt.append(customer_name.strip())
|
||||
if customer_2.strip():
|
||||
projekt.append(customer_2.strip())
|
||||
if not projekt:
|
||||
projekt = ["Projekt"]
|
||||
|
||||
if parsed["kontakt"]:
|
||||
kontakt = parsed["kontakt"]
|
||||
elif customer_address.strip():
|
||||
kontakt = parse_address_lines(customer_address)
|
||||
else:
|
||||
kontakt = []
|
||||
|
||||
sections = [
|
||||
{"heading": s["heading"], "blocks": group_section_blocks(s)}
|
||||
for s in parsed["sections"]
|
||||
]
|
||||
|
||||
project_display = project_label(projekt)
|
||||
date_de = datetime.now().strftime("%d.%m.%Y")
|
||||
office = cfg.office
|
||||
|
||||
return {
|
||||
"tenant": {"name": cfg.name, "id": cfg.id},
|
||||
"colors": cfg.colors,
|
||||
"logo_data_uri": _logo_data_uri(cfg.logo_path),
|
||||
"title": title,
|
||||
"terminart": terminart,
|
||||
"projekt": projekt,
|
||||
"projekt_label": cfg.raw.get("pdf_projekt_label", "Projekt"),
|
||||
"project_display": project_display,
|
||||
"kontakt": kontakt,
|
||||
"office": office,
|
||||
"sections": sections,
|
||||
"date_de": date_de,
|
||||
# {year} keeps a copyright line in the footer note current without a
|
||||
# yearly config edit.
|
||||
"footer_note": cfg.raw.get(
|
||||
"pdf_footer_note",
|
||||
"Internes Arbeitsdokument. Inhalte sind vertraulich und ausschließlich "
|
||||
"für die genannten Empfänger bestimmt.",
|
||||
).replace("{year}", str(datetime.now().year)),
|
||||
}
|
||||
33
vendor/platform/core/pdf/render.py
vendored
Normal file
33
vendor/platform/core/pdf/render.py
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Renders the PDF from an HTML+CSS template via WeasyPrint.
|
||||
|
||||
The template + stylesheet are resolved per tenant (tenant override → core
|
||||
default) in core/config.py. A designer can fully restyle the PDF by editing
|
||||
those two text files — no Python involved.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from core.config import CORE_TEMPLATES
|
||||
|
||||
|
||||
def render_pdf(context: dict, cfg) -> bytes:
|
||||
from weasyprint import CSS, HTML # imported lazily: only needed for PDF
|
||||
|
||||
template_path = cfg.pdf_template_path
|
||||
css_path = cfg.pdf_css_path
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader([str(template_path.parent), str(CORE_TEMPLATES)]),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
template = env.get_template(template_path.name)
|
||||
html_str = template.render(**context)
|
||||
|
||||
# CSS is rendered through Jinja too, so colour tokens can be injected.
|
||||
css_env = Environment(loader=FileSystemLoader([str(css_path.parent)]))
|
||||
css_str = css_env.get_template(css_path.name).render(**context)
|
||||
|
||||
base_url = str(template_path.parent)
|
||||
doc = HTML(string=html_str, base_url=base_url)
|
||||
return doc.write_pdf(stylesheets=[CSS(string=css_str)])
|
||||
160
vendor/platform/core/protocol.py
vendored
Normal file
160
vendor/platform/core/protocol.py
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Parsing/normalisation of the Claude protocol output.
|
||||
|
||||
Tenant-independent: turns the raw LLM text into a structured shape that both
|
||||
the PDF template and the e-mail rendering consume.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
SECTION_HEADINGS = (
|
||||
"Anlass & Kontext",
|
||||
"Besprochene Themen",
|
||||
"Entscheidungen",
|
||||
"Action Items",
|
||||
"Offene Punkte",
|
||||
"Nächste Schritte",
|
||||
)
|
||||
|
||||
|
||||
def normalize_protocol_text(text: str) -> str:
|
||||
normalized_lines = []
|
||||
for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
line = raw_line.rstrip()
|
||||
if line.startswith("===") and line.endswith("==="):
|
||||
normalized_lines.append(line.strip())
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
normalized_lines.append("")
|
||||
continue
|
||||
|
||||
cleaned = re.sub(r"^\s{0,3}#{1,6}\s*", "", stripped)
|
||||
cleaned = cleaned.replace("**", "").replace("__", "")
|
||||
cleaned = re.sub(r"^\s*[-*]\s+", "• ", cleaned)
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||||
normalized_lines.append(cleaned)
|
||||
|
||||
text = "\n".join(normalized_lines)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def parse_protocol_sections(text: str) -> dict:
|
||||
"""Zerlegt den Claude-Output in Metadaten-Blöcke und Abschnitte."""
|
||||
lines = text.replace("\r\n", "\n").split("\n")
|
||||
|
||||
def collect_block(marker: str) -> list[str] | None:
|
||||
open_tag = f"==={marker}==="
|
||||
close_tag = f"===/{marker}==="
|
||||
try:
|
||||
start = lines.index(open_tag)
|
||||
end = lines.index(close_tag, start + 1)
|
||||
except ValueError:
|
||||
return None
|
||||
block = [ln.strip() for ln in lines[start + 1:end] if ln.strip()]
|
||||
return block
|
||||
|
||||
projekt = collect_block("PROJEKT") or []
|
||||
kontakt = collect_block("KONTAKT") or []
|
||||
terminart_block = collect_block("TERMINART") or []
|
||||
terminart = terminart_block[0] if terminart_block else ""
|
||||
|
||||
if len(kontakt) == 1 and kontakt[0].lower().strip("() ") in {"keiner", "keine", "none", ""}:
|
||||
kontakt = []
|
||||
|
||||
body_lines: list[str] = []
|
||||
skip = False
|
||||
for ln in lines:
|
||||
stripped = ln.strip()
|
||||
if stripped.startswith("===") and stripped.endswith("==="):
|
||||
if stripped.startswith("===/"):
|
||||
skip = False
|
||||
continue
|
||||
skip = True
|
||||
continue
|
||||
if skip:
|
||||
continue
|
||||
body_lines.append(ln)
|
||||
|
||||
sections: list[dict] = []
|
||||
current = None
|
||||
for raw in body_lines:
|
||||
line = raw.strip()
|
||||
if line in SECTION_HEADINGS:
|
||||
if current is not None:
|
||||
sections.append(current)
|
||||
current = {"heading": line, "lines": []}
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
if line.startswith("• "):
|
||||
current["lines"].append(("bullet", line[2:].strip()))
|
||||
elif line:
|
||||
current["lines"].append(("paragraph", line))
|
||||
else:
|
||||
current["lines"].append(("blank", ""))
|
||||
|
||||
if current is not None:
|
||||
sections.append(current)
|
||||
|
||||
return {
|
||||
"projekt": projekt,
|
||||
"kontakt": kontakt,
|
||||
"terminart": terminart,
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
def parse_address_lines(address: str) -> list[str]:
|
||||
"""Splits a free-form address string into [street, PLZ city] lines."""
|
||||
address = address.strip()
|
||||
if not address:
|
||||
return []
|
||||
if "\n" in address:
|
||||
return [l.strip() for l in address.splitlines() if l.strip()]
|
||||
if "," in address:
|
||||
return [p.strip() for p in address.split(",") if p.strip()]
|
||||
m = re.search(r"(\b\d{5}\b.*)", address)
|
||||
if m:
|
||||
before = address[: m.start()].strip().rstrip(",").strip()
|
||||
plz_city = m.group(1).strip()
|
||||
return ([before] if before else []) + [plz_city]
|
||||
return [address]
|
||||
|
||||
|
||||
def project_label(projekt_lines: list[str]) -> str:
|
||||
"""Erste nicht-leere Zeile des PROJEKT-Blocks (für Dateinamen/Subject)."""
|
||||
for line in projekt_lines:
|
||||
if line and line.strip():
|
||||
return line.strip()
|
||||
return "Projekt"
|
||||
|
||||
|
||||
def safe_filename(title: str) -> str:
|
||||
safe = re.sub(r"[^\w\-äöüÄÖÜß ]", "", title).strip()
|
||||
safe = re.sub(r"\s+", "_", safe)
|
||||
return safe or "Dokument"
|
||||
|
||||
|
||||
def group_section_blocks(section: dict) -> list[dict]:
|
||||
"""Turn the flat (kind, text) line list into HTML-friendly blocks:
|
||||
consecutive bullets become one {'type':'list','items':[...]}, paragraphs
|
||||
become {'type':'paragraph','text':...}."""
|
||||
blocks: list[dict] = []
|
||||
bullets: list[str] = []
|
||||
|
||||
def flush_bullets():
|
||||
if bullets:
|
||||
blocks.append({"type": "list", "items": list(bullets)})
|
||||
bullets.clear()
|
||||
|
||||
for kind, text in section.get("lines", []):
|
||||
if kind == "bullet":
|
||||
bullets.append(text)
|
||||
elif kind == "paragraph":
|
||||
flush_bullets()
|
||||
blocks.append({"type": "paragraph", "text": text})
|
||||
# blank lines are visual only — ignored in the structured shape
|
||||
flush_bullets()
|
||||
return blocks
|
||||
100
vendor/platform/core/retry.py
vendored
Normal file
100
vendor/platform/core/retry.py
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Lightweight retry helpers for transient provider failures.
|
||||
|
||||
The transcription (Mistral) and e-mail (SMTP) call sites had no retry at all, so
|
||||
a brief provider outage aborted the whole job. These helpers add exponential
|
||||
backoff with jitter for *transient* failures only — network errors, timeouts and
|
||||
5xx/429 — while failing fast on permanent errors (bad request, auth, payload),
|
||||
where retrying cannot help.
|
||||
|
||||
The OpenAI SDK used for Cortecs already retries internally (configured via
|
||||
LLM_MAX_RETRIES at the client); this module covers the two raw call sites.
|
||||
|
||||
All knobs are env-tunable so they can be adjusted without a redeploy:
|
||||
RETRY_MAX_ATTEMPTS total attempts incl. the first (default 4)
|
||||
RETRY_BASE_DELAY base backoff in seconds (default 1.0)
|
||||
RETRY_MAX_DELAY cap per wait in seconds (default 30.0)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
RETRY_MAX_ATTEMPTS = int(os.getenv("RETRY_MAX_ATTEMPTS", "4"))
|
||||
RETRY_BASE_DELAY = float(os.getenv("RETRY_BASE_DELAY", "1.0"))
|
||||
RETRY_MAX_DELAY = float(os.getenv("RETRY_MAX_DELAY", "30.0"))
|
||||
|
||||
# Transient HTTP statuses — worth retrying.
|
||||
RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
||||
# Network-level failures before any HTTP response arrives.
|
||||
RETRYABLE_HTTP_EXC = (requests.exceptions.ConnectionError, requests.exceptions.Timeout)
|
||||
|
||||
|
||||
def _backoff_delay(attempt: int, retry_after: float | None) -> float:
|
||||
"""Seconds to wait before the next attempt (0-indexed)."""
|
||||
if retry_after is not None:
|
||||
return min(retry_after, RETRY_MAX_DELAY)
|
||||
# exponential backoff with full jitter
|
||||
ceiling = min(RETRY_BASE_DELAY * (2 ** attempt), RETRY_MAX_DELAY)
|
||||
return random.uniform(0, ceiling)
|
||||
|
||||
|
||||
def _retry_after_seconds(resp: requests.Response) -> float | None:
|
||||
val = resp.headers.get("Retry-After")
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
return float(val) # delta-seconds form
|
||||
except ValueError:
|
||||
return None # HTTP-date form — fall back to backoff
|
||||
|
||||
|
||||
def request_with_retry(do_request, *, label: str = "request") -> requests.Response:
|
||||
"""Call ``do_request()`` (a zero-arg callable returning a ``requests.Response``)
|
||||
with retries on transient network errors and 5xx/429.
|
||||
|
||||
The caller still inspects the returned response for non-retryable ``>= 400``
|
||||
statuses. ``do_request`` must rebuild any per-attempt state (e.g. re-open the
|
||||
upload file) so each retry sends a complete request.
|
||||
"""
|
||||
for attempt in range(RETRY_MAX_ATTEMPTS):
|
||||
last = attempt == RETRY_MAX_ATTEMPTS - 1
|
||||
try:
|
||||
resp = do_request()
|
||||
except RETRYABLE_HTTP_EXC as exc:
|
||||
if last:
|
||||
raise
|
||||
delay = _backoff_delay(attempt, None)
|
||||
print(f"[retry] {label}: {type(exc).__name__} "
|
||||
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if resp.status_code in RETRYABLE_STATUS and not last:
|
||||
delay = _backoff_delay(attempt, _retry_after_seconds(resp))
|
||||
print(f"[retry] {label}: HTTP {resp.status_code} "
|
||||
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
|
||||
try:
|
||||
resp.close()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(delay)
|
||||
continue
|
||||
return resp
|
||||
# Unreachable: the loop either returns a response or raises on the last attempt.
|
||||
raise RuntimeError(f"{label}: retry loop ended without a result")
|
||||
|
||||
|
||||
def call_with_retry(fn, *, retry_on: tuple[type[BaseException], ...], label: str = "call"):
|
||||
"""Retry an arbitrary callable on the given (transient) exception types."""
|
||||
for attempt in range(RETRY_MAX_ATTEMPTS):
|
||||
try:
|
||||
return fn()
|
||||
except retry_on as exc:
|
||||
if attempt == RETRY_MAX_ATTEMPTS - 1:
|
||||
raise
|
||||
delay = _backoff_delay(attempt, None)
|
||||
print(f"[retry] {label}: {type(exc).__name__} "
|
||||
f"(Versuch {attempt + 1}/{RETRY_MAX_ATTEMPTS}), warte {delay:.1f}s")
|
||||
time.sleep(delay)
|
||||
928
vendor/platform/core/schema/tenant_schema.json
vendored
Normal file
928
vendor/platform/core/schema/tenant_schema.json
vendored
Normal file
@@ -0,0 +1,928 @@
|
||||
{
|
||||
"contract": 1,
|
||||
"description": "Tenant-Schema des Onyscribe-Cores. Quelle der Wahrheit für Admin-Plattform (Formulare, Validierung, Deploy-Env).",
|
||||
"ignored_keys": [
|
||||
"deploy"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"path": "name",
|
||||
"type": "string",
|
||||
"group": "Marke",
|
||||
"label": "Name",
|
||||
"help": "Anzeigename des Kunden (Web-UI, E-Mail, PDF-Fallback).",
|
||||
"default": null,
|
||||
"since": 1,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"path": "title",
|
||||
"type": "string",
|
||||
"group": "Marke",
|
||||
"label": "Seitentitel (Browser-Tab)",
|
||||
"help": "Standard: „<Name> – Gesprächsprotokoll“.",
|
||||
"default": null,
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "description",
|
||||
"type": "string",
|
||||
"group": "Marke",
|
||||
"label": "Beschreibung",
|
||||
"help": "Meta-Beschreibung der Web-UI. Standard: „Gesprächsprotokoll-Tool für <Name>“.",
|
||||
"default": null,
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.accent",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Akzent",
|
||||
"help": "",
|
||||
"default": "#327F24",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.accent_dark",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Akzent dunkel",
|
||||
"help": "",
|
||||
"default": "#235A19",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.accent_mid",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Akzent mittel",
|
||||
"help": "",
|
||||
"default": "#3E9130",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.accent_light",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Akzent hell",
|
||||
"help": "",
|
||||
"default": "#EAF4E7",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.accent_xlight",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Akzent extra hell",
|
||||
"help": "",
|
||||
"default": "#F3FAF1",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.bg",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Seitenhintergrund",
|
||||
"help": "",
|
||||
"default": "#F1F5F0",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.surface",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Karten",
|
||||
"help": "",
|
||||
"default": "#FFFFFF",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.surface2",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Karten 2",
|
||||
"help": "",
|
||||
"default": "#F6FAF5",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.border",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Rahmen",
|
||||
"help": "",
|
||||
"default": "#DCE8D9",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.border2",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Rahmen 2",
|
||||
"help": "",
|
||||
"default": "#BDD1B8",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.text",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Text",
|
||||
"help": "",
|
||||
"default": "#444444",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.text_2",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Text sekundär",
|
||||
"help": "",
|
||||
"default": "#666666",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.text_3",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Text tertiär",
|
||||
"help": "",
|
||||
"default": "#9AA89A",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.red",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Rot (Hinweis)",
|
||||
"help": "",
|
||||
"default": "#DC2626",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "colors.progress_to",
|
||||
"type": "color",
|
||||
"group": "Farben",
|
||||
"label": "Fortschritt (Verlauf-Ende)",
|
||||
"help": "",
|
||||
"default": "#8bbf43",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "office.name",
|
||||
"type": "string",
|
||||
"group": "Absender",
|
||||
"label": "Name",
|
||||
"help": "Kopf-/Fußzeile von PDF, Word und E-Mail.",
|
||||
"default": "",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "office.subtitle",
|
||||
"type": "string",
|
||||
"group": "Absender",
|
||||
"label": "Zusatz (Rechtsform)",
|
||||
"help": "",
|
||||
"default": "",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "office.address",
|
||||
"type": "text",
|
||||
"group": "Absender",
|
||||
"label": "Adresse",
|
||||
"help": "Mehrzeilig möglich.",
|
||||
"default": "",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "office.tel",
|
||||
"type": "string",
|
||||
"group": "Absender",
|
||||
"label": "Telefon",
|
||||
"help": "",
|
||||
"default": "",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "office.email",
|
||||
"type": "string",
|
||||
"group": "Absender",
|
||||
"label": "E-Mail",
|
||||
"help": "",
|
||||
"default": "",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "office.website",
|
||||
"type": "string",
|
||||
"group": "Absender",
|
||||
"label": "Website",
|
||||
"help": "",
|
||||
"default": "",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "pdf_projekt_label",
|
||||
"type": "string",
|
||||
"group": "PDF",
|
||||
"label": "Bezeichnung „Projekt“",
|
||||
"help": "Beschriftung des Projekt-Feldes im PDF (z. B. „Thema“).",
|
||||
"default": "Projekt",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "pdf_footer_note",
|
||||
"type": "text",
|
||||
"group": "PDF",
|
||||
"label": "Fußzeilen-Hinweis",
|
||||
"help": "Platzhalter {year} = aktuelles Jahr.",
|
||||
"default": "Internes Arbeitsdokument. Inhalte sind vertraulich und ausschließlich für die genannten Empfänger bestimmt.",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "ai_detect_org",
|
||||
"type": "string",
|
||||
"group": "Prompt-Identität",
|
||||
"label": "Organisation (voll)",
|
||||
"help": "Ersetzt @@ORG@@ im Standard-Prompt. Standard: Name.",
|
||||
"default": null,
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "org_short",
|
||||
"type": "string",
|
||||
"group": "Prompt-Identität",
|
||||
"label": "Kurzname im Protokolltext",
|
||||
"help": "Ersetzt @@ORG_SHORT@@. Standard: Name.",
|
||||
"default": null,
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "org_profile",
|
||||
"type": "text",
|
||||
"group": "Prompt-Identität",
|
||||
"label": "Profil (ein Satz)",
|
||||
"help": "Ersetzt @@ORG_PROFILE@@ – was die Organisation macht.",
|
||||
"default": "",
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "flags.ui_kontakt_field",
|
||||
"type": "bool",
|
||||
"group": "Funktion",
|
||||
"label": "Feld „Ansprechpartner Kunde“",
|
||||
"help": "Aus: Feld in der Web-UI ausgeblendet (z. B. bei Vereinen).",
|
||||
"default": true,
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "flags.multi_audio",
|
||||
"type": "bool",
|
||||
"group": "Funktion",
|
||||
"label": "Mehrere Audiodateien (bis 5)",
|
||||
"help": "Aus: nur eine Datei pro Protokoll.",
|
||||
"default": true,
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "stt.provider",
|
||||
"type": "enum",
|
||||
"group": "Spracherkennung",
|
||||
"label": "Provider",
|
||||
"help": "Env STT_PROVIDER überschreibt. Bei „cortecs“ genügt der geteilte CORTECS_API_KEY.",
|
||||
"default": "mistral",
|
||||
"since": 1,
|
||||
"options": [
|
||||
"mistral",
|
||||
"cortecs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "stt.model",
|
||||
"type": "string",
|
||||
"group": "Spracherkennung",
|
||||
"label": "Modell",
|
||||
"help": "Env STT_MODEL überschreibt. Standard je Provider.",
|
||||
"default": null,
|
||||
"since": 1
|
||||
},
|
||||
{
|
||||
"path": "stt.cost_per_minute",
|
||||
"type": "number",
|
||||
"group": "Spracherkennung",
|
||||
"label": "Kosten pro Minute (EUR)",
|
||||
"help": "Nur für die Kostenerfassung; Standard je Provider.",
|
||||
"default": null,
|
||||
"since": 1,
|
||||
"advanced": true
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "prompt.txt",
|
||||
"kind": "text",
|
||||
"label": "Prompt",
|
||||
"default": "defaults/prompt.txt",
|
||||
"help": "Platzhalter @@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@. Ohne Datei gilt der Standard."
|
||||
},
|
||||
{
|
||||
"path": "protocol.html",
|
||||
"kind": "text",
|
||||
"label": "PDF-Layout (HTML/Jinja)",
|
||||
"default": "templates/protocol.html"
|
||||
},
|
||||
{
|
||||
"path": "protocol.css",
|
||||
"kind": "text",
|
||||
"label": "PDF-Styling (CSS/Jinja)",
|
||||
"default": "templates/protocol.css"
|
||||
},
|
||||
{
|
||||
"path": "overrides.py",
|
||||
"kind": "code",
|
||||
"label": "Hook-Code",
|
||||
"help": "Klasse Hooks(TenantHooks); Hooks siehe „hooks“. Optional HOOK_API = 1."
|
||||
},
|
||||
{
|
||||
"path": "logo.svg",
|
||||
"kind": "image",
|
||||
"label": "Logo (SVG)",
|
||||
"help": "Web-UI und PDF."
|
||||
},
|
||||
{
|
||||
"path": "logo.png",
|
||||
"kind": "image",
|
||||
"label": "Logo (PNG)",
|
||||
"help": "Word-Export braucht ein Rasterbild."
|
||||
},
|
||||
{
|
||||
"path": "favicon.svg",
|
||||
"kind": "image",
|
||||
"label": "Favicon (SVG)"
|
||||
}
|
||||
],
|
||||
"extra_files": true,
|
||||
"hooks": [
|
||||
{
|
||||
"name": "build_prompt",
|
||||
"help": "Prompt vor dem LLM-Aufruf anpassen (base_prompt, ctx)."
|
||||
},
|
||||
{
|
||||
"name": "transform_transcript",
|
||||
"help": "Transkript nachbearbeiten."
|
||||
},
|
||||
{
|
||||
"name": "pdf_context",
|
||||
"help": "Daten fürs PDF-Template ändern."
|
||||
},
|
||||
{
|
||||
"name": "extra_routes",
|
||||
"help": "Zusätzliche FastAPI-Routen registrieren."
|
||||
}
|
||||
],
|
||||
"prompt_placeholders": [
|
||||
"@@ORG@@",
|
||||
"@@ORG_SHORT@@",
|
||||
"@@ORG_PROFILE@@"
|
||||
],
|
||||
"env": [
|
||||
{
|
||||
"name": "OIDC_ISSUER",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "OIDC-Issuer",
|
||||
"help": "Login-System der Tenant-Container.",
|
||||
"default": "https://login.onyva.dev",
|
||||
"group": "Login",
|
||||
"setting": "oidc_issuer"
|
||||
},
|
||||
{
|
||||
"name": "AUTH_ENABLED",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Login aktiv",
|
||||
"help": "Aus nur für lokale Entwicklung.",
|
||||
"default": "true",
|
||||
"group": "Login",
|
||||
"setting": "auth_enabled",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"name": "APP_COOKIE_SECURE",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Cookies „secure“",
|
||||
"help": "Hinter HTTPS immer true.",
|
||||
"default": "false",
|
||||
"group": "Login",
|
||||
"setting": "app_cookie_secure",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"name": "MAX_FILE_SIZE_MB",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Max. Dateigröße (MB)",
|
||||
"help": "",
|
||||
"default": "300",
|
||||
"group": "Limits",
|
||||
"setting": "max_file_size_mb",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "STT_MAX_AUDIO_MINUTES",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Max. Audiolänge (Minuten)",
|
||||
"help": "Grenze von Voxtral Mini Transcribe V2: 180.",
|
||||
"default": "180",
|
||||
"group": "Limits",
|
||||
"setting": "stt_max_audio_minutes",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "LLM_MODEL",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Protokoll-Modell",
|
||||
"help": "Modell-ID bei Cortecs.",
|
||||
"default": "claude-4-6-sonnet",
|
||||
"group": "LLM",
|
||||
"setting": "llm_model"
|
||||
},
|
||||
{
|
||||
"name": "LLM_MAX_TOKENS",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Max. Ausgabe-Tokens",
|
||||
"help": "",
|
||||
"default": "16000",
|
||||
"group": "LLM",
|
||||
"setting": "llm_max_tokens",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "LLM_TEMPERATURE",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Temperatur",
|
||||
"help": "",
|
||||
"default": "0.2",
|
||||
"group": "LLM",
|
||||
"setting": "llm_temperature",
|
||||
"type": "float"
|
||||
},
|
||||
{
|
||||
"name": "LLM_MAX_RETRIES",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Wiederholungen (SDK)",
|
||||
"help": "",
|
||||
"default": "4",
|
||||
"group": "LLM",
|
||||
"setting": "llm_max_retries",
|
||||
"type": "int",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "LLM_TIMEOUT",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Timeout (s)",
|
||||
"help": "",
|
||||
"default": "600",
|
||||
"group": "LLM",
|
||||
"setting": "llm_timeout",
|
||||
"type": "int",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "CORTECS_BASE_URL",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Cortecs-Basis-URL",
|
||||
"help": "",
|
||||
"default": "https://api.cortecs.ai/v1",
|
||||
"group": "LLM",
|
||||
"setting": "cortecs_base_url"
|
||||
},
|
||||
{
|
||||
"name": "STT_PROVIDER",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "STT-Provider (global)",
|
||||
"help": "Überschreibt tenant.json; leer = je Tenant.",
|
||||
"default": null,
|
||||
"group": "Spracherkennung",
|
||||
"setting": "stt_provider",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "STT_MODEL",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "STT-Modell (global)",
|
||||
"help": "Überschreibt tenant.json; leer = je Tenant.",
|
||||
"default": null,
|
||||
"group": "Spracherkennung",
|
||||
"setting": "stt_model",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "STT_DIARIZE",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Sprechererkennung",
|
||||
"help": "",
|
||||
"default": "true",
|
||||
"group": "Spracherkennung",
|
||||
"setting": "stt_diarize",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"name": "STT_TIMEOUT",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "STT-Timeout (s)",
|
||||
"help": "Wächst zusätzlich mit der Audiolänge.",
|
||||
"default": "600",
|
||||
"group": "Spracherkennung",
|
||||
"setting": "stt_timeout",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "MISTRAL_TRANSCRIBE_MODEL",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Mistral-Modell (Legacy)",
|
||||
"help": "Wirkt nur für Tenants mit Provider mistral.",
|
||||
"default": "voxtral-mini-latest",
|
||||
"group": "Spracherkennung",
|
||||
"setting": "mistral_transcribe_model",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "MISTRAL_DIARIZE",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Mistral Diarize (Legacy)",
|
||||
"help": "",
|
||||
"default": "true",
|
||||
"group": "Spracherkennung",
|
||||
"setting": "mistral_diarize",
|
||||
"type": "bool",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "MISTRAL_TIMEOUT",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Mistral-Timeout (Legacy)",
|
||||
"help": "",
|
||||
"default": "600",
|
||||
"group": "Spracherkennung",
|
||||
"setting": "mistral_timeout",
|
||||
"type": "int",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "SMTP_HOST",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "SMTP-Host",
|
||||
"help": "",
|
||||
"default": "",
|
||||
"group": "E-Mail",
|
||||
"setting": "smtp_host"
|
||||
},
|
||||
{
|
||||
"name": "SMTP_PORT",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "SMTP-Port",
|
||||
"help": "",
|
||||
"default": "587",
|
||||
"group": "E-Mail",
|
||||
"setting": "smtp_port",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "SMTP_USER",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "SMTP-Benutzer",
|
||||
"help": "",
|
||||
"default": "",
|
||||
"group": "E-Mail",
|
||||
"setting": "smtp_user"
|
||||
},
|
||||
{
|
||||
"name": "SMTP_FROM",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Absendername",
|
||||
"help": "",
|
||||
"default": "",
|
||||
"group": "E-Mail",
|
||||
"setting": "smtp_from"
|
||||
},
|
||||
{
|
||||
"name": "COST_API_BASE",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Cost-API-Basis",
|
||||
"help": "Standard: <OIDC_ISSUER>/api/cost.",
|
||||
"default": "",
|
||||
"group": "Kosten",
|
||||
"setting": "cost_api_base"
|
||||
},
|
||||
{
|
||||
"name": "COST_TRACKING_ENABLED",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Kostenerfassung aktiv",
|
||||
"help": "",
|
||||
"default": "true",
|
||||
"group": "Kosten",
|
||||
"setting": "cost_tracking_enabled",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"name": "COST_LLM_INPUT_PER_MTOK",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "LLM-Preis Eingabe (EUR / Mio. Tokens)",
|
||||
"help": "",
|
||||
"default": "2.87",
|
||||
"group": "Kosten",
|
||||
"setting": "cost_llm_input_per_mtok",
|
||||
"type": "float",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "COST_LLM_OUTPUT_PER_MTOK",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "LLM-Preis Ausgabe (EUR / Mio. Tokens)",
|
||||
"help": "",
|
||||
"default": "14.31",
|
||||
"group": "Kosten",
|
||||
"setting": "cost_llm_output_per_mtok",
|
||||
"type": "float",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "COST_STT_PER_MINUTE",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "STT-Preis (EUR / Minute, global)",
|
||||
"help": "Nicht global setzen, wenn Tenants verschiedene Provider nutzen.",
|
||||
"default": null,
|
||||
"group": "Kosten",
|
||||
"setting": "cost_stt_per_minute",
|
||||
"type": "float",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "COST_FLUSH_INTERVAL",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Cost-Flush-Intervall (s)",
|
||||
"help": "",
|
||||
"default": "5",
|
||||
"group": "Kosten",
|
||||
"setting": "cost_flush_interval",
|
||||
"type": "float",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "COST_HTTP_TIMEOUT",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Cost-HTTP-Timeout (s)",
|
||||
"help": "",
|
||||
"default": "5",
|
||||
"group": "Kosten",
|
||||
"setting": "cost_http_timeout",
|
||||
"type": "float",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "RETRY_MAX_ATTEMPTS",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Retry: Versuche",
|
||||
"help": "",
|
||||
"default": "4",
|
||||
"group": "Robustheit",
|
||||
"setting": "retry_max_attempts",
|
||||
"type": "int",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "RETRY_BASE_DELAY",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Retry: Basis-Wartezeit (s)",
|
||||
"help": "",
|
||||
"default": "1.0",
|
||||
"group": "Robustheit",
|
||||
"setting": "retry_base_delay",
|
||||
"type": "float",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "RETRY_MAX_DELAY",
|
||||
"kind": "setting",
|
||||
"scope": "shared",
|
||||
"label": "Retry: maximale Wartezeit (s)",
|
||||
"help": "",
|
||||
"default": "30.0",
|
||||
"group": "Robustheit",
|
||||
"setting": "retry_max_delay",
|
||||
"type": "float",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "CORTECS_API_KEY",
|
||||
"kind": "secret",
|
||||
"scope": "shared",
|
||||
"label": "Cortecs-API-Key",
|
||||
"help": "LLM und (bei Provider cortecs) STT.",
|
||||
"default": null,
|
||||
"group": "Secrets",
|
||||
"setting": "cortecs_api_key",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "SMTP_PASSWORD",
|
||||
"kind": "secret",
|
||||
"scope": "shared",
|
||||
"label": "SMTP-Passwort",
|
||||
"help": "",
|
||||
"default": null,
|
||||
"group": "Secrets",
|
||||
"setting": "smtp_password"
|
||||
},
|
||||
{
|
||||
"name": "MISTRAL_API_KEY",
|
||||
"kind": "secret",
|
||||
"scope": "tenant",
|
||||
"label": "Mistral-API-Key",
|
||||
"help": "Nur bei STT-Provider mistral.",
|
||||
"default": null,
|
||||
"group": "Secrets",
|
||||
"setting": "mistral_api_key",
|
||||
"required_if": "stt.provider!=cortecs"
|
||||
},
|
||||
{
|
||||
"name": "OIDC_CLIENT_ID",
|
||||
"kind": "secret",
|
||||
"scope": "tenant",
|
||||
"label": "OIDC-Client-ID",
|
||||
"help": "Wird vom Admin über die Login-API verwaltet.",
|
||||
"default": null,
|
||||
"group": "Secrets",
|
||||
"setting": "oidc_client_id",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "OIDC_CLIENT_SECRET",
|
||||
"kind": "secret",
|
||||
"scope": "tenant",
|
||||
"label": "OIDC-Client-Secret",
|
||||
"help": "",
|
||||
"default": null,
|
||||
"group": "Secrets",
|
||||
"setting": "oidc_client_secret",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "SESSION_SECRET",
|
||||
"kind": "secret",
|
||||
"scope": "tenant",
|
||||
"label": "Session-Secret",
|
||||
"help": "Signiert Session-Cookies.",
|
||||
"default": null,
|
||||
"group": "Secrets",
|
||||
"setting": "session_secret",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "COST_INGEST_KEY",
|
||||
"kind": "secret",
|
||||
"scope": "tenant",
|
||||
"label": "Cost-Ingest-Key",
|
||||
"help": "Schlüssel für die Kostenerfassung.",
|
||||
"default": null,
|
||||
"group": "Secrets",
|
||||
"setting": "cost_ingest_key",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "TENANT",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "Tenant-ID",
|
||||
"help": "= tenant.id",
|
||||
"default": null,
|
||||
"group": "Laufzeit",
|
||||
"setting": "tenant"
|
||||
},
|
||||
{
|
||||
"name": "DATA_DIR",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "Datenverzeichnis",
|
||||
"help": "Volume des Containers.",
|
||||
"default": "/data",
|
||||
"group": "Laufzeit",
|
||||
"setting": "data_dir"
|
||||
},
|
||||
{
|
||||
"name": "PORT",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "Port",
|
||||
"help": "",
|
||||
"default": "8276",
|
||||
"group": "Laufzeit",
|
||||
"setting": "port"
|
||||
},
|
||||
{
|
||||
"name": "OIDC_REDIRECT_URI",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "OIDC-Redirect-URI",
|
||||
"help": "= https://<host>/auth/callback",
|
||||
"default": null,
|
||||
"group": "Laufzeit",
|
||||
"setting": "oidc_redirect_uri"
|
||||
},
|
||||
{
|
||||
"name": "OIDC_LOGOUT_REDIRECT_URI",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "OIDC-Logout-Ziel",
|
||||
"help": "= https://<host>/",
|
||||
"default": null,
|
||||
"group": "Laufzeit",
|
||||
"setting": "oidc_logout_redirect_uri"
|
||||
},
|
||||
{
|
||||
"name": "TENANT_SOURCE",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "Quelle der Tenant-Daten",
|
||||
"help": "git = ins Image gebacken (Legacy) · admin = Bundle der Admin-Plattform.",
|
||||
"default": "git",
|
||||
"group": "Laufzeit",
|
||||
"setting": "tenant_source"
|
||||
},
|
||||
{
|
||||
"name": "ADMIN_URL",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "URL der Admin-Plattform",
|
||||
"help": "Nur bei TENANT_SOURCE=admin.",
|
||||
"default": null,
|
||||
"group": "Laufzeit",
|
||||
"setting": "admin_url"
|
||||
},
|
||||
{
|
||||
"name": "ADMIN_BUNDLE_TOKEN",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "Bundle-Token",
|
||||
"help": "Pro Tenant; nur bei TENANT_SOURCE=admin.",
|
||||
"default": null,
|
||||
"group": "Laufzeit",
|
||||
"setting": "admin_bundle_token",
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "BUNDLE_CACHE_DIR",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "Bundle-Cache",
|
||||
"help": "Standard: <DATA_DIR>/bundle.",
|
||||
"default": null,
|
||||
"group": "Laufzeit",
|
||||
"setting": "bundle_cache_dir",
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"name": "BUNDLE_FALLBACK_BAKED",
|
||||
"kind": "runtime",
|
||||
"scope": "tenant",
|
||||
"label": "Rückfall auf eingebackenen Tenant",
|
||||
"help": "Nur Migrationszeit; danach false.",
|
||||
"default": "true",
|
||||
"group": "Laufzeit",
|
||||
"setting": "bundle_fallback_baked",
|
||||
"type": "bool",
|
||||
"advanced": true
|
||||
}
|
||||
]
|
||||
}
|
||||
2505
vendor/platform/core/templates/index.template.html
vendored
Normal file
2505
vendor/platform/core/templates/index.template.html
vendored
Normal file
File diff suppressed because it is too large
Load Diff
112
vendor/platform/core/templates/protocol.css
vendored
Normal file
112
vendor/platform/core/templates/protocol.css
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
/* Default PDF stylesheet. Rendered through Jinja, so colour tokens like
|
||||
colors.accent (in double curly braces) from the tenant config are available.
|
||||
Copy this file into a tenant folder and edit freely to give a customer a
|
||||
completely individual PDF design. */
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 26mm 22mm 24mm 22mm;
|
||||
|
||||
@top-left {
|
||||
content: element(runHdrText);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@top-right {
|
||||
content: element(runHdrLogo);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@bottom-left {
|
||||
content: element(runFtr);
|
||||
vertical-align: top;
|
||||
}
|
||||
@bottom-right {
|
||||
content: "Seite " counter(page, decimal-leading-zero) " / " counter(pages, decimal-leading-zero);
|
||||
vertical-align: top;
|
||||
font-size: 7.5pt;
|
||||
font-style: italic;
|
||||
color: {{ colors.text_3 }};
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Noto Sans", "DejaVu Sans", Arial, sans-serif;
|
||||
font-size: 9.8pt;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── running header / footer ── */
|
||||
.run-hdr-text {
|
||||
position: running(runHdrText);
|
||||
font-style: italic;
|
||||
font-size: 9pt;
|
||||
color: {{ colors.text_2 }};
|
||||
}
|
||||
.run-hdr-logo {
|
||||
position: running(runHdrLogo);
|
||||
height: 12mm;
|
||||
}
|
||||
.run-ftr {
|
||||
position: running(runFtr);
|
||||
font-size: 7pt;
|
||||
line-height: 1.4;
|
||||
color: {{ colors.text_2 }};
|
||||
}
|
||||
|
||||
/* ── optional document title (tenant sets doc_title) ── */
|
||||
.doc-title {
|
||||
color: {{ colors.accent_dark }};
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
margin: 0 0 3mm 0;
|
||||
}
|
||||
|
||||
/* ── metadata block ── */
|
||||
.meta {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2mm 0 6mm 0;
|
||||
}
|
||||
.meta-left { width: 50%; vertical-align: top; padding: 0; }
|
||||
.meta-right { width: 50%; vertical-align: top; text-align: right; padding: 0; }
|
||||
.meta-label {
|
||||
color: {{ colors.accent }};
|
||||
font-style: italic;
|
||||
font-size: 11pt;
|
||||
margin-bottom: 1.8mm;
|
||||
}
|
||||
.meta-sublabel {
|
||||
color: {{ colors.accent }};
|
||||
font-weight: 700;
|
||||
font-size: 9.5pt;
|
||||
margin: 3mm 0 1mm 0;
|
||||
}
|
||||
.meta-val {
|
||||
font-size: 10.2pt;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── content sections ── */
|
||||
.proto-section { margin-bottom: 3.5mm; }
|
||||
.section-bar {
|
||||
background: {{ colors.accent_light }};
|
||||
color: {{ colors.accent_dark }};
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
padding: 1.8mm 2.5mm;
|
||||
margin: 0 0 2.5mm 0;
|
||||
break-after: avoid;
|
||||
}
|
||||
.para {
|
||||
margin: 0 0 1.8mm 6mm;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bullets {
|
||||
margin: 0 0 1.8mm 0;
|
||||
padding-left: 10mm;
|
||||
list-style: disc;
|
||||
}
|
||||
.bullets li {
|
||||
margin: 0 0 0.8mm 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
59
vendor/platform/core/templates/protocol.html
vendored
Normal file
59
vendor/platform/core/templates/protocol.html
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Gesprächsprotokoll {{ terminart }} – {{ project_display }}</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{# ── running header/footer: pulled into the @page margin boxes by the CSS ── #}
|
||||
<div class="run-hdr-text">{{ terminart }} | Gesprächsprotokoll</div>
|
||||
{% if logo_data_uri %}<img class="run-hdr-logo" src="{{ logo_data_uri }}" alt="{{ tenant.name }}">{% endif %}
|
||||
<div class="run-ftr">
|
||||
<div>Gesprächsprotokoll · {{ terminart }} · {{ date_de }} · {{ project_display }}</div>
|
||||
<div>Erstellt durch: {{ office.name }}{% if office.subtitle %} {{ office.subtitle }}{% endif %}{% if office.tel %} • {{ office.tel }}{% endif %}{% if office.email %} • {{ office.email }}{% endif %}</div>
|
||||
<div>{{ footer_note }}</div>
|
||||
</div>
|
||||
|
||||
{% if doc_title %}<h1 class="doc-title">{{ doc_title }}</h1>{% endif %}
|
||||
|
||||
{# ── metadata: two columns (Projekt / Erstellt durch) ── #}
|
||||
<table class="meta">
|
||||
<tr>
|
||||
<td class="meta-left">
|
||||
<div class="meta-label">{{ projekt_label }}</div>
|
||||
{% for line in projekt %}<div class="meta-val">{{ line }}</div>{% endfor %}
|
||||
{% if kontakt %}
|
||||
<div class="meta-sublabel">Ansprechpartner Kunde</div>
|
||||
{% for line in kontakt %}<div class="meta-val">{{ line }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="meta-right">
|
||||
<div class="meta-label">Erstellt durch</div>
|
||||
{% if office.name %}<div class="meta-val">{{ office.name }}{% if office.subtitle %} {{ office.subtitle }}{% endif %}</div>{% endif %}
|
||||
{% for line in (office.address or "").splitlines() %}<div class="meta-val">{{ line }}</div>{% endfor %}
|
||||
{% if office.tel %}<div class="meta-val">{{ office.tel }}</div>{% endif %}
|
||||
{% if office.email %}<div class="meta-val">{{ office.email }}</div>{% endif %}
|
||||
{% if office.website %}<div class="meta-val">{{ office.website }}</div>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{# ── content sections ── #}
|
||||
{% for section in sections %}
|
||||
<section class="proto-section">
|
||||
<h2 class="section-bar">{{ section.heading }}</h2>
|
||||
{% for block in section.blocks %}
|
||||
{% if block.type == 'paragraph' %}
|
||||
<p class="para">{{ block.text }}</p>
|
||||
{% elif block.type == 'list' %}
|
||||
<ul class="bullets">
|
||||
{% for item in block['items'] %}<li>{{ item }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
12
vendor/platform/main.py
vendored
Normal file
12
vendor/platform/main.py
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Entry point. The tenant is selected via the TENANT env var (one instance
|
||||
per customer). Run e.g.:
|
||||
|
||||
TENANT=sportklinik python main.py
|
||||
"""
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.getenv("PORT", "8276"))
|
||||
uvicorn.run("core.app:app", host="0.0.0.0", port=port, reload=True)
|
||||
13
vendor/platform/requirements.txt
vendored
Normal file
13
vendor/platform/requirements.txt
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# System-Abhängigkeiten:
|
||||
# ffmpeg (Audio-Konvertierung; nur für Azure-STT nötig)
|
||||
# libpango / libcairo etc. (WeasyPrint PDF-Rendering)
|
||||
# Debian/Ubuntu: apt-get install ffmpeg libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-core
|
||||
fastapi==0.135.1
|
||||
openai==2.46.0
|
||||
python-multipart==0.0.22
|
||||
uvicorn==0.41.0
|
||||
python-dotenv==1.2.2
|
||||
requests>=2.31.0
|
||||
jinja2>=3.1.0
|
||||
weasyprint>=60.0
|
||||
python-docx>=1.1.0
|
||||
Reference in New Issue
Block a user