This commit is contained in:
team3
2026-07-24 11:23:18 +02:00
parent cb68cd671b
commit 1b054497ed
49 changed files with 935 additions and 408 deletions

28
backend/arten.py Normal file
View File

@@ -0,0 +1,28 @@
"""Zentrale Status-/Arten-Registry (Lektion 106): EINE Wahrheit statt
verstreuter Literal-Listen, die still auseinanderdriften."""
# Quellen-Status, die nicht (mehr) zum Korpus beitragen — geteilt von
# suche (Deckel-Zählung), laden (Erzeuger) und Frontend-Anzeige
QUELLEN_TOT = ("fehler", "leer", "braucht_js", "aussortiert")
# Event-Status, die in den Kennzahlen als Fehler zählen (ledger)
EVENT_FEHLER = ("error", "infra", "cap")
# Guide-Befund-Arten mit Verhalten. gate_blockiert: Befund hält das Gate rot;
# fix_route: Gate erzeugt Fix→Klärung→Neuschreiben-Kette dafür.
BEFUND_ARTEN = {
"falsch": {"gate_blockiert": True, "fix_route": True},
"luecke": {"gate_blockiert": True, "fix_route": True},
"redundanz": {"gate_blockiert": True, "fix_route": True},
"vorwaerts": {"gate_blockiert": True, "fix_route": True},
"format_leak": {"gate_blockiert": True, "fix_route": True},
"stil": {"gate_blockiert": True, "fix_route": True},
"laenge": {"gate_blockiert": False, "fix_route": False}, # nur Kennzahl
}
FIX_ARTEN = tuple(a for a, f in BEFUND_ARTEN.items() if f["fix_route"])
# det-Check-Arten, deren offene Alt-Befunde bei jedem Gate-Lauf neu erhoben
# werden (Live-Checks ersetzen die Historie)
DET_ARTEN = ("marker_fehlend", "marker_fremd", "marker_doppelt", "vorwaerts",
"laenge", "format_leak", "link_im_text", "atom_echo", "h_ebene")

View File

@@ -30,16 +30,22 @@ KORPUS_DIR = ROOT / "storage" / "korpus"
SKILLS_DIR = ROOT / "skills"
PIPELINE_YAML = ROOT / "pipeline.yaml"
TOPIC_NAME_RE = re.compile(r"^[a-zA-Z0-9äöüÄÖÜß][a-zA-Z0-9äöüÄÖÜß _-]{0,63}$")
TOPIC_NAME_RE = re.compile(r"^[a-zA-Z0-9äöüÄÖÜß][a-zA-Z0-9äöüÄÖÜß ._-]{0,63}$")
# ── LLM / MiniMax (R3/R9) — alles M3; M2.x kann Thinking nicht abschalten ──
LLM_ENDPOINT = "https://api.minimax.io/anthropic/v1/messages"
# ── LLM (R3/R9) — Anbieter/Modell per env überschreibbar, nicht im Code ──
LLM_ENDPOINT = os.getenv("LLM_ENDPOINT",
"https://api.minimax.io/anthropic/v1/messages")
LLM_MODEL = os.getenv("LLM_MODEL", "MiniMax-M3")
ROLLEN = {
# thinking IMMER explizit ("enabled" gibt 400; Default widersprüchlich dokumentiert)
"extraktion": dict(model="MiniMax-M3", thinking="disabled", temperature=0.2),
"judge": dict(model="MiniMax-M3", thinking="disabled", temperature=0.1),
"writer": dict(model="MiniMax-M3", thinking="adaptive", temperature=0.7),
"extraktion": dict(model=LLM_MODEL, thinking="disabled", temperature=0.2),
"judge": dict(model=LLM_MODEL, thinking="disabled", temperature=0.1),
"writer": dict(model=LLM_MODEL, thinking="adaptive", temperature=0.7),
}
# Anbieter-spezifische Infra-Marker (MiniMax-Statuscodes) getrennt von generischen
INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out",
"connection", "network", "overloaded", "unavailable",
'"status_code":1002', '"status_code":1041', '"status_code":2045')
LLM_MAX_TOKENS = 32_000
MAX_PARALLEL_LLM = int(os.getenv("MAX_PARALLEL_LLM", "12")) # M3: 200 RPM → 8-16 sicher
TAKT_SEKUNDEN = float(os.getenv("TAKT_SEKUNDEN", "3")) # 1 Call-Start je Takt; 0 = Breiten-Drossel
@@ -53,16 +59,8 @@ HEDGE_NACH_S = int(os.getenv("HEDGE_NACH_S", "90")) # Zwilling nach max(90, tim
RUN_BUDGET_TOKENS = int(os.getenv("RUN_BUDGET_TOKENS", "10000000")) # DEFAULT AN; 0 = aus
PREIS_IN, PREIS_OUT = 0.60, 2.40 # $/1M konservativ (R9), real messen
# Timeouts je Knoten: (basis_s, pro_item_s) — creator2-Lektion 14: nie fix;
# pro_item skaliert mit der Bündel-Größe (n in llm.call)
TIMEOUTS = {
"recherche_plan": (60, 20), "soll_extraktion": (180, 60), "soll_konsens": (240, 2),
"atom_extraktion": (240, 60), "anker_fix": (180, 4), "dedup": (120, 8),
"soll_zuordnung": (120, 3), "luecke": (180, 45), "lernziele": (120, 3),
"bausteine": (120, 3), "ordnung": (180, 3), "writer": (420, 0),
"kapitel_intro": (90, 0), "llm_pruefung": (240, 60), "fix": (300, 0),
"redundanz": (120, 8), "klaerung": (180, 0),
}
# Timeouts stehen als Knoten-Property in pipeline.yaml (graph.timeout_fuer);
# hier nur der Default für Knoten ohne Angabe
TIMEOUT_DEFAULT = (180, 0)
# ── Suche (R2) ──
@@ -78,18 +76,25 @@ FETCH_CONNECT_S = 10
FETCH_READ_S = 30
DOMAIN_RATE_S = 1.0
SNAPSHOT_MIN_ZEICHEN = 500
# Privates Lese-Tool, ~20 Seiten/Lauf mit Domain-Bremse — kein robots.txt-Check
# mehr (Lektion 101); Browser-UA, sonst sperren Bot-Filter trotzdem
BROWSER_UA = ("Mozilla/5.0 (X11; Linux x86_64; rv:128.0) "
"Gecko/20100101 Firefox/128.0")
URTEIL_AUSZUG_ZEICHEN = 3_000 # Inhalts-Urteil je geladener Quelle
# ── Korpus ──
RECHERCHE_LENSES = ("ueberblick", "tutorial", "referenz", "praxis") # generisch!
RECHERCHE_RUNDEN_MAX = int(os.getenv("RECHERCHE_RUNDEN_MAX", "4"))
QUERIES_JE_LENS = 3
SOLL_CHUNK_CHARS = 40_000
SOLL_MIN_BELEGE = 2 # ≥2 unabhängige Quellen je bestätigtem Punkt
SOLL_PUNKTE_MIN = int(os.getenv("SOLL_PUNKTE_MIN", "5"))
SOLL_BAND_FAKTOR = 1.0 # Ziel ≈ √Kandidaten (creator2-Band — Lektion 90)
SOLL_BAND_TOLERANZ = 1.5 # mehr als Ziel×1.5 bestätigt → nochmal falten
QUELLEN_MAX = int(os.getenv("QUELLEN_MAX", "24")) # harter Korpus-Deckel
EXTRAKT_FENSTER = 4_000 # Atome nur ±Fenster um Soll-Belege (belegnah)
# Harte Vollständigkeits-Stops (Lektion 103): lieber PAUSE als Mini-Guide
SEED_PFLICHT = True # keine Startquelle nützlich geladen → PAUSE
QUELLEN_MIN = 5 # weniger geladene nützliche Quellen → PAUSE
BEIFANG_MAX_QUOTE = 0.5 # >50 % Atome als Beifang verworfen → PAUSE
# ── Inventar ──
ABSCHNITT_CHARS = 6_000 # 12k riss das 32k-Output-Cap (creator2 empirisch)
@@ -127,22 +132,34 @@ REDUNDANZ_SHINGLE = 5 # gemeinsame 5-Wort-Folge = Kandidat
REDUNDANZ_SATZ_MIN_TOKEN = 6
ZIEL_DEDUP_JACCARD = 0.5 # synonyme Lernziele deterministisch mergen
ECHO_JACCARD = 0.7 # 1. Satz nach Marker ≈ Atom-Definition = Echo
SOLL_MIN_BELEGE_WIKI = 1 # kuratierte Quelle (Wikipedia) = 1 Beleg genügt
FAKTEN_SIGNATUR_MIN = 2 # geteilte Zahlen+Namen je Satzpaar = Kandidat
# ── Engine (R8) ──
POLL_SEKUNDEN = 2.0
MAX_VERSUCHE = 2 # Inhalts-Versuche je Task; Infra zählt separat
STILLSTAND_N = 2 # identischer Befund-Fingerprint n× → PAUSE
GATE_RUNDEN_MAX = {"gate_korpus": 4, "gate_inventar": 8, "gate_struktur": 3, "gate_guide": 12}
# gate_inventar 6: Anker-Fix, 2× Dedup/Lücke, Web-Nachrecherche, Nachzügler-Dedup
# gate_guide 8: Echtlauf konvergierte 45→28→10 — der Stillstand-Fingerprint
# fängt echte Schleifen, das Runden-Limit ist nur die Notbremse dahinter
# Gate-Runden-Caps stehen als runden_max am Gate-Knoten in pipeline.yaml
MAX_PARALLEL_GLOBAL = 24
def timeout_fuer(knoten: str, n: int = 0) -> float:
basis, pro = TIMEOUTS.get(knoten, TIMEOUT_DEFAULT)
return basis + pro * n
# ── Sprache: EIN Parameter, alles referenziert ihn (Lektion 106) ──
SPRACHE = os.getenv("SPRACHE", "de")
SPRACHEN_SUCHE = tuple(os.getenv("SPRACHEN_SUCHE", "de,en").split(","))
STEMMER_NAME = {"de": "german", "en": "english", "fr": "french",
"es": "spanish", "it": "italian"} # sonst: kein Stemming
NEGATIONEN = {"de": {"nicht", "kein", "keine", "keinen", "keiner", "ohne",
"nie", "niemals"},
"en": {"not", "no", "none", "never", "without"}}
NEGATIONS_PRAEFIX = {"de": "nicht", "en": "non"}
SUCHE_LOCALE = {"de": {"country": "DE", "search_lang": "de", "region": "de-de"},
"en": {"country": "US", "search_lang": "en", "region": "us-en"}}
# Ausgabe-Literale, die im Guide landen — eine Stelle, pro Sprache tauschbar
TEXTE = {
"sammel_titel": "Grundlagen",
"sammel_punkt": "Weitere Grundlagen zum Thema",
"sammel_ziel": "Der Lernende kann Grundlagen zu '{punkt}' erklären.",
"teil": "{titel} (Teil {n})",
"abschnitt_verweis": "- {titel} (im Abschnitt '{abschnitt}')",
}
def user_agent() -> str:

