update
This commit is contained in:
289
backend/fake_agents.py
Normal file
289
backend/fake_agents.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""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 "-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
|
||||
if "-luecken-" in key:
|
||||
return "<!-- block: leer -->\n" # Lücken-Nachfass findet nichts Neues
|
||||
if "-subblock-final-" in key or "-subblock-" in key: # Finder + Judge, gleiches Format
|
||||
teile = []
|
||||
for t in self._bloecke_im_prompt(prompt):
|
||||
subs = "\n".join(f"- {s}" for s in self.bloecke[t]["subs"])
|
||||
teile.append(f"<!-- block: {t} -->\n{subs}")
|
||||
return "\n".join(teile) or "<!-- block: leer -->\n"
|
||||
if "-sub-konsolidierung-" 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})
|
||||
return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": []})
|
||||
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 "-facts-check-" in key:
|
||||
return j({"ok": True})
|
||||
if "-facts-" in key: # facts / facts-fix / facts-erg: gleiches Format
|
||||
eintraege = []
|
||||
for t in self._bloecke_im_prompt(prompt):
|
||||
for s in self.bloecke[t]["subs"]:
|
||||
if s in prompt:
|
||||
eintraege.append(self._fakt(t, s))
|
||||
for kt, _m in self.kataloge:
|
||||
if kt in prompt:
|
||||
eintraege.append(self._fakt(t, kt))
|
||||
if not eintraege: # Nachfass-Fälle: Subs ohne Blockkontext im Prompt
|
||||
eintraege = [self._fakt(bt, s) for bt, b in self.bloecke.items()
|
||||
for s in b["subs"] if s in prompt]
|
||||
return j({"facts": eintraege})
|
||||
if "-level-" in key: # Rater + final: alle geforderten Nummern
|
||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
return j({"levels": {k: "beginner" for k in sorted(nums, key=int)} or {"1": "beginner"}})
|
||||
if "-relevance-" in key:
|
||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
return j({"relevance": {k: "relevant" for k in sorted(nums, key=int)} or {"1": "relevant"}})
|
||||
if "-question-pattern-" in key:
|
||||
eintraege = []
|
||||
for t in self._bloecke_im_prompt(prompt):
|
||||
for s in self._subs_im_prompt(prompt):
|
||||
eintraege.append({"block": t, "subblock": s, "question": f"Was ist {s}?"})
|
||||
return j({"pattern": eintraege})
|
||||
if "-artifact-example-check-" in key:
|
||||
return j({"ok": True})
|
||||
if "-artifact-flashcard-" in key:
|
||||
karten = [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"}
|
||||
for t in self._bloecke_im_prompt(prompt) for s in self._subs_im_prompt(prompt)]
|
||||
return j({"cards": karten})
|
||||
if "-artifact-example-" in key:
|
||||
bsp = [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}",
|
||||
"steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"}
|
||||
for t in self._bloecke_im_prompt(prompt) for s in self._subs_im_prompt(prompt)]
|
||||
return j({"examples": bsp})
|
||||
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 "-gatefix-" in key or "-lesefix-" in key:
|
||||
return self._section_aus_prompt(prompt) or "<!-- section: X -->\nRepariert."
|
||||
if "-gate-" in key:
|
||||
return j({"ok": True})
|
||||
if "-cov-" in key:
|
||||
ids = sorted(set(_ZIEL_RE.findall(prompt)))
|
||||
return j({"ziele": {z: True for z in ids}, "luecken": [], "ballast": []})
|
||||
if "-lese-" in key:
|
||||
return j({"ok": True})
|
||||
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"<!-- sub: {lv} | {t} -->\n- Merksatz zu {t}" for lv, t in subs)
|
||||
prosa = "\n".join(f"<!-- sub: {lv} | {t} -->\n" + (f"Lehrtext über {t}. " * 12)
|
||||
for lv, t in subs)
|
||||
return (f"<!-- kapitel: Kapitel 1 -->\n<!-- section: {block} -->\n"
|
||||
f"<!-- compact -->\n{kompakt}\n<!-- ausführlich -->\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"(<!--\s*(?:kapitel|section):.*)", prompt, re.DOTALL)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def standard_bloecke() -> dict:
|
||||
"""3 Blöcke; „Gemeinsamer Grundbegriff" liegt in Alpha UND Beta (Cross-Block-Fall)."""
|
||||
return {
|
||||
"Alpha-Konzept": {"beschreibung": "Das erste Grundkonzept",
|
||||
"subs": ["Definition Alpha", "Alpha Eigenschaften",
|
||||
"Gemeinsamer Grundbegriff"]},
|
||||
"Beta-Verfahren": {"beschreibung": "Das zentrale Verfahren",
|
||||
"subs": ["Beta Ablauf", "Beta Grenzen", "Gemeinsamer Grundbegriff"]},
|
||||
"Gamma-Anwendung": {"beschreibung": "Praktische Anwendung",
|
||||
"subs": ["Gamma Praxisfall", "Gamma Werkzeuge"]},
|
||||
}
|
||||
|
||||
|
||||
_WELT: Welt | None = None # ENV-Modus (CREATOR_FAKE_AGENTS=1): eine Welt pro Prozess
|
||||
|
||||
|
||||
async def respond(agent_key: str, prompt: str, capabilities: str) -> tuple[int, str, str]:
|
||||
global _WELT
|
||||
if _WELT is None:
|
||||
_WELT = Welt()
|
||||
return _WELT.respond(agent_key, prompt, capabilities)
|
||||
Reference in New Issue
Block a user