This commit is contained in:
team3
2026-07-07 00:25:23 +02:00
parent f2feb1dbd0
commit f0c1bdacfa
15 changed files with 1363 additions and 367 deletions

View File

@@ -220,104 +220,60 @@ async def _generate_block(ctx: GenContext, files: dict, title: str, description:
instructions: str = "", ns: str = "", lbl: str = "",
sources: list[str] | None = None,
seeds: list[str] | None = None, melde=None) -> dict | None:
"""GEN_PANEL unabhängige Generatoren liefern je Subs+Facts+Level/Relevanz in EINEM Call;
Konsens im Code (Variant-Cluster über beide Ausgaben, ≥2 unabhängige Generatoren =
consensus). Einzelnennungen und ungedeckte Seeds werden „unsicher" — der Prüfer
entscheidet mit Material (ersetzt Sättigungsrunden + Clarify-Panel). Degraded: liefert
nur EIN Generator, wird alles unsicher. → {raw, facts, unsicher, votes} | None."""
topic, provider = ctx.topic, ctx.provider
"""Bottom-up-Anreicherung: die Subs stehen FEST — Board 1 hat die Atome geclustert und
als Subs persistiert. EIN Call liefert pro vorgegebenem Sub die Lern-Facts + Level +
Relevanz aus dem Material. Er entdeckt und entfernt KEINE Subs (Discovery/Konsens/Seeds
entfallen; die Vollständigkeit kommt aus Board 1). → {raw, facts, unsicher, votes} | None."""
topic = ctx.topic
work_dir = files["arbeit"]
bnorm = _norm_title(title)
source, caps = await asyncio.to_thread(
_inline_source, topic, sources, [f"{title} {description}"])
seeds_txt = ""
if seeds:
seeds_txt = ("\nAlready identified sub-point CANDIDATES of this block (verify against "
"the material; if backed AND not already covered by another entry, include "
"them — rephrased as a standalone statement):\n"
+ "\n".join(f"- {s}" for s in dict.fromkeys(seeds) if s) + "\n")
# feste Subs aus Board 1 (Reihenfolge erhalten, dublettenfrei)
subs: list[str] = []
seen: set[str] = set()
for r in await db.list_subblocks(topic, bnorm):
st = str(r.get("sub_title") or "").strip()
sn = _norm_title(st)
if st and sn and sn not in seen and r.get("status") in ("consensus", "candidate"):
seen.add(sn)
subs.append(st)
if not subs:
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
source, caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + subs)
if melde:
melde("Generate")
h = _h8(title, description, "gen")
paths = [work_dir / f"gen-{h}-g{g}.json" for g in range(1, GEN_PANEL + 1)]
prompt = _prompt("Subblock-Generate", topic=topic,
melde("Anreichern")
h = _h8(title, description, "enrich")
pfad = work_dir / f"enrich-{h}.json"
sub_liste = "\n".join(f"{i}. {t}" for i, t in enumerate(subs, 1))
prompt = _prompt("Subblock-Anreichern", topic=topic,
block=f"{title}{description}" if description else title,
source=source, seeds=seeds_txt, extra=_extra(instructions))
pending = [(g, p) for g, p in enumerate(paths, 1) if _gen_schema(_json_file(p)) is None]
if pending:
slots = [{
"key": f"blocks-{topic}-{ns}sb-gen-{h}-g{g}",
"prompt": prompt, "role": "quick", "capabilities": caps,
"payload": (lambda result, p=p: _sink_json(result, p, _gen_schema)),
} for g, p in pending]
await _race(topic, f"{lbl}Generate", slots, len(slots),
_timeout("generate", 10), provider, cancelled=ctx.is_cancelled)
subs=sub_liste, source=source, extra=_extra(instructions))
if _gen_schema(_json_file(pfad)) is None:
status, _v = await run_single_slot(
ctx, f"{lbl}Anreichern", key=f"blocks-{topic}-{ns}sb-enrich-{h}",
prompt=prompt, role="quick", capabilities=caps,
payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema),
timeout=_timeout("generate", len(subs)))
if status == FAILED:
_log(topic, f"Anreichern {title} ohne Ergebnis — Facts bleiben leer, Prüfer misst")
if ctx.is_cancelled():
return None
outs = [o for p in paths if (o := _gen_schema(_json_file(p))) is not None]
if not outs:
return None
if len(outs) < len(paths):
_log(topic, f"Generate {title}: nur {len(outs)}/{len(paths)} Generatoren — alles unsicher, Prüfer entscheidet")
# Mentions in die DB (QA-Beleg-Signal), Karten-Re-Spawn darf nicht kumulieren
await db.delete_subblocks(topic, bnorm)
alle: list[tuple[dict, int]] = [] # (sub-Eintrag, Generator-Index)
for gi, subs in enumerate(outs):
seen: set[str] = set()
for e in subs:
sn = _norm_title(e["title"])
if not sn or sn in seen:
continue
seen.add(sn)
alle.append((e, gi))
await db.upsert_subblock(topic, bnorm, sn, title, e["title"])
if not alle:
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
sims = await _sims_of([e["title"] for e, _ in alle])
raw: list[str] = []
out = _gen_schema(_json_file(pfad)) or []
by_norm = {_norm_title(e["title"]): e for e in out}
facts: dict[str, dict] = {}
votes: dict[str, dict] = {}
unsicher: list[dict] = []
for c in _variant_clusters([e["title"] for e, _ in alle], [1] * len(alle), sims):
rep = alle[c["rep"]][0]
sn = _norm_title(rep["title"])
fk = _fk_of(rep)
for m in c["members"]:
if m != c["rep"]:
_facts_union(fk, _fk_of(alle[m][0]))
await db.set_subblock_fields(topic, bnorm, _norm_title(alle[m][0]["title"]),
status="variant")
votes[sn] = {"level": [v for m in c["members"] if (v := alle[m][0]["level"])],
"relevance": [v for m in c["members"] if (v := alle[m][0]["relevance"])]}
gens = {alle[m][1] for m in c["members"]}
# degraded (1 Generator): kein Konsens möglich — alles unsicher, Prüfer entscheidet
if len(gens) >= 2 and len(outs) >= 2:
raw.append(rep["title"])
facts[sn] = fk
await db.set_subblock_fields(topic, bnorm, sn, status="consensus")
else:
unsicher.append({**rep, **fk})
# Seed-Garantie: ungedeckte Seeds gehen als unsicher zum Prüfer (der ist das Beleg-Gate)
for seed in dict.fromkeys(s for s in (seeds or []) if s):
st = _sub_tokens(seed)
gedeckt = [t for t in raw + [u["title"] for u in unsicher]]
if not st or any(st <= _sub_tokens(t) for t in gedeckt):
continue
if gedeckt and EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available):
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + gedeckt)
if sims is not None and max(float(sims[0][j]) for j in range(1, len(gedeckt) + 1)) >= SEED_COVER_COS:
continue
unsicher.append({"title": seed, "level": "", "relevance": "", "key_points": [],
"prerequisites": "", "hurdles": "", "cited_facts": [], "example_idea": ""})
raw_map = {title: raw}
await _dedup_subblocks(topic, raw_map) # deterministischer Near-Dup-Filter
facts = {sn: fk for sn, fk in facts.items()
if sn in {_norm_title(s) for s in raw_map[title]}}
return {"raw": raw_map, "facts": {title: facts}, "unsicher": unsicher, "votes": votes}
for st in subs:
sn = _norm_title(st)
e = by_norm.get(sn)
if e:
fk = _fk_of(e)
votes[sn] = {"level": [e["level"]] if e.get("level") else [],
"relevance": [e["relevance"]] if e.get("relevance") else []}
else: # der Call ließ diesen Sub aus — leere Facts, der Prüfer/QA sieht die Lücke
fk = {k: ([] if k in ("key_points", "cited_facts") else "") for k in _FACTS_FIELDS}
votes[sn] = {"level": [], "relevance": []}
facts[sn] = fk
await db.put_subblock(topic, bnorm, sn, title, st, status="consensus")
return {"raw": {title: subs}, "facts": {title: facts}, "unsicher": [], "votes": votes}
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
@@ -336,11 +292,14 @@ def _default_vote(stimmen: list[str], default: str) -> str:
async def _verify_block(ctx: GenContext, files: dict, title: str, gen: dict, q: dict,
instructions: str = "", ns: str = "", lbl: str = "",
sources: list[str] | None = None, melde=None) -> dict | None:
sources: list[str] | None = None, melde=None,
keep_all: bool = False) -> dict | None:
"""VERIFY_PANEL unabhängige Prüfer auditieren den Block in EINEM Call (MECE-Faltung,
Fremd, Lücken, Unsicher-Übernahme, Facts-Korrektheit, Level/Relevanz). Auswertung mit
Schnittmengen-Semantik pro Befundklasse (Faltung/Fremd/Übernahme einstimmig, Discard
2/2, Korrektur ≥1 Stimme); Fix-Tail ist EIN Call für Korrekturen + belegte Lücken.
keep_all=True (Bottom-up): kein Atom wird entfernt/gefaltet/als neu erfunden — nur
Facts-Korrektur + Level/Relevanz bleiben (Board 1 hat schon MECE geclustert).
{raw, facts, sidecar} | None (nur bei Cancel)."""
topic = ctx.topic
work_dir = files["arbeit"]
@@ -427,6 +386,18 @@ async def _verify_block(ctx: GenContext, files: dict, title: str, gen: dict, q:
if einstimmig:
v1, v2 = verdicts[0], verdicts[1]
if keep_all:
# Bottom-up: jedes Board-1-Atom bleibt. Destruktive/erzeugende Befunde
# neutralisieren — die Loops unten laufen dann leer. Nur Facts-Korrektur
# (ohne discard) + Level/Relevanz überleben.
for v in (v1, v2):
v["fremd"] = set()
v["gruppen"] = []
v["kataloge"] = []
v["uebernehmen"] = {}
v["luecken"] = []
# discard abschalten (kein Atom entfernen), aber den Korrektur-Hinweis behalten
v["facts_probleme"] = [{**p, "discard": False} for p in v["facts_probleme"]]
# 1. Fremd (einstimmig): fürs THEMA fremde Aussagen → discarded
for k in sorted(v1["fremd"] & v2["fremd"]):
t = _titel(k)

View File

@@ -223,7 +223,8 @@ async def _proc_verify(ctx: GenContext, flow: Flow, files: dict, q: dict, instru
"unsicher": p.get("unsicher") or [], "votes": p.get("votes") or {}}
res = await _verify_block(ctx, _pfiles(files, norm), title, gen, q, instructions,
ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
sources=p.get("sources"), melde=_melder(flow, norm))
sources=p.get("sources"), melde=_melder(flow, norm),
keep_all=True)
if res is None:
return None # nur Cancel — Karte bleibt liegen
p["raw"], p["facts"], p["sidecar"] = res["raw"], res["facts"], res["sidecar"]
@@ -484,7 +485,7 @@ async def _proc_finalize(ctx: GenContext, flow: Flow, files: dict, cards):
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
ensure_ascii=False)
await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
await db.kanban_advance(topic, BOARD, c["card_id"], "konsolidierung")
await db.kanban_advance(topic, BOARD, c["card_id"], DONE)
_log(topic, f"Artefakte fertig: {title}")
flow.wake.set()
@@ -529,7 +530,8 @@ async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions:
# ── Stage list (appended after board 1 in chain order) ─────────────────────────────
_ALT_STAGES = ("subblocks", "facts", "levels", "relevance", "question_pattern", "artefacts")
_ALT_STAGES = ("subblocks", "facts", "levels", "relevance", "question_pattern", "artefacts",
"konsolidierung") # abgeschaltete Stage: verwaiste Karten auf generate zurück
async def migriere_alt_karten(topic: str) -> int:
@@ -557,12 +559,8 @@ def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)),
Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)),
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
# Cross-Block-Dedup als END-Barriere: als Mittel-Barriere idelte jede fertige Karte
# auf die langsamste (8:46 min/Block gemessen); jetzt faltet sie nach finalize
# per repair.falte_sub — spät gefundene Dubletten kosten Artefakt-Tokens, keine Wandzeit
Stage(BOARD, "konsolidierung",
lambda cs: _proc_konsolidierung(ctx, flow, files, instructions, cs),
barrier=True, drain=True),
# Cross-Block-Dedup (konsolidierung) entfällt im Bottom-up: Board 1 hat die Atome
# global geclustert/dedupliziert, es gibt keine block-übergreifenden Sub-Dubletten mehr.
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
barrier=True, drain=True, gate=research_done),
]

