This commit is contained in:
team3
2026-07-23 16:07:36 +02:00
commit cb68cd671b
88 changed files with 10029 additions and 0 deletions

518
backend/inventar.py Normal file
View 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)