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}

View File

@@ -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,

View File

@@ -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', `<div class="thought">${esc(ev.text)}</div>`);
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');

107
tests/test_agent_preview.py Normal file
View File

@@ -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"]