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

121
app/services/llm.py Normal file
View File

@@ -0,0 +1,121 @@
"""Zugang zu Cortecs (OpenAI-kompatibel + Anthropic-Messages) für Agent und Prompt-Test.
Der Key kommt aus dem geteilten Secret CORTECS_API_KEY (verschlüsselt in der DB) oder der Env CORTECS_API_KEY.
"""
from __future__ import annotations
import os
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Secret, Setting
from app.services import crypto
DEFAULT_BASE = "https://api.cortecs.ai/v1"
class LLMNotConfigured(RuntimeError):
pass
def _setting(db: Session, key: str, default: str = "") -> str:
s = db.get(Setting, key)
return s.value if s and s.value else default
def key_info(db: Session, tenant_id: str | None = None) -> tuple[str, str, str | None]:
"""(key, quelle, fehler). Reihenfolge: geteiltes Secret (DB) → Env CORTECS_API_KEY → Key des Tenants (Rückfall).
Quelle: 'shared' | 'env' | 'tenant' | 'none'. fehler: z. B. nicht entschlüsselbar (SECRET_KEY geändert?)."""
error = None
rows = [("shared", db.scalar(select(Secret).where(Secret.tenant_id.is_(None), Secret.key == "CORTECS_API_KEY")))]
for source, row in rows:
if row and row.value_enc:
try:
return crypto.decrypt(row.value_enc), source, None
except Exception: # noqa: BLE001 – falscher SECRET_KEY o. Ä.
error = "Geteilter CORTECS_API_KEY in der DB ist nicht entschlüsselbar (SECRET_KEY geändert?) – bitte neu setzen."
env = os.getenv("CORTECS_API_KEY", "").strip()
if env:
return env, "env", error
if tenant_id:
row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "CORTECS_API_KEY"))
if row and row.value_enc:
try:
return crypto.decrypt(row.value_enc), "tenant", error
except Exception: # noqa: BLE001
error = error or "CORTECS_API_KEY dieses Tenants ist nicht entschlüsselbar."
return "", "none", error
def api_key(db: Session, tenant_id: str | None = None) -> str:
return key_info(db, tenant_id)[0]
def base_url(db: Session) -> str:
return _setting(db, "cortecs_base_url", DEFAULT_BASE).rstrip("/")
def configured(db: Session, tenant_id: str | None = None) -> bool:
return bool(api_key(db, tenant_id))
def require_key(db: Session, tenant_id: str | None = None) -> str:
k, _, err = key_info(db, tenant_id)
if not k:
raise LLMNotConfigured(err or "CORTECS_API_KEY fehlt – unter Einstellungen → Geteilte Secrets setzen (oder Env CORTECS_API_KEY).")
return k
def list_models(db: Session) -> list[dict]:
"""GET /v1/models – Rohdaten von Cortecs, auf die für die Auswahl nötigen Felder reduziert."""
key = require_key(db)
r = httpx.get(f"{base_url(db)}/models", headers={"Authorization": f"Bearer {key}"}, timeout=30)
r.raise_for_status()
data = r.json()
items = data.get("data") if isinstance(data, dict) else data
out = []
for m in items or []:
if isinstance(m, str):
m = {"id": m}
caps = {k: v for k, v in m.items() if isinstance(v, (bool, list, dict)) or k in ("context_size", "context_length")}
out.append({"id": m.get("id") or m.get("name"), "raw": caps,
"tools": _supports_tools(m)})
return sorted((o for o in out if o["id"]), key=lambda o: o["id"])
def _supports_tools(m: dict) -> bool | None:
"""Cortecs dokumentiert die Feldnamen nicht eindeutig → mehrere plausible Varianten prüfen; None = unbekannt."""
flat = {}
def walk(prefix, v):
if isinstance(v, dict):
for k, x in v.items():
walk(f"{prefix}.{k}".lower(), x)
elif isinstance(v, list):
flat[prefix] = [str(i).lower() for i in v]
else:
flat[prefix] = v
walk("", m)
for k, v in flat.items():
if "tool" in k or "function" in k:
return bool(v)
if isinstance(v, list) and any("tool" in i or "function" in i for i in v):
return True
return None
def summarize(db: Session, system_prompt: str, user_content: str, model: str | None = None, tenant_id: str | None = None) -> dict:
"""Ein Protokoll-Lauf wie im Core (Chat Completions), für den Prompt-Test."""
from openai import OpenAI
client = OpenAI(api_key=require_key(db, tenant_id), base_url=base_url(db), max_retries=2, timeout=300)
model = model or _setting(db, "llm_model", "claude-4-6-sonnet")
resp = client.chat.completions.create(
model=model, temperature=float(_setting(db, "llm_temperature", "0.2")),
max_tokens=int(_setting(db, "llm_max_tokens", "16000")), stream=False,
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}],
)
text = "\n".join(c.message.content for c in resp.choices if c.message and c.message.content).strip()
u = getattr(resp, "usage", None)
return {"text": text, "model": model, "input_tokens": getattr(u, "prompt_tokens", 0), "output_tokens": getattr(u, "completion_tokens", 0),
"truncated": any(getattr(c, "finish_reason", "") in ("length", "max_tokens") for c in resp.choices)}