View File

@@ -1323,8 +1323,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
# ONE wave: TOP + all cluster judges in parallel. The used-ties precedence lives in the
# ORDER of `sources` (TOP first), not in execution order — a failed judge simply leaves
# no valid file and is skipped in the collection below (legacy semantics).
jobs = [("TOP", "Scan the ENTIRE block list below and propose EVERY genuine umbrella "
"you find — do not restrict yourself to any subset.", n)]
jobs = [("TOP", "Card-sort the ENTIRE list below into themes: assign as MANY items as "
"possible to a coherent theme, each item into exactly one.", n)]
jobs += [(str(ci), "\n".join(f"{g + 1}. {texts[g]}" for g in cluster), len(cluster))
for ci, cluster in enumerate(multi)]
await asyncio.gather(*[_assess(tag, cand, count) for tag, cand, count in jobs],
@@ -1346,16 +1346,10 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
members = [m for m in members if m not in used]
if len(members) < 2:
continue
# Bottom-up card-sorting: keine type-gate/min-cos-Vetos mehr — jedes Item soll in
# ein Thema; ein „Fehl-Merge" ist billig (Item bleibt als Sub erhalten, keine Lücke).
mrows = [rows[m - 1] for m in members]
if any(_GROUP_STANDALONE.search(r["title"]) for r in mrows):
skipped.append({"umbrella": title, "grund": "type-gate",
"mitglieder": [r["title"] for r in mrows]})
continue
mc = _min_cos([m - 1 for m in members])
if mc < GROUP_MIN_COS_FLOOR:
skipped.append({"umbrella": title, "grund": "min-cos", "min_cos": mc,
"mitglieder": [r["title"] for r in mrows]})
continue
unorm = _norm_title(title)
member_norms = {r["title_norm"] for r in mrows}
if unorm not in member_norms and unorm in seen_norm:
@@ -1412,7 +1406,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
add = []
for k, new_members in (add or []):
for m in new_members:
if m in used or not (1 <= m <= n) or _GROUP_STANDALONE.search(rows[m - 1]["title"]):
if m in used or not (1 <= m <= n):
continue
used.add(m)
chosen[k]["members"].append(m)
@@ -1622,6 +1616,19 @@ async def _proc_done(ctx: GenContext, flow: Flow, cards):
await db.upsert_block(topic, norm, title, p.get("description", ""), p.get("sources") or [])
await db.set_block_status(topic, norm, "consensus",
title=title, description=p.get("description", ""))
# Bottom-up-Persistenz: die geclusterten Atome bleiben als Subs (jedes Atom genau
# ein Sub). Ein childless Standalone-Block ist sein eigener einziger Sub. Board 2
# reichert diese an, entdeckt sie nie neu — so überlebt jedes Board-1-Atom.
sub_titles = [(ch.get("title") if isinstance(ch, dict) else ch)
for ch in (p.get("children") or [])]
sub_titles = [s for s in sub_titles if s] or [title]
seen_sub: set[str] = set()
for st in sub_titles:
sn = _norm_title(st)
if not sn or sn in seen_sub:
continue
seen_sub.add(sn)
await db.put_subblock(topic, norm, sn, title, st, status="consensus")
mirrored[norm] = c["card_id"]
p.update(mirrored_norm=norm, title=title)
await db.kanban_set_payload(topic, BOARD, c["card_id"], p)

View File

@@ -101,11 +101,14 @@ class Welt:
j = json.dumps
# Board 1 / Inventar
if "-research-" in key:
# Bottom-up: Research liefert die ATOME flach; Board 1 gruppiert sie zu den
# Cluster-Bausteinen. Jedes Atom gehört zu genau einem Cluster.
zeilen = []
n = 1
for t, b in self.bloecke.items():
zeilen.append(f"{n}. {t}{b['beschreibung']}")
n += 1
for cl, b in self.bloecke.items():
for atom in b["subs"]:
zeilen.append(f"{n}. {atom} — Atom aus {cl}")
n += 1
return "\n".join(zeilen)
if "-pair-" in key:
n = prompt.count("\nA: ") or 1
@@ -130,7 +133,19 @@ class Welt:
if "-gruppierung-completion-" in key:
return j({"additions": []})
if "-gruppierung-" in key:
return j({"umbrellas": []})
# jedes Atom seinem Cluster zuordnen (nur die FULL ITEM LIST, 1..n eindeutig)
seg = prompt.split("FULL ITEM LIST", 1)[-1]
atom_cluster = {_norm(a): cl for cl, b in self.bloecke.items() for a in b["subs"]}
umb: dict[str, list[int]] = {}
for m in _NUM_RE.finditer(seg):
nr, zeile = int(m.group(1)), _norm(m.group(2))
for an, cl in atom_cluster.items():
if zeile.startswith(an):
umb.setdefault(cl, []).append(nr)
break
umbrellas = [{"title": cl, "description": self.bloecke[cl]["beschreibung"],
"members": ms} for cl, ms in umb.items() if ms]
return j({"umbrellas": 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"}})
@@ -141,6 +156,16 @@ class Welt:
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-enrich-" in key: # Bottom-up: feste Subs (aus dem Prompt) mit Facts anreichern
seg = prompt.split("FIXED SUBBLOCKS", 1)[-1].split("\n\n", 1)[0]
subs = []
for m in re.finditer(r"^\s*\d+\.\s+(.+)$", seg, re.M):
s = m.group(1).strip()
f = self._fakt("", 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-gen-" in key:
subs = []
for t in self._bloecke_im_prompt(prompt):
@@ -251,13 +276,15 @@ class Welt:
def standard_bloecke() -> dict:
"""3 Blöcke; „Gemeinsamer Grundbegriff" liegt in Alpha UND Beta (Cross-Block-Fall)."""
"""3 Themen-Cluster; die subs sind die ATOME, die Board 1 bottom-up zu genau diesem
Cluster gruppiert. Jedes Atom liegt in genau einem Cluster (kein geteiltes Atom mehr —
das globale Clustern in Board 1 macht Cross-Block-Dubletten unmöglich)."""
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"]},
"subs": ["Beta Ablauf", "Beta Grenzen"]},
"Gamma-Anwendung": {"beschreibung": "Praktische Anwendung",
"subs": ["Gamma Praxisfall", "Gamma Werkzeuge"]},
}
@@ -308,11 +335,18 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
@staticmethod
def embed(texts):
import hashlib
import numpy as np
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
arr = np.zeros((len(texts), max(len(uniq), 1)))
# FESTE Dimension (hash → One-hot): identischer Text = gleiche Spalte = cos 1.0,
# verschiedener Text = andere Spalte = cos 0.0. Anders als eine pro-Aufruf
# variable Breite lassen sich so Vektoren aus verschiedenen embed()-Aufrufen
# concatenieren (Board-1-Cluster-Cache) ohne Dimensions-Mismatch.
D = 4096
arr = np.zeros((len(texts), D))
for r, t in enumerate(texts):
arr[r, uniq[t]] = 1.0
h = int(hashlib.blake2b(t.encode("utf-8"), digest_size=8).hexdigest(), 16) % D
arr[r, h] = 1.0
return arr
@staticmethod
@@ -320,9 +354,14 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
arr = _FakeEmb.embed(texts)
return arr @ arr.T
for mod in (blocks, ba, qa):
setattr_fn(mod, "embedding", _FakeEmb)
# reine Matrix-/Union-Find-Helfer (modell-unabhängig) ans echte Modul delegieren
import embedding as _real_emb
capped_blocks = staticmethod(_real_emb.capped_blocks)
_find = staticmethod(_real_emb._find)
_union = staticmethod(_real_emb._union)
async def emb_ok(flow): # Board-1-Vektorpfade aus — Judge-Wellen reichen
return False
setattr_fn(bi, "_emb_ok", emb_ok)
for mod in (blocks, ba, qa, bi):
setattr_fn(mod, "embedding", _FakeEmb)
# _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage.
# _FakeEmb bildet nur bei identischem Text Nachbarn — verschiedene Atome erzeugen keine
# falschen Vorab-Cluster; das eigentliche Clustern macht der TOP-Judge (Card-sort).

View File

@@ -123,86 +123,74 @@ def env(testdb, tmp_path, monkeypatch):
return testdb, _ctx(), {"arbeit": tmp_path}
def _mk_gen_race(outputs):
"""_race-Fake: pro Generator-Slot (…-gN) die gescriptete Antwort als Text;
fehlender Eintrag = Ausfall."""
def _mk_enrich_slot(output):
"""run_single_slot-Fake für die Anreicherung: schreibt die gescriptete Antwort via
payload (wie der Engine-Sink); output=None → Ausfall."""
calls = []
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
cancelled=None, **kw):
outs = []
for slot in slots:
calls.append(slot["key"])
g = int(slot["key"].rsplit("-g", 1)[1])
out = outputs.get(g)
if out is not None:
outs.append(slot["payload"]((0, json.dumps(out), "")))
return [o for o in outs if o] or None
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if output is None:
return FAILED, None
return OK, payload((0, json.dumps(output), ""))
fake_race.calls = calls
return fake_race
fake.calls = calls
return fake
async def test_generate_schnittmenge_wird_consensus(env, monkeypatch):
"""Von beiden Generatoren genannt → consensus (Facts-Union); Einzelnennungen
werden unsicher und gehen zum Prüfer."""
async def test_enrich_reichert_feste_subs_an(env, monkeypatch):
"""Die Subs stehen fest (Board 1). EIN Call füllt Facts + Level + Relevanz — er
erfindet und entfernt nichts; die Board-1-Subs bleiben in Reihenfolge."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A", kp=["k1"]), _sub("Sub B")]},
2: {"subs": [_sub("Sub A", kp=["k2"]), _sub("Sub C")]},
}))
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [
_sub("Sub A", kp=["k1"]),
_sub("Sub B", level="expert", relevance="peripheral", kp=["k2"])]}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": ["Sub A"]}
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1", "k2"] # Union beider Nennungen
assert {u["title"] for u in gen["unsicher"]} == {"Sub B", "Sub C"}
assert gen["votes"]["sub a"]["level"] == ["beginner", "beginner"]
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1"]
assert gen["unsicher"] == []
assert gen["votes"]["sub b"] == {"level": ["expert"], "relevance": ["peripheral"]}
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "consensus"
assert rows["sub b"] == rows["sub c"] == "candidate"
assert rows["sub a"] == rows["sub b"] == "consensus"
async def test_generate_degraded_alles_unsicher(env, monkeypatch):
"""Liefert nur EIN Generator, ist kein Konsens möglich — alles wird unsicher,
der Prüfer entscheidet mit Material."""
async def test_enrich_ohne_subs_kein_call(env, monkeypatch):
"""Kein Board-1-Sub → nichts anzureichern, kein Modell-Call."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A"), _sub("Sub B")]}, # g2 fällt aus
}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": []}
assert {u["title"] for u in gen["unsicher"]} == {"Sub A", "Sub B"}
assert not any(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "alpha"))
called = []
async def spy(*a, **k):
called.append(1)
return OK, None
monkeypatch.setattr(bc, "run_single_slot", spy)
gen = await bc._generate_block(ctx, files, "Alpha", "d")
assert gen == {"raw": {"Alpha": []}, "facts": {"Alpha": {}}, "unsicher": [], "votes": {}}
assert called == []
async def test_generate_beide_ausgefallen_ist_none(env, monkeypatch):
async def test_enrich_ausgelassener_sub_bleibt_leer(env, monkeypatch):
"""Lässt der Call einen Sub aus, bleibt er erhalten (Vollständigkeit) — mit leeren
Facts, die der Prüfer/QA als Lücke sieht."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({}))
assert await bc._generate_block(ctx, files, "Alpha", "d") is None
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]}))
gen = await bc._generate_block(ctx, files, "Alpha", "d")
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
assert gen["facts"]["Alpha"]["sub b"]["key_points"] == []
assert gen["facts"]["Alpha"]["sub b"]["cited_facts"] == []
async def test_generate_seed_garantie(env, monkeypatch):
"""Ungedeckte Seeds gehen als unsicher zum Prüfer (Beleg-Gate liegt dort);
lexikalisch gedeckte Seeds erzeugen keine Dublette."""
async def test_enrich_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene enrich-Datei → kein neuer Call, Ergebnis wird übernommen."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]},
}))
gen = await bc._generate_block(ctx, files, "Alpha", "d",
seeds=["Escaping Regeln", "Sub"])
assert gen["raw"] == {"Alpha": ["Sub A"]}
assert [u["title"] for u in gen["unsicher"]] == ["Escaping Regeln"] # „Sub" ist gedeckt
assert gen["unsicher"][0]["key_points"] == [] # Seeds kommen ohne Beleg
async def test_generate_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene gen-Dateien → kein neuer _race-Call, Ergebnis wird übernommen."""
db, ctx, files = env
fake = _mk_gen_race({1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]}})
monkeypatch.setattr(bc, "_race", fake)
await _seed_rows(db, "alpha", ["Sub A"])
fake = _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]})
monkeypatch.setattr(bc, "run_single_slot", fake)
gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
n = len(fake.calls)
gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
assert len(fake.calls) == n # alles resumed
assert len(fake.calls) == n # aus Datei resumed
assert gen2["raw"] == gen1["raw"]
@@ -275,24 +263,27 @@ async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
assert rows["css regel"] == "discarded" and rows["echte regel"] == "consensus"
async def test_verify_uebernahme_braucht_beide(env, monkeypatch):
"""Unsicher-Eintrag wird nur bei 2/2 „ja" consensus (samt Generator-Facts);
sonst discarded."""
async def test_verify_keep_all_behaelt_alle_atome(env, monkeypatch):
"""Bottom-up (keep_all=True): kein Board-1-Atom wird entfernt oder gefaltet, selbst
wenn beide Prüfer fremd/gruppen/discard melden. Nur Facts-Korrektur (Hinweis) und
Level/Relevanz greifen weiter."""
db, ctx, files = env
subs = ["Sub A"]
unsicher = [_sub("Unsicher B", kp=["kp b"]), _sub("Unsicher C")]
subs = ["Sub A", "Sub B", "Sub C"]
await _seed_rows(db, "alpha", subs)
await _seed_rows(db, "alpha", ["Unsicher B", "Unsicher C"], status="candidate")
fake = _judge_slot({"1": {"uebernehmen": {"2": "ja", "3": "ja"}},
"2": {"uebernehmen": {"2": "ja", "3": "nein"}}})
fix = {"subs": [_sub("Sub A", kp=["korrigiert"])]}
verdikt = {"fremd": [3], "gruppen": [{"haupt": 1, "weitere": [2]}],
"facts_probleme": [{"nr": 1, "discard": True, "hinweis": "Zahl falsch"},
{"nr": 2, "discard": True}],
"levels": {"2": "expert"}}
fake = _judge_slot({"1": verdikt, "2": verdikt}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
assert res["raw"] == {"Alpha": ["Sub A", "Unsicher B"]}
assert res["facts"]["Alpha"]["unsicher b"]["key_points"] == ["kp b"]
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {}, keep_all=True)
assert res["raw"] == {"Alpha": subs} # alle drei bleiben — nichts entfernt/gefaltet
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["unsicher b"] == "consensus" and rows["unsicher c"] == "discarded"
# der Prüfer-Prompt weist die Unsicher-Nummern aus
assert "UNSICHER" in fake.calls[0]["prompt"] and "entries 23" in fake.calls[0]["prompt"]
assert all(rows[_norm_title(s)] == "consensus" for s in subs)
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["facts"]["key_points"] == ["korrigiert"] # Hinweis trotz discard=True
assert side["Sub B"]["level"] == "expert" # Level-Korrektur greift
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):

