"""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, graph, 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=graph.skills_von(task["knoten"]), 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=graph.skills_von(task["knoten"]), 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 != "KEINER" 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 == "KEINER": 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=graph.skills_von(task["knoten"]), 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=graph.skills_von(task["knoten"], extra="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=graph.skills_von(task["knoten"]), 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, "LEER") 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=graph.skills_von(task["knoten"]), 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": "luecke", "soll_id": 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) # Harter Vollständigkeits-Stop (Lektion 103): kippt über die Hälfte der # extrahierten Atome als Beifang, ist das Soll zu eng → PAUSE statt # löchriger Guide. geprueft = db.one("SELECT COUNT(*) c FROM atome WHERE topic=? AND " "soll_geprueft=1 AND merged_into IS NULL", topic)["c"] beifang = db.one("SELECT COUNT(*) c FROM atome WHERE topic=? AND " "status='verworfen' AND soll_id IS NULL AND " "soll_geprueft=1 AND merged_into IS NULL", topic)["c"] if geprueft and beifang / geprueft > config.BEIFANG_MAX_QUOTE: raise llm.LaufPause(f"vollstaendigkeit:beifang {beifang}/{geprueft}") # (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)