update
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 2–3" 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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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]}}]}}
|
||||
|
||||
@@ -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: ~15–25 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]}}]}}
|
||||
|
||||
32
templates/Prompt/Subblock-Anreichern.md
Normal file
32
templates/Prompt/Subblock-Anreichern.md
Normal 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**: 1–3 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}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user