518 lines
24 KiB
Python
518 lines
24 KiB
Python
"""Ebene 0: Korpus & Soll. Selbst-verifizierend, null Eingriffspunkte.
|
||
|
||
Themen: je Runde ein Recherche-Agent pro Lens (Curriculum/Lehrbuch/Syllabus/Übungen)
|
||
→ Quellen-Snapshots lokal. Uni: Dateien aus topics/<name>/ werden die Quellen.
|
||
Danach Soll-Extraktion je Quelle, Konsens deterministisch im Code (thema: Punkt
|
||
bestätigt ab Belegen aus ≥2 unabhängigen Quellen — Lektion 24; uni: 1 Beleg,
|
||
das Zitat-Gate reicht), Runden bis Sättigung."""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import logging
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import unicodedata
|
||
from pathlib import Path
|
||
|
||
import db
|
||
import llm
|
||
import textkit
|
||
from config import (KORPUS_DIR, RECHERCHE_LENSES, RECHERCHE_RUNDEN_MAX,
|
||
SOLL_CHUNK_CHARS, SOLL_MIN_BELEGE, SOLL_PUNKTE_MIN,
|
||
SOLL_PUNKTE_PER_SQRT, TOPICS_DIR)
|
||
|
||
log = logging.getLogger("creator2.korpus")
|
||
|
||
EBENE = "korpus"
|
||
|
||
|
||
# Quellen-Rolle: aufgaben-Quellen liefern keine eigenen Konzept-Instanzen als Atome
|
||
# (Lektion aak: 76 % der Atome kamen aus Aufgabensammlungen — der Guide dozierte
|
||
# Aufgabentexte). Heuristik am Import; generische Muster, keine Domänen-Regeln.
|
||
_AUFGABEN_MUSTER = re.compile(
|
||
r"aufgab|klausur|serie|übung|uebung|präsenz|praesenz|blatt|exercise|exam|sheet|homework",
|
||
re.IGNORECASE)
|
||
|
||
|
||
def _rolle(titel: str, lens: str | None = None) -> str:
|
||
if lens == "uebungen":
|
||
return "aufgaben"
|
||
return "aufgaben" if _AUFGABEN_MUSTER.search(titel) else "stoff"
|
||
|
||
|
||
def quelltext(quelle: dict) -> str:
|
||
try:
|
||
return Path(quelle["snapshot"]).read_text(encoding="utf-8")
|
||
except OSError:
|
||
return ""
|
||
|
||
|
||
# C0/C1-Kontrollzeichen außer \n und \t: PDF-Schriften mappen Sonderglyphen (ε) auf
|
||
# Steuerbytes — der Reader echot sie als Müll („ž") und das Zitat wird unmatchbar.
|
||
# Ersetzung durch LEERZEICHEN muss VOR ftfy laufen: ftfy löscht Steuerzeichen ersatzlos
|
||
# und verklebt sonst die Nachbarwörter.
|
||
_KONTROLLZEICHEN = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
||
|
||
|
||
def _text_reparieren(inhalt: str) -> str:
|
||
"""Byte-Schicht am Import heilen (Lektionen 82/83): Steuerzeichen→Leerzeichen,
|
||
dann ftfy (Mojibake, Ligaturen fi→fi, C1, NFC). Ohne ftfy → NFC-Fallback —
|
||
gleiches Muster wie der sentence-transformers-Fallback."""
|
||
inhalt = _KONTROLLZEICHEN.sub(" ", inhalt)
|
||
try:
|
||
import ftfy
|
||
return ftfy.fix_text(inhalt, normalization="NFC")
|
||
except ImportError:
|
||
return unicodedata.normalize("NFC", inhalt)
|
||
|
||
|
||
def _snapshot_schreiben(topic: str, inhalt: str) -> tuple[str, str] | None:
|
||
"""→ (pfad, hash) oder None bei Duplikat (gleicher Inhalt schon registriert).
|
||
Text-Reparatur am Import (Lektion 82/83): pdftotext liefert dekomponierte
|
||
Umlaute, Steuerbytes und Ligaturen — ohne Bereinigung scheitert die
|
||
Zitat-Suche an unsichtbar anderen Bytes."""
|
||
inhalt = _text_reparieren(inhalt)
|
||
h = hashlib.sha256(inhalt.encode()).hexdigest()[:16]
|
||
if db.one("SELECT id FROM quellen WHERE topic=? AND hash=?", (topic, h)):
|
||
return None
|
||
ordner = KORPUS_DIR / topic
|
||
ordner.mkdir(parents=True, exist_ok=True)
|
||
pfad = ordner / f"q-{h}.md"
|
||
pfad.write_text(inhalt, encoding="utf-8")
|
||
return str(pfad), h
|
||
|
||
|
||
# LaTeX-Quellen: Umlaut-Escapes am Import auflösen — Modelle zitieren „ü", nie \"u,
|
||
# sonst scheitert der verbatim-Anker. Mathe und Makros bleiben unangetastet.
|
||
_TEX_UMLAUT = re.compile(r'\\"\{?([AOUaou])\}?')
|
||
_TEX_UMLAUTE = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"}
|
||
# \ss ist ein Kontrollwort und frisst das Folge-Leerzeichen: hei\ss t → heißt
|
||
_TEX_SZ = re.compile(r"\\ss(?![a-zA-Z])(?:\{\})?[ \t]?")
|
||
|
||
|
||
def _tex_normalisieren(inhalt: str) -> str:
|
||
_, sep, rumpf = inhalt.partition(r"\begin{document}")
|
||
if sep:
|
||
inhalt = rumpf
|
||
inhalt = inhalt.replace(r"\end{document}", "")
|
||
inhalt = _TEX_UMLAUT.sub(lambda m: _TEX_UMLAUTE[m.group(1)], inhalt)
|
||
return _TEX_SZ.sub("ß", inhalt)
|
||
|
||
|
||
def _datei_lesen(pfad: Path) -> str:
|
||
"""txt/md direkt; tex normalisiert; PDF via pdftotext (treu, strukturarm — Lektion 38)."""
|
||
if pfad.suffix.lower() == ".pdf":
|
||
if shutil.which("pdftotext") is None:
|
||
log.warning("pdftotext fehlt — %s übersprungen", pfad.name)
|
||
return ""
|
||
res = subprocess.run(["pdftotext", "-layout", str(pfad), "-"],
|
||
capture_output=True, text=True, timeout=120)
|
||
return res.stdout if res.returncode == 0 else ""
|
||
try:
|
||
text = pfad.read_text(encoding="utf-8", errors="replace")
|
||
except OSError:
|
||
return ""
|
||
return _tex_normalisieren(text) if pfad.suffix.lower() == ".tex" else text
|
||
|
||
|
||
async def _uni_quellen(ctx: llm.Kontext) -> int:
|
||
"""Dateien aus topics/<name>/ als Quellen registrieren. → Anzahl neu."""
|
||
ordner = TOPICS_DIR / ctx.topic
|
||
neu = 0
|
||
for pfad in sorted(ordner.glob("*")) if ordner.is_dir() else []:
|
||
suffix = pfad.suffix.lower()
|
||
if suffix not in (".tex", ".txt", ".md", ".pdf"):
|
||
continue
|
||
# Vorrang bei gleichem Basename: tex > txt > pdf (treueste Fassung gewinnt)
|
||
if suffix == ".pdf" and (pfad.with_suffix(".tex").exists()
|
||
or pfad.with_suffix(".txt").exists()):
|
||
continue
|
||
if suffix == ".txt" and pfad.with_suffix(".tex").exists():
|
||
continue
|
||
inhalt = _datei_lesen(pfad)
|
||
if not inhalt.strip():
|
||
continue
|
||
snap = _snapshot_schreiben(ctx.topic, inhalt)
|
||
if snap is None:
|
||
continue
|
||
db.insert("quellen", topic=ctx.topic, art="datei", titel=pfad.name,
|
||
snapshot=snap[0], hash=snap[1], status="neu", rolle=_rolle(pfad.name))
|
||
neu += 1
|
||
return neu
|
||
|
||
|
||
async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int:
|
||
"""Ein full-Agent je Lens. → Anzahl neuer Quellen."""
|
||
async def eine(lens: str) -> list[dict]:
|
||
res = await llm.call(ctx, stage="recherche", template="Korpus-Recherche",
|
||
werte={"topic": ctx.topic, "lens": lens},
|
||
role="quick", caps="full", item=f"r{runde}-{lens}",
|
||
erwartet=list)
|
||
return res or []
|
||
|
||
ergebnisse = await llm.alle(eine(lens) for lens in RECHERCHE_LENSES)
|
||
neu = 0
|
||
for lens, quellen in zip(RECHERCHE_LENSES, ergebnisse):
|
||
for q in quellen:
|
||
inhalt = str(q.get("inhalt", "")).strip()
|
||
if len(inhalt) < 500: # leere/dünne Funde sind keine Quelle
|
||
continue
|
||
url = str(q.get("url", ""))[:500]
|
||
# URL-Dedup: dieselbe Seite über Runden erneut geholt liefert leicht
|
||
# abweichende Extrakte (anderer Content-Hash) und zählte dann als
|
||
# „unabhängiger" Beleg im ≥2-Quellen-Konsens (Lernen: eine URL 4×).
|
||
if url and db.one("SELECT id FROM quellen WHERE topic=? AND url=?",
|
||
(ctx.topic, url)):
|
||
continue
|
||
snap = _snapshot_schreiben(ctx.topic, inhalt)
|
||
if snap is None:
|
||
continue
|
||
titel = str(q.get("titel", ""))[:200]
|
||
db.insert("quellen", topic=ctx.topic, art="web", titel=titel,
|
||
url=url, snapshot=snap[0], hash=snap[1],
|
||
runde=runde, status="neu", rolle=_rolle(titel, lens))
|
||
neu += 1
|
||
return neu
|
||
|
||
|
||
async def _soll_extrahieren(ctx: llm.Kontext) -> None:
|
||
"""Je neue Quelle Soll-Kandidaten ziehen (Quelltext INLINE — Lektion 48)."""
|
||
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='neu'", (ctx.topic,))
|
||
|
||
async def eine(q: dict) -> None:
|
||
# Resume-Idempotenz: Kandidaten eines abgebrochenen Passes dieser Quelle
|
||
# zuerst entfernen (Status='neu' ⇒ Konsens lief noch nicht ⇒ diese
|
||
# Kandidaten stammen nur aus dem unterbrochenen Lauf) — sonst blähen
|
||
# Doppel-Kandidaten das √n-Konsensband auf.
|
||
for r in db.query("SELECT id, belege FROM soll WHERE topic=? AND status='kandidat'",
|
||
(ctx.topic,)):
|
||
belege = db.uj(r["belege"])
|
||
if belege and belege[0].get("quelle") == q["id"]:
|
||
db.execute("DELETE FROM soll WHERE id=?", (r["id"],))
|
||
text = quelltext(q)
|
||
for i, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)):
|
||
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||
werte={"topic": ctx.topic, "quelle": q["titel"], "text": chunk},
|
||
role="judge", item=f"q{q['id']}-{i}", erwartet=list)
|
||
for kand in res or []:
|
||
punkt = str(kand.get("punkt", "")).strip()
|
||
zitat = str(kand.get("zitat", "")).strip()
|
||
if not punkt or textkit.finde_zitat(chunk, zitat) is None:
|
||
continue # Beleg muss wörtlich in der Quelle stehen
|
||
db.insert("soll", topic=ctx.topic, punkt=punkt, status="kandidat",
|
||
belege=db.j([{"quelle": q["id"], "zitat": zitat}]))
|
||
db.update("quellen", "id", q["id"], status="extrahiert")
|
||
|
||
await llm.alle(eine(q) for q in offene)
|
||
|
||
|
||
def _min_belege(topic: str) -> int:
|
||
"""uni: Korpus ist vertrauenswürdig und das Zitat-Gate fängt Halluzinationen —
|
||
1 Beleg genügt (die ≥2-Regel warf Skript-only-Stoff weg, aak: Definition P,
|
||
FPTAS, Cook-Levin). thema: ≥2 unabhängige Quellen, weil eine einzelne
|
||
Web-Quelle selbst falsch sein kann."""
|
||
if db.one("SELECT art FROM topics WHERE name=?", (topic,))["art"] == "uni":
|
||
return 1
|
||
n = db.one("SELECT COUNT(*) AS n FROM quellen WHERE topic=?", (topic,))["n"]
|
||
return min(SOLL_MIN_BELEGE, max(1, n))
|
||
|
||
|
||
async def _konsens(ctx: llm.Kontext) -> int:
|
||
"""Kandidaten zu Punkten falten. Der Judge GRUPPIERT nur — die Zählung
|
||
(≥ min unabhängige Quellen) macht der Code. Untergrenze k = c·√(Kandidaten):
|
||
ohne sie faltete der Konsens 254 Atome auf 7 Punkte (aak) — zu grob als
|
||
Kapitel-Ebene. Nach oben offen — eine Obergrenze drückte den Judge dazu,
|
||
Kandidaten still wegzulassen (aak: LPT-Scheduling fehlte im Soll).
|
||
Vollständigkeits-Invariante: jede id wird zugeordnet oder explizit
|
||
abgelehnt; Übrige bekommen Nachrunden. → Anzahl bestätigt."""
|
||
kandidaten = db.query("SELECT * FROM soll WHERE topic=? AND"
|
||
" status IN ('kandidat','gefaltet')", (ctx.topic,))
|
||
if not kandidaten:
|
||
return 0
|
||
je_id = {k["id"]: k for k in kandidaten}
|
||
k_soll = max(SOLL_PUNKTE_MIN, round(SOLL_PUNKTE_PER_SQRT * len(kandidaten) ** 0.5))
|
||
band = str(max(3, int(0.7 * k_soll)))
|
||
min_belege = _min_belege(ctx.topic)
|
||
|
||
async def falten(kand: list[dict], hinweis: str, tag: str) -> list:
|
||
liste = "\n".join(f"{k['id']}: {k['punkt']}" for k in kand)
|
||
res = await llm.call(ctx, stage="soll_konsens", template="Korpus-Soll-Konsens",
|
||
werte={"topic": ctx.topic, "kandidaten": liste,
|
||
"band": band, "hinweis": hinweis},
|
||
role="judge", n=len(kand), item=tag, erwartet=list)
|
||
return res or []
|
||
|
||
punkte: list[dict] = [] # {"punkt": str, "ids": [int]}
|
||
je_punkt: dict[str, dict] = {}
|
||
zugeordnet: set[int] = set()
|
||
abgelehnt: set[int] = set()
|
||
|
||
def einsortieren(res: list) -> None:
|
||
for gruppe in res:
|
||
if not isinstance(gruppe, dict):
|
||
continue
|
||
ids = [i for i in gruppe.get("kandidaten", []) if isinstance(i, int)
|
||
and i in je_id and i not in zugeordnet and i not in abgelehnt]
|
||
if not ids:
|
||
continue
|
||
if gruppe.get("abgelehnt"):
|
||
abgelehnt.update(ids)
|
||
continue
|
||
zugeordnet.update(ids)
|
||
punkt = str(gruppe.get("punkt", "")).strip() or je_id[ids[0]]["punkt"]
|
||
kern = punkt.casefold()
|
||
if kern in je_punkt:
|
||
je_punkt[kern]["ids"].extend(ids)
|
||
else:
|
||
je_punkt[kern] = {"punkt": punkt, "ids": ids}
|
||
punkte.append(je_punkt[kern])
|
||
|
||
res = await falten(kandidaten, "", "k1")
|
||
gruppen = sum(1 for g in res if isinstance(g, dict) and not g.get("abgelehnt"))
|
||
if gruppen < 0.5 * k_soll and len(kandidaten) >= 2 * SOLL_PUNKTE_MIN:
|
||
nochmal = await falten(
|
||
kandidaten,
|
||
f"ACHTUNG: Der letzte Versuch hat zu grob gruppiert ({gruppen} Punkte)."
|
||
f" Trenne feiner — verschiedene Teilthemen NICHT zusammenfassen.", "k2")
|
||
if len(nochmal) > len(res):
|
||
res = nochmal
|
||
einsortieren(res)
|
||
|
||
for runde in (1, 2): # Vollständigkeit: fehlende ids gezielt nachfassen
|
||
fehlend = [k for i, k in je_id.items()
|
||
if i not in zugeordnet and i not in abgelehnt]
|
||
if not fehlend:
|
||
break
|
||
vorhanden = "\n".join(f"- {p['punkt']}" for p in punkte)
|
||
einsortieren(await falten(
|
||
fehlend,
|
||
"NACHRUNDE — diese Kandidaten sind noch keinem Punkt zugeordnet."
|
||
" Ordne JEDEN zu: an einen BESTEHENDEN Punkt (punkt wörtlich"
|
||
" wiederverwenden) oder als neue Gruppe. Ablehnen nur, wenn es kein"
|
||
" lernbares Teilthema ist.\nBESTEHENDE PUNKTE:\n" + vorhanden,
|
||
f"n{runde}"))
|
||
|
||
bestaetigt = []
|
||
for p in punkte:
|
||
belege = [b for i in p["ids"] for b in db.uj(je_id[i]["belege"])]
|
||
if len({b["quelle"] for b in belege}) >= min_belege:
|
||
bestaetigt.append((p, belege))
|
||
# Bestehende bestätigte Zeilen NICHT löschen und neu bauen (das warf die von
|
||
# _belege_nachsuchen ergänzten Belege + den geprueft-Cache weg und churnte die
|
||
# soll.id). Stattdessen abgleichen: Match über punkt-Kern, sonst über einen
|
||
# gemeinsamen (quelle,zitat)-Beleg. Treffer → Belege-Union, geprueft bleibt.
|
||
alt = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
|
||
alt_kern: dict[str, dict] = {}
|
||
alt_beleg: dict[tuple, dict] = {}
|
||
for r in alt:
|
||
alt_kern.setdefault(r["punkt"].casefold(), r)
|
||
for b in db.uj(r["belege"]):
|
||
alt_beleg.setdefault((b["quelle"], b["zitat"]), r)
|
||
behalten: set[int] = set()
|
||
for p, belege in bestaetigt:
|
||
treffer = alt_kern.get(p["punkt"].casefold())
|
||
if treffer is None:
|
||
for b in belege:
|
||
if (treffer := alt_beleg.get((b["quelle"], b["zitat"]))):
|
||
break
|
||
if treffer is not None:
|
||
vorhanden = db.uj(treffer["belege"])
|
||
gesehen = {(b["quelle"], b["zitat"]) for b in vorhanden}
|
||
vereint = vorhanden + [b for b in belege
|
||
if (b["quelle"], b["zitat"]) not in gesehen]
|
||
db.update("soll", "id", treffer["id"], punkt=p["punkt"], belege=db.j(vereint))
|
||
behalten.add(treffer["id"])
|
||
else:
|
||
behalten.add(db.insert("soll", topic=ctx.topic, punkt=p["punkt"],
|
||
status="bestaetigt", belege=db.j(belege)))
|
||
for r in alt: # verwaiste bestätigte Zeilen (kein Konsens mehr) entfernen
|
||
if r["id"] not in behalten:
|
||
db.execute("DELETE FROM soll WHERE id=?", (r["id"],))
|
||
ok_ids = {i for p, _ in bestaetigt for i in p["ids"]}
|
||
for i, k in je_id.items(): # Buchführung: kein Kandidat verschwindet still
|
||
status = ("abgelehnt" if i in abgelehnt else
|
||
"gefaltet" if i in ok_ids else "kandidat")
|
||
if status != k["status"]:
|
||
db.update("soll", "id", i, status=status)
|
||
return len(bestaetigt)
|
||
|
||
|
||
def _rollen_auffrischen(topic: str) -> None:
|
||
"""Rolle für Bestands-Quellen nachziehen (Reset behält die Zeilen; die Heuristik
|
||
kam ggf. erst später dazu). Nur Hochstufung stoff→aufgaben — eine Lens-basierte
|
||
aufgaben-Rolle wird nie zurückgestuft."""
|
||
for q in db.query("SELECT * FROM quellen WHERE topic=? AND rolle='stoff'", (topic,)):
|
||
if _rolle(q["titel"]) == "aufgaben":
|
||
db.update("quellen", "id", q["id"], rolle="aufgaben")
|
||
|
||
|
||
async def bauen(ctx: llm.Kontext) -> None:
|
||
ctx.ebene = EBENE
|
||
_rollen_auffrischen(ctx.topic)
|
||
topic = db.one("SELECT * FROM topics WHERE name=?", (ctx.topic,))
|
||
if topic["status"] not in ("neu", "korpus"):
|
||
return # Ebene fertig — Resume überspringt
|
||
db.update("topics", "name", ctx.topic, status="korpus")
|
||
if topic["art"] == "uni":
|
||
await _uni_quellen(ctx)
|
||
await _soll_extrahieren(ctx)
|
||
await _konsens(ctx)
|
||
else:
|
||
vorher = -1
|
||
for runde in range(1, RECHERCHE_RUNDEN_MAX + 1):
|
||
await _recherche_runde(ctx, runde)
|
||
await _soll_extrahieren(ctx)
|
||
jetzt = await _konsens(ctx)
|
||
if jetzt <= vorher >= 0: # Sättigung: Runde ohne neuen bestätigten Punkt
|
||
break
|
||
vorher = jetzt
|
||
# Auto-Freeze nur bei ≥1 bestätigtem Punkt — sonst bleibt Status 'korpus', der
|
||
# QA-Loop läuft an (soll_leer) und das Gate stoppt den Lauf fail-closed, statt
|
||
# E1 mit leerem Soll zu starten (dort würde jedes Atom kaskadenartig verworfen).
|
||
if db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=? AND status='bestaetigt'",
|
||
(ctx.topic,))["n"]:
|
||
db.update("topics", "name", ctx.topic, status="korpus_fertig")
|
||
|
||
|
||
# ── QA + Repair ───────────────────────────────────────────────────────────────
|
||
|
||
def messen(ctx: llm.Kontext) -> list[dict]:
|
||
"""Invarianten, deterministisch. → Befunde [{art, item, detail}]."""
|
||
befunde = []
|
||
n_quellen = db.one("SELECT COUNT(*) AS n FROM quellen WHERE topic=?", (ctx.topic,))["n"]
|
||
if n_quellen == 0:
|
||
befunde.append({"art": "korpus_leer", "item": "", "detail": "keine Quellen"})
|
||
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
|
||
if not punkte and n_quellen:
|
||
befunde.append({"art": "soll_leer", "item": "", "detail": "kein bestätigter Soll-Punkt"})
|
||
min_belege = _min_belege(ctx.topic)
|
||
for p in punkte:
|
||
quellen = {b["quelle"] for b in db.uj(p["belege"])}
|
||
if len(quellen) < min_belege:
|
||
befunde.append({"art": "soll_wenig_belege", "item": str(p["id"]),
|
||
"detail": f"{p['punkt']}: {len(quellen)} < {min_belege}"})
|
||
if punkte: # Konsens lief: übrige Kandidaten sind unerledigt, kein stiller Verlust
|
||
for k in db.query("SELECT * FROM soll WHERE topic=? AND status='kandidat'",
|
||
(ctx.topic,)):
|
||
befunde.append({"art": "soll_kandidat_offen", "item": str(k["id"]),
|
||
"detail": k["punkt"]})
|
||
return befunde
|
||
|
||
|
||
async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
|
||
"""Gezielte Beleg-Judges, gebündelt: EIN Call je Quelle mit ALLEN dort noch
|
||
offenen Punkten — Punkt×Quelle einzeln waren 3072 Calls / 27 % der
|
||
Themen-Tokens (Lernen). Negativ-Cache soll.geprueft: erfolglos geprüfte
|
||
(Punkt, Quelle)-Paare werden über Repair-Iterationen nie wiederholt.
|
||
Bestätigte Punkte ohne Fund in irgendeiner Quelle → zurück zu Kandidat."""
|
||
punkte = [p for s in dict.fromkeys(soll_ids)
|
||
if (p := db.one("SELECT * FROM soll WHERE id=?", (s,)))]
|
||
if not punkte:
|
||
return False
|
||
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
||
bewegt = False
|
||
|
||
async def eine_quelle(q: dict) -> None:
|
||
nonlocal bewegt
|
||
faellig = []
|
||
for p in punkte:
|
||
belegte = {bl["quelle"] for bl in db.uj(p["belege"])}
|
||
if q["id"] not in belegte and q["id"] not in db.uj(p["geprueft"]):
|
||
faellig.append(p)
|
||
if not faellig:
|
||
return
|
||
text = quelltext(q)
|
||
offen_ids = {p["id"] for p in faellig}
|
||
gefunden: dict[int, str] = {}
|
||
ausfall = False # ein Fenster-Ausfall → Quelle nicht vollständig geprüft
|
||
# ALLE Fenster durchsuchen (nicht nur die ersten 40k) — sonst nagelt der
|
||
# geprueft-Cache Belege hinter dem ersten Fenster dauerhaft fest.
|
||
for fi, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)):
|
||
rest = [p for p in faellig if p["id"] in offen_ids]
|
||
if not rest:
|
||
break
|
||
liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in rest)
|
||
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
|
||
schritt="soll", role="judge", item=f"q{q['id']}-{fi}",
|
||
n=len(rest),
|
||
werte={"punkte": liste, "text": chunk}, erwartet=list)
|
||
if res is None:
|
||
ausfall = True
|
||
continue
|
||
for e in res:
|
||
if not isinstance(e, dict):
|
||
continue
|
||
zitat = str(e.get("zitat", "")).strip()
|
||
if zitat and e.get("soll") in offen_ids \
|
||
and textkit.finde_zitat(chunk, zitat) is not None:
|
||
gefunden[e["soll"]] = zitat
|
||
offen_ids.discard(e["soll"])
|
||
for p in faellig:
|
||
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
|
||
if p["id"] in gefunden:
|
||
belege = db.uj(frisch["belege"]) + [{"quelle": q["id"],
|
||
"zitat": gefunden[p["id"]]}]
|
||
db.update("soll", "id", p["id"], belege=db.j(belege))
|
||
bewegt = True
|
||
elif not ausfall: # nur eine lückenlos durchsuchte Quelle gilt als geprüft
|
||
geprueft = sorted(set(db.uj(frisch["geprueft"])) | {q["id"]})
|
||
db.update("soll", "id", p["id"], geprueft=db.j(geprueft))
|
||
|
||
await llm.alle(eine_quelle(q) for q in quellen)
|
||
for p in punkte:
|
||
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
|
||
if frisch["status"] != "bestaetigt" \
|
||
or len(db.uj(frisch["belege"])) > len(db.uj(p["belege"])):
|
||
continue
|
||
belegte = {bl["quelle"] for bl in db.uj(frisch["belege"])}
|
||
rest = [q for q in quellen if q["id"] not in belegte
|
||
and q["id"] not in db.uj(frisch["geprueft"])]
|
||
if not rest: # wirklich überall erfolglos gesucht — Konsens-Regel bleibt hart
|
||
db.update("soll", "id", p["id"], status="kandidat")
|
||
bewegt = True
|
||
return bewegt
|
||
|
||
|
||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||
ctx.ebene = EBENE
|
||
punkte = [int(b["item"]) for b in befunde if b["art"] == "soll_wenig_belege"]
|
||
offene = [int(b["item"]) for b in befunde if b["art"] == "soll_kandidat_offen"]
|
||
bewegt = await _belege_nachsuchen(ctx, punkte + offene)
|
||
if offene: # neu falten: die Nachrunden im Konsens erzwingen die Zuordnung
|
||
await _konsens(ctx)
|
||
noch = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'",
|
||
(ctx.topic,))
|
||
bewegt = bewegt or len(noch) < len(offene)
|
||
if any(b["art"] in ("korpus_leer", "soll_leer") for b in befunde):
|
||
art = db.one("SELECT art FROM topics WHERE name=?", (ctx.topic,))["art"]
|
||
if art == "thema":
|
||
await _recherche_runde(ctx, runde=99)
|
||
await _soll_extrahieren(ctx)
|
||
await _konsens(ctx)
|
||
bewegt = True
|
||
else: # uni: keine Recherche möglich — vorhandene Quellen neu falten,
|
||
# ohne Kandidaten sogar frisch extrahieren; ehrliches bewegt
|
||
vorher = db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=?"
|
||
" AND status='bestaetigt'", (ctx.topic,))["n"]
|
||
if not db.query("SELECT id FROM soll WHERE topic=? AND"
|
||
" status IN ('kandidat','gefaltet')", (ctx.topic,)):
|
||
db.execute("UPDATE quellen SET status='neu' WHERE topic=?", (ctx.topic,))
|
||
await _soll_extrahieren(ctx)
|
||
await _konsens(ctx)
|
||
nachher = db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=?"
|
||
" AND status='bestaetigt'", (ctx.topic,))["n"]
|
||
bewegt = bewegt or nachher > vorher
|
||
return bewegt
|
||
|
||
|
||
def gate(ctx: llm.Kontext) -> str | None:
|
||
"""Fail-closed-Gate vor dem Ebenen-Marker: ohne Quellen oder ohne bestätigten
|
||
Soll-Punkt darf E1 nicht starten (leeres Soll verwirft in E1 jedes Atom)."""
|
||
if not db.one("SELECT id FROM quellen WHERE topic=? LIMIT 1", (ctx.topic,)):
|
||
return "keine Quellen"
|
||
if not db.one("SELECT id FROM soll WHERE topic=? AND status='bestaetigt' LIMIT 1",
|
||
(ctx.topic,)):
|
||
return "kein bestätigter Soll-Punkt"
|
||
return None
|