696 lines
34 KiB
Python
696 lines
34 KiB
Python
"""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:
|
||
"""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
|
||
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")
|
||
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,
|
||
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)
|
||
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] = []
|
||
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}
|
||
|
||
|
||
# ── 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) -> 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.
|
||
→ {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]
|
||
# 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"]
|
||
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]}}
|