View File

@@ -2,6 +2,7 @@
architektonisch, R8), WAL, kurze Transaktionen. Zustand liegt NUR hier —
Resume = Prozess neu starten, fertige Tasks überspringen."""
import json
import re
import sqlite3
import threading
from datetime import datetime, timezone
@@ -14,6 +15,13 @@ _lock = threading.RLock()
on_change: Callable[[str, dict], None] | None = None # ws.Hub.push
_LIVE_TABELLEN = {"topics", "runs", "tasks", "gate_laeufe", "befunde"}
_RUN_TABELLEN = {"topics", "runs"} # ws-Bereich "run", Rest "graph"
# Topic-skopierte Pipeline-Tabellen — EINE Wahrheit für Reset (main.py)
PIPELINE_TABELLEN = ("tasks", "gate_laeufe", "quellen", "soll", "atome",
"kanten", "lernziele", "bausteine", "kapitel")
_TABELLE_RE = re.compile(
r"^\s*(?:UPDATE|DELETE\s+FROM|INSERT(?:\s+OR\s+\w+)?\s+INTO)\s+(\w+)",
re.IGNORECASE)
SCHEMA = """
CREATE TABLE IF NOT EXISTS topics(
@@ -50,8 +58,8 @@ CREATE TABLE IF NOT EXISTS quellen(
id INTEGER PRIMARY KEY, topic TEXT, titel TEXT DEFAULT '', url TEXT DEFAULT '',
url_norm TEXT DEFAULT '', backend TEXT DEFAULT '', snapshot TEXT DEFAULT '',
roh TEXT DEFAULT '', hash TEXT DEFAULT '', runde INTEGER DEFAULT 0,
zweck TEXT DEFAULT 'korpus', status TEXT DEFAULT 'neu', grund TEXT DEFAULT '',
atome_stand TEXT DEFAULT '', UNIQUE(topic, url_norm));
zweck TEXT DEFAULT 'korpus', soll_id INTEGER, status TEXT DEFAULT 'neu',
grund TEXT DEFAULT '', atome_stand TEXT DEFAULT '', UNIQUE(topic, url_norm));
CREATE TABLE IF NOT EXISTS soll(
id INTEGER PRIMARY KEY, topic TEXT, punkt TEXT, status TEXT DEFAULT 'kandidat',
belege TEXT DEFAULT '[]', kapitel_id INTEGER, geprueft TEXT DEFAULT '[]',
@@ -109,7 +117,8 @@ def connect(pfad: str | None = None) -> None:
for migration in ( # Mini-Migrationen für Bestands-DBs
"ALTER TABLE atome ADD COLUMN soll_geprueft INTEGER DEFAULT 0",
"ALTER TABLE topics ADD COLUMN quellen_max INTEGER",
"ALTER TABLE soll ADD COLUMN verdichtet INTEGER DEFAULT 0"):
"ALTER TABLE soll ADD COLUMN verdichtet INTEGER DEFAULT 0",
"ALTER TABLE quellen ADD COLUMN soll_id INTEGER"):
try:
_conn.execute(migration)
except sqlite3.OperationalError:
@@ -156,8 +165,8 @@ def execute(sql: str, *args) -> int:
with _lock:
cur = _conn.execute(sql, args)
_conn.commit()
tabelle = sql.split()[2] if sql.lstrip().upper().startswith(("UPDATE",)) else ""
_notify(tabelle.lower(), {})
m = _TABELLE_RE.match(sql) # UPDATE/DELETE/INSERT — nicht Positions-Raten
_notify(m.group(1).lower() if m else "", {})
return cur.rowcount

View File

@@ -4,7 +4,7 @@
Merge NUR einstimmig; geprüfte Paare als 'verwandt'-Kante — nie zweimal Tokens."""
from rapidfuzz import fuzz
from . import config, db, engine, llm, panels, textkit
from . import config, db, engine, graph, llm, panels, textkit
def _aktive(topic: str) -> list[dict]:
@@ -74,11 +74,13 @@ def _kandidaten(topic: str) -> list[tuple[dict, dict, str]]:
def _signatur(text: str) -> set:
"""Zahlen + Eigennamen — dieselbe Studie in Paraphrase teilt sie
(a20≈a78: 'Biwer', '2023', '25', '35'), auch über Soll-Grenzen."""
"""Zahlen + großgeschriebene Wörter — dieselbe Studie/Zahl in Paraphrase
teilt sie (a20≈a78: 'Biwer', '2023'). Unicode-Groß statt [A-ZÄÖÜ]-Klasse
(Lektion 106); Schriften ohne Großschreibung liefern nur Zahlen."""
import re
zahlen = set(re.findall(r"\b\d{2,4}\b", text))
namen = {w for w in re.findall(r"\b[A-ZÄÖÜ][a-zäöü]{3,}\b", text)}
namen = {w for w in re.findall(r"\w{4,}", text, re.UNICODE)
if w[0].isupper() and not w[0].isdigit()}
return zahlen | namen
@@ -106,16 +108,16 @@ async def _chunk_urteilen(task: dict, chunk: list, tag: str) -> tuple[int, list]
in ALLEN gültigen Stimmen vorkommen; fehlende → Rückgabe zum Nachprüfen
(nie still als „keine Dublette" werten)."""
topic = task["topic"]
liste = "\n\n".join(
f"PAAR {j + 1}:\nA[{a['id']}] {a['titel']}: {a['definition'][:200]}\n"
f"B[{b['id']}] {b['titel']}: {b['definition'][:200]}"
for j, (a, b, _) in enumerate(chunk))
liste = textkit.paar_liste(
[(f"[{a['id']}] {a['titel']}: {a['definition'][:200]}",
f"[{b['id']}] {b['titel']}: {b['definition'][:200]}")
for a, b, _ in chunk])
async def ruf(p):
text = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="dedup",
item=f"{task['item']}:{tag}:p{p}", role="judge", n=len(chunk),
skill_namen=["richter", "deutsch-praezise", "dedup-urteil"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"paare": liste})
if text is None:
return None

View File

@@ -36,9 +36,10 @@ class Ergebnis:
def module_laden() -> None:
"""Worker-Module importieren → Registrierung; danach Graph validieren."""
for mod in ("suche", "laden", "korpus", "inventar", "dedup",
"struktur", "guide", "redundanz", "montage"):
"""Worker-Module aus dem Graph ableiten (eine Wahrheit: pipeline.yaml),
importieren → Registrierung; danach voll validieren."""
g = graph.laden(None)
for mod in sorted({k.worker.split(".")[0] for k in g.knoten.values()}):
importlib.import_module(f"backend.{mod}")
graph.laden(set(WORKER))
@@ -218,7 +219,7 @@ def _gate_pruefen(g: graph.Graph, topic: str, run_id: int, stufe_i: int) -> None
return
runde = (db.one("SELECT COUNT(*) c FROM gate_laeufe WHERE topic=? AND knoten=?",
topic, gate.id) or {"c": 0})["c"] + 1
if runde > config.GATE_RUNDEN_MAX.get(gate.id, 3):
if runde > gate.runden_max:
_pausieren(run_id, f"gate_cap:{gate.id}:{runde}")
return
db.insert("tasks", ignore=True, run_id=run_id, topic=topic, knoten=gate.id,
@@ -340,21 +341,27 @@ def _pausieren(run_id: int, grund: str, status: str = "paused") -> None:
def _abschliessen(g: graph.Graph, topic: str, run_id: int) -> None:
"""Kein Task mehr offen: done, wenn alle Gates grün + Montage fertig."""
fertig = db.one("SELECT id FROM tasks WHERE topic=? AND knoten='montage' "
"AND status='fertig'", topic)
"""Kein Task mehr offen: done, wenn die letzte Stufe fertig ist."""
terminal = [k.id for k in g.knoten.values() if k.stufe == g.stufen[-1]]
qs = ",".join("?" * len(terminal))
fertig = db.one(f"SELECT id FROM tasks WHERE topic=? AND knoten IN ({qs}) "
"AND status='fertig'", topic, *terminal)
offen_fehler = db.one("SELECT id FROM tasks WHERE topic=? AND status='fehler' "
"LIMIT 1", topic)
if fertig and not offen_fehler:
db.update("runs", "id=?", (run_id,), status="done", beendet=db.now())
else:
grund = "tasks_mit_fehler" if offen_fehler else "leerlauf_ohne_montage"
grund = "tasks_mit_fehler" if offen_fehler else "leerlauf_ohne_abschluss"
_pausieren(run_id, grund)
def _start_tasks(topic: str, run_id: int) -> None:
"""Initiale Tasks der Stufe 1 (idempotent — Resume ist ein No-Op)."""
for lens in config.RECHERCHE_LENSES:
db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="recherche_plan", item=f"r1:lens:{lens}", runde=1,
payload=db.j({"lens": lens}), erzeugt_von="start")
"""Initiale Tasks: Knoten der Stufe 1 ohne Normal-Vorgänger (aus dem
Graph abgeleitet, keine Knoten-Namen im Code). Idempotent."""
g = graph.get()
ziele = {k["nach"] for k in g.kanten if k["art"] == "normal"}
for kn in g.knoten.values():
if kn.stufe == g.stufen[0] and kn.id not in ziele:
db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten=kn.id, item="r1:start", runde=1,
payload="{}", erzeugt_von="start")

View File

