Files
creator2/backend/korpus.py
2026-07-12 16:13:50 +02:00

496 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/<name>/ 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
def _datei_lesen(pfad: Path) -> str:
"""txt/md direkt; 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:
return pfad.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
async def _uni_quellen(ctx: llm.Kontext) -> int:
"""Dateien aus topics/<name>/ als Quellen registrieren. → Anzahl neu."""
ordner = TOPICS_DIR / ctx.topic
neu = 0
for pfad in sorted(ordner.glob("*")) if ordner.is_dir() else []:
if pfad.suffix.lower() not in (".txt", ".md", ".pdf"):
continue
# .pdf überspringen, wenn eine gleichnamige .txt daneben liegt (vorkonvertiert)
if pfad.suffix.lower() == ".pdf" and pfad.with_suffix(".txt").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