Files
Onyscribe-Admin/app/agent/backends.py
RubenRWU 7d8c226da2 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>
2026-10-15 15:30:26 +02:00

130 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""LLM-Backends für den Agenten. Interne Nachrichten im Anthropic-Block-Format:
assistant: [{"type":"text"|"tool_use",...}] · user: str | [{"type":"tool_result","tool_use_id","content"}]
"""
from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass, field
@dataclass
class Turn:
blocks: list[dict]
usage: dict = field(default_factory=dict)
class MessagesBackend:
"""Anthropic-Messages-Protokoll – Cortecs bietet es unter https://api.cortecs.ai/v1/messages an."""
name = "messages"
def __init__(self, api_key: str, base_url: str, model: str):
import anthropic
root = re.sub(r"/v1/?$", "", base_url.rstrip("/")) # SDK hängt /v1/messages selbst an
self.client = anthropic.Anthropic(api_key=api_key, auth_token=api_key, base_url=root, max_retries=2, timeout=180)
self.model = model
def step(self, system: str, messages: list[dict], tools: list[dict]) -> Turn:
r = self.client.messages.create(
model=self.model, max_tokens=8000, system=system, messages=messages,
tools=[{"name": t["name"], "description": t["description"], "input_schema": t["schema"]} for t in tools])
blocks = []
for b in r.content:
if b.type == "text":
blocks.append({"type": "text", "text": b.text})
elif b.type == "tool_use":
blocks.append({"type": "tool_use", "id": b.id, "name": b.name, "input": b.input})
return Turn(blocks, {"in": r.usage.input_tokens, "out": r.usage.output_tokens})
class ChatBackend:
"""OpenAI-Chat-Completions (Fallback, falls ein Modell nur diesen Weg mit Tools kann)."""
name = "chat"
def __init__(self, api_key: str, base_url: str, model: str):
from openai import OpenAI
self.client = OpenAI(api_key=api_key, base_url=base_url, max_retries=2, timeout=180)
self.model = model
@staticmethod
def _convert(system: str, messages: list[dict]) -> list[dict]:
out = [{"role": "system", "content": system}]
for m in messages:
c = m["content"]
if isinstance(c, str):
out.append({"role": m["role"], "content": c})
elif m["role"] == "assistant":
text = "".join(b["text"] for b in c if b["type"] == "text")
calls = [{"id": b["id"], "type": "function", "function": {"name": b["name"], "arguments": json.dumps(b["input"])}}
for b in c if b["type"] == "tool_use"]
msg = {"role": "assistant", "content": text or None}
if calls:
msg["tool_calls"] = calls
out.append(msg)
else:
for b in c:
out.append({"role": "tool", "tool_call_id": b["tool_use_id"], "content": b["content"]})
return out
def step(self, system, messages, tools) -> Turn:
r = self.client.chat.completions.create(
model=self.model, max_tokens=8000, temperature=0.2, messages=self._convert(system, messages),
tools=[{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["schema"]}} for t in tools])
msg = r.choices[0].message
blocks = []
if msg.content:
blocks.append({"type": "text", "text": msg.content})
for tc in msg.tool_calls or []:
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
blocks.append({"type": "tool_use", "id": tc.id, "name": tc.function.name, "input": args})
u = r.usage
return Turn(blocks, {"in": getattr(u, "prompt_tokens", 0), "out": getattr(u, "completion_tokens", 0)})
HEX = re.compile(r"#[0-9a-fA-F]{6}\b")
def _shade(h, f):
h = h.lstrip("#"); c = [max(0, min(255, int(int(h[i:i + 2], 16) * f))) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(c)
def _tint(h, a):
h = h.lstrip("#"); c = [int(int(h[i:i + 2], 16) + (255 - int(h[i:i + 2], 16)) * a) for i in (0, 2, 4)]
return "#%02X%02X%02X" % tuple(c)
class MockBackend:
"""Demo ohne Cortecs-Key: spielt über dieselben Tools zwei einfache Aufgaben durch."""
name = "mock"
model = "mock"
def step(self, system, messages, tools) -> Turn:
used = sum(1 for m in messages if m["role"] == "assistant")
user = next(m["content"] for m in messages if m["role"] == "user" and isinstance(m["content"], str))
low = user.lower()
tid = lambda: "mock_" + uuid.uuid4().hex[:8] # noqa: E731
call = lambda name, **a: {"type": "tool_use", "id": tid(), "name": name, "input": a} # noqa: E731
hexes = HEX.findall(user)
if hexes and any(w in low for w in ("farbe", "akzent", "color", "design")):
a = hexes[0].upper()
plan = [[call("set_config", key=f"colors.{k}", value=v) for k, v in
{"accent": a, "accent_dark": _shade(a, .72), "accent_mid": _shade(a, 1.12),
"accent_light": _tint(a, .88), "accent_xlight": _tint(a, .94)}.items()],
[call("validate")]]
final = f"Akzentfarbe auf {a} gesetzt und die abgeleiteten Töne angepasst."
elif "prompt" in low or "regel" in low:
rule = re.sub(r"^(bitte\s+)?(füge\s+)?(dem\s+)?prompt\s*(hinzu)?[:,]?\s*", "", user.strip(), flags=re.I)
plan = [[call("read_core_default", name="prompt.txt")], [call("__append_prompt__", rule=rule)], [call("validate")]]
final = "Zusatzregel im Prompt ergänzt."
else:
return Turn([{"type": "text", "text": "Demo-Modus – es wurde kein Cortecs-Key gefunden (geprüft: Einstellungen → Geteilte Secrets, Env/.env CORTECS_API_KEY, Key des Tenants). "
"Key setzen und die Seite neu laden. Ohne Key verstehe ich nur „Setze die Akzentfarbe auf #RRGGBB“ und „Füge dem Prompt hinzu: …“."}])
if used < len(plan):
return Turn(plan[used])
return Turn([{"type": "text", "text": final}])