@@ -106,7 +106,7 @@ def _nummerierte_abschnitte(p: str) -> dict[int, str]:
def _recherche_plan(p: str) -> str:
runde = re.search(r"\(Runde (\d+)\)", p)
r = runde.group(1) if runde else "1"
m = re.search(r"BLICKWINKEL:\n(.*?)\n\nBereits", p, re.DOTALL)
m = re.search(r"BLICKWINKEL:\n(.*?)\n\nBISHERIGE", p, re.DOTALL)
lenses = re.findall(r"^- (\w+)$", m.group(1) if m else "", re.MULTILINE)
return "\n".join(f"===QUERY===\nLENS: {l}\nTEXT: thema {l} r{r} nr{i}"
for l in (lenses or ["x"]) for i in (1, 2))
@@ -150,13 +150,13 @@ def _atom_extraktion(p: str) -> str:
def _anker_fix(p: str) -> str:
return "\n".join(f"===FIX===\nID: {i}\nZITAT: VERWERFEN"
return "\n".join(f"===FIX===\nID: {i}\nZITAT: KEINER"
for i, _ in _ids_liste(_abschnitt(p, "ATOME")))
def _dedup_urteil(p: str) -> str:
out = []
for m in re.finditer(r"PAAR (\d+):\nA\[\d+\] ([^:]+):.*?\nB\[\d+\] ([^:]+):",
for m in re.finditer(r"PAAR (\d+):\nA: \[\d+\] ([^:]+):.*?\nB: \[\d+\] ([^:]+):",
p, re.DOTALL):
nr, ta, tb = m.group(1), m.group(2).strip(), m.group(3).strip()
paar = frozenset({ta, tb})
@@ -189,7 +189,7 @@ def _luecke_nachextraktion(p: str) -> str:
out.append(f"===ATOM===\nSOLL: {sid}\nTITEL: {t}\nTYP: {typ}\n"
f"DEFINITION: {d}\nZITAT: {s}")
else:
out.append(f"===NICHTS===\nSOLL: {sid}")
out.append(f"===LEER===\nSOLL: {sid}")
return "\n".join(out)
@@ -217,7 +217,7 @@ def _lernziele(p: str) -> str:
def _bausteine(p: str) -> str:
ids = [str(i) for i, _ in _ids_liste(_abschnitt(p, "ATOME (in Quellreihenfolge)"))]
ids = [str(i) for i, _ in _ids_liste(_abschnitt(p, "ATOME"))]
return f"===BAUSTEIN===\nTITEL: Baustein\nATOME: {','.join(ids)}"
@@ -234,7 +234,7 @@ def _themen_schnitt(p: str) -> str:
def _ordnung(p: str) -> str:
prior = re.search(r"übernehmen\): ([\d,]+)", p)
prior = re.search(r"^PRIOR: ([\d,]+)$", p, re.MULTILINE)
return f"===ORDNUNG===\nIDS: {prior.group(1) if prior else ''}"
@@ -251,15 +251,15 @@ def _writer(p: str) -> str:
def _kapitel_intro(p: str) -> str:
kap = re.search(r"Kapitel „(.+?)\"", p)
kap = re.search(r"^KAPITEL: (.+)$", p, re.MULTILINE)
return (f"===INTRO===\nDieses Kapitel behandelt {kap.group(1) if kap else 'X'}. "
"Es baut auf dem bisher Gelernten auf.")
def _guide_pruefung(p: str) -> str:
ids = re.findall(r"^=== SECTION (\d+) ===$", p, re.MULTILINE)
return "\n".join(f"===OK===\nSECTION: {i}"
for i in dict.fromkeys(ids)) or "===OK===\nSECTION: 0"
return "\n".join(f"===LEER===\nSECTION: {i}"
for i in dict.fromkeys(ids)) or "===LEER===\nSECTION: 0"
def _guide_fix(p: str) -> str:
@@ -273,7 +273,7 @@ def _guide_fix(p: str) -> str:
def _redundanz_urteil(p: str) -> str:
out = []
for m in re.finditer(r"PAAR (\d+):\nKapitel A: (.*)\nKapitel B: (.*)", p):
for m in re.finditer(r"PAAR (\d+):\nA: (.*)\nB: (.*)", p):
redundant = "Fehler lassen sich nie ganz vermeiden" in m.group(2)
out.append(f"===URTEIL===\nPAAR: {m.group(1)}\n"
f"URTEIL: {'redundant' if redundant else 'didaktisch_gewollt'}")
@@ -284,9 +284,10 @@ _HANDLER = {
"recherche-plan": _recherche_plan,
"soll-extraktion": _soll_extraktion,
"soll-konsens": _soll_konsens,
"soll-beleg": lambda p: "===BELEG===\nID: 0\nBELEG: KEIN BELEG",
"quellen-relevanz": lambda p: "===RELEVANT===\nIDS: " + ",".join(
m for m in re.findall(r"^\[(\d+)\]", p, re.MULTILINE)),
"quellen-urteil": lambda p: ("===URTEIL===\nNUETZLICH: nein\nGRUND: Werbung"
if "Werbeliste" in p else
"===URTEIL===\nNUETZLICH: ja\nGRUND: lehrt"),
"lektorat": lambda p: "===LEER===\nOK: ja",
"atom-extraktion": _atom_extraktion,
"anker-fix": _anker_fix,
"dedup-urteil": _dedup_urteil,

View File

@@ -23,7 +23,10 @@ class Knoten:
titel: str = ""
beschreibung: str = ""
skills: list = field(default_factory=list)
extra_skills: dict = field(default_factory=dict) # name → Skill-Liste
klasse: str = "lang" # lang | mittel | kurz → Bündel-Größe je Call
timeout: list | None = None # [basis_s, pro_item_s]
runden_max: int = 3 # nur Gates: Runden-Cap
max_parallel: int = 4
max_versuche: int = config.MAX_VERSUCHE
barriere: bool = False
@@ -139,7 +142,8 @@ def laden(worker_namen: set[str] | None = None, pfad=None) -> Graph:
for kn in knoten.values():
if kn.typ == "llm" and not kn.skills:
raise GraphFehler(f"{kn.id}: llm-Knoten ohne Skills")
for s in kn.skills:
for s in kn.skills + [s for liste in kn.extra_skills.values()
for s in liste]:
skills.lade(s) # wirft SkillFehler, wenn Datei fehlt/kaputt
if worker_namen is not None and kn.worker not in worker_namen:
raise GraphFehler(f"{kn.id}: Worker '{kn.worker}' nicht registriert")
@@ -152,3 +156,21 @@ def get() -> Graph:
if _graph is None:
raise GraphFehler("Graph nicht geladen")
return _graph
def skills_von(knoten_id: str, extra: str | None = None) -> list:
"""EINE Wahrheit für Skill-Kompositionen: pipeline.yaml. Worker fragen
hier nach, statt Listen zu hardcoden (Drift bricht sonst erst zur
Laufzeit)."""
kn = get().knoten[knoten_id]
if extra is None:
return kn.skills
if extra not in kn.extra_skills:
raise GraphFehler(f"{knoten_id}: extra_skills '{extra}' nicht deklariert")
return kn.extra_skills[extra]
def timeout_fuer(knoten_id: str, n: int = 0) -> float:
kn = _graph.knoten.get(knoten_id) if _graph else None
basis, pro = (kn.timeout if kn and kn.timeout else config.TIMEOUT_DEFAULT)
return basis + pro * n

View File

@@ -4,7 +4,7 @@
import hashlib
import re
from . import belege, config, db, engine, llm, panels, textkit
from . import arten, belege, config, db, engine, graph, llm, panels, textkit
_MARKER_RE = re.compile(r"<!--\s*atom:\s*(\d+)\s*-->")
@@ -25,7 +25,7 @@ def _band(n_atome: int) -> tuple[int, int]:
def _slug(titel: str) -> str:
s = textkit.norm(titel).replace(" ", "-")
return re.sub(r"[^a-z0-9äöüß-]", "", s)
return "".join(c for c in s if c.isalnum() or c == "-") # Unicode-fähig
def _bekannt_kontext(topic: str, baustein: dict) -> str:
@@ -39,8 +39,10 @@ def _bekannt_kontext(topic: str, baustein: dict) -> str:
baustein["id"], topic)
if not fruehere:
return "-"
return "\n".join(f"- {f['titel']} (im Abschnitt '{f['bt']}')"
for f in fruehere[:60])
return "\n".join(
config.TEXTE["abschnitt_verweis"].format(titel=f["titel"],
abschnitt=f["bt"])
for f in fruehere[:60])
@engine.worker("guide.writer")
@@ -64,7 +66,7 @@ async def writer(task: dict) -> engine.Ergebnis:
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="writer", item=task["item"],
role="writer",
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "writer"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"baustein": b["titel"], "atome": atome_text,
"belege": belege.fenster_fuer_atome([a["id"] for a in atome])[:40000],
"bekannt": _bekannt_kontext(task["topic"], b),
@@ -167,10 +169,8 @@ async def det_pruefung(task: dict) -> engine.Ergebnis:
frisch = {(b["art"], b["item"], b["detail"][:400]) for b in befunde}
for row in db.query(
"SELECT id, art, item, detail FROM befunde WHERE item=? AND "
"status='offen' AND art IN ('marker_fehlend','marker_fremd',"
"'marker_doppelt','vorwaerts','laenge','format_leak',"
"'link_im_text','atom_echo','h_ebene')",
f"baustein:{bid}"):
f"status='offen' AND art IN ({','.join('?' * len(arten.DET_ARTEN))})",
f"baustein:{bid}", *arten.DET_ARTEN):
if (row["art"], row["item"], row["detail"][:400]) not in frisch:
db.update("befunde", "id=?", (row["id"],), status="veraltet")
for b in befunde:
@@ -210,14 +210,14 @@ async def llm_pruefung(task: dict) -> engine.Ergebnis:
text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="llm_pruefung",
item=f"{task['item']}:p{i}", role="judge", n=len(aktive),
skill_namen=["richter", "deutsch-praezise", "guide-pruefung"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"sections": sections_text})
if text is None:
return None
return {"befunde": [b for b in textkit.bloecke(text, "BEFUND")
if b.get("art") in ("falsch", "luecke", "stil")
and b.get("section", "").strip().isdigit()],
"ok": {int(b["section"]) for b in textkit.bloecke(text, "OK")
"ok": {int(b["section"]) for b in textkit.bloecke(text, "LEER")
if b.get("section", "").strip().isdigit()}}
stimmen = await panels.panel(ruf, config.PRUEF_PANEL)
@@ -277,7 +277,7 @@ async def fix(task: dict) -> engine.Ergebnis:
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="fix", item=task["item"],
role="writer",
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "guide-fix"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"section": sec["text"],
"befunde": "\n".join(f"- [{b['art']}] {b['detail']}" for b in offen),
"belege": belege.fenster_fuer_atome(
@@ -308,7 +308,7 @@ async def _klaerung(task, bid, sec, offen) -> engine.Ergebnis:
text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="fix",
item=f"{task['item']}:k{b['id']}:p{i}", role="judge",
skill_namen=["richter", "deutsch-praezise", "klaerung"],
skill_namen=graph.skills_von(task["knoten"], extra="klaerung"),
werte={"befund": f"[{b['art']}] {b['detail']}", "belege": beleg_text})
urteile = textkit.bloecke(text or "", "URTEIL")
return urteile[0].get("urteil") if urteile else None
@@ -339,7 +339,7 @@ async def kapitel_intro(task: dict) -> engine.Ergebnis:
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="kapitel_intro",
item=task["item"], role="writer",
skill_namen=["autor", "deutsch-praezise", "kapitel-intro"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"kapitel": kap["titel"],
"sections": "\n".join(f"- {t['titel']}" for t in titel),
"vorherige": "\n".join(f"- {v['titel']}" for v in vorherige)
@@ -352,6 +352,46 @@ async def kapitel_intro(task: dict) -> engine.Ergebnis:
{"knoten": "redundanz", "item": f"r{task['runde']}:redundanz"}])
@engine.worker("guide.lektorat")
async def lektorat(task: dict) -> engine.Ergebnis:
"""Gesamt-Wächter (Kurzbuch-Ziel): EIN Blick über den ganzen Guide —
section-übergreifende Dopplungen, Splitter-Sections, zähe Übergänge.
Läuft einmal je Topic; Befunde laufen durch die normale Fix-Maschine."""
topic = task["topic"]
if db.one("SELECT id FROM tasks WHERE topic=? AND knoten='lektorat' AND "
"status='fertig' AND id!=?", topic, task["id"]):
return engine.Ergebnis(daten={"skip": True})
teile = []
for kap in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord",
topic):
teile.append(f"## {kap['titel']}")
for b in db.query(
"SELECT b.id, b.titel, s.text FROM bausteine b JOIN sections s "
"ON s.baustein_id=b.id WHERE b.kapitel_id=? AND s.text!='' "
"ORDER BY b.ord", kap["id"]):
teile.append(f"[SECTION {b['id']}] {b['titel']}\n{b['text']}")
if len(teile) < 2:
return engine.Ergebnis(daten={"skip": True})
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="lektorat",
item=task["item"], role="judge",
skill_namen=graph.skills_von(task["knoten"]),
werte={"guide": "\n\n".join(teile)[:120_000]})
if antwort is None:
raise RuntimeError("lektorat ohne Ergebnis")
befunde_bloecke = textkit.bloecke(antwort, "BEFUND")
if not befunde_bloecke and not textkit.bloecke(antwort, "LEER"):
raise RuntimeError("lektorat: weder BEFUND noch LEER") # Format kaputt
n = 0
for b in befunde_bloecke:
sid, art = b.get("section", "").strip(), b.get("art", "").strip()
if sid.isdigit() and art in ("redundanz", "stil") and befund_merken(
task["run_id"], "lektorat", art, f"baustein:{sid}",
b.get("detail", "")[:300], task["runde"]):
n += 1
return engine.Ergebnis(daten={"befunde": n})
def befund_merken(run_id: int, knoten: str, art: str, item: str, detail: str,
runde: int) -> bool:
"""Befund-Dedupe (creator2-Schlüssel art+item+detail): existiert derselbe
@@ -392,7 +432,8 @@ async def gate(task: dict) -> engine.Ergebnis:
continue
alle_marker += [int(m) for m in _MARKER_RE.findall(sec["text"])]
for f in _det_befunde(topic, b["id"], sec["text"]):
if f["art"] == "laenge": # Länge blockiert nicht hart (nur Befund)
if not arten.BEFUND_ARTEN.get(f["art"], {"gate_blockiert": True})[
"gate_blockiert"]: # z. B. laenge: nur Kennzahl
continue
if (f["art"], f["item"], f["detail"][:400]) in final:
continue
@@ -402,7 +443,10 @@ async def gate(task: dict) -> engine.Ergebnis:
"detail": "Atom in mehreren Sections gelehrt"})
# Kaputte Marker mit erschöpftem Fix → Section NEU schreiben (max 1×!
# sonst resettet jedes Neuschreiben den Fix-Cap → Endlos-Karussell)
for f in [f for f in befunde if f["art"].startswith("marker")]:
# NUR Section-Befunde (item baustein:*) — marker_global_doppelt trägt
# item atom:<marke>, dessen Zahl ist KEINE Baustein-Id (latenter Bug)
for f in [f for f in befunde if f["art"].startswith("marker")
and f["item"].startswith("baustein:")]:
bid = f["item"].split(":")[-1]
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid)
if sec and (sec["fix_versuche"] or 0) >= config.FIX_MAX_VERSUCHE \
@@ -414,7 +458,7 @@ async def gate(task: dict) -> engine.Ergebnis:
offene = db.query(
"SELECT * FROM befunde b WHERE b.run_id IN (SELECT id FROM runs WHERE "
"topic=?) AND b.stufe='guide' AND b.status='offen' AND b.art IN "
"('falsch','luecke','redundanz','vorwaerts','format_leak')", topic)
f"({','.join('?' * len(arten.FIX_ARTEN))})", topic, *arten.FIX_ARTEN)
for b in offene:
befunde.append({"art": b["art"], "item": b["item"],
"detail": b["detail"][:120]})

View File

@@ -1,7 +1,7 @@
"""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
from . import config, db, engine, graph, laden, llm, panels, textkit
def _quelle(qid: int) -> dict:
@@ -50,7 +50,7 @@ async def atom_extraktion(task: dict) -> engine.Ergebnis:
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"],
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())})
@@ -124,7 +124,7 @@ async def anker_fix(task: dict) -> engine.Ergebnis:
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"],
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")
@@ -132,12 +132,12 @@ async def anker_fix(task: dict) -> engine.Ergebnis:
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
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 == "VERWERFEN":
elif neu == "KEINER":
db.update("atome", "id=?", (z["atom_id"],), status="verworfen")
verworfen += 1
# Verankerte sofort aktivieren — Dedup/Zuordnung sehen sonst nichts
@@ -162,7 +162,7 @@ async def soll_zuordnung(task: dict) -> engine.Ergebnis:
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"],
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)})
@@ -191,7 +191,7 @@ async def _freispruch(task: dict, soll: dict) -> bool:
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"],
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
@@ -239,7 +239,7 @@ async def luecke(task: dict) -> engine.Ergebnis:
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"],
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())})
@@ -250,7 +250,7 @@ async def luecke(task: dict) -> engine.Ergebnis:
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")
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():
@@ -306,7 +306,7 @@ async def verdichtung(task: dict) -> engine.Ergebnis:
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"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"soll_punkt": soll["punkt"], "atome": liste,
"ziel": config.VERDICHTUNG_ZIEL})
if text is None:
@@ -450,7 +450,8 @@ async def gate(task: dict) -> engine.Ergebnis:
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']}"}})
"payload": {"query": query, "zweck": "luecke",
"soll_id": s["id"]}})
else:
neue.append({"knoten": "luecke",
"item": f"r{runde + 1}:frei:soll:{s['id']}",
@@ -461,6 +462,16 @@ async def gate(task: dict) -> engine.Ergebnis:
# 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

View File

@@ -3,7 +3,7 @@
mit Sättigungslogik (Nullrunde nur mit neuen Queries gültig, R11)."""
import hashlib
from . import config, db, engine, laden, llm, textkit
from . import config, db, engine, graph, laden, llm, textkit
def _thema(topic: str) -> str:
@@ -11,25 +11,25 @@ def _thema(topic: str) -> str:
return (t.get("titel") or t["name"]) if t else topic
@engine.worker("korpus.recherche_plan", buendel=True)
@engine.worker("korpus.recherche_plan")
async def recherche_plan(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", [])
lens_von = {m["id"]: db.uj(m["payload"], {}).get("lens", "")
for m in mitglieder}
"""EIN Task je Runde plant alle Blickwinkel (das Lens-Auffächern gehört
hierher, nicht in die Engine)."""
lenses = config.RECHERCHE_LENSES
seed = []
if task["runde"] == 1:
# Wikipedia-Seed: kuratierter Grundstock ist Pflicht, nicht Suchglück
# (Audit Run 7: wikipedia_de fehlte komplett) — einmalig, idempotent
seed = [{"knoten": "suche", "item": "r1:wikiseed",
# Startquellen-Seed: kuratierter Grundstock ist Pflicht, nicht
# Suchglück — einmalig, idempotent; läuft durchs Inhalts-Urteil
seed = [{"knoten": "suche", "item": "r1:seed",
"payload": {"seed": True}}]
bisherige = [db.uj(t["ergebnis"], {}).get("query", "") for t in db.query(
"SELECT ergebnis FROM tasks WHERE topic=? AND knoten='suche'", task["topic"])]
text = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="recherche_plan",
item=task["item"], role="judge", n=len(mitglieder),
skill_namen=["planer", "deutsch-praezise", "recherche-plan"],
item=task["item"], role="judge", n=len(lenses),
skill_namen=graph.skills_von(task["knoten"]),
werte={"thema": _thema(task["topic"]),
"lenses": "\n".join(f"- {l}" for l in lens_von.values()),
"lenses": "\n".join(f"- {l}" for l in lenses),
"runde": task["runde"],
"bisherige_queries": "\n".join(f"- {q}" for q in bisherige if q) or "-"})
if text is None:
@@ -39,19 +39,16 @@ async def recherche_plan(task: dict) -> engine.Ergebnis:
q = block.get("text", "").strip()
if q:
je_lens.setdefault(block.get("lens", "").strip(), []).append(q)
neue, teil = [], {}
for m in mitglieder:
queries = je_lens.get(lens_von[m["id"]], [])[:config.QUERIES_JE_LENS]
if not queries: # Lens fehlt in der Antwort → neuer Versuch, nie stumm
teil[m["id"]] = "neu"
continue
teil[m["id"]] = "fertig"
neue = []
for lens in lenses:
queries = je_lens.get(lens, [])[:config.QUERIES_JE_LENS]
if not queries: # Blickwinkel fehlt in der Antwort → Retry, nie stumm
raise RuntimeError(f"recherche_plan: Blickwinkel '{lens}' ohne Query")
for q in queries:
h = hashlib.sha256(q.encode()).hexdigest()[:8]
neue.append({"knoten": "suche", "item": f"r{m['runde']}:q:{h}",
neue.append({"knoten": "suche", "item": f"r{task['runde']}:q:{h}",
"payload": {"query": q}})
return engine.Ergebnis(daten={"queries": len(neue)},
neue_tasks=seed + neue, teil_status=teil)
return engine.Ergebnis(daten={"queries": len(neue)}, neue_tasks=seed + neue)
@engine.worker("korpus.soll_extraktion", buendel=True)
@@ -66,7 +63,7 @@ async def soll_extraktion(task: dict) -> engine.Ergebnis:
antwort = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="soll_extraktion",
item=task["item"], role="extraktion", n=len(mitglieder),
skill_namen=["extraktor", "deutsch-praezise", "soll-extraktion"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"thema": _thema(task["topic"]),
"abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}"
for i, (_, _, c) in chunks.items())})
@@ -117,7 +114,7 @@ async def soll_konsens(task: dict) -> engine.Ergebnis:
antwort = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="soll_konsens",
item=f"{task['item']}:n{len(punkte)}", role="judge", n=len(punkte),
skill_namen=["richter", "deutsch-praezise", "soll-konsens"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"punkte": liste, "ziel": ziel})
if antwort is None:
raise RuntimeError("soll_konsens ohne Ergebnis")
@@ -131,17 +128,11 @@ async def soll_konsens(task: dict) -> engine.Ergebnis:
belege = []
for i in ids:
belege += db.uj(bekannt[i]["belege"], [])
quellen = {b["quelle"] for b in belege}
# DER CODE zählt — ≥2 unabhängige Quellen ODER 1 kuratierte
# (Wikipedia = vertrauenswürdig, creator2-uni-Prinzip; sonst
# verlieren Einzelquellen-Facetten wie „Overflow-Pomodoros")
kuratiert = db.one(
"SELECT id FROM quellen WHERE id IN ({}) AND backend LIKE "
"'wikipedia%'".format(",".join("?" * len(quellen))),
*quellen) if quellen else None
noetig = config.SOLL_MIN_BELEGE_WIKI if kuratiert \
else config.SOLL_MIN_BELEGE
if len(quellen) >= noetig:
# Vollständigkeit vor Vorsicht: 1 wörtlicher Beleg genügt (das
# Zitat-Gate schützt vor Halluzination; die ≥2-Quellen-Regel
# verwarf echte Facetten — Lektion 104). Dedup/Verdichtung/
# Prüfungen fangen Überschuss hinten ab.
if belege:
kopf = ids[0]
db.update("soll", "id=?", (kopf,), status="bestaetigt",
punkt=gruppe.get("text", bekannt[kopf]["punkt"])[:300],
@@ -173,25 +164,31 @@ async def gate(task: dict) -> engine.Ergebnis:
if bestaetigt < config.SOLL_PUNKTE_MIN:
befunde.append({"art": "soll_zu_wenig", "item": topic,
"detail": f"{bestaetigt} < {config.SOLL_PUNKTE_MIN}"})
runden_max = graph.get().knoten[task["knoten"]].runden_max
gesaettigt = bestaetigt == vorher # Nullrunde (mit frischen Queries gelaufen)
if not gesaettigt and runde < config.RECHERCHE_RUNDEN_MAX:
if not gesaettigt and runde < runden_max:
befunde.append({"art": "unsaettigt", "item": topic,
"detail": f"Runde {runde}: {vorher}{bestaetigt}"})
if befunde:
if runde < config.RECHERCHE_RUNDEN_MAX:
neue = [{"knoten": "recherche_plan", "item": f"r{runde + 1}:lens:{lens}",
"runde": runde + 1, "art": "ruecklauf",
"payload": {"lens": lens}}
for lens in config.RECHERCHE_LENSES]
if runde < runden_max:
neue = [{"knoten": "recherche_plan", "item": f"r{runde + 1}:plan",
"runde": runde + 1, "art": "ruecklauf"}]
return engine.Ergebnis(gate_status="rot", befunde=befunde,
neue_tasks=neue,
daten={"bestaetigt": bestaetigt})
# grün → Rest-Kandidaten sind entschieden (Sättigung): verwerfen statt
# als unadjudizierter Ballast liegen zu lassen
db.execute("UPDATE soll SET status='verworfen' WHERE topic=? AND "
"status='kandidat'", topic)
# Harte Vollständigkeits-Stops (Lektion 103): Vollständigkeit ist DAS
# Kernziel — fehlt die Basis, ist die Pipeline kaputt → PAUSE statt
# kleiner Guide. Sichtbar als Run-Grund, Resume prüft erneut.
if config.SEED_PFLICHT and not db.one(
"SELECT id FROM quellen WHERE topic=? AND zweck='seed' AND "
"status='geladen'", topic):
raise llm.LaufPause("vollstaendigkeit:seed_fehlt")
if quellen < config.QUELLEN_MIN:
raise llm.LaufPause(
f"vollstaendigkeit:quellen {quellen}<{config.QUELLEN_MIN}")
# Stufe inventar: Extraktion NUR belegnah (Lektion 92) — 2 Reader je
# Fenster um die Soll-Belege. Quellen ohne Beleg liefern keine Atome.
db.update("topics", "name=?", (topic,), status="korpus_fertig")
@@ -201,7 +198,7 @@ async def gate(task: dict) -> engine.Ergebnis:
for b in db.uj(s["belege"], []):
belege_je_quelle.setdefault(b["quelle"], []).append(b["zitat"])
for q in db.query("SELECT * FROM quellen WHERE topic=? AND status='geladen' "
"AND zweck='korpus'", topic):
"AND zweck IN ('korpus','seed')", topic):
zitate = belege_je_quelle.get(q["id"])
if not zitate:
continue

View File

@@ -6,14 +6,12 @@ import hashlib
import os
import re
import time
import urllib.robotparser
from urllib.parse import urlsplit
import httpx
from . import config, db, engine
from . import config, db, engine, graph, llm, textkit
_robots: dict[str, urllib.robotparser.RobotFileParser | None] = {}
_letzter_fetch: dict[str, float] = {}
@@ -27,20 +25,6 @@ def _text_reparieren(text: str) -> str:
return unicodedata.normalize("NFC", text)
def _robots_erlaubt(url: str) -> bool:
domain = urlsplit(url).netloc
if domain not in _robots:
rp = urllib.robotparser.RobotFileParser()
try:
rp.set_url(f"https://{domain}/robots.txt")
rp.read()
_robots[domain] = rp
except Exception:
_robots[domain] = None # nicht erreichbar → fail-open
rp = _robots[domain]
return rp is None or rp.can_fetch(config.user_agent(), url)
async def _domain_bremse(url: str) -> None:
domain = urlsplit(url).netloc
delta = time.monotonic() - _letzter_fetch.get(domain, 0)
@@ -75,7 +59,7 @@ async def _fetch(url: str) -> tuple[bytes, str]:
async with httpx.AsyncClient(
follow_redirects=True,
timeout=httpx.Timeout(config.FETCH_READ_S, connect=config.FETCH_CONNECT_S),
headers={"User-Agent": config.user_agent()},
headers={"User-Agent": config.BROWSER_UA},
transport=httpx.AsyncHTTPTransport(retries=2)) as client:
r = await client.get(url)
r.raise_for_status()
@@ -121,13 +105,6 @@ async def laden(task: dict) -> engine.Ergebnis:
text = fakes.laden(quelle["url"])
roh = b""
else:
if quelle["url"].lower().endswith(".pdf"):
db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund="pdf_uebersprungen")
db.insert("befunde", run_id=task["run_id"], stufe="korpus",
knoten="laden", art="pdf", item=quelle["url"][:120],
detail="PDF in v1 übersprungen")
return engine.Ergebnis(daten={"pdf": True})
if _WIKI_RE.match(quelle["url"]):
try:
text = await _wikipedia_api(quelle["url"])
@@ -135,34 +112,56 @@ async def laden(task: dict) -> engine.Ergebnis:
text = ""
if text:
text = _text_reparieren(text)
return _snapshot_ablegen(task, quelle, text, b"")
# API-Fehler → normaler Weg (inkl. robots-Check) als Fallback
if not _robots_erlaubt(quelle["url"]):
db.update("quellen", "id=?", (quelle["id"],), status="robots")
db.insert("befunde", run_id=task["run_id"], stufe="korpus",
knoten="laden", art="robots", item=quelle["url"][:120])
return engine.Ergebnis(daten={"robots": True})
return await _snapshot_ablegen(task, quelle, text, b"")
# API-Fehler/Titel unbekannt → normaler Seiten-Weg als Fallback
await _domain_bremse(quelle["url"])
try:
roh, _ = await _fetch(quelle["url"])
roh, ctype = await _fetch(quelle["url"])
except Exception as e:
db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund=f"{type(e).__name__}"[:80])
return engine.Ergebnis(daten={"fetch_fehler": str(e)[:120]})
if "pdf" in ctype.lower(): # Content-Type, nicht URL-Suffix (Lektion 106)
db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund="pdf_uebersprungen")
db.insert("befunde", run_id=task["run_id"], stufe="korpus",
knoten="laden", art="pdf", item=quelle["url"][:120],
detail="PDF in v1 übersprungen")
return engine.Ergebnis(daten={"pdf": True})
text = _extrahieren(roh)
text = _text_reparieren(text)
return _snapshot_ablegen(task, quelle, text, roh)
return await _snapshot_ablegen(task, quelle, text, roh)
def _snapshot_ablegen(task: dict, quelle: dict, text: str,
roh: bytes) -> engine.Ergebnis:
if len(text) < config.SNAPSHOT_MIN_ZEICHEN or "enable javascript" in text.lower():
status = "braucht_js" if text and "javascript" in text.lower() else "leer"
db.update("quellen", "id=?", (quelle["id"],), status=status)
async def _inhalt_nuetzlich(task: dict, quelle: dict, text: str) -> tuple[bool, str]:
"""Inhalts-Urteil statt Titel-Filter (Lektion 102): erst laden, dann auf
dem echten Text entscheiden. AUSNAHMSLOS für jede Quelle — auch Seed/
Wikipedia (Lektion 106: privilegierte Quellen luden „Kosovo" ungeprüft)."""
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="korpus", knoten="laden",
item=f"quelle:{quelle['id']}:urteil", role="judge",
skill_namen=graph.skills_von(task["knoten"], extra="urteil"),
werte={"thema": thema, "titel": quelle["titel"],
"auszug": text[:config.URTEIL_AUSZUG_ZEICHEN]})
bloecke = textkit.bloecke(antwort or "", "URTEIL")
urteil = bloecke[0].get("nuetzlich", "").strip().lower() if bloecke else ""
if urteil not in ("ja", "nein"): # Ausfall/kaputt → Retry, kein Silent-Keep
raise RuntimeError("quellen-urteil ohne verwertbare Antwort")
return urteil == "ja", bloecke[0].get("grund", "")[:80]
async def _snapshot_ablegen(task: dict, quelle: dict, text: str,
roh: bytes) -> engine.Ergebnis:
if len(text) < config.SNAPSHOT_MIN_ZEICHEN:
# zu wenig extrahierbarer Text (auch JS-Walls landen hier) — keine
# Sprach-/Phrasen-Heuristik (Lektion 106)
db.update("quellen", "id=?", (quelle["id"],), status="leer")
db.insert("befunde", run_id=task["run_id"], stufe="korpus", knoten="laden",
art=status, item=quelle["url"][:120])
return engine.Ergebnis(daten={status: True})
art="leer", item=quelle["url"][:120])
return engine.Ergebnis(daten={"leer": True})
hash_ = hashlib.sha256(text.encode()).hexdigest()[:16]
dublette = db.one("SELECT id FROM quellen WHERE topic=? AND hash=? AND id!=?",
@@ -171,6 +170,11 @@ def _snapshot_ablegen(task: dict, quelle: dict, text: str,
db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund="inhalt_dublette")
return engine.Ergebnis(daten={"dublette": True})
nuetzlich, grund = await _inhalt_nuetzlich(task, quelle, text)
if not nuetzlich:
db.update("quellen", "id=?", (quelle["id"],), status="aussortiert",
grund=grund)
return engine.Ergebnis(daten={"aussortiert": True})
pfad = snapshot_pfad(task["topic"], hash_)
_atomar_schreiben(pfad, text.encode("utf-8"))
roh_pfad = ""
@@ -185,16 +189,14 @@ def _snapshot_ablegen(task: dict, quelle: dict, text: str,
def _folge_tasks(task: dict, quelle: dict) -> list:
"""korpus-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen
direkte Atom-Extraktion (Soll steht schon fest)."""
from . import textkit
if quelle["zweck"].startswith("luecke:"):
soll_id = int(quelle["zweck"].split(":")[1])
"""korpus-/seed-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen
(soll_id gesetzt) → direkte Atom-Extraktion (Soll steht schon fest)."""
if quelle["zweck"] == "luecke" and quelle.get("soll_id"):
text = snapshot_lesen(quelle)
return [{"knoten": "atom_extraktion",
"item": f"quelle:{quelle['id']}:a{i}:r1",
"payload": {"quelle_id": quelle["id"], "offset": o,
"chars": len(c), "soll_id": soll_id}}
"chars": len(c), "soll_id": quelle["soll_id"]}}
for i, (o, c) in enumerate(textkit.abschnitte(text))]
text = snapshot_lesen(quelle)
chunks = textkit.abschnitte(text, config.SOLL_CHUNK_CHARS)

View File

@@ -1,6 +1,6 @@
"""Event-Ledger: eine Zeile je LLM-Versuch (im finally — jeder Ausgang zählt,
auch Hedge-Verlierer). Budget = harte Grenze, geprüft VOR und NACH jedem Call."""
from . import config, db
from . import arten, config, db
class BudgetErschoepft(Exception):
@@ -43,9 +43,10 @@ def kennzahlen(run_id: int) -> dict:
zeilen = db.query(
"SELECT stufe, knoten, COUNT(*) calls, SUM(tok_in) tok_in, "
"SUM(tok_out) tok_out, SUM(tok_cache_read) cache_read, "
"SUM(dur_ms) dur_ms, SUM(wait_ms) wait_ms, "
"SUM(status='ok') ok, SUM(status IN ('error','infra','parse','cap')) fehler "
"FROM events WHERE run_id=? GROUP BY stufe, knoten ORDER BY MIN(id)", run_id)
"SUM(dur_ms) dur_ms, SUM(wait_ms) wait_ms, SUM(status='ok') ok, "
f"SUM(status IN ({','.join('?' * len(arten.EVENT_FEHLER))})) fehler "
"FROM events WHERE run_id=? GROUP BY stufe, knoten ORDER BY MIN(id)",
*arten.EVENT_FEHLER, run_id)
gesamt = verbraucht(run_id)
kosten = sum(z["tok_in"] or 0 for z in zeilen) / 1e6 * config.PREIS_IN + \
sum(z["tok_out"] or 0 for z in zeilen) / 1e6 * config.PREIS_OUT

