init
This commit is contained in:
518
backend/inventar.py
Normal file
518
backend/inventar.py
Normal file
@@ -0,0 +1,518 @@
|
||||
"""Stufe 2: Atome extrahieren (2 Reader, Anker-Pflicht), Anker-Fix
|
||||
(deterministisch vor LLM), Soll-Zuordnung, Lücken schließen (Nachextraktion →
|
||||
Web-Nachrecherche → Freispruch-Panel). Gate zählt Coverage deterministisch."""
|
||||
from . import config, db, engine, laden, llm, panels, textkit
|
||||
|
||||
|
||||
def _quelle(qid: int) -> dict:
|
||||
return db.one("SELECT * FROM quellen WHERE id=?", qid)
|
||||
|
||||
|
||||
def _snapshot(qid: int) -> str:
|
||||
return laden.snapshot_lesen(_quelle(qid))
|
||||
|
||||
|
||||
def _halbieren(m: dict, p: dict, run_id: int) -> list:
|
||||
"""Output-Cap: Chunk halbieren statt Retry (Lektion!); Tiefe gedeckelt."""
|
||||
tiefe = p.get("tiefe", 0)
|
||||
if tiefe >= config.CHUNK_HALBIER_TIEFE:
|
||||
db.insert("befunde", run_id=run_id, stufe="inventar",
|
||||
knoten="atom_extraktion", art="quelle_unvollstaendig",
|
||||
item=m["item"], detail="Cap trotz Halbierung")
|
||||
return []
|
||||
halb = p["chars"] // 2
|
||||
return [{"knoten": "atom_extraktion", "item": f"{m['item']}:h{i}",
|
||||
"payload": {**p, "offset": p["offset"] + i * halb,
|
||||
"chars": halb + (p["chars"] % 2 if i else 0),
|
||||
"tiefe": tiefe + 1}}
|
||||
for i in (0, 1)]
|
||||
|
||||
|
||||
@engine.worker("inventar.atom_extraktion", buendel=True)
|
||||
async def atom_extraktion(task: dict) -> engine.Ergebnis:
|
||||
mitglieder = [task] + task.get("_buendel", [])
|
||||
teil, aktive = {}, {} # idx → (mitglied, payload, chunk)
|
||||
idx = 0
|
||||
for m in mitglieder:
|
||||
p = db.uj(m["payload"], {})
|
||||
# Resume-Guard: Atome dieses Tasks (Item als Herkunft) nicht doppeln
|
||||
if db.one("SELECT id FROM atome WHERE topic=? AND typ LIKE ? LIMIT 1",
|
||||
task["topic"], f"%|{m['item']}"):
|
||||
teil[m["id"]] = "fertig"
|
||||
continue
|
||||
text = _snapshot(p["quelle_id"])
|
||||
idx += 1
|
||||
aktive[idx] = (m, p, text[p["offset"]:p["offset"] + p["chars"]])
|
||||
if not aktive:
|
||||
return engine.Ergebnis(daten={"skip": True}, teil_status=teil)
|
||||
thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
|
||||
"WHERE name=?", task["topic"])["t"]
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="inventar", knoten="atom_extraktion",
|
||||
item=task["item"], role="extraktion", n=len(aktive),
|
||||
skill_namen=["extraktor", "deutsch-praezise", "atom-extraktion"],
|
||||
werte={"thema": thema,
|
||||
"abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}"
|
||||
for i, (_, _, c) in aktive.items())})
|
||||
if antwort is None: # Output-Cap: alle Mitglieder halbieren, keiner verloren
|
||||
neue = []
|
||||
for i, (m, p, _) in aktive.items():
|
||||
neue += _halbieren(m, p, task["run_id"])
|
||||
teil[m["id"]] = "fertig"
|
||||
return engine.Ergebnis(daten={"halbiert": True}, neue_tasks=neue,
|
||||
teil_status=teil)
|
||||
je_abschnitt: dict[int, list] = {}
|
||||
for block in textkit.bloecke(antwort, "ATOM"):
|
||||
a = block.get("abschnitt", "").strip()
|
||||
if a.isdigit():
|
||||
je_abschnitt.setdefault(int(a), []).append(block)
|
||||
leer = {int(b["abschnitt"]) for b in textkit.bloecke(antwort, "LEER")
|
||||
if b.get("abschnitt", "").strip().isdigit()}
|
||||
n_gesamt, neue_quellen = 0, []
|
||||
for i, (m, p, chunk) in aktive.items():
|
||||
if i not in je_abschnitt and i not in leer:
|
||||
teil[m["id"]] = "neu" # Abschnitt fehlt in der Antwort → neuer Versuch
|
||||
continue
|
||||
for block in je_abschnitt.get(i, []):
|
||||
titel, zitat = block.get("titel", ""), block.get("zitat", "")
|
||||
if not titel or not zitat:
|
||||
continue
|
||||
span = textkit.finde_zitat(chunk, zitat)
|
||||
atom_id = db.insert(
|
||||
"atome", topic=task["topic"], titel=titel[:120],
|
||||
typ=f"{block.get('typ', 'begriff').split()[0]}|{m['item']}",
|
||||
definition=block.get("definition", "")[:600], status="neu",
|
||||
soll_id=p.get("soll_id"))
|
||||
start, ende = (p["offset"] + span[0], p["offset"] + span[1]) \
|
||||
if span else (-1, -1)
|
||||
# Zitat = exakter Quell-Slice (byte-wörtlich), nicht das LLM-Echo
|
||||
db.insert("anker", atom_id=atom_id, quelle_id=p["quelle_id"],
|
||||
start=start, ende=ende,
|
||||
zitat=(chunk[span[0]:span[1]][:600] if span else zitat[:600]))
|
||||
n_gesamt += 1
|
||||
teil[m["id"]] = "fertig"
|
||||
if p["quelle_id"] not in neue_quellen:
|
||||
neue_quellen.append(p["quelle_id"])
|
||||
return engine.Ergebnis(daten={"atome": n_gesamt}, teil_status=teil, neue_tasks=[
|
||||
{"knoten": "anker_fix", "item": f"quelle:{qid}:r{task['runde']}",
|
||||
"payload": {"quelle_id": qid}} for qid in neue_quellen])
|
||||
|
||||
|
||||
@engine.worker("inventar.anker_fix")
|
||||
async def anker_fix(task: dict) -> engine.Ergebnis:
|
||||
payload = db.uj(task["payload"], {})
|
||||
qid = payload["quelle_id"]
|
||||
text = _snapshot(qid)
|
||||
offen = db.query(
|
||||
"SELECT a.id atom_id, a.titel, an.id anker_id, an.zitat FROM atome a "
|
||||
"JOIN anker an ON an.atom_id=a.id AND an.quelle_id=? AND an.start=-1 "
|
||||
"WHERE a.topic=? AND a.status IN ('neu','aktiv')", qid, task["topic"])
|
||||
det_fix = 0
|
||||
rest = []
|
||||
for z in offen: # Stufe 1: deterministisches Re-Match (0-Call-Fall häufig)
|
||||
span = textkit.finde_zitat(text, z["zitat"])
|
||||
if span:
|
||||
db.update("anker", "id=?", (z["anker_id"],), start=span[0], ende=span[1])
|
||||
det_fix += 1
|
||||
else:
|
||||
rest.append(z)
|
||||
llm_fix = verworfen = 0
|
||||
for i in range(0, len(rest), config.ANKER_FIX_BATCH):
|
||||
batch = rest[i:i + config.ANKER_FIX_BATCH]
|
||||
liste = "\n".join(f"[{z['atom_id']}] {z['titel']}: {z['zitat'][:200]}"
|
||||
for z in batch)
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="inventar", knoten="anker_fix",
|
||||
item=f"{task['item']}:b{i}", role="extraktion", n=len(batch),
|
||||
skill_namen=["extraktor", "deutsch-praezise", "anker-fix"],
|
||||
werte={"atome": liste, "quelltext": text[:config.SOLL_CHUNK_CHARS]})
|
||||
if antwort is None: # Call-Ausfall: neuer Versuch statt stilles Auslassen
|
||||
raise RuntimeError("anker_fix ohne Ergebnis")
|
||||
antworten = {int(b["id"]): b.get("zitat", "") for b in
|
||||
textkit.bloecke(antwort, "FIX") if b.get("id", "").isdigit()}
|
||||
for z in batch:
|
||||
neu = antworten.get(z["atom_id"], "")
|
||||
span = textkit.finde_zitat(text, neu) if neu and neu != "VERWERFEN" else None
|
||||
if span:
|
||||
db.update("anker", "id=?", (z["anker_id"],),
|
||||
start=span[0], ende=span[1], zitat=neu[:600])
|
||||
llm_fix += 1
|
||||
elif neu == "VERWERFEN":
|
||||
db.update("atome", "id=?", (z["atom_id"],), status="verworfen")
|
||||
verworfen += 1
|
||||
# Verankerte sofort aktivieren — Dedup/Zuordnung sehen sonst nichts
|
||||
db.execute("UPDATE atome SET status='aktiv' WHERE topic=? AND status='neu' "
|
||||
"AND id IN (SELECT atom_id FROM anker WHERE quelle_id=? AND "
|
||||
"start>=0)", task["topic"], qid)
|
||||
return engine.Ergebnis(
|
||||
daten={"det": det_fix, "llm": llm_fix, "verworfen": verworfen},
|
||||
neue_tasks=[{"knoten": "dedup", "item": f"r{task['runde']}:dedup"}])
|
||||
|
||||
|
||||
@engine.worker("inventar.soll_zuordnung")
|
||||
async def soll_zuordnung(task: dict) -> engine.Ergebnis:
|
||||
payload = db.uj(task["payload"], {})
|
||||
atome = [a for i in payload["atom_ids"]
|
||||
if (a := db.one("SELECT * FROM atome WHERE id=? AND status='aktiv' "
|
||||
"AND soll_id IS NULL", i))]
|
||||
if not atome:
|
||||
return engine.Ergebnis(daten={"skip": True})
|
||||
soll = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'",
|
||||
task["topic"])
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="inventar", knoten="soll_zuordnung",
|
||||
item=task["item"], role="judge", n=len(atome),
|
||||
skill_namen=["richter", "deutsch-praezise", "soll-zuordnung"],
|
||||
werte={"atome": "\n".join(f"[{a['id']}] {a['titel']}: {a['definition'][:150]}"
|
||||
for a in atome),
|
||||
"soll": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)})
|
||||
if antwort is None:
|
||||
raise RuntimeError("soll_zuordnung ohne Ergebnis")
|
||||
soll_ids = {s["id"] for s in soll}
|
||||
n = 0
|
||||
for b in textkit.bloecke(antwort, "ZUORDNUNG"):
|
||||
if not b.get("atom", "").isdigit():
|
||||
continue
|
||||
ziel = b.get("soll", "")
|
||||
if ziel.isdigit() and int(ziel) in soll_ids:
|
||||
db.update("atome", "id=?", (int(b["atom"]),), soll_id=int(ziel))
|
||||
n += 1
|
||||
# ALLE Atome dieses Batches gelten als geprüft — auch die mit KEINER.
|
||||
# Sonst fragt jede Dedup-Runde dieselben Atome erneut (Endlosschleife).
|
||||
for a in atome:
|
||||
db.update("atome", "id=?", (a["id"],), soll_geprueft=1)
|
||||
return engine.Ergebnis(daten={"zugeordnet": n})
|
||||
|
||||
|
||||
async def _freispruch(task: dict, soll: dict) -> bool:
|
||||
auszuege = _beleg_fenster(soll)
|
||||
|
||||
async def ruf(i):
|
||||
text = await llm.call(
|
||||
run_id=task["run_id"], stufe="inventar", knoten="luecke",
|
||||
item=f"{task['item']}:p{i}", role="judge",
|
||||
skill_namen=["richter", "deutsch-praezise", "luecke-freispruch"],
|
||||
werte={"soll_punkt": soll["punkt"], "auszuege": auszuege[:20000]})
|
||||
urteile = textkit.bloecke(text or "", "URTEIL")
|
||||
return urteile[0].get("urteil", "") if urteile else None
|
||||
stimmen = await panels.panel(ruf, config.MERGE_PANEL)
|
||||
if panels.einstimmig(stimmen, config.MERGE_PANEL,
|
||||
lambda s: True if s == "unerfuellbar"
|
||||
else (False if s == "abgedeckt" else None)):
|
||||
db.update("soll", "id=?", (soll["id"],),
|
||||
freispruch=f"Panel einstimmig unerfuellbar (r{task['runde']})")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@engine.worker("inventar.luecke", buendel=True)
|
||||
async def luecke(task: dict) -> engine.Ergebnis:
|
||||
mitglieder = [task] + task.get("_buendel", [])
|
||||
teil, aktive = {}, {} # soll_id → (mitglied, soll, fenster)
|
||||
for m in mitglieder:
|
||||
p = db.uj(m["payload"], {})
|
||||
soll = db.one("SELECT * FROM soll WHERE id=?", p["soll_id"])
|
||||
if soll is None:
|
||||
teil[m["id"]] = "fertig"
|
||||
continue
|
||||
if not p.get("fenster") and db.one(
|
||||
"SELECT id FROM atome WHERE soll_id=? AND status='aktiv'",
|
||||
soll["id"]):
|
||||
# (Facetten-Tasks haben ein Fenster und laufen auch bei gedecktem Punkt)
|
||||
teil[m["id"]] = "fertig" # inzwischen gedeckt
|
||||
continue
|
||||
if task["art"] == "freispruch":
|
||||
# Panel je Soll-Punkt — Bündel läuft sequenziell durch dieselbe Logik
|
||||
await _freispruch(m, soll)
|
||||
teil[m["id"]] = "fertig"
|
||||
continue
|
||||
# Nachextraktion: gezieltes Facetten-Fenster ODER ±6k um alle Belegstellen
|
||||
if p.get("fenster"):
|
||||
f = p["fenster"]
|
||||
q = _quelle(f["quelle_id"])
|
||||
fenster = laden.snapshot_lesen(q)[f["von"]:f["bis"]] if q else ""
|
||||
else:
|
||||
fenster = _beleg_fenster(soll)
|
||||
aktive[soll["id"]] = (m, soll, fenster[:30000])
|
||||
if not aktive:
|
||||
return engine.Ergebnis(daten={"skip": True}, teil_status=teil)
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="inventar", knoten="luecke",
|
||||
item=task["item"], role="extraktion", n=len(aktive),
|
||||
skill_namen=["extraktor", "deutsch-praezise", "luecke-nachextraktion"],
|
||||
werte={"luecken": "\n\n".join(
|
||||
f"=== LUECKE ===\nSOLL: {sid}\nPUNKT: {s['punkt']}\nFENSTER:\n{f}"
|
||||
for sid, (_, s, f) in aktive.items())})
|
||||
if antwort is None:
|
||||
raise RuntimeError("luecke ohne Ergebnis")
|
||||
je_soll: dict[int, list] = {}
|
||||
for block in textkit.bloecke(antwort, "ATOM"):
|
||||
s = block.get("soll", "").strip()
|
||||
if s.isdigit():
|
||||
je_soll.setdefault(int(s), []).append(block)
|
||||
nichts = {int(b["soll"]) for b in textkit.bloecke(antwort, "NICHTS")
|
||||
if b.get("soll", "").strip().isdigit()}
|
||||
gesamt = 0
|
||||
for sid, (m, soll, _) in aktive.items():
|
||||
if sid not in je_soll and sid not in nichts:
|
||||
teil[m["id"]] = "neu" # Soll-Punkt fehlt in der Antwort → neuer Versuch
|
||||
continue
|
||||
n = 0
|
||||
for block in je_soll.get(sid, []):
|
||||
zitat = block.get("zitat", "")
|
||||
for beleg in db.uj(soll["belege"], []):
|
||||
q = _quelle(beleg["quelle"])
|
||||
if q is None or q["status"] != "geladen":
|
||||
continue
|
||||
text_q = laden.snapshot_lesen(q)
|
||||
span = textkit.finde_zitat(text_q, zitat)
|
||||
if span:
|
||||
atom_id = db.insert(
|
||||
"atome", topic=task["topic"],
|
||||
titel=block.get("titel", "")[:120],
|
||||
typ=f"{block.get('typ', 'begriff').split()[0]}|{m['item']}",
|
||||
definition=block.get("definition", "")[:600],
|
||||
status="aktiv", soll_id=soll["id"])
|
||||
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
|
||||
start=span[0], ende=span[1],
|
||||
zitat=text_q[span[0]:span[1]][:600])
|
||||
n += 1
|
||||
break
|
||||
teil[m["id"]] = "fertig"
|
||||
gesamt += n
|
||||
return engine.Ergebnis(daten={"gefunden": gesamt}, teil_status=teil)
|
||||
|
||||
|
||||
@engine.worker("inventar.verdichtung")
|
||||
async def verdichtung(task: dict) -> engine.Ergebnis:
|
||||
"""Minimalität (Ziel 2): Atome eines Soll-Punkts in möglichst wenige
|
||||
lehrbare Einheiten falten. Fusion NUR im Schnitt beider Panel-Stimmen
|
||||
(einstimmig, Lektion 18); Anker wandern mit — Abdeckung bleibt."""
|
||||
from .dedup import _merge
|
||||
payload = db.uj(task["payload"], {})
|
||||
soll = db.one("SELECT * FROM soll WHERE id=?", payload["soll_id"])
|
||||
if soll is None or soll["verdichtet"]:
|
||||
return engine.Ergebnis(daten={"skip": True})
|
||||
atome = db.query("SELECT * FROM atome WHERE soll_id=? AND status='aktiv'",
|
||||
soll["id"])
|
||||
if len(atome) <= config.VERDICHTUNG_ZIEL:
|
||||
db.update("soll", "id=?", (soll["id"],), verdichtet=1)
|
||||
return engine.Ergebnis(daten={"skip": True})
|
||||
nach_id = {a["id"]: a for a in atome}
|
||||
liste = "\n".join(f"[{a['id']}] {a['titel']}: {a['definition'][:150]}"
|
||||
for a in atome)
|
||||
|
||||
async def ruf(i):
|
||||
text = await llm.call(
|
||||
run_id=task["run_id"], stufe="inventar", knoten="verdichtung",
|
||||
item=f"{task['item']}:p{i}", role="judge", n=len(atome),
|
||||
skill_namen=["richter", "deutsch-praezise", "verdichtung"],
|
||||
werte={"soll_punkt": soll["punkt"], "atome": liste,
|
||||
"ziel": config.VERDICHTUNG_ZIEL})
|
||||
if text is None:
|
||||
return None
|
||||
gruppen, gesehen = [], set()
|
||||
for block in textkit.bloecke(text, "EINHEIT"):
|
||||
ids = [int(x) for x in block.get("ids", "").replace(" ", "").split(",")
|
||||
if x.isdigit() and int(x) in nach_id and int(x) not in gesehen]
|
||||
if ids:
|
||||
gruppen.append(ids)
|
||||
gesehen |= set(ids)
|
||||
gruppen += [[a["id"]] for a in atome if a["id"] not in gesehen]
|
||||
return gruppen
|
||||
|
||||
stimmen = [s for s in await panels.panel(ruf, config.MERGE_PANEL) if s]
|
||||
if len(stimmen) < config.MERGE_PANEL:
|
||||
raise RuntimeError("verdichtung: Panel unvollständig") # fail-closed
|
||||
# Schnitt: nur Paare, die BEIDE Stimmen zusammen gruppieren
|
||||
def paare(gruppen):
|
||||
out = set()
|
||||
for g in gruppen:
|
||||
for i, a in enumerate(g):
|
||||
for b in g[i + 1:]:
|
||||
out.add(frozenset((a, b)))
|
||||
return out
|
||||
gemeinsam = paare(stimmen[0])
|
||||
for s in stimmen[1:]:
|
||||
gemeinsam &= paare(s)
|
||||
# Connected Components über die einstimmigen Paare
|
||||
eltern = {a["id"]: a["id"] for a in atome}
|
||||
def wurzel(x):
|
||||
while eltern[x] != x:
|
||||
eltern[x] = eltern[eltern[x]]
|
||||
x = eltern[x]
|
||||
return x
|
||||
for paar in gemeinsam:
|
||||
a, b = tuple(paar)
|
||||
eltern[wurzel(a)] = wurzel(b)
|
||||
gruppen: dict[int, list[int]] = {}
|
||||
for aid in eltern:
|
||||
gruppen.setdefault(wurzel(aid), []).append(aid)
|
||||
fusionen = 0
|
||||
for mitglieder in gruppen.values():
|
||||
if len(mitglieder) < 2:
|
||||
continue
|
||||
kopf = max(mitglieder, key=lambda i: len(nach_id[i]["definition"]))
|
||||
for m in mitglieder:
|
||||
if m == kopf:
|
||||
continue
|
||||
k = db.one("SELECT * FROM atome WHERE id=?", kopf)
|
||||
v = db.one("SELECT * FROM atome WHERE id=?", m)
|
||||
if k["status"] == "aktiv" and v["status"] == "aktiv":
|
||||
_merge(task["topic"], k, v)
|
||||
fusionen += 1
|
||||
db.update("soll", "id=?", (soll["id"],), verdichtet=1)
|
||||
uebrig = db.one("SELECT COUNT(*) c FROM atome WHERE soll_id=? AND "
|
||||
"status='aktiv'", soll["id"])["c"]
|
||||
return engine.Ergebnis(daten={"vorher": len(atome), "nachher": uebrig,
|
||||
"fusionen": fusionen})
|
||||
|
||||
|
||||
def _beleg_fenster(soll: dict) -> str:
|
||||
teile = []
|
||||
for beleg in db.uj(soll["belege"], []):
|
||||
q = _quelle(beleg["quelle"])
|
||||
if q is None or q["status"] != "geladen":
|
||||
continue
|
||||
text = laden.snapshot_lesen(q)
|
||||
span = textkit.finde_zitat(text, beleg["zitat"])
|
||||
mitte = span[0] if span else 0
|
||||
von = max(0, mitte - config.LUECKE_FENSTER // 2)
|
||||
teile.append(text[von:von + config.LUECKE_FENSTER])
|
||||
return "\n\n---\n\n".join(teile)
|
||||
|
||||
|
||||
@engine.worker("inventar.gate")
|
||||
async def gate(task: dict) -> engine.Ergebnis:
|
||||
topic, runde = task["topic"], task["runde"]
|
||||
befunde, neue = [], []
|
||||
|
||||
# (a) aktive/neue Atome ohne Anker → Reparatur anker_fix
|
||||
unverankert = db.query(
|
||||
"SELECT DISTINCT an.quelle_id q FROM atome a JOIN anker an ON an.atom_id=a.id "
|
||||
"WHERE a.topic=? AND a.status IN ('neu','aktiv') AND an.start=-1", topic)
|
||||
for z in unverankert:
|
||||
befunde.append({"art": "anker", "item": f"quelle:{z['q']}", "detail": ""})
|
||||
neue.append({"knoten": "anker_fix", "item": f"quelle:{z['q']}:fix:r{runde}",
|
||||
"art": "reparatur", "runde": runde + 1,
|
||||
"payload": {"quelle_id": z["q"]}})
|
||||
# Atome 'neu' → 'aktiv' (verankerte)
|
||||
db.execute("UPDATE atome SET status='aktiv' WHERE topic=? AND status='neu' AND "
|
||||
"id IN (SELECT atom_id FROM anker WHERE start>=0)", topic)
|
||||
db.execute("UPDATE atome SET status='verworfen' WHERE topic=? AND status='neu' "
|
||||
"AND id NOT IN (SELECT atom_id FROM anker WHERE start>=0)", topic)
|
||||
|
||||
# (b) neue Atome seit letztem Dedup → Dedup-Runde
|
||||
max_atom = (db.one("SELECT COALESCE(MAX(id),0) m FROM atome WHERE topic=? "
|
||||
"AND status='aktiv'", topic))["m"]
|
||||
dedup_task = db.one("SELECT id, ergebnis FROM tasks WHERE topic=? AND "
|
||||
"knoten='dedup' AND status='fertig' ORDER BY id DESC "
|
||||
"LIMIT 1", topic) or {"id": 0, "ergebnis": "{}"}
|
||||
letzter_dedup = db.uj(dedup_task["ergebnis"], {}).get("stand", 0)
|
||||
# Zuordnung NACH dem letzten Dedup → Gleicher-Soll-Kanal konnte noch nicht
|
||||
# feuern → eine Dedup-Runde nachlegen
|
||||
zuordnung_danach = db.one(
|
||||
"SELECT id FROM tasks WHERE topic=? AND knoten='soll_zuordnung' AND "
|
||||
"status='fertig' AND id>? AND ergebnis LIKE '%zugeordnet%' AND "
|
||||
"ergebnis NOT LIKE '%\"zugeordnet\": 0%' LIMIT 1",
|
||||
topic, dedup_task["id"])
|
||||
if max_atom > letzter_dedup or zuordnung_danach:
|
||||
# Item trägt den BEWEGLICHEN Zustand mit — sonst sieht der Stillstand-
|
||||
# Fingerprint echten Fortschritt (neue Zuordnungen) als identisch an
|
||||
befunde.append({"art": "dedup_ausstehend",
|
||||
"item": f"stand:{max_atom}:z{(zuordnung_danach or {}).get('id', 0)}",
|
||||
"detail": ""})
|
||||
neue.append({"knoten": "dedup", "item": f"g{runde + 1}:dedup",
|
||||
"art": "reparatur", "runde": runde + 1})
|
||||
|
||||
# (c) Coverage: jeder bestätigte Soll-Punkt braucht ≥1 aktives Atom oder Freispruch
|
||||
luecken = db.query(
|
||||
"SELECT s.* FROM soll s WHERE s.topic=? AND s.status='bestaetigt' AND "
|
||||
"s.freispruch='' AND NOT EXISTS (SELECT 1 FROM atome a WHERE "
|
||||
"a.soll_id=s.id AND a.status='aktiv')", topic)
|
||||
web_runden = db.one(
|
||||
"SELECT COUNT(DISTINCT item) c FROM tasks WHERE topic=? AND knoten='suche' "
|
||||
"AND item LIKE 'luecke:%'", topic)["c"]
|
||||
for s in luecken:
|
||||
befunde.append({"art": "luecke", "item": f"soll:{s['id']}",
|
||||
"detail": s["punkt"][:120]})
|
||||
bisher = db.one("SELECT COUNT(*) c FROM tasks WHERE topic=? AND "
|
||||
"knoten='luecke' AND item LIKE ? AND item NOT LIKE '%:frei:%'",
|
||||
topic, f"%:soll:{s['id']}")["c"]
|
||||
if bisher < config.LUECKEN_RUNDEN_MAX:
|
||||
neue.append({"knoten": "luecke", "item": f"r{runde + 1}:soll:{s['id']}",
|
||||
"art": "reparatur", "runde": runde + 1,
|
||||
"payload": {"soll_id": s["id"]}})
|
||||
elif web_runden < config.WEB_NACHRECHERCHE_RUNDEN * max(1, len(luecken)):
|
||||
# Web-Nachrecherche: Query GEGROUNDET aus Punkt + Zitat-Kern (R11)
|
||||
zitat = (db.uj(s["belege"], []) or [{}])[0].get("zitat", "")
|
||||
query = " ".join(sorted(textkit.tokens(s["punkt"]))[:4] +
|
||||
sorted(textkit.tokens(zitat))[:3])
|
||||
neue.append({"knoten": "suche", "item": f"luecke:{s['id']}:r{runde + 1}",
|
||||
"art": "ruecklauf", "runde": runde + 1,
|
||||
"payload": {"query": query, "zweck": f"luecke:{s['id']}"}})
|
||||
else:
|
||||
neue.append({"knoten": "luecke",
|
||||
"item": f"r{runde + 1}:frei:soll:{s['id']}",
|
||||
"art": "freispruch", "runde": runde + 1,
|
||||
"payload": {"soll_id": s["id"]}})
|
||||
|
||||
# (d) Beifang raus: geprüfte Atome ohne Soll-Bezug verwerfen — Abdeckung
|
||||
# misst sich am Soll; alles andere bläht nur (32-Bausteine-Lektion)
|
||||
db.execute("UPDATE atome SET status='verworfen' WHERE topic=? AND "
|
||||
"status='aktiv' AND soll_id IS NULL AND soll_geprueft=1", topic)
|
||||
|
||||
# (d2) Facetten HART: jede Beleg-Stelle braucht ein Atom in der Nähe
|
||||
# (Gutachten: Namensherkunft fehlte trotz 7 Belegen). Cap: 2 gezielte
|
||||
# Nachextraktionen je Stelle, dann eskaliert (dokumentierte Grenze).
|
||||
from . import belege as belege_mod
|
||||
final = {r["item"] for r in db.query(
|
||||
"SELECT item FROM befunde WHERE art='facette' AND status IN "
|
||||
"('eskaliert','freispruch') AND run_id IN (SELECT id FROM runs WHERE "
|
||||
"topic=?)", topic)}
|
||||
for st in belege_mod.unbedeckte_belege(topic):
|
||||
key = f"beleg:{st['quelle_id']}:{st['start']}:soll:{st['soll_id']}"
|
||||
if key in final:
|
||||
continue
|
||||
versuche = db.one("SELECT COUNT(*) c FROM tasks WHERE topic=? AND "
|
||||
"knoten='luecke' AND item LIKE ?",
|
||||
topic, f"%:{key}")["c"]
|
||||
if versuche >= config.LUECKEN_RUNDEN_MAX:
|
||||
db.insert("befunde", run_id=task["run_id"], stufe="inventar",
|
||||
knoten="gate_inventar", art="facette", item=key,
|
||||
detail=f"unabgedeckt trotz Nachextraktion: {st['zitat']}",
|
||||
status="eskaliert", runde=runde)
|
||||
continue
|
||||
befunde.append({"art": "facette", "item": key,
|
||||
"detail": st["zitat"][:120]})
|
||||
von = max(0, st["start"] - config.LUECKE_FENSTER // 2)
|
||||
neue.append({"knoten": "luecke", "item": f"r{runde + 1}:{key}",
|
||||
"art": "reparatur", "runde": runde + 1,
|
||||
"payload": {"soll_id": st["soll_id"],
|
||||
"fenster": {"quelle_id": st["quelle_id"],
|
||||
"von": von,
|
||||
"bis": von + config.LUECKE_FENSTER}}})
|
||||
|
||||
# (e) Minimalität: dicke Soll-Punkte einmalig verdichten (Ziel 2)
|
||||
dicke = db.query(
|
||||
"SELECT s.id, COUNT(a.id) n FROM soll s JOIN atome a ON a.soll_id=s.id "
|
||||
"AND a.status='aktiv' WHERE s.topic=? AND s.status='bestaetigt' AND "
|
||||
"s.verdichtet=0 GROUP BY s.id HAVING n > ?",
|
||||
topic, config.VERDICHTUNG_ZIEL)
|
||||
for d in dicke:
|
||||
befunde.append({"art": "unverdichtet", "item": f"soll:{d['id']}",
|
||||
"detail": f"{d['n']} Atome"})
|
||||
neue.append({"knoten": "verdichtung", "item": f"r{runde + 1}:verd:soll:{d['id']}",
|
||||
"art": "reparatur", "runde": runde + 1,
|
||||
"payload": {"soll_id": d["id"]}})
|
||||
|
||||
if befunde:
|
||||
return engine.Ergebnis(gate_status="rot", befunde=befunde, neue_tasks=neue)
|
||||
|
||||
# grün → Lernziele je Soll-Punkt (+ Sammelziel für Atome ohne Soll)
|
||||
db.update("topics", "name=?", (topic,), status="inventar_fertig")
|
||||
neue = [{"knoten": "lernziele", "item": f"soll:{s['id']}",
|
||||
"payload": {"soll_id": s["id"]}}
|
||||
for s in db.query("SELECT id FROM soll WHERE topic=? AND "
|
||||
"status='bestaetigt'", topic)]
|
||||
return engine.Ergebnis(gate_status="gruen", neue_tasks=neue)
|
||||
Reference in New Issue
Block a user