"""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:"). 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