From 40b3f687f7c5173324f8557584ccb065b030d73e Mon Sep 17 00:00:00 2001 From: RubenRWU Date: Thu, 15 Oct 2026 16:46:32 +0200 Subject: [PATCH] =?UTF-8?q?KI-Agent:=20=C3=84nderungen=20erscheinen=20in?= =?UTF-8?q?=20Live-Vorschau=20und=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/agent/loop.py | 16 ++++-- app/agent/tools.py | 13 ++++- app/agent/workspace.py | 25 ++++++++- app/services/preview.py | 17 +++++- app/static/tenant.js | 9 ++- tests/test_agent_preview.py | 107 ++++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 tests/test_agent_preview.py diff --git a/app/agent/loop.py b/app/agent/loop.py index 9bc1293..36e0b30 100644 --- a/app/agent/loop.py +++ b/app/agent/loop.py @@ -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:"). 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]: diff --git a/app/agent/tools.py b/app/agent/tools.py index 509220f..06be44d 100644 --- a/app/agent/tools.py +++ b/app/agent/tools.py @@ -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" diff --git a/app/agent/workspace.py b/app/agent/workspace.py index 5b56d3c..329fa40 100644 --- a/app/agent/workspace.py +++ b/app/agent/workspace.py @@ -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} diff --git a/app/services/preview.py b/app/services/preview.py index 12ca638..d8adc89 100644 --- a/app/services/preview.py +++ b/app/services/preview.py @@ -47,7 +47,7 @@ def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]: from app.agent.loop import WORKSPACES if source[3:] not in WORKSPACES: raise SourceGone("Der Arbeitsstand des Agenten ist nicht mehr im Speicher (Neustart?). Bitte „Entwurf“ wählen.") - cfg, files = WORKSPACES[source[3:]] + cfg, files, _sample = WORKSPACES[source[3:]] return dict(cfg), dict(files) if source.startswith("proposal:"): from app.agent.loop import proposal_content @@ -59,9 +59,22 @@ def resolve_content(db: Session, t: Tenant, source: str) -> tuple[dict, dict]: return store.content(db, t, source) +def resolve_sample(db: Session, t: Tenant, source: str) -> str | None: + """Beispielprotokoll-Override einer Agent-Quelle (None = das des Tenants).""" + if source.startswith("ws:"): + from app.agent.loop import WORKSPACES + w = WORKSPACES.get(source[3:]) + return w[2] if w else None + if source.startswith("proposal:"): + from app.models import ChatMessage + m = db.get(ChatMessage, int(source[9:] or 0)) + return (m.proposal or {}).get("sample") if m and m.tenant_id == t.id else None + return None + + def run(db: Session, t: Tenant, source: str, kind: str, sample: str | None = None, args: dict | None = None) -> bytes | dict: cfg, files = resolve_content(db, t, source) - return run_content(db, t, cfg, files, kind, sample, args) + return run_content(db, t, cfg, files, kind, sample if sample is not None else resolve_sample(db, t, source), args) def run_content(db: Session, t: Tenant, cfg: dict, files: dict, kind: str, sample: str | None = None, diff --git a/app/static/tenant.js b/app/static/tenant.js index 305f543..93f0845 100644 --- a/app/static/tenant.js +++ b/app/static/tenant.js @@ -427,12 +427,12 @@ async function sendChat(text){ let i; while((i = buf.indexOf('\n\n')) >= 0){ const ev = JSON.parse(buf.slice(6, i)); buf = buf.slice(i+2); if(ev.type==='mode'){ ui.agentSrc = 'ws:' + ev.run; $('#pv-agent').textContent = 'Agent-Stand (live)'; setPvSrc('agent'); } - else if(ev.type==='changed'){ refreshPreviewLive(); } + else if(ev.type==='changed'){ showPdfIfRelevant(ev.paths); refreshPreviewLive(); } else if(ev.type==='step') live.querySelector('.steps').insertAdjacentHTML('beforeend', stepsHtml([ev])); else if(ev.type==='thought') live.querySelector('.steps').insertAdjacentHTML('beforeend', `
${esc(ev.text)}
`); else if(ev.type==='error') live.querySelector('.typing').textContent = '⚠ ' + ev.text; else if(ev.type==='done'){ live.replaceWith(msgEl(ev.message)); - if(ev.message.proposal){ ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); } + if(ev.message.proposal){ showPdfIfRelevant((ev.message.proposal.items||[]).map(i=>i.path)); ui.agentSrc = 'proposal:' + ev.message.id; $('#pv-agent').textContent = 'Agent-Vorschlag'; setPvSrc('agent'); } else { ui.agentSrc = null; setPvSrc('draft'); } refreshPreview(true); } box.scrollTop = 1e9; } @@ -440,6 +440,11 @@ async function sendChat(text){ } catch(e){ live.remove(); toast(e.message, 1); } $('#chat-send').disabled = false; } +const PDF_PATH = /^(protocol\.|pdf_|office\.|preview_sample)/; +function showPdfIfRelevant(paths){ + if(!(paths||[]).some(p => PDF_PATH.test(p))) return; + if(ui.pvKind !== 'pdf'){ ui.pvKind = 'pdf'; document.querySelectorAll('#pv-kind button').forEach(b => b.classList.toggle('on', b.dataset.k === 'pdf')); } +} async function decide(id, d){ const m = await api('POST', A(`/chat/${id}/${d}`)); $('#m'+id).replaceWith(msgEl(m)); ui.agentSrc = null; setPvSrc('draft'); diff --git a/tests/test_agent_preview.py b/tests/test_agent_preview.py new file mode 100644 index 0000000..7e96b0b --- /dev/null +++ b/tests/test_agent_preview.py @@ -0,0 +1,107 @@ +"""Änderungen des KI-Agenten müssen in Vorschau (live, auch PDF) und nach der Freigabe sichtbar sein.""" +import io +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from pypdf import PdfReader + +from app.agent import loop +from app.agent.backends import Turn +from app.db import session +from app.main import app +from app.models import ChatMessage, Tenant +from app.services import preview +from app.services.importer import import_all +from app.settings import LEGACY_PLATFORM + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + with session() as db: + import_all(db, Path(LEGACY_PLATFORM)) + yield c + + +def pdf_text(b: bytes) -> str: + return "\n".join(p.extract_text() or "" for p in PdfReader(io.BytesIO(b)).pages) + + +class Scripted: + name, model = "scripted", "x" + + def __init__(self, *calls): + self.calls, self.i = calls, 0 + + def step(self, system, messages, tools): + self.i += 1 + if self.i == 1: + return Turn([{"type": "tool_use", "id": str(n), "name": nm, "input": a} for n, (nm, a) in enumerate(self.calls)]) + return Turn([{"type": "text", "text": "Fertig."}]) + + +CSS = "body { background: #ffee99; } h2 { color: #cc0000; }" +SAMPLE = "===TERMINART===\nWorkshop\n===/TERMINART===\n===PROJEKT===\nAgentenprojekt\n===/PROJEKT===\nAnlass & Kontext\nEin Text nur aus dem Agenten-Beispiel.\n" + + +def test_live_preview_sees_files_written_by_the_running_agent(client): + """Regression: Dateien lagen in der offenen Transaktion des Agenten, die Vorschau (andere Sitzung) bekam KeyError.""" + with session() as db: + t = db.get(Tenant, "sprinz") + base = preview.run(db, t, "draft", "pdf") + events = [] + for ev in loop.run(db, t, "x", backend=Scripted(("write_file", {"path": "protocol.css", "content": CSS}), ("set_config", {"key": "pdf_projekt_label", "value": "AGENTENTHEMA"}))): + events.append(ev) + if ev["type"] == "changed": + with session() as other: # wie der Preview-Request: eigene Sitzung, Agent-Transaktion läuft noch + live = preview.run(other, other.get(Tenant, "sprinz"), f"ws:{ev['run']}", "pdf") + assert live != base # kein KeyError, und die Änderung ist im PDF + changed = [e for e in events if e["type"] == "changed"] + assert "protocol.css" in changed[0]["paths"] and any("pdf_projekt_label" in p for p in changed[-1]["paths"]) + + +def test_sample_set_by_agent_reaches_live_preview_proposal_and_draft(client): + with session() as db: + t = db.get(Tenant, "revier") + events = list(loop.run(db, t, "x", backend=Scripted(("set_sample", {"text": SAMPLE})))) + db.commit() + run = next(e["run"] for e in events if e["type"] == "mode") + prop = next(e for e in events if e["type"] == "proposal")["proposal"] + assert prop["sample"] == SAMPLE and any(i["path"] == "Vorschau-Beispielprotokoll" for i in prop["items"]) + assert "Agentenprojekt" in pdf_text(preview.run(db, t, f"ws:{run}", "pdf")) # live + assert "Agentenprojekt" not in pdf_text(preview.run(db, t, "draft", "pdf")) # Entwurf unberührt + m = ChatMessage(tenant_id="revier", role="assistant", content="ok", proposal=prop, proposal_status="open") + db.add(m) + db.commit() + assert "Agentenprojekt" in pdf_text(preview.run(db, t, f"proposal:{m.id}", "pdf")) # Vorschlag + loop.apply_proposal(db, t, prop) + db.commit() + assert "Agentenprojekt" in pdf_text(preview.run(db, t, "draft", "pdf")) # nach Freigabe im Entwurf + + +def test_prompt_test_result_becomes_the_pdf_sample(client, monkeypatch): + from app.services import llm + llm_text = SAMPLE.replace("Agentenprojekt", "LLMERGEBNIS") + monkeypatch.setattr(llm, "summarize", lambda db, system, user, model=None, tenant_id=None: {"text": llm_text, "model": "m", "input_tokens": 1, "output_tokens": 1, "truncated": False}) + with session() as db: + t = db.get(Tenant, "demo") + events = list(loop.run(db, t, "x", backend=Scripted(("run_prompt_test", {})))) + run = next(e["run"] for e in events if e["type"] == "mode") + assert "LLMERGEBNIS" in pdf_text(preview.run(db, t, f"ws:{run}", "pdf")) + assert next(e for e in events if e["type"] == "proposal")["proposal"]["sample"] == llm_text + + +def test_api_flow_through_sse_and_apply(client, monkeypatch): + """Über die echte API: Agent schreibt CSS + Beispiel → Vorschlag → Freigabe → Entwurf-PDF zeigt es.""" + monkeypatch.setattr(loop, "make_backend", lambda db, tid=None: Scripted(("write_file", {"path": "protocol.css", "content": CSS}), ("set_sample", {"text": SAMPLE}))) + r = client.post("/api/tenants/onyva/chat", json={"message": "mach was"}) + import json + ev = [json.loads(l[6:]) for l in r.text.split("\n\n") if l.startswith("data: ")] + assert any(e["type"] == "changed" and "protocol.css" in e["paths"] for e in ev) + msg = ev[-1]["message"] + live = client.get(f"/preview/onyva/pdf?source=proposal:{msg['id']}") + assert live.status_code == 200 and "Agentenprojekt" in pdf_text(live.content) + assert client.post(f"/api/tenants/onyva/chat/{msg['id']}/apply").status_code == 200 + assert "Agentenprojekt" in pdf_text(client.get("/preview/onyva/pdf?source=draft").content) + assert client.get("/api/tenants/onyva/state").json()["files"]["protocol.css"]