Files
creator/backend/block_calls.py
2026-07-07 00:25:23 +02:00

667 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 3555 Calls und ~14 min Wandzeit — bei p50 2050 s pro Call zählt NUR die Zahl
der seriellen Segmente. Hier: Generate(∥2) → Verify(∥2, + Fix-Tail) → Artefakte(Gen+Check)
= 45 Segmente, 69 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 (01 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"]
else: # Lücken-Fund: neuer consensus-Sub
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]}}