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

File diff suppressed because it is too large Load Diff

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

View File

@@ -214,7 +214,7 @@ function removeArtefactsClick() {
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('artefacts')">{{ repairBusy === 'artefacts' ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.artefacts" class="repair-info">{{ repairInfo.artefacts }}</span>
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen (Fortsetzen)" @click="emit('continueAll')">Generieren</button>
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen (Fortsetzen)" @click="emit('continueAll', { qaForce: true })">Generieren</button>
<button v-if="ready || partial" class="gen-act danger" :class="{ armed: isArmed('remove-art') }"
title="Nur die Artefakte leeren — Inventar bleibt"
@click="armOrRun('remove-art', removeArtefactsClick)">{{ isArmed('remove-art') ? 'Sicher?' : 'Entfernen' }}</button>

View File

@@ -2,7 +2,7 @@
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBlockLearnState } from '../api.js'
import { renderMarkdown } from '../markdown.js'
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE } from '../levels.js'
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, BADGE_VIEW_RANK } from '../levels.js'
import { useChat } from '../composables/useChat.js'
import BlockPanel from './BlockPanel.vue'
import BlockFocus from './BlockFocus.vue'
@@ -36,9 +36,12 @@ let mdObserver = null
// Auto: Ansichtsstufe des Blocks folgt dem Prüfungs-Score (Erreicht + 1); Override = global fest.
function viewLevelFor(title) {
if (props.stufeAnsicht !== 'auto') return Number(props.stufeAnsicht)
// Auto = real freigeschaltete Stufe je Baustein (Backend `freie_level` aus dem Prüf-Score):
// Stufe A initial, die nächste Stufe erst, wenn die Prüf-Grenze erreicht ist.
return learnstate.value[title]?.freie_level || 1
// Auto = an die erreichte Badge-Stufe gekoppelt (gleiche Schwelle wie das Level-Abzeichen):
// Advanced-Badge → advanced-Subs sichtbar. Vorher hing es an `freie_level` (Sub-Zahl × 25),
// das bei vielen beginner-Subs SPÄTER umschaltet als der Badge (0.4 × cap) — dann zeigte der
// Badge „Advanced", die advanced-Inhalte blieben aber verborgen.
const st = levelOf(title)
return st ? (BADGE_VIEW_RANK[st.key] || 1) : 1
}
function htmlFor(s) {

View File

@@ -28,6 +28,10 @@ export function malusRegel(score, cap) {
return '20'
}
// Reached badge level (LEVELS key) → view level 1..4, so unlocked SUBS match the badge:
// Advanced-Badge → advanced subs visible. Same threshold as stufeFuer, no drift.
export const BADGE_VIEW_RANK = { beginner: 1, advanced: 2, expert: 3, master: 4 }
// Sub-level tag (from the guide markers) → view level 1..4 (A/F/E/V).
export const SUB_RANK = { beginner: 1, advanced: 2, expert: 3, peripheral: 4, einfach: 1, mittel: 2, schwer: 3 }
export const VIEW_KURZ = { 1: 'A', 2: 'F', 3: 'E', 4: 'V' }

View File

@@ -1,18 +1,17 @@
Topic "{topic}". Umbrella blocks were formed, each bundling the constituent parts of ONE model/definition. Some parts were missed and are still listed as standalone blocks. Your job: for each umbrella, find which of the remaining standalone blocks are ALSO constituent parts of that same parent, so the model is complete.
Topic "{topic}". Theme blocks were formed by card-sorting a fine-grained item list. Some items were left unassigned. Your job: attach EACH remaining item to the theme it belongs to, so every item lands in a theme. This is organisation, not judgement — items are never discarded.
UMBRELLAS (parent — already-collected parts):
THEMES (heading — already-assigned members):
{umbrellas}
REMAINING STANDALONE BLOCKS (numbered):
REMAINING ITEMS (numbered):
{rest}
## Rule — attach a block to an umbrella only if BOTH hold
1. **Presupposition:** the block's definition **requires the umbrella's parent to already exist** — it makes no sense as a topic on its own without that model (e.g. „Alphabet Σ", „Übergangsfunktion δ", „Konfiguration", „Akzeptierende Berechnung" all presuppose the Turing-machine; „Literale", „Klausel", „Belegung" presuppose the KNF/logic definition). The parent must NOT presuppose the block (directional).
2. **Not standalone:** the block is a *definitional component / notation*, NOT itself a named **algorithm, problem, theorem, reduction, or complexity class** (those stay their own block — a downstream guard will reject them anyway).
Do NOT attach a block merely because it shares a topic. When unsure → leave it standalone. Most remaining blocks will NOT be attached; a few genuine missed parts will.
## Rule
- Attach each remaining item to the ONE theme whose subject it shares — the theme a learner would meet it under (a variable/flag/field/instance/facet of that theme's model, system, setup, tool family, or category).
- Assign as many as you reasonably can. Only leave an item unassigned if it fits NO theme at all — it then becomes its own single-item block (a genuinely standalone concept).
- Each item goes into at most ONE theme.
Write ONLY the JSON file to: {out_path}
Format (`additions` may be empty; `umbrella` = the UMBRELLA index, `members` = standalone block numbers to attach):
Format (`additions` may be empty; `umbrella` = the THEME index, `members` = item numbers to attach):
{{"additions": [{{"umbrella": 0, "members": [8, 12, 34]}}]}}

View File

@@ -1,35 +1,23 @@
Topic "{topic}". A previous step produced a flat list of learning blocks that is TOO FINE-GRAINED — several blocks are **constituent sub-definitions / notation of ONE larger definition or model** and should become a single umbrella block. Find these groups. A good run finds several genuine umbrellas AND leaves most blocks standalone; judge each candidate on its merits.
Topic "{topic}". A previous step produced a flat list of learning items that is TOO FINE-GRAINED for a table of contents — single environment variables, single CLI flags, single config fields, near-synonyms. Your job is **card sorting**: group the items into a small set of **theme blocks**, so that EVERY item lands in exactly one theme. Nothing is discarded — grouping never loses an item; it only organises them.
**Propose real umbrellas, judge each on its merits.** Merge when the members are constituent parts of one whole (test 3); keep a member separate when it stands as a full, usable unit on its own. Do NOT withhold a genuine merge merely because the members are lexically dissimilar (facets of one whole routinely are) or because you are unsure of the parent's exact name.
Think of the result as chapters: ~1525 themes for a whole topic, each a coherent learning block that a learner meets as one unit. A theme with only one fitting item is fine (a genuinely standalone concept), but prefer pulling related items together over leaving many singletons.
CANDIDATES (your starting point):
CANDIDATES you may group (your starting point — a pre-clustered neighbourhood):
{candidates}
FULL BLOCK LIST (you may pull in ANY numbers below that are constituents of the same definition):
FULL ITEM LIST (you may pull in ANY numbers below that share the theme):
{list}
## Merge test — propose an umbrella when ALL THREE hold
1. **One parent.** The members are constituent parts/facets of ONE named parent (a model, system, setup, or definition) — each member PRESUPPOSES that parent: you cannot introduce it without first invoking the parent, and it has no purpose outside it.
2. **Studied together.** A learner meets them together as one unit.
3. **No standalone unit among them — autonomy test (decisive).** Remove the OTHER members, then ask of each: does it still stand as a COMPLETE, usable unit with its own goal? YES → autonomous → a SIBLING, keep separate (dropping the others took nothing from it). NO → alone it serves no goal and exists only to build the shared parent → a constituent PART, merge it. Structural containment („X is a file/step/field of Y") is NOT the test — autonomy is: own goal, complete without the siblings. This holds in every domain.
## How to form a theme
- **One coherent subject.** The members belong together because a learner would study them as one block — the parts/facets/instances of one model, system, setup, tool family, or category. Examples of the *kind* of grouping (not domain rules): many individual settings/variables/flags of one configuration → one „…-Konfiguration" theme; the parts of one setup → one „…-Setup" theme; a base concept plus its variants/instances → one theme.
- **Lexical dissimilarity is NORMAL.** Facets of one theme often read very differently — that is not a reason to split them.
- **Title:** name the theme after the shared subject. A real self-contained heading; must NOT contain „ — " (reserved separator) — use „(…)" or „:".
- **Description:** name EVERY member explicitly (the next step recovers them as sub-points). E.g. „Environment-Konfiguration: APP_ENV, APP_SECRET, DATABASE_URL, MAILER_DSN, …".
- **Members:** the item NUMBERS from the full list. Each number goes into exactly ONE theme; do not repeat a number across themes.
*Note on test 1: „can this be defined at all?" is the WRONG question — Alphabet Σ and DTM CAN be stated in isolation, yet in THIS topic they are parts of the Turing-machine model and belong together. The question is whether the member PRESUPPOSES the shared parent, not whether a standalone sentence exists.*
## Examples
DO NOT MERGE — distinct named units that merely share a topic:
- „Greedy-Algorithmus GA" + „ModifiedGreedy" + „Multiple-Choice-Knapsack" → two algorithms + a problem, each standalone (test 3 fails). Keep separate.
- „Plugin" + „Plugin-Konfiguration (config.xml)" + „Plugin lifecycle" → „Plugin-Konfiguration" is its OWN unit (authoring config fields, snippets, its own tasks), a SIBLING of „Plugin", not a facet (test 3 fails). Structural containment — the file belongs to the plugin — is NOT the merge test; being studied as one definition is. Keep „Plugin-Konfiguration" standalone.
MERGE — one definition decomposed (the canonical cases — end here so this is your default lens):
- „Alphabet Σ" + „NDTM" + „DTM" + „Akzeptierende Berechnung" + „Folgekonfiguration" → ONE umbrella **„Turingmaschine (Modell)"**. (The members are lexically very different from each other — that is EXPECTED for facets of one model and is NOT a reason to keep them apart.)
- „Klausel" + „Boolesche Variable" + „Erfüllende Belegung" + „KNF" → ONE umbrella **„Aussagenlogik & KNF"**.
## Synthesize each umbrella
- `title`: the parent concept's name (e.g. „Turingmaschine (Modell)"). A real self-contained definition; must NOT contain „ — " (a reserved separator) — use „(…)" or „:".
- `description`: **name EVERY merged child explicitly** — the next step recovers the children as sub-points from the source. E.g. „Formales TM-Modell: Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung, Folgekonfiguration."
- `members`: the block NUMBERS (from the full list) folded in. At least 2 per umbrella; each number appears in at most one umbrella.
Assign as many items as you confidently can. Items you are unsure about you may leave out — a later completion step and a singleton fallback catch them; never force a bad fit.
Write ONLY the JSON file to: {out_path}
Format (`umbrellas` may be empty):
{{"umbrellas": [{{"title": "Turingmaschine (Modell)", "description": "Formales TM-Modell: Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung, Folgekonfiguration.", "members": [1, 2, 5, 12, 34]}}]}}
Format (`umbrellas` = the themes; may be empty if nothing fits together):
{{"umbrellas": [{{"title": "Environment-Konfiguration", "description": "Konfiguration über Umgebungsvariablen: APP_ENV, APP_SECRET, DATABASE_URL, MAILER_DSN, .env-Dateien.", "members": [1, 2, 5, 12, 34]}}]}}

View File

@@ -0,0 +1,32 @@
The block below (topic "{topic}") is ALREADY decomposed into its subblocks — the list is FIXED. Your job is NOT to find subblocks, but to extract for EACH given subblock its learning facts from the material. A later guide writes a short text PER subblock; the facts are the binding basis for guide text, levels, and exam questions — they must be **correct**.
BLOCK:
{block}
FIXED SUBBLOCKS (numbered — return one entry per subblock, in this order):
{subs}
{source}
HARD RULES:
- Return EXACTLY these subblocks — do NOT add, remove, split, merge or rename any. Copy each `title` VERBATIM from the list above (code identifiers stay original).
- One entry per given subblock, same order. If the material barely covers a subblock, still return it with whatever is backable (empty fields are fine) — never drop it.
Per subblock, collect ONLY the essentials (all field content in GERMAN; technical terms/code identifiers stay original):
- **level**: beginner | advanced | expert — difficulty within this block.
- **relevance**: relevant (core of the topic) | peripheral (edge knowledge).
- **key_points**: 13 concise statements — what must one understand?
- **prerequisites**: what must one know beforehand (a half-sentence)? Empty if nothing.
- **hurdles**: typical beginner misconception (a half-sentence). Empty if none.
- **cited_facts**: hard facts (definitions, formulas, values, names) — **only what the excerpts back**, each with the location (e.g. „Skript Def. 6.3, Z.66"). Invent no values, compute nothing yourself.
- **example_idea**: ONE example that carries understanding — freely phrased. Empty if an example adds nothing.
HARD SEPARATION: `cited_facts` = only backable material. A worked example, an invented sentence, a constructed case → `example_idea`, NEVER `cited_facts`. When in doubt: better to leave a field empty than to claim falsely.
Reply with ONLY the JSON as your final message — no code fences, no other text. EXACTLY this format:
{{"subs": [
{{"title": "…", "level": "beginner|advanced|expert", "relevance": "relevant|peripheral",
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
]}}
{extra}

View File

@@ -1,26 +1,18 @@
You are auditing the decomposition of ONE block of the topic "{topic}" into subblocks. Below are the numbered subblocks with their captured facts, then source excerpts. Apply the 100%-decomposition test: no entry removable without a gap, none addable without duplication — and verify the facts.
You are auditing ONE block of the topic "{topic}". Its subblocks are FIXED (already decomposed and deduplicated upstream) — you do NOT add, remove, merge or split them. You only verify the captured FACTS against the source and correct the level/relevance classification.
BLOCK: {block}
SUBBLOCKS (numbered; key points indented):
{subs}
{unsicher}
{source}
Judge ALL of the following (use the numbers):
1. **gruppen** — entries that state the SAME thing or where one is a subset of the other: group them and name the number that should remain (`haupt` = the base statement, not the detail).
2. **kataloge** — pure enumeration entries of ONE kind (e.g. five option rows): bundle them under ONE short GERMAN collective title.
3. **fremd** — entries off-topic for the topic "{topic}" (not this block — the TOPIC).
4. **luecken** — essential aspects of THIS block that the excerpts cover but no entry captures (short German phrases). Only real gaps, no nice-to-haves.
5. **uebernehmen** — for each UNSICHER-numbered entry: "ja" if the excerpts back it and it fills a real spot, else "nein".
6. **facts_probleme** — entries whose facts contain something wrong or unbackable: `discard: true` ONLY if the entry as a whole is unsupportable in substance; otherwise `discard: false` with a short `hinweis` what to correct.
7. **levels** / **relevanz** — ONLY entries whose level (beginner/advanced/expert) or relevance (relevant/peripheral) is clearly wrong: number → correct value.
Judge from the excerpts only, using the numbers:
1. **facts_probleme** — entries whose captured facts contain something wrong or unbackable: give a short `hinweis` what to correct (keep `discard: false` the subblock itself always stays; only its facts get fixed).
2. **levels** / **relevanz** — ONLY entries whose level (beginner/advanced/expert) or relevance (relevant/peripheral) is clearly wrong: number → correct value.
Judge ONLY from the excerpts. If everything is fine, return empty lists/objects.
If everything is fine, return empty lists/objects.
Reply with ONLY the JSON — no code fences, no other text. Format:
{{"gruppen": [{{"haupt": 1, "weitere": [4]}}], "kataloge": [{{"titel": "…", "mitglieder": [2, 5]}}],
"fremd": [7], "luecken": ["…"], "uebernehmen": {{"9": "ja"}},
"facts_probleme": [{{"nr": 3, "discard": false, "hinweis": "…"}}],
{{"facts_probleme": [{{"nr": 3, "discard": false, "hinweis": "…"}}],
"levels": {{"2": "expert"}}, "relevanz": {{"5": "peripheral"}}}}
{extra}