"""Board 2, verschmolzene Call-Struktur: 3 Bausteine pro Block statt ~20 serieller Segmente. Die alte Stage-Treppe (Finder-Runden → Facts find/erg/check → Konsolidierung → Lücken → Nachfass → Levels → Relevanz → Fragen → Kritik → Flashcards → Beispiele → Check) kostete pro Block 35–55 Calls und ~14 min Wandzeit — bei p50 20–50 s pro Call zählt NUR die Zahl der seriellen Segmente. Hier: Generate(∥2) → Verify(∥2, + Fix-Tail) → Artefakte(Gen+Check) = 4–5 Segmente, 6–9 Calls. Unabhängigkeit bleibt: Generatoren und Prüfer sind getrennte Agenten, Konsens (≥2 unabhängige Nennungen) und Einstimmigkeits-Faltung wie zuvor. Output-Kontrakt unverändert (finalize/QA/Guide/Übungssystem lesen dieselben Strukturen): raw {block: [sub]}, facts {block: {sub_norm: 5-Felder}}, sidecar {block: [{title, level, relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashcard/example}.""" import asyncio import hashlib import logging import database as db import embedding from blocks import ( _SOURCE_TEMPLATE, _FACTS_FIELDS, _agreed_cliques, _cited_evidence, _dedup_subblocks, _evidence_pack, _facts_lines, _facts_union, _luecken_schnitt, _neg_set, _pairs_of, _sink_json, _sub_tokens, _subs_hash, _variant_clusters, load_source, material_folder, source_folder, ) from config import (ART_SPLIT_SUBS, EMBEDDING_AKTIV, GEN_PANEL, SEED_COVER_COS, VERIFY_PANEL) from jsonio import read_json_file as _json_file from pipeline import FAILED, GenContext, _extra, _log, _prompt, _race, _timeout, run_single_slot from textkit import _norm_title, clean_title log = logging.getLogger("creator.block_calls") _STUFEN = ("beginner", "advanced", "expert") _RELEVANZ = ("relevant", "peripheral") def _h8(*parts: str) -> str: return hashlib.md5("|".join(parts).encode("utf-8")).hexdigest()[:8] # ── Schemas ───────────────────────────────────────────────────────────────────────── def _gen_schema(data) -> list[dict] | None: """{"subs": [{title, level, relevance, …facts}]} → normalisierte Liste · sonst None. Feld-Normalisierung wie _facts_schema; ungültiges level/relevance fällt auf "" (die Stimme entfällt im Vote, der Sub bleibt).""" if not isinstance(data, dict) or not isinstance(data.get("subs"), list): return None out = [] for e in data["subs"]: if not isinstance(e, dict) or not str(e.get("title", "")).strip(): continue bf = [{"text": t, "source": str(f.get("source", "")).strip()} for f in (e.get("cited_facts") or []) if isinstance(f, dict) and (t := str(f.get("text", "")).strip())] lv = str(e.get("level", "")).strip().casefold() rv = str(e.get("relevance", "")).strip().casefold() out.append({ "title": clean_title(str(e["title"]).strip()), "level": lv if lv in _STUFEN else "", "relevance": rv if rv in _RELEVANZ else "", "key_points": [k for x in (e.get("key_points") or []) if (k := str(x).strip())], "prerequisites": str(e.get("prerequisites", "")).strip(), "hurdles": str(e.get("hurdles", "")).strip(), "cited_facts": bf, "example_idea": str(e.get("example_idea", "")).strip(), }) return out or None def _vid(x, n: int) -> int | None: """Prüfer-Nummer → int in 1..n, else None (bools sind keine ids).""" if isinstance(x, bool): return None if isinstance(x, str) and x.isdigit(): x = int(x) return x if isinstance(x, int) and 1 <= x <= n else None def _verify_schema(data, n: int) -> dict | None: """Prüfer-Output → normalisiertes Verdikt · None wenn kaputt. Alle Felder optional außer der Grundform (dict) — ein leeres Verdikt {"gruppen": []} heißt „alles ok".""" if not isinstance(data, dict): return None pflicht = ("gruppen", "kataloge", "fremd", "luecken", "uebernehmen", "facts_probleme", "levels", "relevanz") if not any(k in data for k in pflicht): return None def _ids(lst): return sorted({i for x in (lst or []) if (i := _vid(x, n)) is not None}) gruppen = [] for g in data.get("gruppen") or []: if not isinstance(g, dict): continue haupt = _vid(g.get("haupt"), n) ids = _ids(([haupt] if haupt else []) + list(g.get("weitere") or [])) if len(ids) >= 2: gruppen.append({"haupt": haupt if haupt in ids else None, "ids": ids}) kataloge = [] for k in data.get("kataloge") or []: if not isinstance(k, dict): continue ids = _ids(k.get("mitglieder")) titel = str(k.get("titel") or "").strip() if len(ids) >= 2 and titel: kataloge.append({"titel": titel, "ids": ids}) uebernehmen = {} for k, v in (data.get("uebernehmen") or {}).items() if isinstance(data.get("uebernehmen"), dict) else []: if (i := _vid(k, n)) is not None: uebernehmen[i] = str(v).strip().casefold() probleme = [] for p in data.get("facts_probleme") or []: if isinstance(p, dict) and (i := _vid(p.get("nr"), n)) is not None: probleme.append({"nr": i, "discard": bool(p.get("discard")), "hinweis": str(p.get("hinweis", "")).strip()}) def _enum_map(key, allowed): out = {} raw = data.get(key) for k, v in (raw.items() if isinstance(raw, dict) else []): if (i := _vid(k, n)) is not None and str(v).strip().casefold() in allowed: out[i] = str(v).strip().casefold() return out return {"gruppen": gruppen, "kataloge": kataloge, "fremd": set(_ids(data.get("fremd"))), "luecken": [s.strip() for s in data.get("luecken") or [] if isinstance(s, str) and s.strip()], "uebernehmen": uebernehmen, "facts_probleme": probleme, "levels": _enum_map("levels", _STUFEN), "relevanz": _enum_map("relevanz", _RELEVANZ)} def _pattern_liste(lst) -> list[dict]: out = [] for e in lst or []: if isinstance(e, dict): blk, sub, q = (str(e.get(k, "")).strip() for k in ("block", "subblock", "question")) if blk and sub and q: out.append({"block": blk, "subblock": sub, "question": q}) return out def _art_gen_schema(data) -> dict | None: """{"pattern": […], "cards": […], "examples": […]} → normalisiert · None wenn kaputt. pattern ist Pflicht (Leitner hängt an Fragen), cards/examples best-effort.""" if not isinstance(data, dict): return None pattern = _pattern_liste(data.get("pattern")) if not pattern: return None cards = [] for e in data.get("cards") or []: if isinstance(e, dict): blk, sub, q, a = (str(e.get(k, "")).strip() for k in ("block", "subblock", "question", "answer")) if blk and sub and q and a: cards.append({"block": blk, "subblock": sub, "question": q, "answer": a}) examples = [] for e in data.get("examples") or []: if isinstance(e, dict): blk, sub, pr, res = (str(e.get(k, "")).strip() for k in ("block", "subblock", "problem", "result")) steps = [s for x in (e.get("steps") or []) if (s := str(x).strip())] if blk and sub and pr and steps: examples.append({"block": blk, "subblock": sub, "problem": pr, "steps": steps, "result": res}) return {"pattern": pattern, "cards": cards, "examples": examples} def _art_check_schema(data) -> dict | None: """{"ok": true} → leeres Verdikt · sonst pattern (bereinigt) + pattern_ergaenzt + examples_probleme (1-basierte Indizes).""" if not isinstance(data, dict): return None if data.get("ok") is True: return {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()} if not any(k in data for k in ("pattern", "pattern_ergaenzt", "examples_probleme")): return None probleme = set() for p in data.get("examples_probleme") or []: i = p.get("index") if isinstance(p, dict) else p if isinstance(i, str) and i.isdigit(): i = int(i) if isinstance(i, int) and not isinstance(i, bool) and i >= 1: probleme.add(i) return {"pattern": _pattern_liste(data.get("pattern")), "pattern_ergaenzt": _pattern_liste(data.get("pattern_ergaenzt")), "examples_probleme": probleme} # ── Gemeinsames ───────────────────────────────────────────────────────────────────── def _inline_source(topic: str, sources: list[str] | None, queries: list[str]) -> tuple[str, str]: """→ (source-Slot, capabilities). Korpus-Auszüge inline (uni/projekt/link oder thema-Research-Material); ohne Treffer fail-open auf die alte Selbst-Recherche.""" mat = material_folder(topic) ev = _evidence_pack(mat, sources, queries) if mat else "" if ev: return _prompt("Blocks-Source-Inline", excerpts=ev), "none" _type = load_source(topic).get("type", "thema") folder = source_folder(topic) if _type in _SOURCE_TEMPLATE: return _prompt(_SOURCE_TEMPLATE[_type], project=folder), ("files" if folder else "full") return _prompt("Blocks-Source-Thema", topic=topic), "full" async def _sims_of(titles: list[str]): """Ähnlichkeitsmatrix fürs Variant-Clustering; ohne Modell exakte Norm-Gleichheit.""" if EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available): sims = await asyncio.to_thread(embedding.embed_sims, titles) if sims is not None: return sims norms = [_norm_title(t) for t in titles] return [[1.0 if norms[i] == norms[j] else 0.0 for j in range(len(titles))] for i in range(len(titles))] def _fk_of(e: dict) -> dict: return {k: e.get(k) for k in _FACTS_FIELDS} # ── Generate ──────────────────────────────────────────────────────────────────────── async def _generate_block(ctx: GenContext, files: dict, title: str, description: str, instructions: str = "", ns: str = "", lbl: str = "", sources: list[str] | None = None, seeds: list[str] | None = None, melde=None) -> dict | None: """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) # 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("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, 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 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] = {} 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) ───────────────────────────────────────────────────────────── def _default_vote(stimmen: list[str], default: str) -> str: """Mehrheit über die Stimmen (Gen-Vorschläge + implizite/explizite Prüfer-Stimmen); Patt oder leer → default.""" counter: dict[str, int] = {} for s in stimmen: if s: counter[s] = counter.get(s, 0) + 1 best = max(counter.values(), default=0) winners = [s for s, v in counter.items() if v == best] return winners[0] if len(winners) == 1 and best else default 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, 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"] bnorm = _norm_title(title) subs = list(gen["raw"].get(title) or []) bfacts: dict[str, dict] = dict(gen["facts"].get(title) or {}) unsicher: list[dict] = list(gen.get("unsicher") or []) votes: dict[str, dict] = gen.get("votes") or {} nummern = subs + [u["title"] for u in unsicher] # 1-basiert: consensus, dann unsicher n = len(nummern) def _kp(t: str) -> list: fk = bfacts.get(_norm_title(t)) or next( (u for u in unsicher if u["title"] == t), {}) return (fk.get("key_points") or [])[:3] if melde: melde("Verify") verdicts: list[dict] = [] if n: zeilen = "\n".join(f"{k}. {t}" + "".join(f"\n - {p}" for p in _kp(t)) for k, t in enumerate(nummern, 1)) u_txt = "" if unsicher: erste = len(subs) + 1 u_txt = (f"\nUNSICHER — entries {erste}–{n} were named by only ONE generator " "(or are seed candidates). Judge their adoption under `uebernehmen`.\n") cites = [bf.get("source", "") for fk in bfacts.values() for bf in fk.get("cited_facts", [])] mat = material_folder(topic) ev = _cited_evidence(mat, sources, cites, [title] + nummern) if mat else "" if ev: source = _prompt("Blocks-Source-Inline", excerpts=ev) else: source, _caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + nummern) sh = _subs_hash({title: nummern}) pfade = {j: work_dir / f"verify-{sh}-j{j}.json" for j in (*range(1, VERIFY_PANEL + 1), "E")} async def _judge(j): if _verify_schema(_json_file(pfade[j]), n) is not None: return # resume status, _v = await run_single_slot( ctx, f"{lbl}Verify j{j}", key=f"blocks-{topic}-{ns}sb-verify-{sh}-j{j}", prompt=_prompt("Subblock-Verify", topic=topic, block=title, subs=zeilen, unsicher=u_txt, source=source, extra=_extra(instructions)), role="judge", capabilities="none", payload=lambda result, p=pfade[j]: _sink_json(result, p, lambda d: _verify_schema(d, n)), timeout=_timeout("verify", n)) if status == FAILED: _log(topic, f"Verify {title} j{j} ohne Ergebnis — fail-open") await asyncio.gather(*[_judge(j) for j in range(1, VERIFY_PANEL + 1)]) if ctx.is_cancelled(): return None verdicts = [v for j in range(1, VERIFY_PANEL + 1) if (v := _verify_schema(_json_file(pfade[j]), n)) is not None] if len(verdicts) == 1 and VERIFY_PANEL >= 2: # Ersatz-Richter statt fail-open await _judge("E") if ctx.is_cancelled(): return None verdicts = [v for j in (*range(1, VERIFY_PANEL + 1), "E") if (v := _verify_schema(_json_file(pfade[j]), n)) is not None][:2] einstimmig = len(verdicts) >= 2 if n and not einstimmig: _log(topic, f"Verify {title}: nur {len(verdicts)}/2 Prüfer — fail-open, unsicher verworfen") negs = [_neg_set(t) for t in nummern] gone: set[int] = set() keep = list(subs) korrekturen: list[dict] = [] # {titel, hinweis} luecken: list[str] = [] def _titel(k: int) -> str: return nummern[k - 1] async def _fold(k: int, wf: dict | None): t = _titel(k) lf = bfacts.pop(_norm_title(t), None) or {} if wf is not None: _facts_union(wf, lf) await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="variant") if t in keep: keep.remove(t) gone.add(k) 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) bfacts.pop(_norm_title(t), None) await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded") if t in keep: keep.remove(t) gone.add(k) # 2. Unsicher-Übernahme (2/2 „ja"): wird consensus samt Generator-Facts for k in range(len(subs) + 1, n + 1): if k in gone: continue u = unsicher[k - len(subs) - 1] if v1["uebernehmen"].get(k) == "ja" and v2["uebernehmen"].get(k) == "ja": sn = _norm_title(u["title"]) bfacts[sn] = _fk_of(u) keep.append(u["title"]) await db.set_subblock_fields(topic, bnorm, sn, status="consensus") else: await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded") gone.add(k) # 3. Gruppen (einstimmige Paare): haupt-Votum, sonst key_points/Länge haupt_votes: dict[int, int] = {} for v in (v1, v2): for g in v["gruppen"]: if g["haupt"]: haupt_votes[g["haupt"]] = haupt_votes.get(g["haupt"], 0) + 1 for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["gruppen"]]) for v in (v1, v2)], negs, n): g = [k for k in g if k not in gone] if len(g) < 2: continue win = max(g, key=lambda k: (haupt_votes.get(k, 0), len(_kp(_titel(k))), len(_titel(k)), -k)) wf = bfacts.setdefault(_norm_title(_titel(win)), {}) for k in g: if k != win: await _fold(k, wf) # 4. Kataloge: Aufzählungszeilen → EIN neuer benannter Sub (Facts-Union) for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["kataloge"]]) for v in (v1, v2)], negs, n): g = [k for k in g if k not in gone] if len(g) < 2: continue titel = next((clean_title(x["titel"]) for x in v1["kataloge"] + v2["kataloge"] if set(x["ids"]) & set(g) and clean_title(x["titel"])), "") kn = _norm_title(titel) if not kn or kn in {_norm_title(s) for s in keep}: continue kf: dict = {} for k in g: await _fold(k, kf) bfacts[kn] = kf keep.append(titel) await db.put_subblock(topic, bnorm, kn, title, titel, status="consensus") # 5. Facts-Probleme: discard nur 2/2 (irreversibel), Korrektur ab 1 Stimme d1 = {p["nr"] for p in v1["facts_probleme"] if p["discard"]} d2 = {p["nr"] for p in v2["facts_probleme"] if p["discard"]} for k in sorted(d1 & d2): if k in gone: continue t = _titel(k) bfacts.pop(_norm_title(t), None) await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded") if t in keep: keep.remove(t) gone.add(k) for p in v1["facts_probleme"] + v2["facts_probleme"]: k = p["nr"] if k in gone or not p["hinweis"]: continue t = _titel(k) if t in keep and all(x["titel"] != t for x in korrekturen): korrekturen.append({"titel": t, "hinweis": p["hinweis"]}) # 6. Lücken (Schnitt beider Prüfer, Cap) luecken = _luecken_schnitt(v1["luecken"], v2["luecken"]) else: # fail-open: consensus bleibt, unsicher wird verworfen (wie heutiges Clarify-Aus) for u in unsicher: await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded") # 7. Level/Relevanz: Stimmen = Generatoren + Prüfer (explizite Korrektur schlägt # die implizite Zustimmung); Patt → advanced/relevant (heutige Defaults) sidecar_subs = [] for t in keep: sn = _norm_title(t) try: k = nummern.index(t) + 1 except ValueError: k = 0 # Katalog-/Fix-Neuzugänge haben keine Nummer stimmen_l = list((votes.get(sn) or {}).get("level") or []) stimmen_r = list((votes.get(sn) or {}).get("relevance") or []) for v in verdicts[:2]: if k and k in v["levels"]: stimmen_l += [v["levels"][k]] * 2 # explizite Korrektur wiegt doppelt if k and k in v["relevanz"]: stimmen_r += [v["relevanz"][k]] * 2 fk = bfacts.get(sn) or {} sidecar_subs.append({"title": t, "level": _default_vote(stimmen_l, "advanced"), "relevance": _default_vote(stimmen_r, "relevant"), "facts": fk}) # 8. Fix-Tail (0–1 Call): Korrekturen + belegte Lücken if (korrekturen or luecken) and not ctx.is_cancelled(): if melde: melde("Fix") neu = await _fix_befunde(ctx, files, title, korrekturen, luecken, [s["title"] for s in sidecar_subs], instructions, ns, lbl, sources) for e in neu or []: sn = _norm_title(e["title"]) vorhanden = next((s for s in sidecar_subs if _norm_title(s["title"]) == sn), None) if vorhanden is not None: # Korrektur: Facts ersetzen, Einstufung bleibt vorhanden["facts"] = _fk_of(e) bfacts[sn] = vorhanden["facts"] elif not keep_all: # Lücken-Fund: neuer consensus-Sub # (Bottom-up unterdrückt das: die Subs stehen fest, der Fix-Call darf keine # neuen einschleusen — sonst wächst die Sub-Zahl über die Board-1-Atome hinaus) bfacts[sn] = _fk_of(e) keep.append(e["title"]) sidecar_subs.append({"title": e["title"], "level": e["level"] or "advanced", "relevance": e["relevance"] or "relevant", "facts": bfacts[sn]}) await db.put_subblock(topic, bnorm, sn, title, e["title"], status="consensus") if len(keep) != len(subs): _log(topic, f"Verify {title}: {len(subs)} consensus + {len(unsicher)} unsicher → {len(keep)}") return {"raw": {title: [s["title"] for s in sidecar_subs]}, "facts": {title: bfacts}, "sidecar": {title: sidecar_subs}} async def _fix_befunde(ctx: GenContext, files: dict, title: str, korrekturen: list[dict], luecken: list[str], vorhanden: list[str], instructions: str, ns: str, lbl: str, sources: list[str] | None) -> list[dict]: """EIN Call korrigiert beanstandete Facts und füllt gemeldete Lücken. Hartes Beleg-Gate für Neuzugänge (key_points/cited_facts nicht leer) + Dedup gegen den Bestand — ein unbelegter Lücken-„Fund" flutet sonst das Fakten-Gate des Guides.""" topic = ctx.topic work_dir = files["arbeit"] auftraege = [f"- KORRIGIEREN: „{k['titel']}“ — {k['hinweis']}" for k in korrekturen] auftraege += [f"- LÜCKE (neuer Subbaustein, nur wenn belegbar): {l}" for l in luecken] source, caps = await asyncio.to_thread( _inline_source, topic, sources, [title] + [k["titel"] for k in korrekturen] + list(luecken)) sh = _h8(title, *sorted(a for a in auftraege)) pfad = work_dir / f"fix-{sh}.json" if _gen_schema(_json_file(pfad)) is None: status, _v = await run_single_slot( ctx, f"{lbl}Fix", key=f"blocks-{topic}-{ns}sb-fix-{sh}", prompt=_prompt("Subblock-Fix", topic=topic, block=title, source=source, auftraege="\n".join(auftraege), extra=_extra(instructions)), role="quick", capabilities=caps, payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema), timeout=_timeout("fix", len(auftraege))) if status == FAILED: _log(topic, f"Fix {title} ohne Ergebnis — Befunde bleiben offen") return [] out = _gen_schema(_json_file(pfad)) or [] korrektur_norms = {_norm_title(k["titel"]) for k in korrekturen} have_norms = {_norm_title(t) for t in vorhanden} angenommen = [] for e in out: sn = _norm_title(e["title"]) if sn in korrektur_norms: angenommen.append(e) continue if sn in have_norms or not (e["key_points"] or e["cited_facts"]): continue # unbelegt oder Dublette → verfällt st = _sub_tokens(e["title"]) if any(st <= _sub_tokens(t) or _sub_tokens(t) <= st for t in vorhanden): continue angenommen.append(e) have_norms.add(sn) return angenommen # ── Artefakte ─────────────────────────────────────────────────────────────────────── def _subs_text(title: str, sidecar_subs: list[dict]) -> str: return f"BLOCK: {title}\n" + "\n".join( f"- {s['title']}\n" + "\n".join(f" {z}" for z in _facts_lines(s.get("facts") or {}).splitlines()) for s in sidecar_subs) async def _artefakte_block(ctx: GenContext, files: dict, title: str, sidecar_subs: list[dict], instructions: str = "", ns: str = "", lbl: str = "", melde=None) -> dict | None: """EIN Generator-Call liefert Fragen + Flashcards + Beispiele (Split in 2 parallele Calls bei > ART_SPLIT_SUBS Subs), EIN Prüfer-Call verifiziert Beispiele, bereinigt die Fragen und ergänzt fehlende. → {pattern, artefacts} | None (nur Cancel).""" topic = ctx.topic work_dir = files["arbeit"] if not sidecar_subs: return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}} if melde: melde("Artefakte gen") sh = _subs_hash({title: sidecar_subs}) haelften = ([sidecar_subs] if len(sidecar_subs) <= ART_SPLIT_SUBS else [sidecar_subs[:len(sidecar_subs) // 2], sidecar_subs[len(sidecar_subs) // 2:]]) async def _gen(gi: int, teil: list[dict]): pfad = work_dir / f"art-{sh}-t{gi}.json" if _art_gen_schema(_json_file(pfad)) is not None: return status, _v = await run_single_slot( ctx, f"{lbl}Artefakte {gi}", key=f"blocks-{topic}-{ns}art-gen-{sh}-t{gi}", prompt=_prompt("Artefakt-Generate", topic=topic, blocks=_subs_text(title, teil), extra=_extra(instructions)), role="quick", capabilities="none", payload=lambda result, p=pfad: _sink_json(result, p, _art_gen_schema), timeout=_timeout("artefakt", len(teil))) if status == FAILED: _log(topic, f"Artefakte {title} Teil {gi} ohne Ergebnis") await asyncio.gather(*[_gen(gi, teil) for gi, teil in enumerate(haelften, 1)]) if ctx.is_cancelled(): return None pattern: list[dict] = [] cards: list[dict] = [] examples: list[dict] = [] for gi in range(1, len(haelften) + 1): o = _art_gen_schema(_json_file(work_dir / f"art-{sh}-t{gi}.json")) if o: pattern += o["pattern"] cards += o["cards"] examples += o["examples"] # Prüfer: Beispiele verifizieren, Fragen bereinigen + fehlende ergänzen sub_titles = [s["title"] for s in sidecar_subs] fehlend = [t for t in sub_titles if _norm_title(t) not in {_norm_title(p["subblock"]) for p in pattern}] if pattern or examples: if melde: melde("Artefakte check") tabelle = "\n".join(f"({p['subblock']}) {p['question']}" for p in pattern) or "(keine)" beisp = "\n\n".join( f"{k}. PROBLEM: {e['problem']}\n SCHRITTE: " + " | ".join(e["steps"]) + (f"\n ERGEBNIS: {e['result']}" if e.get("result") else "") for k, e in enumerate(examples, 1)) or "(keine)" fehlend_txt = ("\nSUBBLOCKS STILL MISSING A QUESTION:\n" + "\n".join(f"- {t}" for t in fehlend) + "\n") if fehlend else "\n" pfad = work_dir / f"artcheck-{sh}.json" if _art_check_schema(_json_file(pfad)) is None: status, _v = await run_single_slot( ctx, f"{lbl}Artefakt-Check", key=f"blocks-{topic}-{ns}art-check-{sh}", prompt=_prompt("Artefakt-Check", topic=topic, facts=_subs_text(title, sidecar_subs), table=tabelle, fehlend=fehlend_txt, examples=beisp, extra=_extra(instructions)), role="judge", capabilities="none", payload=lambda result, p=pfad: _sink_json(result, p, _art_check_schema), timeout=_timeout("artefakt_check", len(sidecar_subs))) if status == FAILED: _log(topic, f"Artefakt-Check {title} ohne Ergebnis — Rohfassung übernommen") check = _art_check_schema(_json_file(pfad)) if check: if check["examples_probleme"]: examples = [e for k, e in enumerate(examples, 1) if k not in check["examples_probleme"]] _log(topic, f"Artefakt-Check {title}: {len(check['examples_probleme'])} Beispiel(e) verworfen") if check["pattern"]: # bereinigte Fassung ersetzt die Rohfassung pattern = check["pattern"] pattern += check["pattern_ergaenzt"] pattern_map = {title: [{"subblock": p["subblock"], "question": p["question"]} for p in pattern]} # block-Feld auf den Karten-Block normieren (Ein-Block-Call — Agent-Echos abfangen) for e in cards + examples: e["block"] = title return {"pattern": pattern_map, "artefacts": {"flashcard": [{k: e[k] for k in ("block", "subblock", "question", "answer")} for e in cards], "example": [{k: e[k] for k in ("block", "subblock", "problem", "steps", "result")} for e in examples]}}