Files
Onyscribe-Admin/app/agent/loop.py
RubenRWU 40b3f687f7 KI-Agent: Änderungen erscheinen in Live-Vorschau und PDF
- Dateien des Agenten werden sofort committet (die Vorschau liest in anderer Sitzung; vorher KeyError/unverändert)
- Beispielprotokoll der PDF-Vorschau gehört zum Arbeitsstand: neues Tool set_sample, run_prompt_test setzt das LLM-Ergebnis als Beispiel; im Vorschlag und bei der Freigabe übernommen
- Vorschau wechselt bei PDF-relevanten Änderungen automatisch auf die PDF-Ansicht
- Regressionstests inkl. echter SSE-/Freigabe-Strecke

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:46:32 +02:00

180 lines
9.8 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, str | None]]" = OrderedDict()
def remember(run_id: str, ws: Workspace) -> None:
WORKSPACES[run_id] = (deepcopy(ws.cfg), dict(ws.files), ws.sample)
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.
- Die PDF-Vorschau rendert ein BEISPIELPROTOKOLL. Prompt-Änderungen wirken sich darauf nicht aus: Willst du ihre Wirkung im PDF zeigen, rufe run_prompt_test auf (das Ergebnis wird zum Beispiel) – für Layout-Tests mit passendem Inhalt nutze set_sample.
- 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, last_snap = json.dumps([ws.cfg, ws.files, ws.sample], sort_keys=True), ws.snapshot()
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, ws.sample], sort_keys=True)
if sig != last_sig: # Arbeitsstand hat sich geändert → Live-Vorschau neu rendern lassen
paths = ws.changed_paths(last_snap)
last_sig, last_snap = sig, ws.snapshot()
remember(run_id, ws)
yield {"type": "changed", "run": run_id, "paths": paths}
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)
if proposal.get("sample") is not None:
tenant.preview_sample = proposal["sample"]
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