This commit is contained in:
team3
2026-07-12 16:13:50 +02:00
parent a284e03225
commit 31a42bbf1d
48 changed files with 3625 additions and 253 deletions

View File

@@ -129,7 +129,7 @@ async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int:
erwartet=list)
return res or []
ergebnisse = await asyncio.gather(*(eine(lens) for lens in RECHERCHE_LENSES))
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:
@@ -159,6 +159,15 @@ async def _soll_extrahieren(ctx: llm.Kontext) -> None:
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",
@@ -173,7 +182,7 @@ async def _soll_extrahieren(ctx: llm.Kontext) -> None:
belege=db.j([{"quelle": q["id"], "zitat": zitat}]))
db.update("quellen", "id", q["id"], status="extrahiert")
await asyncio.gather(*(eine(q) for q in offene))
await llm.alle(eine(q) for q in offene)
def _min_belege(topic: str) -> int:
@@ -267,11 +276,37 @@ async def _konsens(ctx: llm.Kontext) -> int:
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))
for r in db.query("SELECT id FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
db.execute("DELETE FROM soll WHERE id=?", (r["id"],)) # idempotent: neu aufbauen
# 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:
db.insert("soll", topic=ctx.topic, punkt=p["punkt"], status="bestaetigt",
belege=db.j(belege))
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
@@ -310,7 +345,12 @@ async def bauen(ctx: llm.Kontext) -> None:
if jetzt <= vorher >= 0: # Sättigung: Runde ohne neuen bestätigten Punkt
break
vorher = jetzt
db.update("topics", "name", ctx.topic, status="korpus_fertig") # Auto-Freeze
# 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 ───────────────────────────────────────────────────────────────
@@ -361,22 +401,31 @@ async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
if not faellig:
return
text = quelltext(q)
liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in faellig)
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
schritt="soll", role="judge", item=f"q{q['id']}",
n=len(faellig),
werte={"punkte": liste, "text": text[:SOLL_CHUNK_CHARS]},
erwartet=list)
if res is None:
return # Ausfall: nicht als „geprüft" verbuchen
offen_ids = {p["id"] for p in faellig}
gefunden: dict[int, str] = {}
for e in res:
if not isinstance(e, dict):
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
zitat = str(e.get("zitat", "")).strip()
if zitat and e.get("soll") in {p["id"] for p in faellig} \
and textkit.finde_zitat(text, zitat) is not None:
gefunden[e["soll"]] = zitat
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:
@@ -384,7 +433,7 @@ async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
"zitat": gefunden[p["id"]]}]
db.update("soll", "id", p["id"], belege=db.j(belege))
bewegt = True
else:
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))
@@ -414,10 +463,33 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
(ctx.topic,))
bewegt = bewegt or len(noch) < len(offene)
if any(b["art"] in ("korpus_leer", "soll_leer") for b in befunde):
topic = db.one("SELECT art FROM topics WHERE name=?", (ctx.topic,))
if topic["art"] == "thema":
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