Files
Onyscribe-Admin/app/agent/loop.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

176 lines
9.3 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.
"""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