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>
This commit is contained in:
RubenRWU
2026-10-15 16:46:32 +02:00
parent 9c352be05b
commit 40b3f687f7
6 changed files with 174 additions and 13 deletions

View File

@@ -19,11 +19,11 @@ 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()
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))
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)
@@ -39,6 +39,7 @@ Arbeitsweise:
- 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.
@@ -89,7 +90,7 @@ def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterato
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)
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)
@@ -130,11 +131,12 @@ def run(db: Session, tenant: Tenant, user_message: str, backend=None) -> Iterato
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)
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
last_sig = sig
paths = ws.changed_paths(last_snap)
last_sig, last_snap = sig, ws.snapshot()
remember(run_id, ws)
yield {"type": "changed", "run": run_id}
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:
@@ -162,6 +164,8 @@ def apply_proposal(db: Session, tenant: Tenant, proposal: dict) -> None:
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]:

View File

@@ -30,6 +30,9 @@ TOOLS = [
"schema": {"type": "object", "properties": {}}},
{"name": "show_final_prompt", "description": "Zeigt den finalen System-Prompt, wie der Core ihn an das LLM schickt (nach Platzhalter-Ersetzung und Hooks). Namenslisten werden gekürzt.",
"schema": {"type": "object", "properties": {}}},
{"name": "set_sample", "description": "Setzt das Beispielprotokoll, das die PDF-Vorschau rendert (Format wie vom LLM: ===TERMINART=== … ===/TERMINART===, dann Überschriften und Text). "
"Sinnvoll, um Layout-Änderungen mit passendem Inhalt zu prüfen.",
"schema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}},
{"name": "run_prompt_test", "description": "Führt das Protokoll-LLM mit dem Workspace-Prompt auf einem Beispieltranskript aus (kostet Geld – höchstens 1× pro Antwort). Liefert den Protokolltext.",
"schema": {"type": "object", "properties": {"transcript": {"type": "string", "description": "Optional; sonst das Standard-Beispieltranskript."}}}},
]
@@ -41,6 +44,7 @@ class ToolRunner:
self.llm_calls = 0
def _preview(self, kind, **kw):
kw.setdefault("sample", self.ws.sample) # Beispielprotokoll des Workspace (None → Standard des Tenants)
return preview.run_content(self.db, self.tenant, self.ws.cfg, self.ws.files, kind, **kw)
def run(self, name: str, a: dict) -> tuple[str, str]:
@@ -107,4 +111,11 @@ class ToolRunner:
self.llm_calls += 1
from app.agent.testrun import prompt_test
res = prompt_test(self.db, self.tenant, self.ws.cfg, self.ws.files, a.get("transcript"))
return "Beispieltranskript", f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens]\n{res['text'][:6000]}"
if res["text"].strip():
self.ws.sample = res["text"] # das erzeugte Protokoll wird zum Beispiel der PDF-Vorschau → Prompt-Änderungen sind im PDF sichtbar
return "Beispieltranskript", (f"[{res['model']}, {res['input_tokens']}→{res['output_tokens']} Tokens] Das Ergebnis ist jetzt das Beispielprotokoll "
f"der PDF-Vorschau.\n{res['text'][:6000]}")
def t_set_sample(self, a):
self.ws.sample = a["text"]
return "Beispielprotokoll", f"gesetzt ({len(a['text'])} Zeichen) – die PDF-Vorschau rendert jetzt dieses Protokoll"

View File

@@ -29,6 +29,7 @@ class Workspace:
cfg, files = store.content(db, tenant, "draft")
self.base_cfg, self.base_files = deepcopy(cfg), dict(files)
self.cfg, self.files = deepcopy(cfg), dict(files) # files: {pfad: sha}
self.base_sample = self.sample = tenant.preview_sample # Beispielprotokoll der PDF-Vorschau (None = Standard)
# ── Lesen ──
def list_files(self) -> list[dict]:
@@ -60,6 +61,7 @@ class Workspace:
if path.endswith(".py"):
compile(content, path, "exec") # Syntaxfehler früh melden
self.files[path] = store.put_blob(self.db, content.encode("utf-8"), path)
self.db.commit() # Blob sofort sichtbar machen: die Live-Vorschau liest in einer ANDEREN Sitzung, während der Agent noch läuft
def edit(self, path: str, old: str, new: str) -> None:
cur = self.read(path) if path != "tenant.json" else json.dumps(self.cfg, indent=2, ensure_ascii=False)
@@ -90,7 +92,24 @@ class Workspace:
# ── Ergebnis ──
def changed(self) -> bool:
return self.cfg != self.base_cfg or self.files != self.base_files
return self.cfg != self.base_cfg or self.files != self.base_files or self.sample != self.base_sample
def snapshot(self) -> tuple[dict, dict, str | None]:
return deepcopy(self.cfg), dict(self.files), self.sample
def changed_paths(self, prev: tuple[dict, dict, str | None]) -> list[str]:
"""Was hat sich seit `prev` geändert? (für die Vorschau: PDF-relevante Änderungen → PDF-Ansicht)"""
cfg0, files0, sample0 = prev
out = [p for p in set(self.files) | set(files0) if self.files.get(p) != files0.get(p)]
for k in set(self.cfg) | set(cfg0):
if self.cfg.get(k) != cfg0.get(k):
if isinstance(self.cfg.get(k), dict) or isinstance(cfg0.get(k), dict):
out += [f"{k}.{kk}" for kk in set(self.cfg.get(k) or {}) | set(cfg0.get(k) or {}) if (self.cfg.get(k) or {}).get(kk) != (cfg0.get(k) or {}).get(kk)]
else:
out.append(k)
if self.sample != sample0:
out.append("preview_sample")
return sorted(out)
def proposal(self, summary: str) -> dict | None:
if not self.changed():
@@ -102,5 +121,7 @@ class Workspace:
items.append({"path": "tenant.json", "kind": "geändert"})
items += [{"path": p, "kind": "neu" if p not in self.base_files else "geändert"} for p in files]
items += [{"path": p, "kind": "gelöscht"} for p in deleted]
if self.sample != self.base_sample:
items.append({"path": "Vorschau-Beispielprotokoll", "kind": "geändert"})
return {"summary": summary, "config": self.cfg if self.cfg != self.base_cfg else None,
"files": files, "deleted": deleted, "items": items}
"files": files, "deleted": deleted, "items": items, "sample": self.sample if self.sample != self.base_sample else None}