Files
creator2/backend/inventar.py
2026-07-12 04:20:07 +02:00

536 lines
26 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.
"""Ebene 1: Inventar. Atome aus dem Korpus extrahieren — mit Verbatim-Anker als
Identitätskern. Dedup dreistufig (Lektionen 18/25/31):
1. Anker-Überlappung → Auto-Merge (deterministisch, gleiche Quellstelle = gleiches Atom)
2. Embedding-Kandidaten auf DEFINITIONEN (nie Titel) + Negations-Guard
3. 2er-Judge-Panel, Merge nur EINSTIMMIG; sonst „verwandt"-Kante (Falsch-Merge >> Dublette)
Danach Soll-Zuordnung je Atom; Soll-Punkte ohne Atom lösen gezielte Nachextraktion aus."""
import asyncio
import logging
import re
import db
import embedding
import korpus
import llm
import textkit
from config import (ANKER_OVERLAP_MERGE, LUECKEN_RUNDEN_MAX, MERGE_KANDIDAT_COS,
MERGE_KANDIDAT_JACCARD,
MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME)
log = logging.getLogger("creator2.inventar")
EBENE = "inventar"
TYPEN = ("begriff", "aussage", "verfahren")
LEVELS = ("E", "M", "S")
PAAR_CHUNK = 10 # Merge-Paare pro Judge-Call (40 riss das Output-Cap: 5871 % Parse-Fehler)
def aktive_atome(topic: str) -> list[dict]:
return db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen') ORDER BY id", (topic,))
# ── Extraktion ────────────────────────────────────────────────────────────────
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False) -> None:
# Idempotenz-Guard: hat die Quelle schon verankerte Atome (z. B. nach Soll-Reset),
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion. force=True (Repair
# quelle_unvollstaendig) liest trotzdem; Doppelte fängt der Anker-Dedup.
schon = db.one(
"SELECT a.id FROM atome a JOIN anker k ON k.atom_id=a.id"
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN ('gemerged','verworfen')"
" LIMIT 1", (ctx.topic, quelle["id"]))
if schon and not force:
db.update("quellen", "id", quelle["id"], status="atome")
return
text = korpus.quelltext(quelle)
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
template = ("Atom-Extraktion-Aufgaben" if quelle.get("rolle") == "aufgaben"
else "Atom-Extraktion")
fehl: list[int] = []
async def reader(offset: int, chunk: str, r: int, tiefe: int = 0) -> None:
res = await llm.call(ctx, stage="extraktion", template=template,
werte={"topic": ctx.topic, "quelle": quelle["titel"], "text": chunk},
role="quick",
item=f"q{quelle['id']}-o{offset}-r{r}" + "-h" * tiefe,
erwartet=list)
if res is None:
# Leerantwort/Parse-Fehler ist meist das Output-Cap (stop=max_tokens,
# deterministisch — stumpfes Wiederholen hilft nie): Chunk halbieren.
if tiefe < 2 and len(chunk) > 2000:
mitte = len(chunk) // 2
await asyncio.gather(reader(offset, chunk[:mitte], r, tiefe + 1),
reader(offset + mitte, chunk[mitte:], r, tiefe + 1))
else:
fehl.append(offset) # nicht still: Quelle bleibt unvollständig
return
for a in res or []:
titel = str(a.get("titel", "")).strip()
definition = str(a.get("definition", "")).strip()
zitat = str(a.get("zitat", "")).strip()
typ = str(a.get("typ", "")).strip().lower()
level = str(a.get("level", "M")).strip().upper()
if not titel or not definition or typ not in TYPEN:
continue
span = textkit.finde_zitat(chunk, zitat)
braucht = [str(t).strip() for t in a.get("braucht", []) if str(t).strip()]
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
definition=definition,
level=level if level in LEVELS else "M",
status="neu" if span else "ohne_anker",
braucht=db.j(braucht))
if span:
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
start=offset + span[0], ende=offset + span[1], zitat=zitat)
elif zitat:
# Fehlgeschlagenes Zitat NICHT wegwerfen (start=-1 = unverankert):
# der Repair matcht es erst deterministisch neu, LLM nur als Fallback.
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
start=-1, ende=-1, zitat=zitat)
stuecke = textkit.abschnitte(text)
await asyncio.gather(*(reader(off, chunk, r)
for off, chunk in stuecke
for r in range(READER_JE_ABSCHNITT)))
if fehl: # stiller Chunk-Verlust hieß bisher trotzdem „atome" (Lernen: 41 Calls leer)
log.warning("Quelle %s: %d Abschnitt(e) ohne Extraktion", quelle["id"], len(fehl))
db.update("quellen", "id", quelle["id"], status="teilweise" if fehl else "atome")
# ── Dedup ─────────────────────────────────────────────────────────────────────
def _wurzel(atom_id: int) -> int:
"""merged_into-Kette bis zum lebenden Atom folgen (Merge-Ketten im selben Lauf)."""
for _ in range(20):
row = db.one("SELECT merged_into FROM atome WHERE id=?", (atom_id,))
if not row or not row["merged_into"]:
return atom_id
atom_id = row["merged_into"]
return atom_id
def _merge(topic: str, gewinner: int, verlierer: int) -> None:
"""Anker + braucht wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar."""
gewinner, verlierer = _wurzel(gewinner), _wurzel(verlierer)
if gewinner == verlierer:
return
g = db.one("SELECT * FROM atome WHERE id=?", (gewinner,))
v = db.one("SELECT * FROM atome WHERE id=?", (verlierer,))
if not g or not v or g["status"] == "verworfen" or v["status"] == "verworfen":
return
db.execute("UPDATE anker SET atom_id=? WHERE atom_id=?", (gewinner, verlierer))
braucht = sorted(set(db.uj(g["braucht"]) + db.uj(v["braucht"])))
db.update("atome", "id", gewinner, braucht=db.j(braucht))
db.update("atome", "id", verlierer, status="gemerged", merged_into=gewinner)
# Kanten einzeln umhängen: ein blindes UPDATE kollidiert mit dem UNIQUE-Index,
# wenn der Gewinner dieselbe Kante schon hat; Selbstkanten entstehen beim
# Merge zweier verbundener Atome — beide Fälle werden gelöscht statt verschoben.
for k in db.query("SELECT * FROM kanten WHERE von_atom=? OR zu_atom=?",
(verlierer, verlierer)):
von = gewinner if k["von_atom"] == verlierer else k["von_atom"]
zu = gewinner if k["zu_atom"] == verlierer else k["zu_atom"]
doppel = db.one("SELECT id FROM kanten WHERE von_atom=? AND zu_atom=? AND art=?"
" AND id!=?", (von, zu, k["art"], k["id"]))
if von == zu or doppel:
db.execute("DELETE FROM kanten WHERE id=?", (k["id"],))
else:
db.execute("UPDATE kanten SET von_atom=?, zu_atom=? WHERE id=?",
(von, zu, k["id"]))
def _anker_dedup(topic: str) -> None:
"""Gleiche Quellstelle = gleiches Atom. Deterministisch, kein Judge (Lektion: Anker
ist die Identität). Gewinner = längere Definition."""
atome = aktive_atome(topic)
anker = {a["id"]: db.query("SELECT * FROM anker WHERE atom_id=? AND start>=0", (a["id"],))
for a in atome}
je_def = {a["id"]: a["definition"] for a in atome}
ids = [a["id"] for a in atome if anker[a["id"]]]
for i, a_id in enumerate(ids):
for b_id in ids[i + 1:]:
if db.one("SELECT status FROM atome WHERE id=?", (a_id,))["status"] == "gemerged":
break
if db.one("SELECT status FROM atome WHERE id=?", (b_id,))["status"] == "gemerged":
continue
passt = any(ka["quelle_id"] == kb["quelle_id"]
and textkit.ueberlappung((ka["start"], ka["ende"]),
(kb["start"], kb["ende"])) >= ANKER_OVERLAP_MERGE
for ka in anker[a_id] for kb in anker[b_id])
if passt:
gew, ver = (a_id, b_id) if len(je_def[a_id]) >= len(je_def[b_id]) else (b_id, a_id)
_merge(topic, gew, ver)
def _geprueft(a: int, b: int) -> bool:
lo, hi = min(a, b), max(a, b)
return db.one("SELECT id FROM kanten WHERE von_atom=? AND zu_atom=? AND art='verwandt'",
(lo, hi)) is not None
async def _judge_dedup(ctx: llm.Kontext) -> None:
"""Kandidaten (Definitions-Ähnlichkeit ≥ Floor ODER gleicher Titel-Kern,
gleiche Negationsmenge) ans 2er-Panel. Einstimmig gleich → Merge. Alles
andere → „verwandt" (persistiert, damit dasselbe Paar nie zweimal Tokens
kostet). Titel-Kern-Paare sind deterministisch — Dubletten aus
verschiedenen Quellen haben disjunkte Anker und paraphrasierte
Definitionen, das Embedding allein übersah sie (aak Lauf 8: 26 Gruppen)."""
atome = aktive_atome(topic := ctx.topic)
if len(atome) < 2:
return
je_kern: dict[str, list[int]] = {}
for idx, a in enumerate(atome):
if kern := textkit.titel_kern(a["titel"]):
je_kern.setdefault(kern, []).append(idx)
titel_paare = [(g[x], g[y], 1.0) for g in je_kern.values() if len(g) > 1
for x in range(len(g)) for y in range(x + 1, len(g))]
paare = embedding.kandidaten_paare([a["definition"] for a in atome],
MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD)
offen = []
gesehen: set[tuple[int, int]] = set()
for i, j, cos in titel_paare + paare:
a, b = atome[i], atome[j]
if (a["id"], b["id"]) in gesehen:
continue
gesehen.add((a["id"], b["id"]))
if textkit.negations_menge(a["definition"]) != textkit.negations_menge(b["definition"]):
continue # Antonyme messen 0.910.95 (Lektion 31)
if _geprueft(a["id"], b["id"]):
continue
offen.append((a, b))
if not offen:
return
async def chunk_urteil(chunk: list) -> None:
liste = "\n".join(
f"PAAR {n}:\nA(id {a['id']}): {a['titel']}{a['definition']}\n"
f" Beleg A: {_erster_anker(a['id'])}\n"
f"B(id {b['id']}): {b['titel']}{b['definition']}\n"
f" Beleg B: {_erster_anker(b['id'])}"
for n, (a, b) in enumerate(chunk, 1))
stimmen = await llm.panel(ctx, MERGE_PANEL, stage="merge", template="Atom-Merge",
werte={"paare": liste}, role="judge",
n=len(chunk), item=f"c{chunk[0][0]['id']}", erwartet=list)
for n, (a, b) in enumerate(chunk, 1):
urteile = []
for stimme in stimmen:
for e in stimme:
if isinstance(e, dict) and e.get("paar") == n:
urteile.append(bool(e.get("gleich")))
break
if len(urteile) < MERGE_PANEL:
continue # Panel unvollständig (Parse-/Infra-Ausfall): Paar bleibt
# offen für die nächste Runde — ein dauerhaftes „nein" ließ
# Dubletten systematisch überleben (aak: 42/73 Calls unparsbar)
if all(urteile):
gew, ver = ((a["id"], b["id"]) if len(a["definition"]) >= len(b["definition"])
else (b["id"], a["id"]))
_merge(topic, gew, ver)
else:
lo, hi = min(a["id"], b["id"]), max(a["id"], b["id"])
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art, status)"
" VALUES(?,?,?,'verwandt','nein')", (topic, lo, hi))
chunks = [offen[i:i + PAAR_CHUNK] for i in range(0, len(offen), PAAR_CHUNK)]
await asyncio.gather(*(chunk_urteil(c) for c in chunks))
def _erster_anker(atom_id: int) -> str:
row = db.one("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT 1", (atom_id,))
return (row["zitat"][:300] if row else "(kein Anker)")
def _kanten_aufloesen(topic: str) -> None:
"""braucht-Titel → Atom-IDs. Norm-Gleichheit zuerst, Titel-Kern als zweite
Stufe (Norm allein löste nur 7279 % auf — der Rest fiel still weg und
Ordnung/Level-Logik arbeiteten auf einem lückigen Graph)."""
atome = aktive_atome(topic)
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
je_kern: dict[str, int] = {}
for a in atome:
if kern := textkit.titel_kern(a["titel"]):
je_kern.setdefault(kern, a["id"])
for a in atome:
for titel in db.uj(a["braucht"]):
ziel = je_norm.get(textkit.norm(titel))
if not ziel and (kern := textkit.titel_kern(titel)):
ziel = je_kern.get(kern)
if ziel and ziel != a["id"]:
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
# ── Soll-Zuordnung ────────────────────────────────────────────────────────────
async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None:
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
if not punkte:
return
atome = [a for a in aktive_atome(ctx.topic) if not (nur_offene and a["soll_id"])]
soll_liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in punkte)
gueltig = {p["id"] for p in punkte}
async def chunk_zuordnen(chunk: list) -> None:
liste = "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in chunk)
res = await llm.call(ctx, stage="soll_zuordnung", template="Atom-Soll-Zuordnung",
werte={"soll": soll_liste, "atome": liste}, role="judge",
n=len(chunk), item=f"z{chunk[0]['id']}", erwartet=list)
je_atom = {e.get("atom"): e.get("soll") for e in res or [] if isinstance(e, dict)}
for a in chunk:
sid = je_atom.get(a["id"])
if sid in gueltig:
db.update("atome", "id", a["id"], soll_id=sid)
chunks = [atome[i:i + ZIELE_CHUNK_ATOME] for i in range(0, len(atome), ZIELE_CHUNK_ATOME)]
await asyncio.gather(*(chunk_zuordnen(c) for c in chunks))
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='extrahiert'", (ctx.topic,))
await asyncio.gather(*(_extrahiere_quelle(ctx, q) for q in offene))
_anker_dedup(ctx.topic)
await _judge_dedup(ctx)
_kanten_aufloesen(ctx.topic)
await _soll_zuordnen(ctx)
# ── QA + Repair ───────────────────────────────────────────────────────────────
_TITEL_KATALOG = re.compile(r"^(Satz|Lemma|Korollar|Bemerkung|Definition|Aufgabe"
r"|Übung|Transformation|Theorem)\s+[IVX]*\d", re.IGNORECASE)
def _titel_kaputt(titel: str) -> bool:
"""Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript)
und abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |")."""
if _TITEL_KATALOG.match(titel):
return True
if titel.rstrip().endswith(("|", ",", ";", ":", "-", "", "(", "{", "", "=")):
return True
return titel.count("(") != titel.count(")")
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
atome = aktive_atome(ctx.topic)
belegte_soll = set()
for a in atome:
if a["status"] == "ohne_anker" or not db.one(
"SELECT id FROM anker WHERE atom_id=? AND start>=0 LIMIT 1", (a["id"],)):
befunde.append({"art": "atom_ohne_anker", "item": str(a["id"]), "detail": a["titel"]})
if not a["soll_id"]:
befunde.append({"art": "atom_ohne_soll", "item": str(a["id"]), "detail": a["titel"]})
else:
belegte_soll.add(a["soll_id"])
if _titel_kaputt(a["titel"]):
befunde.append({"art": "titel_katalog", "item": str(a["id"]), "detail": a["titel"]})
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
if p["id"] not in belegte_soll:
befunde.append({"art": "soll_ohne_atom", "item": str(p["id"]), "detail": p["punkt"]})
for q in db.query("SELECT * FROM quellen WHERE topic=? AND status='teilweise'",
(ctx.topic,)):
befunde.append({"art": "quelle_unvollstaendig", "item": str(q["id"]),
"detail": q["titel"]})
return befunde
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
async def einer(b: dict) -> bool:
try:
item = int(b["item"])
except (TypeError, ValueError):
return False
if b["art"] == "atom_ohne_soll":
return await _soll_stichentscheid(ctx, item)
if b["art"] == "soll_ohne_atom":
return await _luecke_schliessen(ctx, item)
if b["art"] == "quelle_unvollstaendig":
q = db.one("SELECT * FROM quellen WHERE id=?", (item,))
if q:
await _extrahiere_quelle(ctx, q, force=True)
return True
return False
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
# Lauf-Tokens); Rest PARALLEL (Semaphoren deckeln die echte Last).
anker_ids = [int(b["item"]) for b in befunde
if b["art"] == "atom_ohne_anker" and str(b["item"]).isdigit()]
bewegt = await _anker_fixen_batch(ctx, anker_ids) if anker_ids else False
titel_ids = [int(b["item"]) for b in befunde
if b["art"] == "titel_katalog" and str(b["item"]).isdigit()]
if titel_ids:
bewegt |= await _titel_fixen(ctx, titel_ids)
rest = [b for b in befunde if b["art"] not in ("atom_ohne_anker", "titel_katalog")]
bewegt |= any(await llm.alle(einer(b) for b in rest))
if bewegt:
_anker_dedup(ctx.topic)
await _judge_dedup(ctx)
_kanten_aufloesen(ctx.topic)
await _soll_zuordnen(ctx)
return bewegt
async def _titel_fixen(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
"""Kaputte Titel (Katalognummern, Fragmente) aus der Definition neu benennen."""
atome = [a for i in atom_ids if (a := db.one("SELECT * FROM atome WHERE id=?", (i,)))
and a["status"] not in ("gemerged", "verworfen")]
if not atome:
return False
liste = "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in atome)
res = await llm.call(ctx, stage="titel_fix", template="Atom-Titel-Fix",
schritt="fix", werte={"atome": liste}, role="judge",
n=len(atome), item=f"t{atome[0]['id']}", erwartet=list)
gueltig = {a["id"] for a in atome}
bewegt = False
for e in res or []:
if isinstance(e, dict) and e.get("atom") in gueltig:
titel = str(e.get("titel", "")).strip()
if titel and not _titel_kaputt(titel):
db.update("atome", "id", e["atom"], titel=titel)
bewegt = True
return bewegt
ANKER_FIX_CHUNK = 20 # Atome pro Batch-Call (ein Quelltext-Abschnitt trägt viele Fixes)
async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
"""Stufe 1 deterministisch (kostenlos): gespeicherte, unverankerte Zitate
(start=-1) mit der toleranten Suche gegen alle Quellen neu matchen.
Stufe 2 gebündelt: EIN Call je (Quelltext-Abschnitt × Atom-Chunk) statt
Atom×Quelle einzeln (war 83 % der Lauf-Tokens). Ohne Fund → verworfen —
ein Atom ohne Quellstelle verletzt das Kernprinzip."""
offen: dict[int, dict] = {}
for atom_id in atom_ids:
a = db.one("SELECT * FROM atome WHERE id=?", (atom_id,))
if a and a["status"] not in ("gemerged", "verworfen"):
offen[atom_id] = a
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
texte = {q["id"]: korpus.quelltext(q) for q in quellen}
for atom_id in list(offen):
for roh in db.query("SELECT * FROM anker WHERE atom_id=? AND start<0", (atom_id,)):
treffer = next(((qid, span) for qid, text in texte.items()
if (span := textkit.finde_zitat(text, roh["zitat"]))), None)
if treffer:
db.update("anker", "id", roh["id"], quelle_id=treffer[0],
start=treffer[1][0], ende=treffer[1][1])
db.update("atome", "id", atom_id, status="neu")
offen.pop(atom_id, None)
break
async def abschnitt_call(q: dict, offset: int, chunk_text: str, ids: list[int]) -> None:
liste = "\n".join(f"{i}: {offen[i]['titel']}{offen[i]['definition']}"
for i in ids if i in offen)
if not liste:
return
res = await llm.call(ctx, stage="anker_fix", template="Atom-Anker-Fix-Batch",
schritt="fix", role="judge", n=len(ids),
item=f"q{q['id']}-o{offset}",
werte={"atome": liste, "text": chunk_text}, erwartet=list)
for e in res or []:
atom_id = e.get("atom")
zitat = str(e.get("zitat", "")).strip()
if atom_id not in offen or not zitat:
continue
span = textkit.finde_zitat(texte[q["id"]], zitat)
if span:
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
start=span[0], ende=span[1], zitat=zitat)
db.update("atome", "id", atom_id, status="neu")
offen.pop(atom_id, None)
for q in quellen:
if not offen:
break
for offset, chunk_text in textkit.abschnitte(texte[q["id"]], 40_000):
ids = list(offen)
if not ids:
break
await llm.alle(abschnitt_call(q, offset, chunk_text, ids[i:i + ANKER_FIX_CHUNK])
for i in range(0, len(ids), ANKER_FIX_CHUNK))
for atom_id in offen:
db.update("atome", "id", atom_id, status="verworfen")
return bool(atom_ids)
async def _soll_stichentscheid(ctx: llm.Kontext, atom_id: int) -> bool:
"""2er-Panel entscheidet: Punkt zuordnen oder fremd. Verwerfen (destruktiv) nur
einstimmig — im Zweifel behalten (Lektion 67), dann bekommt es den Mehrheits-Punkt."""
a = db.one("SELECT * FROM atome WHERE id=?", (atom_id,))
if not a or a["status"] in ("gemerged", "verworfen") or a["soll_id"]:
return False
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
soll_liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in punkte)
stimmen = await llm.panel(ctx, 2, stage="soll_stich", template="Atom-Soll-Stich",
schritt="soll_zuordnung", role="judge", item=f"a{atom_id}",
werte={"titel": a["titel"], "definition": a["definition"],
"beleg": _erster_anker(atom_id), "soll": soll_liste},
erwartet=dict)
urteile = [s.get("soll", "fremd") for s in stimmen]
if urteile and all(u == "fremd" for u in urteile):
db.update("atome", "id", atom_id, status="verworfen")
return True
gueltig = {p["id"] for p in punkte}
for u in urteile:
if u in gueltig:
db.update("atome", "id", atom_id, soll_id=u)
return True
return False
async def _luecke_schliessen(ctx: llm.Kontext, soll_id: int) -> bool:
"""Gezielte Nachextraktion an der Belegstelle; nach LUECKEN_RUNDEN_MAX entscheidet
ein 2er-Panel, ob der Punkt durch bestehende Atome abgedeckt ist (Freispruch,
persistiert — Lektion 69)."""
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
if not p or p["status"] != "bestaetigt":
return False
runden = db.one( # bisherige Nachextraktions-Versuche: aus dem Ledger, topic-weit
"SELECT COUNT(*) AS n FROM events e JOIN runs r ON e.run_id=r.id"
" WHERE r.topic=? AND e.stage='luecke' AND e.item=?", (ctx.topic, f"s{soll_id}"))["n"]
if runden >= LUECKEN_RUNDEN_MAX:
atome = aktive_atome(ctx.topic)
liste = "\n".join(f"- {a['titel']}: {a['definition']}" for a in atome[:200])
stimmen = await llm.panel(ctx, 2, stage="luecke_stich", template="Soll-Abgedeckt",
schritt="soll_zuordnung", role="judge", item=f"s{soll_id}",
werte={"punkt": p["punkt"], "atome": liste}, erwartet=dict)
if stimmen and all(bool(s.get("abgedeckt")) for s in stimmen):
db.update("soll", "id", soll_id, status="abgedeckt")
return True
return False
belege = db.uj(p["belege"])
if not belege:
return False
q = db.one("SELECT * FROM quellen WHERE id=?", (belege[0]["quelle"],))
if not q:
return False
text = korpus.quelltext(q)
pos = textkit.finde_zitat(text, belege[0]["zitat"])
fenster = text[max(0, (pos[0] if pos else 0) - 6000):(pos[1] if pos else 0) + 6000]
res = await llm.call(ctx, stage="luecke", template="Atom-Luecke",
role="quick", item=f"s{soll_id}",
werte={"punkt": p["punkt"], "quelle": q["titel"], "text": fenster},
erwartet=list)
neu = False
for a in res or []:
titel = str(a.get("titel", "")).strip()
definition = str(a.get("definition", "")).strip()
zitat = str(a.get("zitat", "")).strip()
typ = str(a.get("typ", "")).strip().lower()
span = textkit.finde_zitat(fenster, zitat)
if not titel or not definition or typ not in TYPEN or span is None:
continue
offset = text.find(fenster)
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
definition=definition, level="M", status="neu",
soll_id=soll_id, braucht=db.j([]))
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
start=offset + span[0], ende=offset + span[1], zitat=zitat)
neu = True
return neu