"""Deterministischer Agenten-Ersatz für E2E-Tests: beantwortet run_agent-Aufrufe ohne LLM. Eine `Welt` beschreibt das Thema (Blöcke → Subs → Facts …); `respond()` routet per agent_key-Muster und liefert (rc, stdout, stderr) wie ein echter Agent — files-Agenten schreiben die out_path-Datei aus dem Prompt, none-Agenten antworten als Text (der Engine-Sink parst/persistiert). Damit laufen ALLE echten Schichten (_race, Quorum, Retry, Panels, Producer, QA-Gate) in Sekunden. Störungen sind deterministisch: `Welt.stoerungen` matcht agent_keys per Regex und liefert n-mal einen Fehler / kaputtes JSON / eine feste Antwort (z. B. Dissens). Aktivierung: pytest-Fixture `fake_welt` (tests/conftest.py) oder ENV CREATOR_FAKE_AGENTS=1 (echter Server, Sekunden-Smoke im Frontend). """ import json import re from pathlib import Path _PATH_RE = re.compile(r"(/\S+\.(?:json|md))") _NUM_RE = re.compile(r"^\s*(\d+)[.)]\s+(.*\S)", re.MULTILINE) _SUBLIST_RE = re.compile(r"^- (?:\[(\w+)\] )?(.+\S)\s*$", re.MULTILINE) _ZIEL_RE = re.compile(r"\(([a-z]\d+)\)") _PAIR_RE = re.compile(r"^(\d+)\.\s*\nA: \[Block: (.*?)\] (.*?)\n", re.MULTILINE) def _norm(s: str) -> str: return " ".join((s or "").casefold().split()) class Welt: """Deterministisches Themen-Modell. bloecke: {titel: {"beschreibung": str, "subs": [titel]}}; optionale Regeln steuern Konsolidierung/Cross-Block.""" def __init__(self, bloecke: dict | None = None, *, gruppen: list | None = None, kataloge: list | None = None, stoerungen: list | None = None): self.bloecke = bloecke if bloecke is not None else standard_bloecke() self.gruppen = gruppen or [] # [(haupt_titel, [weitere_titel])] → In-Block-Fold self.kataloge = kataloge or [] # [(katalog_titel, [mitglieder_titel])] self.stoerungen = [dict(s, rest=int(s.get("mal", 1))) for s in (stoerungen or [])] self.calls: list[str] = [] # Auditspur: jeder agent_key in Reihenfolge # ── Nachschlagen ──────────────────────────────────────────────────────────────── def _alle_subs(self) -> dict[str, str]: """sub_norm → sub_titel über alle Blöcke (inkl. Katalog-Titel).""" out = {} for b in self.bloecke.values(): for s in b["subs"]: out[_norm(s)] = s for kt, _m in self.kataloge: out[_norm(kt)] = kt return out def _bloecke_im_prompt(self, prompt: str) -> list[str]: return [t for t in self.bloecke if t in prompt] def _subs_im_prompt(self, prompt: str) -> list[str]: gefunden = [s for s in self._alle_subs().values() if s in prompt] return gefunden # ── Störungen ─────────────────────────────────────────────────────────────────── def _stoerung(self, agent_key: str): for s in self.stoerungen: if s["rest"] > 0 and re.search(s["muster"], agent_key): s["rest"] -= 1 return s return None # ── Haupteinstieg ────────────────────────────────────────────────────────────── def respond(self, agent_key: str, prompt: str, capabilities: str) -> tuple[int, str, str]: self.calls.append(agent_key) if (s := self._stoerung(agent_key)): if s["modus"] == "fehler": return 1, "", "fake-stoerung" if s["modus"] == "garbage": return self._liefern(prompt, '{"kaputt": ') if s["modus"] == "antwort": return self._liefern(prompt, s["antwort"]) text = self._antwort(agent_key, prompt) if text is None: return 1, "", f"fake: kein Handler für {agent_key}" return self._liefern(prompt, text) @staticmethod def _liefern(prompt: str, text: str) -> tuple[int, str, str]: """files-Agenten schreiben die out_path-Datei aus dem Prompt; none-Agenten antworten als Text. Wir tun einfach BEIDES — steht ein Pfad im Prompt, wird er geschrieben (dann liest das payload die Datei), und stdout trägt den Text (dann parst ihn der Sink). Ein Weg von beiden greift immer.""" if (m := _PATH_RE.search(prompt)): p = Path(m.group(1)) try: p.parent.mkdir(parents=True, exist_ok=True) p.write_text(text, encoding="utf-8") except OSError: pass return 0, text, "" # ── Antwort-Generatoren je Key-Muster ────────────────────────────────────────── def _antwort(self, key: str, prompt: str) -> str | None: # noqa: C901 — bewusst ein Router j = json.dumps # Board 1 / Inventar if "-research-" in key: zeilen = [] n = 1 for t, b in self.bloecke.items(): zeilen.append(f"{n}. {t} — {b['beschreibung']}") n += 1 return "\n".join(zeilen) if "-pair-" in key: n = prompt.count("\nA: ") or 1 return j({"pairs": {str(i): "ja" for i in range(1, n + 1)}}) if "-dedup-" in key: n = prompt.count("\nA: ") or 1 return j({"pairs": {str(i): "nein" for i in range(1, n + 1)}}) if "-clarify-" in key: keep = [ln[2:] for ln in prompt.splitlines() if ln.startswith("- ")] return j({"keep": keep, "rest": []}) if "-naming-" in key: # deckt auch naming_check (gleicher Key) return j({"best": 1}) if "-sanierung-" in key: # Titel/Beschreibung unverändert zurück (Fake-Welt ist sauber) t = re.search(r"^Title: (.+)$", prompt, re.M) d = re.search(r"^Description: (.+)$", prompt, re.M) beschr = (d.group(1).strip() if d else "") if beschr == "(leer)": beschr = "Beschreibung aus dem Material." return j({"title": t.group(1).strip() if t else "", "description": beschr}) if "-filter-" in key: # auch filter-recheck return j({"fragments": {}, "drop": []}) if "-gruppierung-completion-" in key: return j({"additions": []}) if "-gruppierung-" in key: return j({"umbrellas": []}) if "-supplement-beleg" in key or "-anker-beleg-" in key: nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}}) if "-supplement" in key: return j({"blocks": []}) if "-source-relevance-" in key: nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}}) # Board 2 / Artefakte (verschmolzene Calls, block_calls.py) if "-sb-gen-" in key: subs = [] for t in self._bloecke_im_prompt(prompt): for s in self.bloecke[t]["subs"]: f = self._fakt(t, s) subs.append({"title": s, "level": "beginner", "relevance": "relevant", **{k: f[k] for k in ("key_points", "prerequisites", "hurdles", "cited_facts", "example_idea")}}) return j({"subs": subs}) if "-sb-verify-" in key: nummern = {_norm(m.group(2)): m.group(1) for m in _NUM_RE.finditer(prompt)} gruppen = [] for haupt, weitere in self.gruppen: h, w = nummern.get(_norm(haupt)), [nummern[_norm(x)] for x in weitere if _norm(x) in nummern] if h and w: gruppen.append({"haupt": int(h), "weitere": [int(x) for x in w]}) kataloge = [] for kt, mitglieder in self.kataloge: m = [int(nummern[_norm(x)]) for x in mitglieder if _norm(x) in nummern] if len(m) >= 2: kataloge.append({"titel": kt, "mitglieder": m}) unsicher = {} if (u := prompt.find("UNSICHER")) != -1: # alle Unsicher-Nummern übernehmen import re as _re for m in _re.finditer(r"entries (\d+)–(\d+)", prompt[u:u + 200]): unsicher = {str(k): "ja" for k in range(int(m.group(1)), int(m.group(2)) + 1)} return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": [], "uebernehmen": unsicher, "facts_probleme": [], "levels": {}, "relevanz": {}}) if "-sb-fix-" in key: return j({"subs": []}) if "-sub-crossblock-" in key: urteile = {} for m in _PAIR_RE.finditer(prompt): urteile[m.group(1)] = "a" # identischer Text (nur so wird gepaart) → A behält return j({"pairs": urteile or {"1": "nein"}}) if "-art-gen-" in key: subs = self._subs_im_prompt(prompt) t = (self._bloecke_im_prompt(prompt) or ["?"])[0] return j({"pattern": [{"block": t, "subblock": s, "question": f"Was ist {s}?"} for s in subs], "cards": [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"} for s in subs], "examples": [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}", "steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"} for s in subs]}) if "-art-check-" in key: return j({"ok": True}) if "-outline-prereqs" in key: return j({"prereqs": {}}) if "-outline-review" in key: return j({"moves": {}}) if "-outline-" in key or key.endswith("-outline-judge"): nums = sorted({int(m.group(1)) for m in _NUM_RE.finditer(prompt)}) or [1] return j({"chapters": [{"title": "Kapitel 1", "numbers": nums}]}) # Guide-Board if "-ziele-" in key: ziele = [{"id": f"z{i}", "text": f"Verstehen von {s}", "sub": s} for i, s in enumerate(self._subs_im_prompt(prompt), 1)][:8] return j({"ziele": ziele or [{"id": "z1", "text": "Grundlagen verstehen", "sub": ""}]}) if "-gfix-" in key: return self._section_aus_prompt(prompt) or "\nRepariert." if "-pruef-" in key: # verschmolzener Prüfer: Fakten + Coverage + Lesbarkeit ids = sorted(set(_ZIEL_RE.findall(prompt))) return j({"claims": [], "ziele": {z: True for z in ids}, "luecken": [], "ballast": [], "lese_probleme": []}) if "-w-" in key: return self._writer_md(prompt) # QA / Repair / Guide-QA (alle Text, _yesno_schema) if key.startswith(("qa-guide-",)): nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} return j({"relevant": {k: "nein" for k in sorted(nums, key=int)} or {"1": "nein"}}) if key.startswith(("qa-", "repair-")): # Semantik je Template: Bausteine „ja" = echt; Dubletten/Lücken „nein" = kein Befund wert = "ja" if ("bausteine" in key or "beleg" in key or "fremd" in key) else "nein" nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} return j({"relevant": {k: wert for k in sorted(nums, key=int)} or {"1": wert}}) return None # ── Bausteine der Antworten ───────────────────────────────────────────────────── @staticmethod def _fakt(block: str, sub: str) -> dict: return {"block": block, "subblock": sub, "key_points": [f"Kernaussage zu {sub}", f"Zweite Aussage zu {sub}"], "prerequisites": "", "hurdles": "", "cited_facts": [{"text": f"Beleg für {sub}", "source": "Fake-Quelle"}], "example_idea": f"Beispiel zu {sub}"} def _writer_md(self, prompt: str) -> str: """Section im Marker-Format; Subs aus der SUBBLOCKS-Liste des Prompts (`- [label] titel`), Länge je Sub im 150–1080-Rahmen.""" block = next(iter(self._bloecke_im_prompt(prompt)), None) or "Abschnitt" subs = [(lv or "beginner", t) for lv, t in _SUBLIST_RE.findall(prompt) if _norm(t) in self._alle_subs()] if not subs: subs = [("beginner", s) for s in self.bloecke.get(block, {}).get("subs", ["Inhalt"])] kompakt = "\n".join(f"\n- Merksatz zu {t}" for lv, t in subs) prosa = "\n".join(f"\n" + (f"Lehrtext über {t}. " * 12) for lv, t in subs) return (f"\n\n" f"\n{kompakt}\n\n" f"Einstieg in {block}.\n{prosa}") @staticmethod def _section_aus_prompt(prompt: str) -> str | None: """Fix-Agenten geben die Section unverändert zurück (minimal-invasiv).""" m = re.search(r"(