Onyscribe Admin: Initialer Stand

Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
RubenRWU
2026-10-15 15:30:26 +02:00
commit 7d8c226da2
89 changed files with 13975 additions and 0 deletions

1
vendor/platform/CORE_VERSION vendored Normal file
View File

@@ -0,0 +1 @@
13055b811ca0030841f41ce1f10e4f30cba1a7db admin-platform-integration

1
vendor/platform/core/BUILD_INFO.json vendored Normal file
View File

@@ -0,0 +1 @@
{"sha": "13055b811ca0030841f41ce1f10e4f30cba1a7db", "built_at": "2026-10-15T13:26:28Z"}

0
vendor/platform/core/__init__.py vendored Normal file
View 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
View 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
View File

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

111
vendor/platform/core/contract.py vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
}
]
}

File diff suppressed because it is too large Load Diff

View 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;
}

View 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 }} &nbsp;|&nbsp; 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
View 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
View 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