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:
@@ -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]:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
107
tests/test_agent_preview.py
Normal 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"]
|
||||
Reference in New Issue
Block a user