View File

@@ -10,13 +10,9 @@ from dataclasses import dataclass, field
import httpx
from . import config, db, skills
from . import config, db, graph, skills
from .ledger import BudgetErschoepft, budget_pruefen, log_call # noqa: F401
_INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out",
"connection", "network", "overloaded", "unavailable",
'"status_code":1002', '"status_code":1041', '"status_code":2045')
class LaufPause(Exception):
"""Infrastruktur erschöpft — Lauf pausieren, nie fail-open weiterrechnen."""
@@ -31,6 +27,7 @@ class ApiErgebnis:
rc: int
text: str = ""
err: str = ""
cap: bool = False # stop=max_tokens — strukturiert, kein String-Match
tokens: dict = field(default_factory=dict)
@@ -88,7 +85,7 @@ def _slot_frei() -> None:
def _ist_infra(err: str) -> bool:
e = err.lower()
return any(m in e for m in _INFRA_MARKER)
return any(m in e for m in config.INFRA_MARKER)
async def _api(prompt: str, role: str, timeout: float) -> ApiErgebnis:
@@ -130,6 +127,7 @@ async def _api(prompt: str, role: str, timeout: float) -> ApiErgebnis:
return ApiErgebnis(1, err=resp.text[:300], tokens=tokens)
if not text.strip():
return ApiErgebnis(1, err=f"leere Antwort (stop={daten.get('stop_reason')})",
cap=daten.get("stop_reason") == "max_tokens",
tokens=tokens)
_erfolg_melden()
return ApiErgebnis(0, text=text, tokens=tokens)
@@ -165,7 +163,7 @@ async def call(*, run_id: int, stufe: str, knoten: str, item: str,
"""Zentraler LLM-Engpass. Rückgabe: Antworttext oder None (Inhaltsfehler/cap).
Wirft LaufPause (Infra erschöpft/manuell) oder BudgetErschoepft."""
prompt, skill_hash, paare = skills.komponieren(skill_namen, werte)
timeout = config.timeout_fuer(knoten, n)
timeout = graph.timeout_fuer(knoten, n)
infra_rest = config.INFRA_MAX_RETRIES
inhalt_rest = config.INHALT_MAX_RESTARTS
@@ -197,7 +195,7 @@ async def call(*, run_id: int, stufe: str, knoten: str, item: str,
tokens, err, text = res.tokens, res.err, res.text
if res.rc == 0:
status = "ok"
elif "stop=max_tokens" in err:
elif res.cap:
status = "cap"
elif _ist_infra(err):
status = "infra"

View File

@@ -73,8 +73,7 @@ def topic_anlegen(auftrag: TopicNeu):
def _pipeline_daten_loeschen(topic: str) -> None:
"""Alle Pipeline-Daten eines Topics räumen (Snapshots inklusive)."""
for tabelle in ("tasks", "gate_laeufe", "quellen", "soll", "atome", "kanten",
"lernziele", "bausteine", "kapitel"):
for tabelle in db.PIPELINE_TABELLEN:
db.execute(f"DELETE FROM {tabelle} WHERE topic=?", topic)
db.execute("DELETE FROM befunde WHERE run_id IN "
"(SELECT id FROM runs WHERE topic=?)", topic)

View File

@@ -2,7 +2,8 @@
VERSCHIEDENEN Kapiteln mit Jaccard ≥0.6 → 2er-Panel entscheidet „redundant vs.
didaktisch gewollt" (Recap ist keine Dopplung). Befunde behebt der Fix-Knoten
per Ein-Satz-Refresher + Anker-Link."""
from . import config, db, engine, llm, panels, textkit
from . import config, db, engine, graph, llm, panels, textkit
from .dedup import _signatur
def kandidaten_paare(topic: str) -> list[dict]:
@@ -32,8 +33,14 @@ def kandidaten_paare(topic: str) -> list[dict]:
>= config.REDUNDANZ_JACCARD
# Shingle-Kanal: identische Wort-Folge in längeren Sätzen —
# Jaccard übersieht sie, wenn der Restsatz verschieden ist
if jaccard_treffer or (shingles(a["satz"]) & shingles(b["satz"])):
paare.append({"a": a, "b": b})
wortgleich = bool(shingles(a["satz"]) & shingles(b["satz"]))
# Fakten-Kanal: dieselbe Studie/Zahl in Paraphrase teilt Zahlen +
# Eigennamen (Audit: „Ariga & Lleras 2011" stand 3× im Guide,
# Wort-Jaccard nur 0.21)
fakten = len(_signatur(a["satz"]) & _signatur(b["satz"])) \
>= config.FAKTEN_SIGNATUR_MIN
if jaccard_treffer or wortgleich or fakten:
paare.append({"a": a, "b": b, "wortgleich": wortgleich})
return paare
@@ -43,15 +50,14 @@ async def pruefen(task: dict) -> engine.Ergebnis:
befunde = 0
for i in range(0, len(paare), config.DEDUP_PAARE_PRO_CALL):
chunk = paare[i:i + config.DEDUP_PAARE_PRO_CALL]
liste = "\n\n".join(
f"PAAR {j + 1}:\nKapitel A: {p['a']['satz']}\nKapitel B: {p['b']['satz']}"
for j, p in enumerate(chunk))
liste = textkit.paar_liste(
[(q["a"]["satz"], q["b"]["satz"]) for q in chunk])
async def ruf(p, liste=liste, n=len(chunk)):
text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="redundanz",
item=f"{task['item']}:c{i}:p{p}", role="judge", n=n,
skill_namen=["richter", "deutsch-praezise", "redundanz-urteil"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"paare": liste})
if text is None:
return None
@@ -67,7 +73,11 @@ async def pruefen(task: dict) -> engine.Ergebnis:
u = s[j + 1]
return True if u == "redundant" else (
False if u == "didaktisch_gewollt" else None)
if panels.einstimmig(stimmen, config.PRUEF_PANEL, urteil):
# Fast wörtliche Paare (gemeinsame 5-Wort-Folge) sind NIE
# „didaktisch gewollt" — das Panel darf sie nicht freisprechen
# (Audit: 94-Studierende-Satz stand 2× nahezu wortgleich im Text)
if p.get("wortgleich") or panels.einstimmig(
stimmen, config.PRUEF_PANEL, urteil):
# Der SPÄTERE Baustein trägt den Befund (dort wird verwiesen)
spaeter = max(p["a"], p["b"], key=lambda x: x["pos"])
schon = db.one(
@@ -80,4 +90,9 @@ async def pruefen(task: dict) -> engine.Ergebnis:
item=f"baustein:{spaeter['bid']}",
detail=spaeter["satz"][:400], runde=task["runde"])
befunde += 1
return engine.Ergebnis(daten={"paare": len(paare), "befunde": befunde})
neue = []
if not db.one("SELECT id FROM tasks WHERE topic=? AND knoten='lektorat' "
"AND status='fertig'", task["topic"]):
neue = [{"knoten": "lektorat", "item": f"r{task['runde']}:lektorat"}]
return engine.Ergebnis(daten={"paare": len(paare), "befunde": befunde},
neue_tasks=neue)

View File

@@ -3,7 +3,7 @@
Gate: exakte Partition + Coverage (jeder Soll-Punkt ≥1 Baustein)."""
import math
from . import config, db, engine, llm, textkit
from . import config, db, engine, graph, llm, textkit
def _atom_rang(atom_id: int) -> tuple:
@@ -27,7 +27,7 @@ async def lernziele(task: dict) -> engine.Ergebnis:
else:
atome = db.query("SELECT * FROM atome WHERE topic=? AND "
"status='aktiv' AND soll_id IS NULL", task["topic"])
punkt = "Weitere Grundlagen zum Thema"
punkt = config.TEXTE["sammel_punkt"]
if not atome:
teil[m["id"]] = "fertig"
continue
@@ -45,7 +45,7 @@ async def lernziele(task: dict) -> engine.Ergebnis:
run_id=task["run_id"], stufe="struktur", knoten="lernziele",
item=task["item"], role="judge",
n=sum(len(a) for (_, _, _, a) in aktive.values()),
skill_namen=["planer", "deutsch-praezise", "lernziele"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"soll_punkte": "\n\n".join(
f"=== SOLL {i} ===\nPUNKT: {punkt}\nATOME:\n" +
"\n".join(f"[{a['id']}] {a['titel']}" for a in atome)
@@ -78,9 +78,9 @@ async def lernziele(task: dict) -> engine.Ergebnis:
rest = ids_alle - zugeordnet # deterministisches Sammelziel — nichts verlieren
if rest:
ziel_id = db.insert(
"lernziele", topic=task["topic"], titel="Grundlagen",
soll_id=soll_id,
text=f"Der Lernende kann Grundlagen zu '{punkt}' erklären.")
"lernziele", topic=task["topic"],
titel=config.TEXTE["sammel_titel"], soll_id=soll_id,
text=config.TEXTE["sammel_ziel"].format(punkt=punkt))
for aid in rest:
db.update("atome", "id=?", (aid,), ziel_id=ziel_id)
teil[m["id"]] = "fertig"
@@ -115,17 +115,17 @@ async def bausteine(task: dict) -> engine.Ergebnis:
antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="bausteine",
item=task["item"], role="judge", n=len(atome),
skill_namen=["planer", "deutsch-praezise", "bausteine"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"ziel": ziel["text"],
"band": f"{config.BAUSTEIN_MIN_ATOME}-{config.BAUSTEIN_MAX_ATOME}",
"atome": "\n".join(f"[{a['id']}] {a['titel']}" for a in atome)})
if antwort is None: # Call-Ausfall: neuer Versuch statt stiller Fallback
raise RuntimeError("bausteine ohne Ergebnis")
gruppen = _gruppen_validieren(antwort, atome)
gruppen = _min_erzwingen(_gruppen_validieren(antwort, atome))
for i, gruppe in enumerate(gruppen):
titel = ziel["titel"] or gruppe[0]["titel"]
if len(gruppen) > 1:
titel = f"{titel} (Teil {i + 1})"
titel = config.TEXTE["teil"].format(titel=titel, n=i + 1)
bid = db.insert("bausteine", topic=task["topic"], ziel_id=ziel["id"],
titel=titel[:120], ord=i)
for j, a in enumerate(gruppe):
@@ -134,6 +134,31 @@ async def bausteine(task: dict) -> engine.Ergebnis:
neue_tasks=[{"knoten": "ordnung", "item": "ordnung"}])
def _min_erzwingen(gruppen: list[list[dict]]) -> list[list[dict]]:
"""Splitter-Bremse (Audit: 8 „Teil"-Sections mit 1-2 Atomen): Gruppen
unter BAUSTEIN_MIN_ATOME mit dem kleineren Nachbarn mergen, solange das
MAX hält. Quellreihenfolge bleibt erhalten."""
geaendert = True
while geaendert and len(gruppen) > 1:
geaendert = False
for i, g in enumerate(gruppen):
if len(g) >= config.BAUSTEIN_MIN_ATOME:
continue
nachbarn = sorted((j for j in (i - 1, i + 1)
if 0 <= j < len(gruppen)),
key=lambda j: len(gruppen[j]))
for j in nachbarn:
if len(g) + len(gruppen[j]) <= config.BAUSTEIN_MAX_ATOME:
a, b = min(i, j), max(i, j)
gruppen[a] = gruppen[a] + gruppen[b]
del gruppen[b]
geaendert = True
break
if geaendert:
break
return gruppen
def _gruppen_validieren(antwort: str | None, atome: list[dict]) -> list[list[dict]]:
"""LLM-Split validieren (jede ID genau einmal); sonst deterministischer
divmod-Split entlang der Quellreihenfolge."""
@@ -159,22 +184,38 @@ def _gruppen_validieren(antwort: str | None, atome: list[dict]) -> list[list[dic
return gruppen
def _ziel_kerne(ziele: list[dict]) -> list[set]:
"""Inhaltswort-Kerne aller Lernziele: Boilerplate wird GEMESSEN statt
gelistet (Lektion 106 — die alte deutsche Floskel-Liste wäre in jeder
anderen Sprache ein No-op). Stämme, die in der Mehrheit der Ziele
vorkommen, sind Schablone („Der Lernende kann …") und fliegen raus."""
stamm_je_ziel = [textkit.stamm_tokens(z["text"]) for z in ziele]
n = len(stamm_je_ziel)
alle = set().union(*stamm_je_ziel) if stamm_je_ziel else set()
boilerplate = {t for t in alle
if sum(t in s for s in stamm_je_ziel) > max(2, n * 0.5)}
return [{t for t in s if len(t) > 3} - boilerplate for s in stamm_je_ziel]
def _ziele_dedup(topic: str) -> int:
"""Synonyme Lernziele deterministisch mergen (Audit: „einordnen" vs.
„verorten" erzeugte Doppel-Bausteine → Writer-Echos). Falsch-Merge ist
harmlos: Atome bleiben erhalten, hängen nur am selben Ziel."""
„verorten" erzeugte Doppel-Bausteine → Writer-Echos). Nur Paare mit
gleichem Soll-Punkt; Falsch-Merge ist harmlos: Atome bleiben erhalten,
hängen nur am selben Ziel."""
ziele = db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", topic)
kerne = _ziel_kerne(ziele)
merges = 0
for i, a in enumerate(ziele):
if a.get("_weg"):
continue
for b in ziele[i + 1:]:
if b.get("_weg"):
for j, b in enumerate(ziele[i + 1:], i + 1):
if b.get("_weg") or a["soll_id"] != b["soll_id"]:
continue
ka, kb = kerne[i], kerne[j]
kern_jaccard = len(ka & kb) / len(ka | kb) if ka and kb else 0.0
if textkit.titel_kern(a["titel"] or a["text"]) == \
textkit.titel_kern(b["titel"] or b["text"]) or \
textkit.jaccard(a["text"], b["text"]) >= \
config.ZIEL_DEDUP_JACCARD:
kern_jaccard >= config.ZIEL_DEDUP_JACCARD:
db.update("atome", "ziel_id=?", (b["id"],), ziel_id=a["id"])
db.execute("DELETE FROM bausteine WHERE ziel_id=?", b["id"])
db.execute("DELETE FROM lernziele WHERE id=?", b["id"])
@@ -223,7 +264,7 @@ async def ordnung(task: dict) -> engine.Ergebnis:
antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="ordnung",
item=f"perm:kapitel:{kap['id']}", role="judge", n=len(bs),
skill_namen=["planer", "deutsch-praezise", "ordnung"],
skill_namen=graph.skills_von(task["knoten"]),
werte={"art": "Bausteine",
"liste": "\n".join(f"[{b['id']}] {b['titel']}" for b in prior),
"prior": ",".join(map(str, ids))})
@@ -251,7 +292,7 @@ async def _kapitel_schnitt(task: dict, soll: list[dict]) -> list[tuple[str, list
antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="ordnung",
item="themen_schnitt", role="judge", n=len(ids),
skill_namen=["planer", "deutsch-praezise", "themen-schnitt"],
skill_namen=graph.skills_von(task["knoten"], extra="schnitt"),
werte={"thema": thema, "richtwert": config.KAPITEL_SOLL_PUNKTE,
"punkte": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)})
if antwort:
@@ -269,7 +310,8 @@ async def _kapitel_schnitt(task: dict, soll: list[dict]) -> list[tuple[str, list
detail="Schnitt ungültig, √n-Fallback")
n_kap = max(1, round(math.sqrt(len(ids))))
groesse = math.ceil(len(ids) / n_kap)
return [(f"Teil {i + 1}", ids[i * groesse:(i + 1) * groesse])
return [(config.TEXTE["teil"].format(titel=thema, n=i + 1),
ids[i * groesse:(i + 1) * groesse])
for i in range(n_kap) if ids[i * groesse:(i + 1) * groesse]]

View File

@@ -2,12 +2,11 @@
Circuit Breaker je Backend; Teilausfall = degraded-Befund, nie Lauf-Crash."""
import asyncio
import os
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import httpx
from . import config, db, engine, llm, textkit
from . import config, db, engine
_TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"fbclid", "gclid", "ref"}
@@ -42,7 +41,7 @@ async def _http_json(url: str, **kw) -> dict | list | None:
async def _wikipedia(query: str) -> list[dict]:
treffer = []
for sprache in ("de", "en"):
for sprache in config.SPRACHEN_SUCHE:
backend = f"wikipedia_{sprache}"
if _tot(backend):
continue
@@ -79,9 +78,11 @@ async def _brave(query: str) -> list[dict]:
key = os.getenv("BRAVE_API_KEY")
if not key:
return []
locale = config.SUCHE_LOCALE.get(config.SPRACHE, {})
daten = await _http_json("https://api.search.brave.com/res/v1/web/search",
params={"q": query, "count": config.SUCHE_MAX_TREFFER,
"country": "DE", "search_lang": "de"},
"country": locale.get("country", "US"),
"search_lang": locale.get("search_lang", "en")},
headers={"X-Subscription-Token": key})
return [{"titel": t.get("title", ""), "url": t["url"], "backend": "brave"}
for t in (daten or {}).get("web", {}).get("results", [])]
@@ -90,7 +91,8 @@ async def _brave(query: str) -> list[dict]:
async def _ddgs(query: str) -> list[dict]:
def _sync():
from ddgs import DDGS
return DDGS().text(query, region="de-de",
region = config.SUCHE_LOCALE.get(config.SPRACHE, {}).get("region", "us-en")
return DDGS().text(query, region=region,
max_results=config.SUCHE_MAX_TREFFER)
await asyncio.sleep(config.DDGS_PAUSE_S) # Drosselung (ToS-Grauzone)
rows = await asyncio.to_thread(_sync)
@@ -102,9 +104,11 @@ _KETTE = (("tavily", _tavily), ("brave", _brave), ("ddgs", _ddgs))
async def web_suchen(query: str) -> tuple[list[dict], list[str]]:
"""→ (treffer, fehler). Wikipedia immer; Kette bis zum ersten Backend mit
Ergebnissen (Fallback statt Fan-out, R2)."""
treffer = await _wikipedia(query)
"""→ (treffer, fehler). Kette bis zum ersten Backend mit Ergebnissen
(Fallback statt Fan-out, R2). Wikipedia ist KEINE privilegierte
Extra-Quelle mehr — sie kommt über die Startquellen (Seed) und über
normale Web-Treffer herein (Lektion 106)."""
treffer: list[dict] = []
fehler = []
for name, fn in _KETTE:
if _tot(name):
@@ -123,26 +127,6 @@ async def web_suchen(query: str) -> tuple[list[dict], list[str]]:
return treffer, fehler
async def _relevanz_filter(task: dict, thema: str, treffer: list) -> list:
"""Kurzurteil je Trefferliste: Werbe-/App-Listen und Off-Topic raus
(Lektion 91). AUCH Wikipedia-Treffer — Run 7 lud „Pasta" und „Xinjiang"."""
web = treffer
if len(web) <= 2:
return treffer
liste = "\n".join(f"[{i}] {t['titel']}{t['url']}" for i, t in enumerate(web))
antwort = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="suche",
item=f"{task['item']}:relevanz", role="judge",
skill_namen=["richter", "deutsch-praezise", "quellen-relevanz"],
werte={"thema": thema, "treffer": liste})
if antwort is None:
return treffer # Urteil ausgefallen → lieber behalten als verlieren
bloecke = textkit.bloecke(antwort, "RELEVANT")
ids = {int(i) for i in (bloecke[0].get("ids", "") if bloecke else "")
.replace(" ", "").split(",") if i.isdigit()}
return [t for i, t in enumerate(web) if i in ids]
@engine.worker("suche.suchen")
async def suchen(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
@@ -150,27 +134,30 @@ async def suchen(task: dict) -> engine.Ergebnis:
zweck = payload.get("zweck", "korpus")
seed = bool(payload.get("seed"))
if seed:
# Wikipedia-Seed: deterministisch de+en zur Topic-Bezeichnung —
# umgeht Suche, Relevanz-Judge und Quellen-Deckel (2 Pflichtquellen)
# Startquellen-Seed: Pflicht-Kandidaten (umgehen den Deckel), aber
# OHNE weitere Privilegien — das Inhalts-Urteil läuft wie überall.
# Titel erst über die Wiki-Suche auflösen („AlpineJS" heißt dort
# „Alpine.js"); der direkte URL-Bau ist nur Fallback.
from urllib.parse import quote
zweck = "seed"
titel = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
"WHERE name=?", task["topic"])["t"]
treffer = [{"titel": f"Wikipedia ({s}): {titel}",
"url": f"https://{s}.wikipedia.org/wiki/"
f"{quote(titel.replace(' ', '_'))}",
"backend": f"wikipedia_{s}"} for s in ("de", "en")]
gefunden = {} if config.FAKE else {
t["backend"]: t for t in reversed(await _wikipedia(titel))}
treffer = [gefunden.get(f"wikipedia_{s}",
{"titel": f"Wikipedia ({s}): {titel}",
"url": f"https://{s}.wikipedia.org/wiki/"
f"{quote(titel.replace(' ', '_'))}",
"backend": f"wikipedia_{s}"})
for s in config.SPRACHEN_SUCHE]
fehler = []
elif config.FAKE:
from . import fakes
treffer, fehler = fakes.suche(query), []
else:
treffer, fehler = await web_suchen(query)
# Wikipedia zuerst (kuratierter Grundstock schlägt Blog-Treffer)
treffer.sort(key=lambda t: not t["backend"].startswith("wikipedia"))
if zweck == "korpus" and treffer and not config.FAKE and not seed:
thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
"WHERE name=?", task["topic"])["t"]
treffer = await _relevanz_filter(task, thema, treffer)
# Keine Vorsortierung, kein Titel-Filter — beurteilt wird nach dem Laden
# auf dem Inhalt (Lektion 102/106).
# Quellen-Deckel je Topic (0 = unbegrenzt, dann stoppt nur die Sättigung)
einstellung = db.one("SELECT quellen_max FROM topics WHERE name=?",
task["topic"]) or {}
@@ -179,17 +166,18 @@ async def suchen(task: dict) -> engine.Ergebnis:
deckel = config.QUELLEN_MAX
neue = []
for t in treffer:
if zweck == "korpus" and deckel > 0 and not seed:
if zweck == "korpus" and deckel > 0:
vorhanden = db.one("SELECT COUNT(*) c FROM quellen WHERE topic=? AND "
"zweck='korpus' AND status NOT IN "
"('fehler','leer','robots','braucht_js')",
"('fehler','leer','braucht_js','aussortiert')",
task["topic"])["c"]
if vorhanden >= deckel:
break
url_norm = url_normieren(t["url"])
qid = db.insert("quellen", ignore=True, topic=task["topic"],
titel=t["titel"][:200], url=t["url"], url_norm=url_norm,
backend=t["backend"], runde=task["runde"], zweck=zweck)
backend=t["backend"], runde=task["runde"], zweck=zweck,
soll_id=payload.get("soll_id"))
if qid:
neue.append({"knoten": "laden", "item": f"quelle:{qid}",
"payload": {"quelle_id": qid}})