View File

@@ -78,7 +78,8 @@ async def board_env(testdb, tmp_path, monkeypatch):
for s in subs}},
"unsicher": [], "votes": {}}
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None, melde=None):
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
melde=None, keep_all=False):
subs = gen["raw"].get(title) or []
bfacts = gen["facts"].get(title) or {}
sidecar = [{"title": s, "level": "beginner",
@@ -776,9 +777,11 @@ async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
base_verify = ba._verify_block
snapshot = {}
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None, melde=None):
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
melde=None, keep_all=False):
await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl, sources=sources)
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl,
sources=sources, keep_all=keep_all)
base_outline = ba._outline_block

View File

@@ -42,11 +42,16 @@ async def test_e2e_thema_vollpfad(fake_welt, testdb, tmp_path):
db = testdb
done = [c for c in await db.kanban_cards(TOPIC, board="inventory", stage="done_block")]
titel = {c["payload"]["title"] for c in done}
# Bottom-up: 7 Atome zu 3 Themen-Clustern gruppiert
assert titel == {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"}
# Cross-Block-Dublette: „Gemeinsamer Grundbegriff" überlebt in genau EINEM Block
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
# jedes Atom liegt in genau EINEM Cluster — keine Cross-Block-Dublette mehr
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "variant"]
assert [r["status"] for r in gemeinsam] == ["consensus"]
# Vollständigkeit: alle 7 Atome sind als consensus-Sub erhalten
consensus = {r["sub_norm"] for r in subs if r["status"] == "consensus"}
assert consensus == {"definition alpha", "alpha eigenschaften", "gemeinsamer grundbegriff",
"beta ablauf", "beta grenzen", "gamma praxisfall", "gamma werkzeuge"}
fehler = await pruefe_invarianten(TOPIC, files)
assert fehler == []
@@ -83,9 +88,7 @@ async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
@pytest.mark.parametrize("stoerung", [
{"muster": r"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE
{"muster": r"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE
{"muster": r"-sb-gen-.*-g1$", "modus": "fehler", "mal": 1}, # degraded: 1 Generator
{"muster": r"-art-gen-.*-t1$", "modus": "fehler", "mal": 1}, # Slot-Restart
{"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot
])
@@ -97,35 +100,6 @@ async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_crossblock_dissent_failopen(fake_welt, testdb, tmp_path):
"""j1 sagt a, j2 sagt b, j3 fällt aus → Paar bleibt (fail-open), Rest konsistent."""
fake_welt.stoerungen += [
{"muster": r"-sub-crossblock-.*-j2$", "modus": "antwort",
"antwort": '{"pairs": {"1": "b"}}', "mal": 1, "rest": 1},
{"muster": r"-sub-crossblock-.*-j3$", "modus": "fehler", "mal": 3, "rest": 3},
]
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "consensus"] # kein Fold
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_inblock_gruppe_faltet(fake_welt, testdb, tmp_path):
"""Welt-Regel: „Alpha Eigenschaften" faltet unter „Definition Alpha" — beide Judges
liefern die Gruppe, der Verlierer wird variant, seine facts wandern zum Gewinner."""
fake_welt.gruppen.append(("Definition Alpha", ["Alpha Eigenschaften"]))
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha-konzept")}
assert rows.get("alpha eigenschaften") == "variant"
assert rows.get("definition alpha") == "consensus"
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_gate_vollinventur_ohne_fix(fake_welt, testdb, tmp_path):
"""Gate-Judge liefert eine Voll-Inventur (belegte Claims mit „Belegt…"-Grund) —
der Schema-Filter wirft sie raus, es läuft KEIN Fakten-Fix."""
@@ -189,10 +163,11 @@ async def test_e2e_uni_anker_gate(fake_welt, testdb, tmp_path, monkeypatch):
assert ok
db = testdb
alle = [dict(c) for c in await db.kanban_cards(TOPIC, board="inventory")]
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Kanon-Klassiker"
# das unbelegte Atom „Klassiker Detail" (einziges Atom von Kanon-Klassiker) wird rejected
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Klassiker Detail"
for c in alle)
assert not any(c["kind"] == "block" and c["payload"].get("title") == "Kanon-Klassiker"
for c in alle) # nie zum Block geworden
assert not any(c["payload"].get("title") in ("Klassiker Detail", "Kanon-Klassiker")
and c["stage"] == "done_block" for c in alle) # nie zum Block geworden
done = {c["payload"].get("title") for c in alle
if c["kind"] == "block" and c["stage"] == "done_block"}
assert {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"} <= done