update
This commit is contained in:
686
backend/block_calls.py
Normal file
686
backend/block_calls.py
Normal file
@@ -0,0 +1,686 @@
|
||||
"""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 json
|
||||
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) -> 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")
|
||||
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) -> 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]
|
||||
|
||||
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():
|
||||
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 = "") -> 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": []}}
|
||||
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:
|
||||
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]}}
|
||||
Reference in New Issue
Block a user