View File

@@ -6,14 +6,17 @@ import unicodedata
from . import config
# Sprache kommt aus config.SPRACHE (Lektion 106) — kein hartkodiertes Deutsch
try:
import Stemmer
_stemmer = Stemmer.Stemmer("german")
_name = config.STEMMER_NAME.get(config.SPRACHE)
_stemmer = Stemmer.Stemmer(_name) if _name else None
except ImportError: # Fallback ohne C-Extension: ungestemmt
_stemmer = None
_WORT_RE = re.compile(r"[a-zäöüß0-9]+")
_NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne"}
_WORT_RE = re.compile(r"\w+", re.UNICODE) # alle Schriften, nicht nur Latein
_NEGATION = config.NEGATIONEN.get(config.SPRACHE, set())
_NEG_PRAEFIX = config.NEGATIONS_PRAEFIX.get(config.SPRACHE, "\0")
def norm(text: str) -> str:
@@ -37,9 +40,10 @@ def jaccard(a: str, b: str, gestemmt: bool = True) -> float:
def negations_menge(text: str) -> frozenset:
"""Antonyme messen 0.91-0.95 Cosinus (Lektion 31) — harte Merge-Vorbedingung."""
"""Antonyme messen 0.91-0.95 Cosinus (Lektion 31) — harte Merge-Vorbedingung.
Lexikon je Sprache; ohne Eintrag ist der Guard bewusst aus (dokumentiert)."""
t = tokens(text)
return frozenset(w for w in t if w in _NEGATION or w.startswith("nicht"))
return frozenset(w for w in t if w in _NEGATION or w.startswith(_NEG_PRAEFIX))
def titel_kern(titel: str) -> str:
@@ -81,19 +85,34 @@ def ueberlappung(a: tuple[int, int], b: tuple[int, int]) -> float:
return schnitt / kuerzer if kuerzer > 0 else 0.0
_SATZ_RE = re.compile(r"(?<=[.!?])\s+(?=[A-ZÄÖÜ])")
_SATZ_RE = re.compile(r"(?<=[.!?])\s+")
def satz_split(text: str) -> list[str]:
"""Terminator + Whitespace; Teile, die klein weitergehen (Abkürzungen,
„z. B."), werden wieder angefügt — Unicode-Groß statt [A-ZÄÖÜ]-Klasse."""
saetze = []
for absatz in text.split("\n"):
absatz = absatz.strip()
if not absatz or absatz.startswith(("#", "```", "|", "<!--")):
continue
saetze += [s.strip() for s in _SATZ_RE.split(absatz) if s.strip()]
for teil in _SATZ_RE.split(absatz):
teil = teil.strip()
if not teil:
continue
if saetze and teil[0].islower():
saetze[-1] += " " + teil
else:
saetze.append(teil)
return saetze
def paar_liste(paare: list[tuple[str, str]]) -> str:
"""EIN Paar-Rendering für alle Paar-Urteils-Aufgaben (dedup, redundanz)."""
return "\n\n".join(f"PAAR {i + 1}:\nA: {a}\nB: {b}"
for i, (a, b) in enumerate(paare))
# ── Positions-Maps für Anker-Suche ──
def _map_bauen(text: str, wandler) -> tuple[str, list[int]]:

View File

@@ -19,8 +19,8 @@ class Hub:
"""Thread-sicher aus der DB-Schicht aufrufbar."""
if self._loop is None or self._queue is None:
return
bereich = {"tasks": "graph", "gate_laeufe": "graph", "befunde": "graph",
"runs": "run", "topics": "run"}.get(tabelle, "graph")
from . import db
bereich = "run" if tabelle in db._RUN_TABELLEN else "graph"
msg = json.dumps({"typ": "dirty", "bereich": bereich}, ensure_ascii=False)
self._loop.call_soon_threadsafe(self._queue.put_nowait, msg)