"""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// 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// 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