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>
176 lines
9.3 KiB
Python
176 lines
9.3 KiB
Python
"""Agent-Loop: Modell ↔ Werkzeuge auf dem Entwurfs-Workspace → Vorschlag zur Freigabe."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import uuid
|
||
from collections import OrderedDict
|
||
from copy import deepcopy
|
||
from typing import Iterator
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.agent import backends
|
||
from app.agent.tools import TOOLS, ToolRunner
|
||
from app.agent.workspace import Workspace
|
||
from app.models import ChatMessage, Setting, Tenant
|
||
from app.services import llm, store
|
||
|
||
MAX_TURNS = 14
|
||
|
||
# Arbeitsstände laufender/letzter Agent-Läufe (für die Live-Vorschau: source = "ws:<run_id>"). Nur im Speicher, höchstens 24.
|
||
WORKSPACES: "OrderedDict[str, tuple[dict, dict]]" = OrderedDict()
|
||
|
||
|
||
def remember(run_id: str, ws: Workspace) -> None:
|
||
WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files))
|
||
WORKSPACES.move_to_end(run_id)
|
||
while len(WORKSPACES) > 24:
|
||
WORKSPACES.popitem(last=False)
|
||
DEFAULT_MODEL = "claude-4-6-sonnet"
|
||
|
||
SYSTEM = """Du bist der Konfigurations-Agent der Onyscribe-Admin-Plattform. Onyscribe erzeugt aus Gesprächsaufzeichnungen Protokolle
|
||
(Transkription → LLM-Protokoll → PDF/Word). Jeder Kunde ist ein „Tenant“; alles, was ihn vom Standard unterscheidet, sind DATEN:
|
||
tenant.json (Name, Farben, Kontaktdaten, Flags, STT), prompt.txt, protocol.html/protocol.css (PDF-Layout), optional overrides.py
|
||
(Hook-Code) und Zusatzdateien. Der Core-Code der Plattform ist NICHT veränderbar und nicht sichtbar – du arbeitest ausschließlich an Tenant-Daten.
|
||
|
||
Arbeitsweise:
|
||
- Du arbeitest auf einem ENTWURF im Speicher. Nichts ist live; am Ende prüft ein Mensch deinen Vorschlag und gibt ihn frei.
|
||
- Lies zuerst, was du ändern willst (read_file / read_core_default). Ist eine Datei nicht im Tenant, gilt der Core-Standard – lege bei Bedarf eine eigene Kopie an (write_file) und ändere diese.
|
||
- Ändere minimal und gezielt (edit_file statt write_file, wenn möglich). Behalte bestehende Prompt-Struktur, Platzhalter (@@ORG@@, @@ORG_SHORT@@, @@ORG_PROFILE@@) und Metadatenblöcke (===TERMINART=== …) bei, sofern nicht ausdrücklich anders gewünscht.
|
||
- Rufe nach Änderungen validate auf (echtes Core-Rendering). Bei PDF-Änderungen zusätzlich render_pdf. Beheb Fehler, bevor du antwortest.
|
||
- Hook-Code (overrides.py) nur, wenn es mit Daten/Flags nicht geht; schreibe ihn knapp und nutze nur die Hooks build_prompt, transform_transcript, pdf_context, extra_routes.
|
||
- Dateien mit personenbezogenen Daten (Namenslisten) sind gesperrt. Secrets, Keys und Zugangsdaten kennst du nicht und fasst du nie an.
|
||
- Schreibe Prompt-Inhalte auf Deutsch, im Ton der vorhandenen Datei. Erfinde keine Fakten über den Kunden.
|
||
- Antworte am Ende kurz auf Deutsch (du-Form): was du geändert hast und worauf der Mensch beim Prüfen achten soll. Keine Markdown-Überschriften."""
|
||
|
||
|
||
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 make_backend(db: Session, tenant_id: str | None = None):
|
||
model = _setting(db, "agent_model", DEFAULT_MODEL)
|
||
key = llm.api_key(db, tenant_id)
|
||
if not key:
|
||
return backends.MockBackend()
|
||
api = _setting(db, "agent_api", "messages")
|
||
cls = backends.MessagesBackend if api == "messages" else backends.ChatBackend
|
||
return cls(key, llm.base_url(db), model)
|
||
|
||
|
||
def _context(tenant: Tenant, ws: Workspace) -> str:
|
||
return (f"Tenant: {tenant.id} („{ws.cfg.get('name', '')}“). Dateien: "
|
||
+ ", ".join(f['path'] for f in ws.list_files()) + ". Der Entwurf kann bereits unveröffentlichte Änderungen enthalten.")
|
||
|
||
|
||
def _history(db: Session, tenant_id: str, limit: int = 8) -> list[dict]:
|
||
rows = db.scalars(select(ChatMessage).where(ChatMessage.tenant_id == tenant_id).order_by(ChatMessage.id.desc()).limit(limit)).all()
|
||
out = []
|
||
for m in reversed(rows):
|
||
if m.content:
|
||
out.append({"role": m.role, "content": m.content})
|
||
# Anthropic verlangt Rollenwechsel beginnend mit user
|
||
while out and out[0]["role"] != "user":
|
||
out.pop(0)
|
||
merged = []
|
||
for m in out:
|
||
if merged and merged[-1]["role"] == m["role"]:
|
||
merged[-1]["content"] += "\n" + m["content"]
|
||
else:
|
||
merged.append(dict(m))
|
||
return merged
|
||
|
||
|
||
def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterator[dict]:
|
||
"""Generator von Ereignissen: step | text | proposal | usage | error. Schreibt NICHT in den Entwurf."""
|
||
backend = backend or make_backend(db, tenant.id)
|
||
ws = Workspace(db, tenant)
|
||
run_id = uuid.uuid4().hex[:12]
|
||
remember(run_id, ws)
|
||
last_sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
|
||
runner = ToolRunner(ws)
|
||
system = SYSTEM + "\n\n" + _context(tenant, ws)
|
||
messages = _history(db, tenant.id)
|
||
messages.append({"role": "user", "content": user_message})
|
||
usage = {"in": 0, "out": 0}
|
||
final_text = ""
|
||
yield {"type": "mode", "backend": backend.name, "model": getattr(backend, "model", ""), "run": run_id}
|
||
try:
|
||
for _ in range(MAX_TURNS):
|
||
turn = backend.step(system, messages, TOOLS)
|
||
usage["in"] += turn.usage.get("in", 0)
|
||
usage["out"] += turn.usage.get("out", 0)
|
||
messages.append({"role": "assistant", "content": turn.blocks})
|
||
calls = [b for b in turn.blocks if b["type"] == "tool_use"]
|
||
texts = [b["text"] for b in turn.blocks if b["type"] == "text" and b["text"].strip()]
|
||
if texts:
|
||
final_text = "\n".join(texts)
|
||
if calls:
|
||
yield {"type": "thought", "text": final_text}
|
||
if not calls:
|
||
break
|
||
results = []
|
||
for c in calls:
|
||
name, args = c["name"], c["input"]
|
||
if name == "__append_prompt__": # nur Mock: Standard-Prompt kopieren + Regel anhängen
|
||
base = ws.read("prompt.txt") if "prompt.txt" in ws.files else (
|
||
__import__("app.settings", fromlist=["PLATFORM_DIR"]).PLATFORM_DIR / "core" / "defaults" / "prompt.txt").read_text(encoding="utf-8")
|
||
ws.write("prompt.txt", base.rstrip() + f"\n\nZusätzliche Regel:\n{args['rule'].strip()}\n")
|
||
arg, res = "prompt.txt", "Zusatzregel angehängt"
|
||
name = "edit_file"
|
||
else:
|
||
arg, res = runner.run(name, args)
|
||
short = res if len(res) < 220 else res[:220] + " …"
|
||
if name == "validate" and not res.startswith("FEHLER"):
|
||
try:
|
||
v = json.loads(res)
|
||
short = "✔ bestanden" + (f" ({len(v['warnings'])} Hinweis(e))" if v.get("warnings") else "") if v.get("ok") else "✘ " + "; ".join(v.get("errors", []))[:200]
|
||
except (ValueError, KeyError):
|
||
pass
|
||
yield {"type": "step", "tool": name, "arg": arg, "result": short, "error": res.startswith("FEHLER")}
|
||
sig = json.dumps([ws.cfg, ws.files], sort_keys=True)
|
||
if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen
|
||
last_sig = sig
|
||
remember(run_id, ws)
|
||
yield {"type": "changed", "run": run_id}
|
||
results.append({"type": "tool_result", "tool_use_id": c["id"], "content": res})
|
||
messages.append({"role": "user", "content": results})
|
||
else:
|
||
final_text = (final_text + "\n\n(Abgebrochen: maximale Schrittzahl erreicht.)").strip()
|
||
except llm.LLMNotConfigured as exc:
|
||
yield {"type": "error", "text": str(exc)}
|
||
return
|
||
except Exception as exc: # noqa: BLE001 – Provider-/Netzfehler sauber an die UI melden
|
||
yield {"type": "error", "text": f"{type(exc).__name__}: {str(exc)[:400]}"}
|
||
return
|
||
prop = ws.proposal(final_text.split("\n")[0][:140] or "Änderungsvorschlag")
|
||
if prop is None and any(True for m in messages if m["role"] == "assistant" and isinstance(m["content"], list)
|
||
and any(b.get("name") in ("write_file", "edit_file", "set_config", "delete_file") for b in m["content"] if b["type"] == "tool_use")):
|
||
final_text = (final_text + "\n\n(Keine Änderung gegenüber dem aktuellen Entwurf.)").strip()
|
||
yield {"type": "text", "text": final_text or "(keine Antwort)"}
|
||
yield {"type": "proposal", "proposal": prop}
|
||
yield {"type": "usage", "usage": usage}
|
||
|
||
|
||
def apply_proposal(db: Session, tenant: Tenant, proposal: dict) -> None:
|
||
"""Freigabe: Vorschlag in den Entwurf übernehmen (nur die betroffenen Pfade)."""
|
||
if proposal.get("config") is not None:
|
||
store.set_config(db, tenant, proposal["config"])
|
||
for path, sha in (proposal.get("files") or {}).items():
|
||
store.set_file(db, tenant, path, store.get_blob(db, sha))
|
||
for path in proposal.get("deleted") or []:
|
||
store.delete_file(db, tenant, path)
|
||
|
||
|
||
def proposal_content(db: Session, tenant: Tenant, proposal: dict) -> tuple[dict, dict]:
|
||
"""Inhalt, der nach Freigabe im Entwurf stünde (für die Diff-Anzeige)."""
|
||
cfg, files = store.content(db, tenant, "draft")
|
||
if proposal.get("config") is not None:
|
||
cfg = proposal["config"]
|
||
files = {**files, **(proposal.get("files") or {})}
|
||
for p in proposal.get("deleted") or []:
|
||
files.pop(p, None)
|
||
return cfg, files
|