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

View File

@@ -85,3 +85,37 @@ Lektion 50 (API statt CLI) ist die Gründungsentscheidung von creator3.
parse-Befund statt stiller „verwandt"-Kante. Stille None-Pfade parse-Befund statt stiller „verwandt"-Kante. Stille None-Pfade
(kapitel_intro, lernziele, bausteine, anker_fix, luecke) sind jetzt (kapitel_intro, lernziele, bausteine, anker_fix, luecke) sind jetzt
Raise → Task-Retry. Stille Fehler sind NIE erlaubt. Raise → Task-Retry. Stille Fehler sind NIE erlaubt.
101. **robots.txt raus für privates Lesen** — der höfliche Bot-Check kostete
AlpineJS 10/21 Quellen inkl. offizieller Doku; ein Browser fragt auch
nicht. Privat, ~20 Seiten/Lauf, Domain-Bremse bleibt; Browser-UA
(laden.py), Wikipedia weiter über die Action-API.
102. **Inhalt beurteilen, nicht Titel** — der Titel-Judge behielt Udemy-Kurse
(„klingt lehrend") und konnte Paywalls nicht sehen. Jetzt: viele Quellen
laden, DANN ein Kurz-Urteil auf dem echten Text (quellen-urteil,
status=aussortiert). Einfacher UND treffsicherer.
103. **Vollständigkeit stoppt hart** — sie ist DAS Kernziel; ohne sie ist der
Rest wertlos. Läufe pausieren mit sprechendem Grund statt Mini-Guide:
kein Wikipedia geladen, < QUELLEN_MIN nützliche Quellen, > 50 % Atome
als Beifang verworfen (korpus.gate/inventar.gate, LaufPause).
104. **Messlatte darf nicht aus dem eigenen Korpus schrumpfen** — „100 %
Abdeckung" maß nur das bestätigte Soll; die ≥2-Quellen-Regel hatte es
vorher auf 6 Punkte geschrumpft (34 echte Facetten verworfen). Regel
raus: 1 wörtlicher Beleg bestätigt (Zitat-Gate schützt), Dedup/
Verdichtung/Prüfungen fangen Überschuss hinten ab.
105. **Struktur-Splitter deterministisch verhindern** — das MIN-Band stand
nur im Prompt; 30/32 Sections hatten <4 Atome, ein Ziel wurde zu 8
„Teil"-Sections. Jetzt erzwingt _min_erzwingen das Band, der Ziel-Dedup
vergleicht Inhaltswort-Kerne (Floskeln raus — Ganz-Satz-Jaccard kam nie
über 0.36), der Redundanz-Vorfilter hat einen Zahlen+Namen-Kanal
(Ariga 3×, Wort-Jaccard 0.21), wortgleiche Paare sind nie „didaktisch
gewollt", und ein Lektorat-Knoten liest einmal den GANZEN Guide.
106. **Sonderregeln sind Schulden** — 5-Agenten-Audit fand 40+ Spezialfälle:
Wikipedia übersprang das Inhalts-Urteil (lud „Kosovo" ungeprüft), Deutsch
steckte in Stemmer/Regexes/Floskel-Listen/Output-Literalen, Knoten-Namen
klebten in config/engine, vier Leere-Marker-Erfindungen, Fakes hingen an
Prompt-Prosa. Jetzt: JEDE Quelle wird beurteilt (Seed = nur
Deckel-Umgehung); Sprache = config.SPRACHE (Stemmer, Negationen, Locale,
TEXTE); pipeline.yaml ist die einzige Wahrheit für Skills/Timeouts/
Runden-Caps; backend/arten.py bündelt Status-Listen; EIN Leere-Marker
(LEER), EIN Nichts-Wert (KEINER), skills/README.md ist der Vertrag.
Boilerplate wird gemessen (Ziel-Floskeln), nicht gelistet.

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" SKILLS_DIR = ROOT / "skills"
PIPELINE_YAML = ROOT / "pipeline.yaml" 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 (R3/R9) — Anbieter/Modell per env überschreibbar, nicht im Code ──
LLM_ENDPOINT = "https://api.minimax.io/anthropic/v1/messages" LLM_ENDPOINT = os.getenv("LLM_ENDPOINT",
"https://api.minimax.io/anthropic/v1/messages")
LLM_MODEL = os.getenv("LLM_MODEL", "MiniMax-M3")
ROLLEN = { ROLLEN = {
# thinking IMMER explizit ("enabled" gibt 400; Default widersprüchlich dokumentiert) # thinking IMMER explizit ("enabled" gibt 400; Default widersprüchlich dokumentiert)
"extraktion": dict(model="MiniMax-M3", thinking="disabled", temperature=0.2), "extraktion": dict(model=LLM_MODEL, thinking="disabled", temperature=0.2),
"judge": dict(model="MiniMax-M3", thinking="disabled", temperature=0.1), "judge": dict(model=LLM_MODEL, thinking="disabled", temperature=0.1),
"writer": dict(model="MiniMax-M3", thinking="adaptive", temperature=0.7), "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 LLM_MAX_TOKENS = 32_000
MAX_PARALLEL_LLM = int(os.getenv("MAX_PARALLEL_LLM", "12")) # M3: 200 RPM → 8-16 sicher 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 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 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 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; # Timeouts stehen als Knoten-Property in pipeline.yaml (graph.timeout_fuer);
# pro_item skaliert mit der Bündel-Größe (n in llm.call) # hier nur der Default für Knoten ohne Angabe
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),
}
TIMEOUT_DEFAULT = (180, 0) TIMEOUT_DEFAULT = (180, 0)
# ── Suche (R2) ── # ── Suche (R2) ──
@@ -78,18 +76,25 @@ FETCH_CONNECT_S = 10
FETCH_READ_S = 30 FETCH_READ_S = 30
DOMAIN_RATE_S = 1.0 DOMAIN_RATE_S = 1.0
SNAPSHOT_MIN_ZEICHEN = 500 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 ── # ── Korpus ──
RECHERCHE_LENSES = ("ueberblick", "tutorial", "referenz", "praxis") # generisch! RECHERCHE_LENSES = ("ueberblick", "tutorial", "referenz", "praxis") # generisch!
RECHERCHE_RUNDEN_MAX = int(os.getenv("RECHERCHE_RUNDEN_MAX", "4"))
QUERIES_JE_LENS = 3 QUERIES_JE_LENS = 3
SOLL_CHUNK_CHARS = 40_000 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_PUNKTE_MIN = int(os.getenv("SOLL_PUNKTE_MIN", "5"))
SOLL_BAND_FAKTOR = 1.0 # Ziel ≈ √Kandidaten (creator2-Band — Lektion 90) 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 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 QUELLEN_MAX = int(os.getenv("QUELLEN_MAX", "24")) # harter Korpus-Deckel
EXTRAKT_FENSTER = 4_000 # Atome nur ±Fenster um Soll-Belege (belegnah) 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 ── # ── Inventar ──
ABSCHNITT_CHARS = 6_000 # 12k riss das 32k-Output-Cap (creator2 empirisch) 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 REDUNDANZ_SATZ_MIN_TOKEN = 6
ZIEL_DEDUP_JACCARD = 0.5 # synonyme Lernziele deterministisch mergen ZIEL_DEDUP_JACCARD = 0.5 # synonyme Lernziele deterministisch mergen
ECHO_JACCARD = 0.7 # 1. Satz nach Marker ≈ Atom-Definition = Echo 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) ── # ── Engine (R8) ──
POLL_SEKUNDEN = 2.0 POLL_SEKUNDEN = 2.0
MAX_VERSUCHE = 2 # Inhalts-Versuche je Task; Infra zählt separat MAX_VERSUCHE = 2 # Inhalts-Versuche je Task; Infra zählt separat
STILLSTAND_N = 2 # identischer Befund-Fingerprint n× → PAUSE STILLSTAND_N = 2 # identischer Befund-Fingerprint n× → PAUSE
GATE_RUNDEN_MAX = {"gate_korpus": 4, "gate_inventar": 8, "gate_struktur": 3, "gate_guide": 12} # Gate-Runden-Caps stehen als runden_max am Gate-Knoten in pipeline.yaml
# 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
MAX_PARALLEL_GLOBAL = 24 MAX_PARALLEL_GLOBAL = 24
# ── Sprache: EIN Parameter, alles referenziert ihn (Lektion 106) ──
def timeout_fuer(knoten: str, n: int = 0) -> float: SPRACHE = os.getenv("SPRACHE", "de")
basis, pro = TIMEOUTS.get(knoten, TIMEOUT_DEFAULT) SPRACHEN_SUCHE = tuple(os.getenv("SPRACHEN_SUCHE", "de,en").split(","))
return basis + pro * n 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: def user_agent() -> str:

View File

@@ -2,6 +2,7 @@
architektonisch, R8), WAL, kurze Transaktionen. Zustand liegt NUR hier — architektonisch, R8), WAL, kurze Transaktionen. Zustand liegt NUR hier —
Resume = Prozess neu starten, fertige Tasks überspringen.""" Resume = Prozess neu starten, fertige Tasks überspringen."""
import json import json
import re
import sqlite3 import sqlite3
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -14,6 +15,13 @@ _lock = threading.RLock()
on_change: Callable[[str, dict], None] | None = None # ws.Hub.push on_change: Callable[[str, dict], None] | None = None # ws.Hub.push
_LIVE_TABELLEN = {"topics", "runs", "tasks", "gate_laeufe", "befunde"} _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 = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS topics( 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 '', id INTEGER PRIMARY KEY, topic TEXT, titel TEXT DEFAULT '', url TEXT DEFAULT '',
url_norm TEXT DEFAULT '', backend TEXT DEFAULT '', snapshot TEXT DEFAULT '', url_norm TEXT DEFAULT '', backend TEXT DEFAULT '', snapshot TEXT DEFAULT '',
roh TEXT DEFAULT '', hash TEXT DEFAULT '', runde INTEGER DEFAULT 0, roh TEXT DEFAULT '', hash TEXT DEFAULT '', runde INTEGER DEFAULT 0,
zweck TEXT DEFAULT 'korpus', status TEXT DEFAULT 'neu', grund TEXT DEFAULT '', zweck TEXT DEFAULT 'korpus', soll_id INTEGER, status TEXT DEFAULT 'neu',
atome_stand TEXT DEFAULT '', UNIQUE(topic, url_norm)); grund TEXT DEFAULT '', atome_stand TEXT DEFAULT '', UNIQUE(topic, url_norm));
CREATE TABLE IF NOT EXISTS soll( CREATE TABLE IF NOT EXISTS soll(
id INTEGER PRIMARY KEY, topic TEXT, punkt TEXT, status TEXT DEFAULT 'kandidat', id INTEGER PRIMARY KEY, topic TEXT, punkt TEXT, status TEXT DEFAULT 'kandidat',
belege TEXT DEFAULT '[]', kapitel_id INTEGER, geprueft TEXT DEFAULT '[]', 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 for migration in ( # Mini-Migrationen für Bestands-DBs
"ALTER TABLE atome ADD COLUMN soll_geprueft INTEGER DEFAULT 0", "ALTER TABLE atome ADD COLUMN soll_geprueft INTEGER DEFAULT 0",
"ALTER TABLE topics ADD COLUMN quellen_max INTEGER", "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: try:
_conn.execute(migration) _conn.execute(migration)
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -156,8 +165,8 @@ def execute(sql: str, *args) -> int:
with _lock: with _lock:
cur = _conn.execute(sql, args) cur = _conn.execute(sql, args)
_conn.commit() _conn.commit()
tabelle = sql.split()[2] if sql.lstrip().upper().startswith(("UPDATE",)) else "" m = _TABELLE_RE.match(sql) # UPDATE/DELETE/INSERT — nicht Positions-Raten
_notify(tabelle.lower(), {}) _notify(m.group(1).lower() if m else "", {})
return cur.rowcount return cur.rowcount

View File

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

View File

@@ -36,9 +36,10 @@ class Ergebnis:
def module_laden() -> None: def module_laden() -> None:
"""Worker-Module importieren → Registrierung; danach Graph validieren.""" """Worker-Module aus dem Graph ableiten (eine Wahrheit: pipeline.yaml),
for mod in ("suche", "laden", "korpus", "inventar", "dedup", importieren → Registrierung; danach voll validieren."""
"struktur", "guide", "redundanz", "montage"): g = graph.laden(None)
for mod in sorted({k.worker.split(".")[0] for k in g.knoten.values()}):
importlib.import_module(f"backend.{mod}") importlib.import_module(f"backend.{mod}")
graph.laden(set(WORKER)) graph.laden(set(WORKER))
@@ -218,7 +219,7 @@ def _gate_pruefen(g: graph.Graph, topic: str, run_id: int, stufe_i: int) -> None
return return
runde = (db.one("SELECT COUNT(*) c FROM gate_laeufe WHERE topic=? AND knoten=?", runde = (db.one("SELECT COUNT(*) c FROM gate_laeufe WHERE topic=? AND knoten=?",
topic, gate.id) or {"c": 0})["c"] + 1 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}") _pausieren(run_id, f"gate_cap:{gate.id}:{runde}")
return return
db.insert("tasks", ignore=True, run_id=run_id, topic=topic, knoten=gate.id, 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: def _abschliessen(g: graph.Graph, topic: str, run_id: int) -> None:
"""Kein Task mehr offen: done, wenn alle Gates grün + Montage fertig.""" """Kein Task mehr offen: done, wenn die letzte Stufe fertig ist."""
fertig = db.one("SELECT id FROM tasks WHERE topic=? AND knoten='montage' " terminal = [k.id for k in g.knoten.values() if k.stufe == g.stufen[-1]]
"AND status='fertig'", topic) 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' " offen_fehler = db.one("SELECT id FROM tasks WHERE topic=? AND status='fehler' "
"LIMIT 1", topic) "LIMIT 1", topic)
if fertig and not offen_fehler: if fertig and not offen_fehler:
db.update("runs", "id=?", (run_id,), status="done", beendet=db.now()) db.update("runs", "id=?", (run_id,), status="done", beendet=db.now())
else: 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) _pausieren(run_id, grund)
def _start_tasks(topic: str, run_id: int) -> None: def _start_tasks(topic: str, run_id: int) -> None:
"""Initiale Tasks der Stufe 1 (idempotent — Resume ist ein No-Op).""" """Initiale Tasks: Knoten der Stufe 1 ohne Normal-Vorgänger (aus dem
for lens in config.RECHERCHE_LENSES: Graph abgeleitet, keine Knoten-Namen im Code). Idempotent."""
db.insert("tasks", ignore=True, run_id=run_id, topic=topic, g = graph.get()
knoten="recherche_plan", item=f"r1:lens:{lens}", runde=1, ziele = {k["nach"] for k in g.kanten if k["art"] == "normal"}
payload=db.j({"lens": lens}), erzeugt_von="start") 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: def _recherche_plan(p: str) -> str:
runde = re.search(r"\(Runde (\d+)\)", p) runde = re.search(r"\(Runde (\d+)\)", p)
r = runde.group(1) if runde else "1" 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) 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}" 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)) 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: 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"))) for i, _ in _ids_liste(_abschnitt(p, "ATOME")))
def _dedup_urteil(p: str) -> str: def _dedup_urteil(p: str) -> str:
out = [] 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): p, re.DOTALL):
nr, ta, tb = m.group(1), m.group(2).strip(), m.group(3).strip() nr, ta, tb = m.group(1), m.group(2).strip(), m.group(3).strip()
paar = frozenset({ta, tb}) 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" out.append(f"===ATOM===\nSOLL: {sid}\nTITEL: {t}\nTYP: {typ}\n"
f"DEFINITION: {d}\nZITAT: {s}") f"DEFINITION: {d}\nZITAT: {s}")
else: else:
out.append(f"===NICHTS===\nSOLL: {sid}") out.append(f"===LEER===\nSOLL: {sid}")
return "\n".join(out) return "\n".join(out)
@@ -217,7 +217,7 @@ def _lernziele(p: str) -> str:
def _bausteine(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)}" return f"===BAUSTEIN===\nTITEL: Baustein\nATOME: {','.join(ids)}"
@@ -234,7 +234,7 @@ def _themen_schnitt(p: str) -> str:
def _ordnung(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 ''}" 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: 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'}. " return (f"===INTRO===\nDieses Kapitel behandelt {kap.group(1) if kap else 'X'}. "
"Es baut auf dem bisher Gelernten auf.") "Es baut auf dem bisher Gelernten auf.")
def _guide_pruefung(p: str) -> str: def _guide_pruefung(p: str) -> str:
ids = re.findall(r"^=== SECTION (\d+) ===$", p, re.MULTILINE) ids = re.findall(r"^=== SECTION (\d+) ===$", p, re.MULTILINE)
return "\n".join(f"===OK===\nSECTION: {i}" return "\n".join(f"===LEER===\nSECTION: {i}"
for i in dict.fromkeys(ids)) or "===OK===\nSECTION: 0" for i in dict.fromkeys(ids)) or "===LEER===\nSECTION: 0"
def _guide_fix(p: str) -> str: def _guide_fix(p: str) -> str:
@@ -273,7 +273,7 @@ def _guide_fix(p: str) -> str:
def _redundanz_urteil(p: str) -> str: def _redundanz_urteil(p: str) -> str:
out = [] 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) redundant = "Fehler lassen sich nie ganz vermeiden" in m.group(2)
out.append(f"===URTEIL===\nPAAR: {m.group(1)}\n" out.append(f"===URTEIL===\nPAAR: {m.group(1)}\n"
f"URTEIL: {'redundant' if redundant else 'didaktisch_gewollt'}") f"URTEIL: {'redundant' if redundant else 'didaktisch_gewollt'}")
@@ -284,9 +284,10 @@ _HANDLER = {
"recherche-plan": _recherche_plan, "recherche-plan": _recherche_plan,
"soll-extraktion": _soll_extraktion, "soll-extraktion": _soll_extraktion,
"soll-konsens": _soll_konsens, "soll-konsens": _soll_konsens,
"soll-beleg": lambda p: "===BELEG===\nID: 0\nBELEG: KEIN BELEG", "quellen-urteil": lambda p: ("===URTEIL===\nNUETZLICH: nein\nGRUND: Werbung"
"quellen-relevanz": lambda p: "===RELEVANT===\nIDS: " + ",".join( if "Werbeliste" in p else
m for m in re.findall(r"^\[(\d+)\]", p, re.MULTILINE)), "===URTEIL===\nNUETZLICH: ja\nGRUND: lehrt"),
"lektorat": lambda p: "===LEER===\nOK: ja",
"atom-extraktion": _atom_extraktion, "atom-extraktion": _atom_extraktion,
"anker-fix": _anker_fix, "anker-fix": _anker_fix,
"dedup-urteil": _dedup_urteil, "dedup-urteil": _dedup_urteil,

View File

@@ -23,7 +23,10 @@ class Knoten:
titel: str = "" titel: str = ""
beschreibung: str = "" beschreibung: str = ""
skills: list = field(default_factory=list) 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 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_parallel: int = 4
max_versuche: int = config.MAX_VERSUCHE max_versuche: int = config.MAX_VERSUCHE
barriere: bool = False barriere: bool = False
@@ -139,7 +142,8 @@ def laden(worker_namen: set[str] | None = None, pfad=None) -> Graph:
for kn in knoten.values(): for kn in knoten.values():
if kn.typ == "llm" and not kn.skills: if kn.typ == "llm" and not kn.skills:
raise GraphFehler(f"{kn.id}: llm-Knoten ohne 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 skills.lade(s) # wirft SkillFehler, wenn Datei fehlt/kaputt
if worker_namen is not None and kn.worker not in worker_namen: if worker_namen is not None and kn.worker not in worker_namen:
raise GraphFehler(f"{kn.id}: Worker '{kn.worker}' nicht registriert") raise GraphFehler(f"{kn.id}: Worker '{kn.worker}' nicht registriert")
@@ -152,3 +156,21 @@ def get() -> Graph:
if _graph is None: if _graph is None:
raise GraphFehler("Graph nicht geladen") raise GraphFehler("Graph nicht geladen")
return _graph 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 hashlib
import re 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*-->") _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: def _slug(titel: str) -> str:
s = textkit.norm(titel).replace(" ", "-") 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: def _bekannt_kontext(topic: str, baustein: dict) -> str:
@@ -39,8 +39,10 @@ def _bekannt_kontext(topic: str, baustein: dict) -> str:
baustein["id"], topic) baustein["id"], topic)
if not fruehere: if not fruehere:
return "-" return "-"
return "\n".join(f"- {f['titel']} (im Abschnitt '{f['bt']}')" return "\n".join(
for f in fruehere[:60]) config.TEXTE["abschnitt_verweis"].format(titel=f["titel"],
abschnitt=f["bt"])
for f in fruehere[:60])
@engine.worker("guide.writer") @engine.worker("guide.writer")
@@ -64,7 +66,7 @@ async def writer(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="writer", item=task["item"], run_id=task["run_id"], stufe="guide", knoten="writer", item=task["item"],
role="writer", role="writer",
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "writer"], skill_namen=graph.skills_von(task["knoten"]),
werte={"baustein": b["titel"], "atome": atome_text, werte={"baustein": b["titel"], "atome": atome_text,
"belege": belege.fenster_fuer_atome([a["id"] for a in atome])[:40000], "belege": belege.fenster_fuer_atome([a["id"] for a in atome])[:40000],
"bekannt": _bekannt_kontext(task["topic"], b), "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} frisch = {(b["art"], b["item"], b["detail"][:400]) for b in befunde}
for row in db.query( for row in db.query(
"SELECT id, art, item, detail FROM befunde WHERE item=? AND " "SELECT id, art, item, detail FROM befunde WHERE item=? AND "
"status='offen' AND art IN ('marker_fehlend','marker_fremd'," f"status='offen' AND art IN ({','.join('?' * len(arten.DET_ARTEN))})",
"'marker_doppelt','vorwaerts','laenge','format_leak'," f"baustein:{bid}", *arten.DET_ARTEN):
"'link_im_text','atom_echo','h_ebene')",
f"baustein:{bid}"):
if (row["art"], row["item"], row["detail"][:400]) not in frisch: if (row["art"], row["item"], row["detail"][:400]) not in frisch:
db.update("befunde", "id=?", (row["id"],), status="veraltet") db.update("befunde", "id=?", (row["id"],), status="veraltet")
for b in befunde: for b in befunde:
@@ -210,14 +210,14 @@ async def llm_pruefung(task: dict) -> engine.Ergebnis:
text = await llm.call( text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="llm_pruefung", run_id=task["run_id"], stufe="guide", knoten="llm_pruefung",
item=f"{task['item']}:p{i}", role="judge", n=len(aktive), 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}) werte={"sections": sections_text})
if text is None: if text is None:
return None return None
return {"befunde": [b for b in textkit.bloecke(text, "BEFUND") return {"befunde": [b for b in textkit.bloecke(text, "BEFUND")
if b.get("art") in ("falsch", "luecke", "stil") if b.get("art") in ("falsch", "luecke", "stil")
and b.get("section", "").strip().isdigit()], 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()}} if b.get("section", "").strip().isdigit()}}
stimmen = await panels.panel(ruf, config.PRUEF_PANEL) stimmen = await panels.panel(ruf, config.PRUEF_PANEL)
@@ -277,7 +277,7 @@ async def fix(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="fix", item=task["item"], run_id=task["run_id"], stufe="guide", knoten="fix", item=task["item"],
role="writer", role="writer",
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "guide-fix"], skill_namen=graph.skills_von(task["knoten"]),
werte={"section": sec["text"], werte={"section": sec["text"],
"befunde": "\n".join(f"- [{b['art']}] {b['detail']}" for b in offen), "befunde": "\n".join(f"- [{b['art']}] {b['detail']}" for b in offen),
"belege": belege.fenster_fuer_atome( "belege": belege.fenster_fuer_atome(
@@ -308,7 +308,7 @@ async def _klaerung(task, bid, sec, offen) -> engine.Ergebnis:
text = await llm.call( text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="fix", run_id=task["run_id"], stufe="guide", knoten="fix",
item=f"{task['item']}:k{b['id']}:p{i}", role="judge", 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}) werte={"befund": f"[{b['art']}] {b['detail']}", "belege": beleg_text})
urteile = textkit.bloecke(text or "", "URTEIL") urteile = textkit.bloecke(text or "", "URTEIL")
return urteile[0].get("urteil") if urteile else None 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( antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="kapitel_intro", run_id=task["run_id"], stufe="guide", knoten="kapitel_intro",
item=task["item"], role="writer", item=task["item"], role="writer",
skill_namen=["autor", "deutsch-praezise", "kapitel-intro"], skill_namen=graph.skills_von(task["knoten"]),
werte={"kapitel": kap["titel"], werte={"kapitel": kap["titel"],
"sections": "\n".join(f"- {t['titel']}" for t in titel), "sections": "\n".join(f"- {t['titel']}" for t in titel),
"vorherige": "\n".join(f"- {v['titel']}" for v in vorherige) "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"}]) {"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, def befund_merken(run_id: int, knoten: str, art: str, item: str, detail: str,
runde: int) -> bool: runde: int) -> bool:
"""Befund-Dedupe (creator2-Schlüssel art+item+detail): existiert derselbe """Befund-Dedupe (creator2-Schlüssel art+item+detail): existiert derselbe
@@ -392,7 +432,8 @@ async def gate(task: dict) -> engine.Ergebnis:
continue continue
alle_marker += [int(m) for m in _MARKER_RE.findall(sec["text"])] alle_marker += [int(m) for m in _MARKER_RE.findall(sec["text"])]
for f in _det_befunde(topic, b["id"], 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 continue
if (f["art"], f["item"], f["detail"][:400]) in final: if (f["art"], f["item"], f["detail"][:400]) in final:
continue continue
@@ -402,7 +443,10 @@ async def gate(task: dict) -> engine.Ergebnis:
"detail": "Atom in mehreren Sections gelehrt"}) "detail": "Atom in mehreren Sections gelehrt"})
# Kaputte Marker mit erschöpftem Fix → Section NEU schreiben (max 1×! # Kaputte Marker mit erschöpftem Fix → Section NEU schreiben (max 1×!
# sonst resettet jedes Neuschreiben den Fix-Cap → Endlos-Karussell) # 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] bid = f["item"].split(":")[-1]
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid) sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid)
if sec and (sec["fix_versuche"] or 0) >= config.FIX_MAX_VERSUCHE \ 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( offene = db.query(
"SELECT * FROM befunde b WHERE b.run_id IN (SELECT id FROM runs WHERE " "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 " "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: for b in offene:
befunde.append({"art": b["art"], "item": b["item"], befunde.append({"art": b["art"], "item": b["item"],
"detail": b["detail"][:120]}) "detail": b["detail"][:120]})

View File

@@ -1,7 +1,7 @@
"""Stufe 2: Atome extrahieren (2 Reader, Anker-Pflicht), Anker-Fix """Stufe 2: Atome extrahieren (2 Reader, Anker-Pflicht), Anker-Fix
(deterministisch vor LLM), Soll-Zuordnung, Lücken schließen (Nachextraktion → (deterministisch vor LLM), Soll-Zuordnung, Lücken schließen (Nachextraktion →
Web-Nachrecherche → Freispruch-Panel). Gate zählt Coverage deterministisch.""" 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: def _quelle(qid: int) -> dict:
@@ -50,7 +50,7 @@ async def atom_extraktion(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="atom_extraktion", run_id=task["run_id"], stufe="inventar", knoten="atom_extraktion",
item=task["item"], role="extraktion", n=len(aktive), 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, werte={"thema": thema,
"abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}" "abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}"
for i, (_, _, c) in aktive.items())}) for i, (_, _, c) in aktive.items())})
@@ -124,7 +124,7 @@ async def anker_fix(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="anker_fix", run_id=task["run_id"], stufe="inventar", knoten="anker_fix",
item=f"{task['item']}:b{i}", role="extraktion", n=len(batch), 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]}) werte={"atome": liste, "quelltext": text[:config.SOLL_CHUNK_CHARS]})
if antwort is None: # Call-Ausfall: neuer Versuch statt stilles Auslassen if antwort is None: # Call-Ausfall: neuer Versuch statt stilles Auslassen
raise RuntimeError("anker_fix ohne Ergebnis") 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()} textkit.bloecke(antwort, "FIX") if b.get("id", "").isdigit()}
for z in batch: for z in batch:
neu = antworten.get(z["atom_id"], "") 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: if span:
db.update("anker", "id=?", (z["anker_id"],), db.update("anker", "id=?", (z["anker_id"],),
start=span[0], ende=span[1], zitat=neu[:600]) start=span[0], ende=span[1], zitat=neu[:600])
llm_fix += 1 llm_fix += 1
elif neu == "VERWERFEN": elif neu == "KEINER":
db.update("atome", "id=?", (z["atom_id"],), status="verworfen") db.update("atome", "id=?", (z["atom_id"],), status="verworfen")
verworfen += 1 verworfen += 1
# Verankerte sofort aktivieren — Dedup/Zuordnung sehen sonst nichts # Verankerte sofort aktivieren — Dedup/Zuordnung sehen sonst nichts
@@ -162,7 +162,7 @@ async def soll_zuordnung(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="soll_zuordnung", run_id=task["run_id"], stufe="inventar", knoten="soll_zuordnung",
item=task["item"], role="judge", n=len(atome), 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]}" werte={"atome": "\n".join(f"[{a['id']}] {a['titel']}: {a['definition'][:150]}"
for a in atome), for a in atome),
"soll": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)}) "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( text = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="luecke", run_id=task["run_id"], stufe="inventar", knoten="luecke",
item=f"{task['item']}:p{i}", role="judge", 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]}) werte={"soll_punkt": soll["punkt"], "auszuege": auszuege[:20000]})
urteile = textkit.bloecke(text or "", "URTEIL") urteile = textkit.bloecke(text or "", "URTEIL")
return urteile[0].get("urteil", "") if urteile else None return urteile[0].get("urteil", "") if urteile else None
@@ -239,7 +239,7 @@ async def luecke(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="luecke", run_id=task["run_id"], stufe="inventar", knoten="luecke",
item=task["item"], role="extraktion", n=len(aktive), 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( werte={"luecken": "\n\n".join(
f"=== LUECKE ===\nSOLL: {sid}\nPUNKT: {s['punkt']}\nFENSTER:\n{f}" f"=== LUECKE ===\nSOLL: {sid}\nPUNKT: {s['punkt']}\nFENSTER:\n{f}"
for sid, (_, s, f) in aktive.items())}) for sid, (_, s, f) in aktive.items())})
@@ -250,7 +250,7 @@ async def luecke(task: dict) -> engine.Ergebnis:
s = block.get("soll", "").strip() s = block.get("soll", "").strip()
if s.isdigit(): if s.isdigit():
je_soll.setdefault(int(s), []).append(block) 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()} if b.get("soll", "").strip().isdigit()}
gesamt = 0 gesamt = 0
for sid, (m, soll, _) in aktive.items(): for sid, (m, soll, _) in aktive.items():
@@ -306,7 +306,7 @@ async def verdichtung(task: dict) -> engine.Ergebnis:
text = await llm.call( text = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="verdichtung", run_id=task["run_id"], stufe="inventar", knoten="verdichtung",
item=f"{task['item']}:p{i}", role="judge", n=len(atome), 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, werte={"soll_punkt": soll["punkt"], "atome": liste,
"ziel": config.VERDICHTUNG_ZIEL}) "ziel": config.VERDICHTUNG_ZIEL})
if text is None: if text is None:
@@ -450,7 +450,8 @@ async def gate(task: dict) -> engine.Ergebnis:
sorted(textkit.tokens(zitat))[:3]) sorted(textkit.tokens(zitat))[:3])
neue.append({"knoten": "suche", "item": f"luecke:{s['id']}:r{runde + 1}", neue.append({"knoten": "suche", "item": f"luecke:{s['id']}:r{runde + 1}",
"art": "ruecklauf", "runde": 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: else:
neue.append({"knoten": "luecke", neue.append({"knoten": "luecke",
"item": f"r{runde + 1}:frei:soll:{s['id']}", "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) # misst sich am Soll; alles andere bläht nur (32-Bausteine-Lektion)
db.execute("UPDATE atome SET status='verworfen' WHERE topic=? AND " db.execute("UPDATE atome SET status='verworfen' WHERE topic=? AND "
"status='aktiv' AND soll_id IS NULL AND soll_geprueft=1", topic) "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 # (d2) Facetten HART: jede Beleg-Stelle braucht ein Atom in der Nähe
# (Gutachten: Namensherkunft fehlte trotz 7 Belegen). Cap: 2 gezielte # (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).""" mit Sättigungslogik (Nullrunde nur mit neuen Queries gültig, R11)."""
import hashlib import hashlib
from . import config, db, engine, laden, llm, textkit from . import config, db, engine, graph, laden, llm, textkit
def _thema(topic: str) -> str: 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 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: async def recherche_plan(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", []) """EIN Task je Runde plant alle Blickwinkel (das Lens-Auffächern gehört
lens_von = {m["id"]: db.uj(m["payload"], {}).get("lens", "") hierher, nicht in die Engine)."""
for m in mitglieder} lenses = config.RECHERCHE_LENSES
seed = [] seed = []
if task["runde"] == 1: if task["runde"] == 1:
# Wikipedia-Seed: kuratierter Grundstock ist Pflicht, nicht Suchglück # Startquellen-Seed: kuratierter Grundstock ist Pflicht, nicht
# (Audit Run 7: wikipedia_de fehlte komplett) — einmalig, idempotent # Suchglück — einmalig, idempotent; läuft durchs Inhalts-Urteil
seed = [{"knoten": "suche", "item": "r1:wikiseed", seed = [{"knoten": "suche", "item": "r1:seed",
"payload": {"seed": True}}] "payload": {"seed": True}}]
bisherige = [db.uj(t["ergebnis"], {}).get("query", "") for t in db.query( bisherige = [db.uj(t["ergebnis"], {}).get("query", "") for t in db.query(
"SELECT ergebnis FROM tasks WHERE topic=? AND knoten='suche'", task["topic"])] "SELECT ergebnis FROM tasks WHERE topic=? AND knoten='suche'", task["topic"])]
text = await llm.call( text = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="recherche_plan", run_id=task["run_id"], stufe="korpus", knoten="recherche_plan",
item=task["item"], role="judge", n=len(mitglieder), item=task["item"], role="judge", n=len(lenses),
skill_namen=["planer", "deutsch-praezise", "recherche-plan"], skill_namen=graph.skills_von(task["knoten"]),
werte={"thema": _thema(task["topic"]), 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"], "runde": task["runde"],
"bisherige_queries": "\n".join(f"- {q}" for q in bisherige if q) or "-"}) "bisherige_queries": "\n".join(f"- {q}" for q in bisherige if q) or "-"})
if text is None: if text is None:
@@ -39,19 +39,16 @@ async def recherche_plan(task: dict) -> engine.Ergebnis:
q = block.get("text", "").strip() q = block.get("text", "").strip()
if q: if q:
je_lens.setdefault(block.get("lens", "").strip(), []).append(q) je_lens.setdefault(block.get("lens", "").strip(), []).append(q)
neue, teil = [], {} neue = []
for m in mitglieder: for lens in lenses:
queries = je_lens.get(lens_von[m["id"]], [])[:config.QUERIES_JE_LENS] queries = je_lens.get(lens, [])[:config.QUERIES_JE_LENS]
if not queries: # Lens fehlt in der Antwort → neuer Versuch, nie stumm if not queries: # Blickwinkel fehlt in der Antwort → Retry, nie stumm
teil[m["id"]] = "neu" raise RuntimeError(f"recherche_plan: Blickwinkel '{lens}' ohne Query")
continue
teil[m["id"]] = "fertig"
for q in queries: for q in queries:
h = hashlib.sha256(q.encode()).hexdigest()[:8] 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}}) "payload": {"query": q}})
return engine.Ergebnis(daten={"queries": len(neue)}, return engine.Ergebnis(daten={"queries": len(neue)}, neue_tasks=seed + neue)
neue_tasks=seed + neue, teil_status=teil)
@engine.worker("korpus.soll_extraktion", buendel=True) @engine.worker("korpus.soll_extraktion", buendel=True)
@@ -66,7 +63,7 @@ async def soll_extraktion(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="soll_extraktion", run_id=task["run_id"], stufe="korpus", knoten="soll_extraktion",
item=task["item"], role="extraktion", n=len(mitglieder), 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"]), werte={"thema": _thema(task["topic"]),
"abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}" "abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}"
for i, (_, _, c) in chunks.items())}) for i, (_, _, c) in chunks.items())})
@@ -117,7 +114,7 @@ async def soll_konsens(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="soll_konsens", run_id=task["run_id"], stufe="korpus", knoten="soll_konsens",
item=f"{task['item']}:n{len(punkte)}", role="judge", n=len(punkte), 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}) werte={"punkte": liste, "ziel": ziel})
if antwort is None: if antwort is None:
raise RuntimeError("soll_konsens ohne Ergebnis") raise RuntimeError("soll_konsens ohne Ergebnis")
@@ -131,17 +128,11 @@ async def soll_konsens(task: dict) -> engine.Ergebnis:
belege = [] belege = []
for i in ids: for i in ids:
belege += db.uj(bekannt[i]["belege"], []) belege += db.uj(bekannt[i]["belege"], [])
quellen = {b["quelle"] for b in belege} # Vollständigkeit vor Vorsicht: 1 wörtlicher Beleg genügt (das
# DER CODE zählt — ≥2 unabhängige Quellen ODER 1 kuratierte # Zitat-Gate schützt vor Halluzination; die ≥2-Quellen-Regel
# (Wikipedia = vertrauenswürdig, creator2-uni-Prinzip; sonst # verwarf echte Facetten — Lektion 104). Dedup/Verdichtung/
# verlieren Einzelquellen-Facetten wie „Overflow-Pomodoros") # Prüfungen fangen Überschuss hinten ab.
kuratiert = db.one( if belege:
"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:
kopf = ids[0] kopf = ids[0]
db.update("soll", "id=?", (kopf,), status="bestaetigt", db.update("soll", "id=?", (kopf,), status="bestaetigt",
punkt=gruppe.get("text", bekannt[kopf]["punkt"])[:300], 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: if bestaetigt < config.SOLL_PUNKTE_MIN:
befunde.append({"art": "soll_zu_wenig", "item": topic, befunde.append({"art": "soll_zu_wenig", "item": topic,
"detail": f"{bestaetigt} < {config.SOLL_PUNKTE_MIN}"}) "detail": f"{bestaetigt} < {config.SOLL_PUNKTE_MIN}"})
runden_max = graph.get().knoten[task["knoten"]].runden_max
gesaettigt = bestaetigt == vorher # Nullrunde (mit frischen Queries gelaufen) 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, befunde.append({"art": "unsaettigt", "item": topic,
"detail": f"Runde {runde}: {vorher}{bestaetigt}"}) "detail": f"Runde {runde}: {vorher}{bestaetigt}"})
if befunde: if befunde:
if runde < config.RECHERCHE_RUNDEN_MAX: if runde < runden_max:
neue = [{"knoten": "recherche_plan", "item": f"r{runde + 1}:lens:{lens}", neue = [{"knoten": "recherche_plan", "item": f"r{runde + 1}:plan",
"runde": runde + 1, "art": "ruecklauf", "runde": runde + 1, "art": "ruecklauf"}]
"payload": {"lens": lens}}
for lens in config.RECHERCHE_LENSES]
return engine.Ergebnis(gate_status="rot", befunde=befunde, return engine.Ergebnis(gate_status="rot", befunde=befunde,
neue_tasks=neue, neue_tasks=neue,
daten={"bestaetigt": bestaetigt}) daten={"bestaetigt": bestaetigt})
# grün → Rest-Kandidaten sind entschieden (Sättigung): verwerfen statt # Harte Vollständigkeits-Stops (Lektion 103): Vollständigkeit ist DAS
# als unadjudizierter Ballast liegen zu lassen # Kernziel — fehlt die Basis, ist die Pipeline kaputt → PAUSE statt
db.execute("UPDATE soll SET status='verworfen' WHERE topic=? AND " # kleiner Guide. Sichtbar als Run-Grund, Resume prüft erneut.
"status='kandidat'", topic) 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 # Stufe inventar: Extraktion NUR belegnah (Lektion 92) — 2 Reader je
# Fenster um die Soll-Belege. Quellen ohne Beleg liefern keine Atome. # Fenster um die Soll-Belege. Quellen ohne Beleg liefern keine Atome.
db.update("topics", "name=?", (topic,), status="korpus_fertig") 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"], []): for b in db.uj(s["belege"], []):
belege_je_quelle.setdefault(b["quelle"], []).append(b["zitat"]) belege_je_quelle.setdefault(b["quelle"], []).append(b["zitat"])
for q in db.query("SELECT * FROM quellen WHERE topic=? AND status='geladen' " 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"]) zitate = belege_je_quelle.get(q["id"])
if not zitate: if not zitate:
continue continue

View File

@@ -6,14 +6,12 @@ import hashlib
import os import os
import re import re
import time import time
import urllib.robotparser
from urllib.parse import urlsplit from urllib.parse import urlsplit
import httpx 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] = {} _letzter_fetch: dict[str, float] = {}
@@ -27,20 +25,6 @@ def _text_reparieren(text: str) -> str:
return unicodedata.normalize("NFC", text) 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: async def _domain_bremse(url: str) -> None:
domain = urlsplit(url).netloc domain = urlsplit(url).netloc
delta = time.monotonic() - _letzter_fetch.get(domain, 0) delta = time.monotonic() - _letzter_fetch.get(domain, 0)
@@ -75,7 +59,7 @@ async def _fetch(url: str) -> tuple[bytes, str]:
async with httpx.AsyncClient( async with httpx.AsyncClient(
follow_redirects=True, follow_redirects=True,
timeout=httpx.Timeout(config.FETCH_READ_S, connect=config.FETCH_CONNECT_S), 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: transport=httpx.AsyncHTTPTransport(retries=2)) as client:
r = await client.get(url) r = await client.get(url)
r.raise_for_status() r.raise_for_status()
@@ -121,13 +105,6 @@ async def laden(task: dict) -> engine.Ergebnis:
text = fakes.laden(quelle["url"]) text = fakes.laden(quelle["url"])
roh = b"" roh = b""
else: 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"]): if _WIKI_RE.match(quelle["url"]):
try: try:
text = await _wikipedia_api(quelle["url"]) text = await _wikipedia_api(quelle["url"])
@@ -135,34 +112,56 @@ async def laden(task: dict) -> engine.Ergebnis:
text = "" text = ""
if text: if text:
text = _text_reparieren(text) text = _text_reparieren(text)
return _snapshot_ablegen(task, quelle, text, b"") return await _snapshot_ablegen(task, quelle, text, b"")
# API-Fehler → normaler Weg (inkl. robots-Check) als Fallback # API-Fehler/Titel unbekannt → normaler Seiten-Weg 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})
await _domain_bremse(quelle["url"]) await _domain_bremse(quelle["url"])
try: try:
roh, _ = await _fetch(quelle["url"]) roh, ctype = await _fetch(quelle["url"])
except Exception as e: except Exception as e:
db.update("quellen", "id=?", (quelle["id"],), status="fehler", db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund=f"{type(e).__name__}"[:80]) grund=f"{type(e).__name__}"[:80])
return engine.Ergebnis(daten={"fetch_fehler": str(e)[:120]}) 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 = _extrahieren(roh)
text = _text_reparieren(text) 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, async def _inhalt_nuetzlich(task: dict, quelle: dict, text: str) -> tuple[bool, str]:
roh: bytes) -> engine.Ergebnis: """Inhalts-Urteil statt Titel-Filter (Lektion 102): erst laden, dann auf
if len(text) < config.SNAPSHOT_MIN_ZEICHEN or "enable javascript" in text.lower(): dem echten Text entscheiden. AUSNAHMSLOS für jede Quelle — auch Seed/
status = "braucht_js" if text and "javascript" in text.lower() else "leer" Wikipedia (Lektion 106: privilegierte Quellen luden „Kosovo" ungeprüft)."""
db.update("quellen", "id=?", (quelle["id"],), status=status) 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", db.insert("befunde", run_id=task["run_id"], stufe="korpus", knoten="laden",
art=status, item=quelle["url"][:120]) art="leer", item=quelle["url"][:120])
return engine.Ergebnis(daten={status: True}) return engine.Ergebnis(daten={"leer": True})
hash_ = hashlib.sha256(text.encode()).hexdigest()[:16] hash_ = hashlib.sha256(text.encode()).hexdigest()[:16]
dublette = db.one("SELECT id FROM quellen WHERE topic=? AND hash=? AND id!=?", 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", db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund="inhalt_dublette") grund="inhalt_dublette")
return engine.Ergebnis(daten={"dublette": True}) 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_) pfad = snapshot_pfad(task["topic"], hash_)
_atomar_schreiben(pfad, text.encode("utf-8")) _atomar_schreiben(pfad, text.encode("utf-8"))
roh_pfad = "" roh_pfad = ""
@@ -185,16 +189,14 @@ def _snapshot_ablegen(task: dict, quelle: dict, text: str,
def _folge_tasks(task: dict, quelle: dict) -> list: def _folge_tasks(task: dict, quelle: dict) -> list:
"""korpus-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen """korpus-/seed-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen
direkte Atom-Extraktion (Soll steht schon fest).""" (soll_id gesetzt) → direkte Atom-Extraktion (Soll steht schon fest)."""
from . import textkit if quelle["zweck"] == "luecke" and quelle.get("soll_id"):
if quelle["zweck"].startswith("luecke:"):
soll_id = int(quelle["zweck"].split(":")[1])
text = snapshot_lesen(quelle) text = snapshot_lesen(quelle)
return [{"knoten": "atom_extraktion", return [{"knoten": "atom_extraktion",
"item": f"quelle:{quelle['id']}:a{i}:r1", "item": f"quelle:{quelle['id']}:a{i}:r1",
"payload": {"quelle_id": quelle["id"], "offset": o, "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))] for i, (o, c) in enumerate(textkit.abschnitte(text))]
text = snapshot_lesen(quelle) text = snapshot_lesen(quelle)
chunks = textkit.abschnitte(text, config.SOLL_CHUNK_CHARS) 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, """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.""" 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): class BudgetErschoepft(Exception):
@@ -43,9 +43,10 @@ def kennzahlen(run_id: int) -> dict:
zeilen = db.query( zeilen = db.query(
"SELECT stufe, knoten, COUNT(*) calls, SUM(tok_in) tok_in, " "SELECT stufe, knoten, COUNT(*) calls, SUM(tok_in) tok_in, "
"SUM(tok_out) tok_out, SUM(tok_cache_read) cache_read, " "SUM(tok_out) tok_out, SUM(tok_cache_read) cache_read, "
"SUM(dur_ms) dur_ms, SUM(wait_ms) wait_ms, " "SUM(dur_ms) dur_ms, SUM(wait_ms) wait_ms, SUM(status='ok') ok, "
"SUM(status='ok') ok, SUM(status IN ('error','infra','parse','cap')) fehler " f"SUM(status IN ({','.join('?' * len(arten.EVENT_FEHLER))})) fehler "
"FROM events WHERE run_id=? GROUP BY stufe, knoten ORDER BY MIN(id)", run_id) "FROM events WHERE run_id=? GROUP BY stufe, knoten ORDER BY MIN(id)",
*arten.EVENT_FEHLER, run_id)
gesamt = verbraucht(run_id) gesamt = verbraucht(run_id)
kosten = sum(z["tok_in"] or 0 for z in zeilen) / 1e6 * config.PREIS_IN + \ 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 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 import httpx
from . import config, db, skills from . import config, db, graph, skills
from .ledger import BudgetErschoepft, budget_pruefen, log_call # noqa: F401 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): class LaufPause(Exception):
"""Infrastruktur erschöpft — Lauf pausieren, nie fail-open weiterrechnen.""" """Infrastruktur erschöpft — Lauf pausieren, nie fail-open weiterrechnen."""
@@ -31,6 +27,7 @@ class ApiErgebnis:
rc: int rc: int
text: str = "" text: str = ""
err: str = "" err: str = ""
cap: bool = False # stop=max_tokens — strukturiert, kein String-Match
tokens: dict = field(default_factory=dict) tokens: dict = field(default_factory=dict)
@@ -88,7 +85,7 @@ def _slot_frei() -> None:
def _ist_infra(err: str) -> bool: def _ist_infra(err: str) -> bool:
e = err.lower() 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: 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) return ApiErgebnis(1, err=resp.text[:300], tokens=tokens)
if not text.strip(): if not text.strip():
return ApiErgebnis(1, err=f"leere Antwort (stop={daten.get('stop_reason')})", return ApiErgebnis(1, err=f"leere Antwort (stop={daten.get('stop_reason')})",
cap=daten.get("stop_reason") == "max_tokens",
tokens=tokens) tokens=tokens)
_erfolg_melden() _erfolg_melden()
return ApiErgebnis(0, text=text, tokens=tokens) 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). """Zentraler LLM-Engpass. Rückgabe: Antworttext oder None (Inhaltsfehler/cap).
Wirft LaufPause (Infra erschöpft/manuell) oder BudgetErschoepft.""" Wirft LaufPause (Infra erschöpft/manuell) oder BudgetErschoepft."""
prompt, skill_hash, paare = skills.komponieren(skill_namen, werte) 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 infra_rest = config.INFRA_MAX_RETRIES
inhalt_rest = config.INHALT_MAX_RESTARTS 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 tokens, err, text = res.tokens, res.err, res.text
if res.rc == 0: if res.rc == 0:
status = "ok" status = "ok"
elif "stop=max_tokens" in err: elif res.cap:
status = "cap" status = "cap"
elif _ist_infra(err): elif _ist_infra(err):
status = "infra" status = "infra"

View File

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

View File

@@ -2,7 +2,8 @@
VERSCHIEDENEN Kapiteln mit Jaccard ≥0.6 → 2er-Panel entscheidet „redundant vs. VERSCHIEDENEN Kapiteln mit Jaccard ≥0.6 → 2er-Panel entscheidet „redundant vs.
didaktisch gewollt" (Recap ist keine Dopplung). Befunde behebt der Fix-Knoten didaktisch gewollt" (Recap ist keine Dopplung). Befunde behebt der Fix-Knoten
per Ein-Satz-Refresher + Anker-Link.""" 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]: def kandidaten_paare(topic: str) -> list[dict]:
@@ -32,8 +33,14 @@ def kandidaten_paare(topic: str) -> list[dict]:
>= config.REDUNDANZ_JACCARD >= config.REDUNDANZ_JACCARD
# Shingle-Kanal: identische Wort-Folge in längeren Sätzen — # Shingle-Kanal: identische Wort-Folge in längeren Sätzen —
# Jaccard übersieht sie, wenn der Restsatz verschieden ist # Jaccard übersieht sie, wenn der Restsatz verschieden ist
if jaccard_treffer or (shingles(a["satz"]) & shingles(b["satz"])): wortgleich = bool(shingles(a["satz"]) & shingles(b["satz"]))
paare.append({"a": a, "b": b}) # 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 return paare
@@ -43,15 +50,14 @@ async def pruefen(task: dict) -> engine.Ergebnis:
befunde = 0 befunde = 0
for i in range(0, len(paare), config.DEDUP_PAARE_PRO_CALL): for i in range(0, len(paare), config.DEDUP_PAARE_PRO_CALL):
chunk = paare[i:i + config.DEDUP_PAARE_PRO_CALL] chunk = paare[i:i + config.DEDUP_PAARE_PRO_CALL]
liste = "\n\n".join( liste = textkit.paar_liste(
f"PAAR {j + 1}:\nKapitel A: {p['a']['satz']}\nKapitel B: {p['b']['satz']}" [(q["a"]["satz"], q["b"]["satz"]) for q in chunk])
for j, p in enumerate(chunk))
async def ruf(p, liste=liste, n=len(chunk)): async def ruf(p, liste=liste, n=len(chunk)):
text = await llm.call( text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="redundanz", run_id=task["run_id"], stufe="guide", knoten="redundanz",
item=f"{task['item']}:c{i}:p{p}", role="judge", n=n, 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}) werte={"paare": liste})
if text is None: if text is None:
return None return None
@@ -67,7 +73,11 @@ async def pruefen(task: dict) -> engine.Ergebnis:
u = s[j + 1] u = s[j + 1]
return True if u == "redundant" else ( return True if u == "redundant" else (
False if u == "didaktisch_gewollt" else None) 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) # Der SPÄTERE Baustein trägt den Befund (dort wird verwiesen)
spaeter = max(p["a"], p["b"], key=lambda x: x["pos"]) spaeter = max(p["a"], p["b"], key=lambda x: x["pos"])
schon = db.one( schon = db.one(
@@ -80,4 +90,9 @@ async def pruefen(task: dict) -> engine.Ergebnis:
item=f"baustein:{spaeter['bid']}", item=f"baustein:{spaeter['bid']}",
detail=spaeter["satz"][:400], runde=task["runde"]) detail=spaeter["satz"][:400], runde=task["runde"])
befunde += 1 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).""" Gate: exakte Partition + Coverage (jeder Soll-Punkt ≥1 Baustein)."""
import math import math
from . import config, db, engine, llm, textkit from . import config, db, engine, graph, llm, textkit
def _atom_rang(atom_id: int) -> tuple: def _atom_rang(atom_id: int) -> tuple:
@@ -27,7 +27,7 @@ async def lernziele(task: dict) -> engine.Ergebnis:
else: else:
atome = db.query("SELECT * FROM atome WHERE topic=? AND " atome = db.query("SELECT * FROM atome WHERE topic=? AND "
"status='aktiv' AND soll_id IS NULL", task["topic"]) "status='aktiv' AND soll_id IS NULL", task["topic"])
punkt = "Weitere Grundlagen zum Thema" punkt = config.TEXTE["sammel_punkt"]
if not atome: if not atome:
teil[m["id"]] = "fertig" teil[m["id"]] = "fertig"
continue continue
@@ -45,7 +45,7 @@ async def lernziele(task: dict) -> engine.Ergebnis:
run_id=task["run_id"], stufe="struktur", knoten="lernziele", run_id=task["run_id"], stufe="struktur", knoten="lernziele",
item=task["item"], role="judge", item=task["item"], role="judge",
n=sum(len(a) for (_, _, _, a) in aktive.values()), 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( werte={"soll_punkte": "\n\n".join(
f"=== SOLL {i} ===\nPUNKT: {punkt}\nATOME:\n" + f"=== SOLL {i} ===\nPUNKT: {punkt}\nATOME:\n" +
"\n".join(f"[{a['id']}] {a['titel']}" for a in atome) "\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 rest = ids_alle - zugeordnet # deterministisches Sammelziel — nichts verlieren
if rest: if rest:
ziel_id = db.insert( ziel_id = db.insert(
"lernziele", topic=task["topic"], titel="Grundlagen", "lernziele", topic=task["topic"],
soll_id=soll_id, titel=config.TEXTE["sammel_titel"], soll_id=soll_id,
text=f"Der Lernende kann Grundlagen zu '{punkt}' erklären.") text=config.TEXTE["sammel_ziel"].format(punkt=punkt))
for aid in rest: for aid in rest:
db.update("atome", "id=?", (aid,), ziel_id=ziel_id) db.update("atome", "id=?", (aid,), ziel_id=ziel_id)
teil[m["id"]] = "fertig" teil[m["id"]] = "fertig"
@@ -115,17 +115,17 @@ async def bausteine(task: dict) -> engine.Ergebnis:
antwort = await llm.call( antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="bausteine", run_id=task["run_id"], stufe="struktur", knoten="bausteine",
item=task["item"], role="judge", n=len(atome), 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"], werte={"ziel": ziel["text"],
"band": f"{config.BAUSTEIN_MIN_ATOME}-{config.BAUSTEIN_MAX_ATOME}", "band": f"{config.BAUSTEIN_MIN_ATOME}-{config.BAUSTEIN_MAX_ATOME}",
"atome": "\n".join(f"[{a['id']}] {a['titel']}" for a in atome)}) "atome": "\n".join(f"[{a['id']}] {a['titel']}" for a in atome)})
if antwort is None: # Call-Ausfall: neuer Versuch statt stiller Fallback if antwort is None: # Call-Ausfall: neuer Versuch statt stiller Fallback
raise RuntimeError("bausteine ohne Ergebnis") raise RuntimeError("bausteine ohne Ergebnis")
gruppen = _gruppen_validieren(antwort, atome) gruppen = _min_erzwingen(_gruppen_validieren(antwort, atome))
for i, gruppe in enumerate(gruppen): for i, gruppe in enumerate(gruppen):
titel = ziel["titel"] or gruppe[0]["titel"] titel = ziel["titel"] or gruppe[0]["titel"]
if len(gruppen) > 1: 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"], bid = db.insert("bausteine", topic=task["topic"], ziel_id=ziel["id"],
titel=titel[:120], ord=i) titel=titel[:120], ord=i)
for j, a in enumerate(gruppe): for j, a in enumerate(gruppe):
@@ -134,6 +134,31 @@ async def bausteine(task: dict) -> engine.Ergebnis:
neue_tasks=[{"knoten": "ordnung", "item": "ordnung"}]) 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]]: def _gruppen_validieren(antwort: str | None, atome: list[dict]) -> list[list[dict]]:
"""LLM-Split validieren (jede ID genau einmal); sonst deterministischer """LLM-Split validieren (jede ID genau einmal); sonst deterministischer
divmod-Split entlang der Quellreihenfolge.""" divmod-Split entlang der Quellreihenfolge."""
@@ -159,22 +184,38 @@ def _gruppen_validieren(antwort: str | None, atome: list[dict]) -> list[list[dic
return gruppen 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: def _ziele_dedup(topic: str) -> int:
"""Synonyme Lernziele deterministisch mergen (Audit: „einordnen" vs. """Synonyme Lernziele deterministisch mergen (Audit: „einordnen" vs.
„verorten" erzeugte Doppel-Bausteine → Writer-Echos). Falsch-Merge ist „verorten" erzeugte Doppel-Bausteine → Writer-Echos). Nur Paare mit
harmlos: Atome bleiben erhalten, hängen nur am selben Ziel.""" 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) ziele = db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", topic)
kerne = _ziel_kerne(ziele)
merges = 0 merges = 0
for i, a in enumerate(ziele): for i, a in enumerate(ziele):
if a.get("_weg"): if a.get("_weg"):
continue continue
for b in ziele[i + 1:]: for j, b in enumerate(ziele[i + 1:], i + 1):
if b.get("_weg"): if b.get("_weg") or a["soll_id"] != b["soll_id"]:
continue 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"]) == \ if textkit.titel_kern(a["titel"] or a["text"]) == \
textkit.titel_kern(b["titel"] or b["text"]) or \ textkit.titel_kern(b["titel"] or b["text"]) or \
textkit.jaccard(a["text"], b["text"]) >= \ kern_jaccard >= config.ZIEL_DEDUP_JACCARD:
config.ZIEL_DEDUP_JACCARD:
db.update("atome", "ziel_id=?", (b["id"],), ziel_id=a["id"]) 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 bausteine WHERE ziel_id=?", b["id"])
db.execute("DELETE FROM lernziele WHERE 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( antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="ordnung", run_id=task["run_id"], stufe="struktur", knoten="ordnung",
item=f"perm:kapitel:{kap['id']}", role="judge", n=len(bs), 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", werte={"art": "Bausteine",
"liste": "\n".join(f"[{b['id']}] {b['titel']}" for b in prior), "liste": "\n".join(f"[{b['id']}] {b['titel']}" for b in prior),
"prior": ",".join(map(str, ids))}) "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( antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="ordnung", run_id=task["run_id"], stufe="struktur", knoten="ordnung",
item="themen_schnitt", role="judge", n=len(ids), 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, werte={"thema": thema, "richtwert": config.KAPITEL_SOLL_PUNKTE,
"punkte": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)}) "punkte": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)})
if antwort: 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") detail="Schnitt ungültig, √n-Fallback")
n_kap = max(1, round(math.sqrt(len(ids)))) n_kap = max(1, round(math.sqrt(len(ids))))
groesse = math.ceil(len(ids) / n_kap) 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]] 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.""" Circuit Breaker je Backend; Teilausfall = degraded-Befund, nie Lauf-Crash."""
import asyncio import asyncio
import os import os
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import httpx 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", _TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"fbclid", "gclid", "ref"} "fbclid", "gclid", "ref"}
@@ -42,7 +41,7 @@ async def _http_json(url: str, **kw) -> dict | list | None:
async def _wikipedia(query: str) -> list[dict]: async def _wikipedia(query: str) -> list[dict]:
treffer = [] treffer = []
for sprache in ("de", "en"): for sprache in config.SPRACHEN_SUCHE:
backend = f"wikipedia_{sprache}" backend = f"wikipedia_{sprache}"
if _tot(backend): if _tot(backend):
continue continue
@@ -79,9 +78,11 @@ async def _brave(query: str) -> list[dict]:
key = os.getenv("BRAVE_API_KEY") key = os.getenv("BRAVE_API_KEY")
if not key: if not key:
return [] return []
locale = config.SUCHE_LOCALE.get(config.SPRACHE, {})
daten = await _http_json("https://api.search.brave.com/res/v1/web/search", daten = await _http_json("https://api.search.brave.com/res/v1/web/search",
params={"q": query, "count": config.SUCHE_MAX_TREFFER, 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}) headers={"X-Subscription-Token": key})
return [{"titel": t.get("title", ""), "url": t["url"], "backend": "brave"} return [{"titel": t.get("title", ""), "url": t["url"], "backend": "brave"}
for t in (daten or {}).get("web", {}).get("results", [])] 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]: async def _ddgs(query: str) -> list[dict]:
def _sync(): def _sync():
from ddgs import DDGS 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) max_results=config.SUCHE_MAX_TREFFER)
await asyncio.sleep(config.DDGS_PAUSE_S) # Drosselung (ToS-Grauzone) await asyncio.sleep(config.DDGS_PAUSE_S) # Drosselung (ToS-Grauzone)
rows = await asyncio.to_thread(_sync) 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]]: async def web_suchen(query: str) -> tuple[list[dict], list[str]]:
"""→ (treffer, fehler). Wikipedia immer; Kette bis zum ersten Backend mit """→ (treffer, fehler). Kette bis zum ersten Backend mit Ergebnissen
Ergebnissen (Fallback statt Fan-out, R2).""" (Fallback statt Fan-out, R2). Wikipedia ist KEINE privilegierte
treffer = await _wikipedia(query) Extra-Quelle mehr — sie kommt über die Startquellen (Seed) und über
normale Web-Treffer herein (Lektion 106)."""
treffer: list[dict] = []
fehler = [] fehler = []
for name, fn in _KETTE: for name, fn in _KETTE:
if _tot(name): if _tot(name):
@@ -123,26 +127,6 @@ async def web_suchen(query: str) -> tuple[list[dict], list[str]]:
return treffer, fehler 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") @engine.worker("suche.suchen")
async def suchen(task: dict) -> engine.Ergebnis: async def suchen(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {}) payload = db.uj(task["payload"], {})
@@ -150,27 +134,30 @@ async def suchen(task: dict) -> engine.Ergebnis:
zweck = payload.get("zweck", "korpus") zweck = payload.get("zweck", "korpus")
seed = bool(payload.get("seed")) seed = bool(payload.get("seed"))
if seed: if seed:
# Wikipedia-Seed: deterministisch de+en zur Topic-Bezeichnung — # Startquellen-Seed: Pflicht-Kandidaten (umgehen den Deckel), aber
# umgeht Suche, Relevanz-Judge und Quellen-Deckel (2 Pflichtquellen) # 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 from urllib.parse import quote
zweck = "seed"
titel = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics " titel = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
"WHERE name=?", task["topic"])["t"] "WHERE name=?", task["topic"])["t"]
treffer = [{"titel": f"Wikipedia ({s}): {titel}", gefunden = {} if config.FAKE else {
"url": f"https://{s}.wikipedia.org/wiki/" t["backend"]: t for t in reversed(await _wikipedia(titel))}
f"{quote(titel.replace(' ', '_'))}", treffer = [gefunden.get(f"wikipedia_{s}",
"backend": f"wikipedia_{s}"} for s in ("de", "en")] {"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 = [] fehler = []
elif config.FAKE: elif config.FAKE:
from . import fakes from . import fakes
treffer, fehler = fakes.suche(query), [] treffer, fehler = fakes.suche(query), []
else: else:
treffer, fehler = await web_suchen(query) treffer, fehler = await web_suchen(query)
# Wikipedia zuerst (kuratierter Grundstock schlägt Blog-Treffer) # Keine Vorsortierung, kein Titel-Filter — beurteilt wird nach dem Laden
treffer.sort(key=lambda t: not t["backend"].startswith("wikipedia")) # auf dem Inhalt (Lektion 102/106).
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)
# Quellen-Deckel je Topic (0 = unbegrenzt, dann stoppt nur die Sättigung) # Quellen-Deckel je Topic (0 = unbegrenzt, dann stoppt nur die Sättigung)
einstellung = db.one("SELECT quellen_max FROM topics WHERE name=?", einstellung = db.one("SELECT quellen_max FROM topics WHERE name=?",
task["topic"]) or {} task["topic"]) or {}
@@ -179,17 +166,18 @@ async def suchen(task: dict) -> engine.Ergebnis:
deckel = config.QUELLEN_MAX deckel = config.QUELLEN_MAX
neue = [] neue = []
for t in treffer: 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 " vorhanden = db.one("SELECT COUNT(*) c FROM quellen WHERE topic=? AND "
"zweck='korpus' AND status NOT IN " "zweck='korpus' AND status NOT IN "
"('fehler','leer','robots','braucht_js')", "('fehler','leer','braucht_js','aussortiert')",
task["topic"])["c"] task["topic"])["c"]
if vorhanden >= deckel: if vorhanden >= deckel:
break break
url_norm = url_normieren(t["url"]) url_norm = url_normieren(t["url"])
qid = db.insert("quellen", ignore=True, topic=task["topic"], qid = db.insert("quellen", ignore=True, topic=task["topic"],
titel=t["titel"][:200], url=t["url"], url_norm=url_norm, 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: if qid:
neue.append({"knoten": "laden", "item": f"quelle:{qid}", neue.append({"knoten": "laden", "item": f"quelle:{qid}",
"payload": {"quelle_id": qid}}) "payload": {"quelle_id": qid}})

View File

@@ -6,14 +6,17 @@ import unicodedata
from . import config from . import config
# Sprache kommt aus config.SPRACHE (Lektion 106) — kein hartkodiertes Deutsch
try: try:
import Stemmer 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 except ImportError: # Fallback ohne C-Extension: ungestemmt
_stemmer = None _stemmer = None
_WORT_RE = re.compile(r"[a-zäöüß0-9]+") _WORT_RE = re.compile(r"\w+", re.UNICODE) # alle Schriften, nicht nur Latein
_NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne"} _NEGATION = config.NEGATIONEN.get(config.SPRACHE, set())
_NEG_PRAEFIX = config.NEGATIONS_PRAEFIX.get(config.SPRACHE, "\0")
def norm(text: str) -> str: 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: 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) 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: 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 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]: 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 = [] saetze = []
for absatz in text.split("\n"): for absatz in text.split("\n"):
absatz = absatz.strip() absatz = absatz.strip()
if not absatz or absatz.startswith(("#", "```", "|", "<!--")): if not absatz or absatz.startswith(("#", "```", "|", "<!--")):
continue 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 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 ── # ── Positions-Maps für Anker-Suche ──
def _map_bauen(text: str, wandler) -> tuple[str, list[int]]: 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.""" """Thread-sicher aus der DB-Schicht aufrufbar."""
if self._loop is None or self._queue is None: if self._loop is None or self._queue is None:
return return
bereich = {"tasks": "graph", "gate_laeufe": "graph", "befunde": "graph", from . import db
"runs": "run", "topics": "run"}.get(tabelle, "graph") bereich = "run" if tabelle in db._RUN_TABELLEN else "graph"
msg = json.dumps({"typ": "dirty", "bereich": bereich}, ensure_ascii=False) msg = json.dumps({"typ": "dirty", "bereich": bereich}, ensure_ascii=False)
self._loop.call_soon_threadsafe(self._queue.put_nowait, msg) self._loop.call_soon_threadsafe(self._queue.put_nowait, msg)

View File

@@ -6,72 +6,82 @@ stufen: [korpus, inventar, struktur, guide, montage]
knoten: knoten:
- {id: recherche_plan, typ: llm, worker: korpus.recherche_plan, stufe: korpus, - {id: recherche_plan, typ: llm, worker: korpus.recherche_plan, stufe: korpus,
titel: "Recherche-Plan", beschreibung: "Plant die Suchanfragen zum Thema", titel: "Recherche-Plan", beschreibung: "Plant die Suchanfragen zum Thema",
skills: [planer, deutsch-praezise, recherche-plan], max_parallel: 16, klasse: kurz} skills: [planer, deutsch-praezise, recherche-plan], max_parallel: 16, timeout: [60, 20]}
- {id: suche, typ: system, worker: suche.suchen, stufe: korpus, max_parallel: 4, - {id: suche, typ: system, worker: suche.suchen, stufe: korpus, max_parallel: 4,
titel: "Websuche", beschreibung: "Sucht Quellen im Web, filtert Off-Topic raus"} titel: "Websuche", beschreibung: "Sucht Quellen im Web"}
- {id: laden, typ: system, worker: laden.laden, stufe: korpus, max_parallel: 4, - {id: laden, typ: system, worker: laden.laden, stufe: korpus, max_parallel: 4,
titel: "Quellen laden", beschreibung: "Lädt Quellen und friert den Text ein"} titel: "Quellen laden", beschreibung: "Lädt Quellen, friert den Text ein, beurteilt Nützlichkeit",
extra_skills: {urteil: [richter, deutsch-praezise, quellen-urteil]}}
- {id: soll_extraktion, typ: llm, worker: korpus.soll_extraktion, stufe: korpus, - {id: soll_extraktion, typ: llm, worker: korpus.soll_extraktion, stufe: korpus,
titel: "Soll-Punkte sammeln", beschreibung: "Zieht aus jeder Quelle, was der Guide abdecken soll", titel: "Soll-Punkte sammeln", beschreibung: "Zieht aus jeder Quelle, was der Guide abdecken soll",
skills: [extraktor, deutsch-praezise, soll-extraktion], max_parallel: 16, klasse: mittel} skills: [extraktor, deutsch-praezise, soll-extraktion], max_parallel: 16, klasse: mittel, timeout: [180, 60]}
- {id: soll_konsens, typ: llm, worker: korpus.soll_konsens, stufe: korpus, - {id: soll_konsens, typ: llm, worker: korpus.soll_konsens, stufe: korpus,
titel: "Soll-Konsens", beschreibung: "Bestätigt Punkte mit genug unabhängigen Belegen", titel: "Soll-Konsens", beschreibung: "Fasst gleiche Punkte zusammen, bestätigt wörtlich belegte",
skills: [richter, deutsch-praezise, soll-konsens], barriere: true} skills: [richter, deutsch-praezise, soll-konsens], barriere: true, timeout: [240, 2]}
- {id: gate_korpus, typ: gate, worker: korpus.gate, stufe: korpus, - {id: gate_korpus, typ: gate, worker: korpus.gate, stufe: korpus,
titel: "Gate Korpus", beschreibung: "Prüft Quellen und Soll, lässt sonst nachsuchen"} titel: "Gate Korpus", beschreibung: "Prüft Quellen und Soll, lässt sonst nachsuchen",
runden_max: 4}
- {id: atom_extraktion, typ: llm, worker: inventar.atom_extraktion, stufe: inventar, - {id: atom_extraktion, typ: llm, worker: inventar.atom_extraktion, stufe: inventar,
titel: "Atome extrahieren", beschreibung: "Holt die kleinsten Lerneinheiten aus den Quellen", titel: "Atome extrahieren", beschreibung: "Holt die kleinsten Lerneinheiten aus den Quellen",
skills: [extraktor, deutsch-praezise, atom-extraktion], max_parallel: 16, klasse: mittel} skills: [extraktor, deutsch-praezise, atom-extraktion], max_parallel: 16, klasse: mittel, timeout: [240, 60]}
- {id: anker_fix, typ: llm, worker: inventar.anker_fix, stufe: inventar, - {id: anker_fix, typ: llm, worker: inventar.anker_fix, stufe: inventar,
titel: "Anker reparieren", beschreibung: "Repariert Zitate, die nicht zur Quelle passen", titel: "Anker reparieren", beschreibung: "Repariert Zitate, die nicht zur Quelle passen",
skills: [extraktor, deutsch-praezise, anker-fix], max_parallel: 16} skills: [extraktor, deutsch-praezise, anker-fix], max_parallel: 16, timeout: [180, 4]}
- {id: dedup, typ: llm, worker: dedup.dedup, stufe: inventar, - {id: dedup, typ: llm, worker: dedup.dedup, stufe: inventar,
titel: "Dubletten-Urteil", beschreibung: "Findet doppelte Atome und verschmilzt sie", titel: "Dubletten-Urteil", beschreibung: "Findet doppelte Atome und verschmilzt sie",
skills: [richter, deutsch-praezise, dedup-urteil], barriere: true, klasse: kurz} skills: [richter, deutsch-praezise, dedup-urteil], barriere: true, klasse: kurz, timeout: [120, 8]}
- {id: soll_zuordnung, typ: llm, worker: inventar.soll_zuordnung, stufe: inventar, - {id: soll_zuordnung, typ: llm, worker: inventar.soll_zuordnung, stufe: inventar,
titel: "Soll-Zuordnung", beschreibung: "Ordnet jedes Atom einem Soll-Punkt zu", titel: "Soll-Zuordnung", beschreibung: "Ordnet jedes Atom einem Soll-Punkt zu",
skills: [richter, deutsch-praezise, soll-zuordnung], max_parallel: 16, klasse: kurz} skills: [richter, deutsch-praezise, soll-zuordnung], max_parallel: 16, klasse: kurz, timeout: [120, 3]}
- {id: luecke, typ: llm, worker: inventar.luecke, stufe: inventar, - {id: luecke, typ: llm, worker: inventar.luecke, stufe: inventar,
titel: "Lücken füllen", beschreibung: "Extrahiert nach, wo Soll-Punkte ohne Atom sind", titel: "Lücken füllen", beschreibung: "Extrahiert nach, wo Soll-Punkte ohne Atom sind",
skills: [extraktor, deutsch-praezise, luecke-nachextraktion], max_parallel: 16, klasse: mittel} skills: [extraktor, deutsch-praezise, luecke-nachextraktion], max_parallel: 16, klasse: mittel,
timeout: [180, 45], extra_skills: {freispruch: [richter, deutsch-praezise, luecke-freispruch]}}
- {id: verdichtung, typ: llm, worker: inventar.verdichtung, stufe: inventar, - {id: verdichtung, typ: llm, worker: inventar.verdichtung, stufe: inventar,
titel: "Verdichtung", beschreibung: "Verschmilzt Splitter-Atome zum minimalen Set", titel: "Verdichtung", beschreibung: "Verschmilzt Splitter-Atome zum minimalen Set",
skills: [richter, deutsch-praezise, verdichtung], max_parallel: 16, klasse: kurz} skills: [richter, deutsch-praezise, verdichtung], max_parallel: 16, klasse: kurz, timeout: [180, 0]}
- {id: gate_inventar, typ: gate, worker: inventar.gate, stufe: inventar, - {id: gate_inventar, typ: gate, worker: inventar.gate, stufe: inventar,
titel: "Gate Atome", beschreibung: "Prüft Abdeckung und Dubletten, stößt Reparaturen an"} titel: "Gate Atome", beschreibung: "Prüft Abdeckung und Dubletten, stößt Reparaturen an",
runden_max: 8}
- {id: lernziele, typ: llm, worker: struktur.lernziele, stufe: struktur, - {id: lernziele, typ: llm, worker: struktur.lernziele, stufe: struktur,
titel: "Lernziele", beschreibung: "Formuliert Lernziele aus den Atomen", titel: "Lernziele", beschreibung: "Formuliert Lernziele aus den Atomen",
skills: [planer, deutsch-praezise, lernziele], max_parallel: 16, klasse: kurz} skills: [planer, deutsch-praezise, lernziele], max_parallel: 16, klasse: kurz, timeout: [120, 3]}
- {id: bausteine, typ: llm, worker: struktur.bausteine, stufe: struktur, - {id: bausteine, typ: llm, worker: struktur.bausteine, stufe: struktur,
titel: "Bausteine", beschreibung: "Bündelt Atome je Lernziel zu Bausteinen", titel: "Bausteine", beschreibung: "Bündelt Atome je Lernziel zu Bausteinen",
skills: [planer, deutsch-praezise, bausteine], max_parallel: 16} skills: [planer, deutsch-praezise, bausteine], max_parallel: 16, timeout: [120, 3]}
- {id: ordnung, typ: llm, worker: struktur.ordnung, stufe: struktur, - {id: ordnung, typ: llm, worker: struktur.ordnung, stufe: struktur,
titel: "Reihenfolge", beschreibung: "Bringt Bausteine in Lernreihenfolge und Kapitel", titel: "Reihenfolge", beschreibung: "Bringt Bausteine in Lernreihenfolge und Kapitel",
skills: [planer, deutsch-praezise, ordnung], barriere: true} skills: [planer, deutsch-praezise, ordnung], barriere: true, timeout: [180, 3],
extra_skills: {schnitt: [planer, deutsch-praezise, themen-schnitt]}}
- {id: gate_struktur, typ: gate, worker: struktur.gate, stufe: struktur, - {id: gate_struktur, typ: gate, worker: struktur.gate, stufe: struktur,
titel: "Gate Struktur", beschreibung: "Prüft die Gliederung, lässt Bausteine nachbessern"} titel: "Gate Struktur", beschreibung: "Prüft die Gliederung, lässt Bausteine nachbessern"}
- {id: writer, typ: llm, worker: guide.writer, stufe: guide, - {id: writer, typ: llm, worker: guide.writer, stufe: guide,
titel: "Schreiben", beschreibung: "Schreibt eine Guide-Section je Baustein", titel: "Schreiben", beschreibung: "Schreibt eine Guide-Section je Baustein",
skills: [autor, deutsch-praezise, guide-lesbarkeit, writer], max_parallel: 16} skills: [autor, deutsch-praezise, guide-lesbarkeit, darstellung, writer], max_parallel: 16, timeout: [420, 0]}
- {id: kapitel_intro, typ: llm, worker: guide.kapitel_intro, stufe: guide, max_parallel: 16, - {id: kapitel_intro, typ: llm, worker: guide.kapitel_intro, stufe: guide, max_parallel: 16,
titel: "Kapitel-Intros", beschreibung: "Schreibt die Einleitung je Kapitel", titel: "Kapitel-Intros", beschreibung: "Schreibt die Einleitung je Kapitel",
skills: [autor, deutsch-praezise, kapitel-intro]} skills: [autor, deutsch-praezise, kapitel-intro], timeout: [90, 0]}
- {id: det_pruefung, typ: system, worker: guide.det_pruefung, stufe: guide, max_parallel: 8, - {id: det_pruefung, typ: system, worker: guide.det_pruefung, stufe: guide, max_parallel: 8,
titel: "Format-Prüfung", beschreibung: "Prüft Sections deterministisch auf Regelverstöße"} titel: "Format-Prüfung", beschreibung: "Prüft Sections deterministisch auf Regelverstöße"}
- {id: llm_pruefung, typ: llm, worker: guide.llm_pruefung, stufe: guide, - {id: llm_pruefung, typ: llm, worker: guide.llm_pruefung, stufe: guide,
titel: "Inhalts-Prüfung", beschreibung: "Prüft Sections auf Fehler gegen die Quellen-Anker", titel: "Inhalts-Prüfung", beschreibung: "Prüft Sections auf Fehler gegen die Quellen-Anker",
skills: [richter, deutsch-praezise, guide-pruefung], max_parallel: 16, klasse: mittel} skills: [richter, deutsch-praezise, guide-pruefung], max_parallel: 16, klasse: mittel, timeout: [240, 60]}
- {id: fix, typ: llm, worker: guide.fix, stufe: guide, - {id: fix, typ: llm, worker: guide.fix, stufe: guide,
titel: "Korrektur", beschreibung: "Behebt gefundene Befunde in den Sections", titel: "Korrektur", beschreibung: "Behebt gefundene Befunde in den Sections",
skills: [autor, deutsch-praezise, guide-lesbarkeit, guide-fix], max_parallel: 16} skills: [autor, deutsch-praezise, guide-lesbarkeit, darstellung, guide-fix], max_parallel: 16,
timeout: [300, 0], extra_skills: {klaerung: [richter, deutsch-praezise, klaerung]}}
- {id: redundanz, typ: llm, worker: redundanz.pruefen, stufe: guide, - {id: redundanz, typ: llm, worker: redundanz.pruefen, stufe: guide,
titel: "Dopplungs-Check", beschreibung: "Sucht Wiederholungen zwischen den Sections", titel: "Dopplungs-Check", beschreibung: "Sucht Wiederholungen zwischen den Sections",
skills: [richter, deutsch-praezise, redundanz-urteil], barriere: true, klasse: kurz} skills: [richter, deutsch-praezise, redundanz-urteil], barriere: true, klasse: kurz, timeout: [120, 8]}
- {id: lektorat, typ: llm, worker: guide.lektorat, stufe: guide,
titel: "Lektorat", beschreibung: "Liest den ganzen Guide: Dopplungen, Splitter, zähe Übergänge",
skills: [richter, deutsch-praezise, lektorat], max_parallel: 1, timeout: [300, 0]}
- {id: gate_guide, typ: gate, worker: guide.gate, stufe: guide, - {id: gate_guide, typ: gate, worker: guide.gate, stufe: guide,
titel: "Gate Guide", beschreibung: "Prüft den Text, stößt Fixes und Re-Prüfung an"} titel: "Gate Guide", beschreibung: "Prüft den Text, stößt Fixes und Re-Prüfung an",
runden_max: 12}
- {id: montage, typ: system, worker: montage.montieren, stufe: montage, - {id: montage, typ: system, worker: montage.montieren, stufe: montage,
titel: "Montage", beschreibung: "Fügt alle Teile zum fertigen Guide zusammen"} titel: "Montage", beschreibung: "Fügt alle Teile zum fertigen Guide zusammen"}
@@ -102,6 +112,7 @@ kanten:
- {von: fix, nach: redundanz, art: normal} - {von: fix, nach: redundanz, art: normal}
- {von: llm_pruefung, nach: redundanz, art: normal} - {von: llm_pruefung, nach: redundanz, art: normal}
- {von: kapitel_intro, nach: redundanz, art: normal} - {von: kapitel_intro, nach: redundanz, art: normal}
- {von: redundanz, nach: lektorat, art: normal}
- {von: redundanz, nach: gate_guide, art: normal} - {von: redundanz, nach: gate_guide, art: normal}
- {von: gate_guide, nach: montage, art: normal} - {von: gate_guide, nach: montage, art: normal}

28
skills/README.md Normal file
View File

@@ -0,0 +1,28 @@
# Skill-Vertrag (Lektion 106: ein Protokoll, keine Sonderfälle)
Komposition: 1 Rolle + 0..n Stile + 1 Aufgabe. Nur die Aufgabe definiert das
Ausgabeformat. Welche Skills ein Knoten nutzt, steht AUSSCHLIESSLICH in
pipeline.yaml (`skills:` + `extra_skills:`) — nie im Worker-Code.
## Prompt-Struktur (Parser und Fakes verlassen sich darauf)
- Eingabe-Abschnitte: `NAME:` in GROSSBUCHSTABEN am Zeilenanfang, Inhalt in
den Folgezeilen. Erlaubte Klammer-Zusätze klein dahinter.
- Nummerierte Mehrfach-Eingaben: `=== NAME <n> ===`-Zeilen.
- Der Format-Abschnitt beginnt mit einer Zeile, die mit `Ausgabeformat`
anfängt. Regeln stehen unter `Regeln:`.
## Ausgabe-Protokoll
- Blöcke: `===MARKE===`, Felder `FELD: wert` (GROSS, 2-20 Zeichen).
- Leere pro Eingabe-Element: genau EIN Marker projektweit — `===LEER===`
mit dem ID-Feld des Elements. Kein NICHTS, kein OK, kein Freitext.
- Leeres Einzel-Feld: genau EIN Wert projektweit — `KEINER`.
- Paar-Aufgaben rendern Eingaben einheitlich über `textkit.paar_liste`
(`PAAR n:` / `A:` / `B:`).
- Protokoll-Tokens (Urteile wie `dublette`, `mangel`) sind sprachneutrale
Enum-Marker und ändern sich bei Übersetzungen NICHT.
Sprachvorgabe für Prosa-Ausgaben liegt allein im Stil-Skill
(deutsch-praezise) — Aufgaben-Prompts enthalten keine sprachspezifischen
Beispiel-Floskeln.

View File

@@ -13,9 +13,9 @@ ATOME:
QUELLTEXT: QUELLTEXT:
{{quelltext}} {{quelltext}}
Regeln: ZITAT zeichengetreu aus dem Quelltext (mindestens ein voller Satz); gibt es keine passende Stelle, schreibe exakt VERWERFEN. Regeln: ZITAT zeichengetreu aus dem Quelltext (mindestens ein voller Satz); gibt es keine passende Stelle, schreibe exakt KEINER.
Ausgabeformat — GENAU so, ein Block pro Atom: Ausgabeformat — GENAU so, ein Block pro Atom:
===FIX=== ===FIX===
ID: <Atom-ID> ID: <Atom-ID>
ZITAT: <wörtliches Zitat oder VERWERFEN> ZITAT: <wörtliches Zitat oder KEINER>

View File

@@ -7,10 +7,10 @@ platzhalter: [ziel, atome, band]
Teile die Atome des Lernziels „{{ziel}}" in Bausteine mit je {{band}} Atomen. Ein Baustein ist eine didaktische Einheit, die man am Stück lernt. Teile die Atome des Lernziels „{{ziel}}" in Bausteine mit je {{band}} Atomen. Ein Baustein ist eine didaktische Einheit, die man am Stück lernt.
ATOME (in Quellreihenfolge): ATOME:
{{atome}} {{atome}}
Regeln: jede Atom-ID genau einmal; Reihenfolge innerhalb eines Bausteins beibehalten; TITEL beschreibt den Baustein-Inhalt. Regeln: die Atome stehen in Quellreihenfolge; jede Atom-ID genau einmal; Reihenfolge innerhalb eines Bausteins beibehalten; TITEL beschreibt den Baustein-Inhalt.
Ausgabeformat — GENAU so, ein Block pro Baustein: Ausgabeformat — GENAU so, ein Block pro Baustein:
===BAUSTEIN=== ===BAUSTEIN===

View File

@@ -16,7 +16,7 @@ BEFUNDE:
BELEGE (Faktenbasis): BELEGE (Faktenbasis):
{{belege}} {{belege}}
Regeln: Marker-Zeilen `<!-- atom: id -->` unverändert lassen; bei Redundanz-Befunden die Wiederholung ersatzlos streichen oder durch einen knappen Prosa-Halbsatz ersetzen („wie bereits beim … beschrieben") — KEINE Links; keine H1-H3, keine Fettzeilen-Definitionen; Zahlen nur mit ehrlicher Quellenangabe. Regeln: Marker-Zeilen `<!-- atom: id -->` unverändert lassen; bei Redundanz-Befunden die Wiederholung ersatzlos streichen oder durch einen knappen Prosa-Halbsatz ersetzen („wie bereits beim … beschrieben") — KEINE Links; Zahlen nur mit benannter Quelle. Vorhandene Tabellen, Listen und Code-Blöcke strukturell erhalten — nur ihren Inhalt korrigieren.
Ausgabeformat: Ausgabeformat:
===TEXT=== ===TEXT===

View File

@@ -9,13 +9,13 @@ Prüfe jede Guide-Section gegen ihre Belege.
{{sections}} {{sections}}
Suche NUR: (a) falsch = Aussage widerspricht den Belegen, ist unbelegt erfunden, oder verkauft eine Zahl ohne konkrete Studie als pauschale Studienaussage bzw. erfindet Bedingungen dazu; (b) luecke = ein Atom des Bausteins wird nicht wirklich erklärt; (c) stil = grober Verstoß (englische Passagen, Floskeln, kaputtes Markdown). Kein Geschmacks-Feedback. SECTION ist die angegebene Section-ID. Suche NUR: (a) falsch = Aussage widerspricht den Belegen, ist unbelegt erfunden, oder nennt eine Zahl/Quantität ohne benannte Quelle bzw. erfindet Bedingungen dazu; (b) luecke = ein Atom des Bausteins wird nicht wirklich erklärt; (c) stil = grober Verstoß (englische Passagen, Floskeln, kaputtes Markdown). Kein Geschmacks-Feedback. SECTION ist die angegebene Section-ID.
Ausgabeformat — GENAU so, keine Prosa. Je Section: ein Block pro Befund; hat eine Section keine Befunde, schreibe für sie einen OK-Block. JEDE Section muss in der Ausgabe vorkommen: Ausgabeformat — GENAU so, keine Prosa. Je Section: ein Block pro Befund; hat eine Section keine Befunde, schreibe für sie einen LEER-Block. JEDE Section muss in der Ausgabe vorkommen:
===BEFUND=== ===BEFUND===
SECTION: <Section-ID> SECTION: <Section-ID>
ART: falsch | luecke | stil ART: falsch | luecke | stil
STELLE: <wörtliches Kurzzitat aus der Section> STELLE: <wörtliches Kurzzitat aus der Section>
DETAIL: <ein Satz> DETAIL: <ein Satz>
===OK=== ===LEER===
SECTION: <Section-ID> SECTION: <Section-ID>

View File

@@ -5,7 +5,9 @@ beschreibung: Inhaltsfreien 2-3-Satz-Einstieg für ein Kapitel schreiben
platzhalter: [kapitel, sections, vorherige] platzhalter: [kapitel, sections, vorherige]
--- ---
Schreibe 2-3 Einstiegssätze für das Kapitel „{{kapitel}}": Was lernt man hier und warum jetzt. Schreibe 2-3 Einstiegssätze für das folgende Kapitel: Was lernt man hier und warum jetzt.
KAPITEL: {{kapitel}}
SECTION-TITEL DES KAPITELS: SECTION-TITEL DES KAPITELS:
{{sections}} {{sections}}
@@ -13,7 +15,7 @@ SECTION-TITEL DES KAPITELS:
BEREITS GELESENE KAPITEL (nur DIESE dürfen als Rückbezug vorkommen — gibt es keine, dann KEIN Rückbezug): BEREITS GELESENE KAPITEL (nur DIESE dürfen als Rückbezug vorkommen — gibt es keine, dann KEIN Rückbezug):
{{vorherige}} {{vorherige}}
Regeln: KEINE fachlichen Inhalte vorwegnehmen; KEINE Links; keine Floskeln wie „schlägt die Brücke", „des betrachteten Systems", „Lerngruppe" — konkret zum Thema bleiben. Regeln: KEINE fachlichen Inhalte vorwegnehmen; KEINE Links; keine inhaltsleeren Überleitungs- und Verwaltungsfloskeln — konkret zum Thema bleiben.
Ausgabeformat: Ausgabeformat:
===INTRO=== ===INTRO===

View File

@@ -0,0 +1,25 @@
---
name: lektorat
art: aufgabe
beschreibung: Den ganzen Guide als Lektor lesen — Dopplungen, Splitter, zähe Übergänge
platzhalter: [guide]
---
Lies den kompletten Lern-Guide wie ein Lektor eines Kurzbuchs. Die Sections sind mit [SECTION <id>] markiert.
GUIDE:
{{guide}}
Suche NUR die drei größten Probleme je Art:
(a) redundanz = derselbe Fakt, dieselbe Studie oder Aussage wird in mehreren Sections erneut erklärt (Rückverweis in einem Halbsatz ist okay);
(b) stil = eine Section ist ein Splitter (nur 1-2 Sätze) oder ein Übergang ist zäh/formelhaft und bremst den Lesefluss.
Nenne höchstens 8 Befunde, die wichtigsten zuerst. SECTION ist die ID der Section, die geändert werden soll (bei Dopplung: die spätere).
Ausgabeformat — GENAU so, ein Block pro Befund; keine Befunde → als einzige Ausgabe ein LEER-Block:
===BEFUND===
SECTION: <id>
ART: redundanz | stil
DETAIL: <ein Satz: was doppelt/zäh ist und was stattdessen>
===LEER===
OK: ja

View File

@@ -11,12 +11,12 @@ Zu den folgenden Soll-Punkten fehlen Wissensatome. Extrahiere aus dem jeweiligen
Regeln: wie bei der Atom-Extraktion (TITEL/TYP/DEFINITION/ZITAT, Zitat zeichengetreu aus dem Fenster des Punkts); Aufzählungspunkte derselben Kategorie sind zusammen EIN Atom; SOLL ist die ID des Soll-Punkts. Regeln: wie bei der Atom-Extraktion (TITEL/TYP/DEFINITION/ZITAT, Zitat zeichengetreu aus dem Fenster des Punkts); Aufzählungspunkte derselben Kategorie sind zusammen EIN Atom; SOLL ist die ID des Soll-Punkts.
Ausgabeformat — GENAU so, keine Prosa. Deckt ein Fenster seinen Punkt nicht ab, schreibe für ihn einen NICHTS-Block. JEDER Soll-Punkt muss in der Ausgabe vorkommen: Ausgabeformat — GENAU so, keine Prosa. Deckt ein Fenster seinen Punkt nicht ab, schreibe für ihn einen LEER-Block. JEDER Soll-Punkt muss in der Ausgabe vorkommen:
===ATOM=== ===ATOM===
SOLL: <Soll-ID> SOLL: <Soll-ID>
TITEL: … TITEL: …
TYP: … TYP: …
DEFINITION: … DEFINITION: …
ZITAT: … ZITAT: …
===NICHTS=== ===LEER===
SOLL: <Soll-ID> SOLL: <Soll-ID>

View File

@@ -10,7 +10,9 @@ Ordne die folgenden {{art}} in die beste Lernreihenfolge: Definition/Begriff/Her
ELEMENTE: ELEMENTE:
{{liste}} {{liste}}
Vorschlag nach Quellreihenfolge (nur bei Gleichwertigkeit übernehmen): {{prior}} PRIOR: {{prior}}
PRIOR ist der Vorschlag nach Quellreihenfolge; nur bei Gleichwertigkeit übernehmen.
Regeln: Ausgabe ist eine EXAKTE Permutation aller IDs — jede genau einmal. Regeln: Ausgabe ist eine EXAKTE Permutation aller IDs — jede genau einmal.

View File

@@ -1,17 +0,0 @@
---
name: quellen-relevanz
art: aufgabe
beschreibung: Suchtreffer auf inhaltliche Relevanz für ein Lernthema filtern
platzhalter: [thema, treffer]
---
Unten stehen Web-Suchtreffer für einen Lern-Guide zum Thema „{{thema}}". Behalte nur Treffer, die inhaltlich LEHREN (Erklärungen, Anleitungen, Hintergründe).
TREFFER:
{{treffer}}
Aussortieren: Produkt-/App-Listen, Shops, Werbeseiten, Foren-Schnipsel, Off-Topic — auch Job-, HR-, Karriere- und Ratgeberportale, deren Kern ein ANDERES Thema ist (die das Thema nur beiläufig erwähnen).
Ausgabeformat — GENAU so:
===RELEVANT===
IDS: <kommagetrennte Nummern der behaltenen Treffer>

View File

@@ -0,0 +1,21 @@
---
name: quellen-urteil
art: aufgabe
beschreibung: Geladene Quelle auf Nützlichkeit für ein Lernthema beurteilen
platzhalter: [thema, titel, auszug]
---
Entscheide, ob diese geladene Quelle für einen Lern-Guide zum Thema „{{thema}}" nützlich ist.
TITEL: {{titel}}
AUSZUG (Anfang des Textes):
{{auszug}}
Nützlich = der Text LEHRT etwas zum Thema (Erklärungen, Anleitungen, Hintergründe, Referenz).
Nicht nützlich = Produkt-/App-Listen, Shops, Kurs-Werbung, Login-/Fehlerseiten, reine Linklisten, oder der Kern ist ein ANDERES Thema.
Ausgabeformat — GENAU so, genau ein Block:
===URTEIL===
NUETZLICH: ja | nein
GRUND: <ein kurzer Satz>

View File

@@ -10,10 +10,10 @@ Erzeuge je Blickwinkel 3 Web-Suchanfragen zum Thema „{{thema}}" (Runde {{runde
BLICKWINKEL: BLICKWINKEL:
{{lenses}} {{lenses}}
Bereits verwendete Suchanfragen (NICHT wiederholen, neue Winkel finden): BISHERIGE SUCHANFRAGEN (nicht wiederholen, neue Winkel finden):
{{bisherige_queries}} {{bisherige_queries}}
Regeln: kurze, konkrete Suchanfragen (2-6 Wörter); deutsch oder englisch je nachdem, wo bessere Quellen zu erwarten sind; LENS ist exakt einer der gegebenen Blickwinkel-Namen. Regeln: kurze, konkrete Suchanfragen (2-6 Wörter); in der Sprache, in der die besten Quellen zu erwarten sind; LENS ist exakt einer der gegebenen Blickwinkel-Namen.
Ausgabeformat — GENAU so, ein Block pro Suchanfrage, keine Prosa; JEDER Blickwinkel bekommt seine Blöcke: Ausgabeformat — GENAU so, ein Block pro Suchanfrage, keine Prosa; JEDER Blickwinkel bekommt seine Blöcke:
===QUERY=== ===QUERY===

View File

@@ -1,21 +0,0 @@
---
name: soll-beleg
art: aufgabe
beschreibung: Gebündelte Beleg-Nachsuche für offene Soll-Punkte in einer Quelle
platzhalter: [punkte, quelltext]
---
Suche für jeden der folgenden Soll-Punkte ein wörtliches Beleg-Zitat im Quelltext.
PUNKTE:
{{punkte}}
QUELLTEXT:
{{quelltext}}
Regeln: BELEG zeichengetreu aus dem Quelltext; findet sich keiner, schreibe exakt KEIN BELEG.
Ausgabeformat — GENAU so, ein Block pro Punkt:
===BELEG===
ID: <Punkt-ID>
BELEG: <wörtliches Zitat oder KEIN BELEG>

View File

@@ -16,7 +16,7 @@ BELEGE (Faktenbasis — nichts behaupten, was hier nicht steht):
BEREITS BEHANDELT (nur als knapper Prosa-Halbsatz erwähnen, falls nötig — NIE erneut erklären, KEINE Links): BEREITS BEHANDELT (nur als knapper Prosa-Halbsatz erwähnen, falls nötig — NIE erneut erklären, KEINE Links):
{{bekannt}} {{bekannt}}
Regeln: Länge {{band}} Wörter; jeder Marker `<!-- atom: id -->` genau einmal; KEINE Markdown-Links; keine Überschrift für den Baustein-Titel und keine H1-H3 (Unterpunkte als ####); Feldnamen/Rohformate aus der Eingabe nie übernehmen; Zahlen und Statistiken nur mit ehrlicher Quellenangabe aus den Belegen („laut Anbieter X", „eine Studie der Universität Y") — nie pauschal „Studien zufolge", nichts dazuerfinden. Regeln: Länge {{band}} Wörter; jeder Marker `<!-- atom: id -->` genau einmal; KEINE Markdown-Links; keine Überschrift für den Baustein-Titel; Feldnamen/Rohformate aus der Eingabe nie übernehmen; Zahlen und Quantitäten nur mit benannter Quelle aus den Belegen — nie pauschal zugeschrieben, nichts dazuerfinden.
Ausgabeformat — GENAU so, keine Prosa außerhalb: Ausgabeformat — GENAU so, keine Prosa außerhalb:
===TEXT=== ===TEXT===

View File

@@ -0,0 +1,13 @@
---
name: darstellung
art: stil
beschreibung: Darstellungsform folgt dem Inhaltstyp — Liste, Tabelle, Code statt Schachtelsatz
---
Wähle je Inhalt die am schnellsten lesbare Form:
- Abläufe und Verfahren mit festen Schritten → nummerierte Liste (1., 2., …), ein Schritt pro Zeile.
- Gegenüberstellungen, Varianten, Kennwerte → Tabelle mit maximal 4 Spalten und kurzen Zellen.
- Formale Notation (Code, Formeln, Konfiguration, strukturierte Auszüge) → umschlossener Block mit passender Kennzeichnung; kurze Bezeichner im Fließtext als `Inline-Code`.
- Alles andere → knappe Prosa.
Grenzen: Form ersetzt Prosa, sie ergänzt sie nicht — kein Inhalt doppelt als Text UND Tabelle. Keine zusätzlichen Beispiele, Analogien oder Merksätze erfinden. Höchstens zwei ####-Zwischentitel pro Section.

View File

@@ -5,9 +5,8 @@ beschreibung: Lesbarkeits-Regeln für Guide-Text (linear lesbar, keine Links, ke
--- ---
- Ein Konzept pro Abschnitt; nie zwei neue Ideen gleichzeitig einführen. - Ein Konzept pro Abschnitt; nie zwei neue Ideen gleichzeitig einführen.
- Der Text wird LINEAR gelesen: KEINE Links im Fließtext, kein „Wie in [X] gezeigt". Rückbezug auf früher Gelehrtes = knapper Halbsatz in Prosa („wie beim Vierer-Rhythmus beschrieben") — und nur, wenn er dem Verständnis dient. - Der Text wird LINEAR gelesen: KEINE Links im Fließtext, kein „Wie in [X] gezeigt". Rückbezug auf früher Gelehrtes = knapper Halbsatz in Prosa („wie im früheren Abschnitt beschrieben) — und nur, wenn er dem Verständnis dient.
- Bereits Gelehrtes NIE erneut erklären. Auch keine Meta-Sätze darüber („wird hier nicht erneut aufgegriffen", „schlägt die Brücke", „dieses Kapitel behandelt"). - Bereits Gelehrtes NIE erneut erklären. Auch keine Meta-Sätze darüber („wird hier nicht erneut aufgegriffen", „schlägt die Brücke", „dieses Kapitel behandelt").
- Gelieferte Kern-Definitionen EINARBEITEN, nie als Fettzeile zitieren und danach paraphrasieren — jede Information genau einmal. - Gelieferte Kern-Definitionen EINARBEITEN, nie als Fettzeile zitieren und danach paraphrasieren — jede Information genau einmal.
- Erklärung vor Beispiel: erst max. 5 Sätze Konzept, direkt danach das Beispiel. - Erklärung vor Beispiel: erst max. 5 Sätze Konzept, direkt danach das Beispiel.
- Unterpunkte innerhalb des Textes als H4 (####) oder Aufzählung — H1 bis H3 setzt die Montage, nie du. - Unterpunkte innerhalb des Textes als H4 (####) oder Aufzählung — H1 bis H3 setzt die Montage, nie du.
- Tabellen nur mit maximal 4 Spalten und kurzen Zellen; sonst Liste.

View File

@@ -11,7 +11,7 @@ from backend import config, db, engine, llm
# Kleine Fake-Welt → kleine Schwellen # Kleine Fake-Welt → kleine Schwellen
config.SOLL_PUNKTE_MIN = 3 config.SOLL_PUNKTE_MIN = 3
config.RECHERCHE_RUNDEN_MAX = 2
config.KAPITEL_SOLL_PUNKTE = 2 config.KAPITEL_SOLL_PUNKTE = 2
config.LUECKEN_RUNDEN_MAX = 1 config.LUECKEN_RUNDEN_MAX = 1
config.POLL_SEKUNDEN = 0.05 config.POLL_SEKUNDEN = 0.05
@@ -20,8 +20,12 @@ config.DDGS_PAUSE_S = 0
config.QUERIES_JE_LENS = 2 config.QUERIES_JE_LENS = 2
config.VERDICHTUNG_ZIEL = 1 # zwingt den Verdichtungs-Pfad in der Fake-Welt config.VERDICHTUNG_ZIEL = 1 # zwingt den Verdichtungs-Pfad in der Fake-Welt
config.TAKT_SEKUNDEN = 0 # kein 3s-Takt in Tests config.TAKT_SEKUNDEN = 0 # kein 3s-Takt in Tests
config.SEED_PFLICHT = False # Fake-Welt hat keine echten Startquellen
config.QUELLEN_MIN = 1
engine.module_laden() engine.module_laden()
from backend import graph as _graph
_graph.get().knoten["gate_korpus"].runden_max = 2 # kleine Fake-Welt
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@@ -79,7 +79,7 @@ async def test_llm_pruefung_buendel_teilantwort(monkeypatch):
async def antwort(**kw): async def antwort(**kw):
assert f"=== SECTION {b2} ===" in kw["werte"]["sections"] assert f"=== SECTION {b2} ===" in kw["werte"]["sections"]
return f"===OK===\nSECTION: {b1}" # b2 fehlt in beiden Stimmen return f"===LEER===\nSECTION: {b1}" # b2 fehlt in beiden Stimmen
monkeypatch.setattr(llm_mod, "call", antwort) monkeypatch.setattr(llm_mod, "call", antwort)
task = dict(db.one("SELECT * FROM tasks WHERE id=?", t1)) task = dict(db.one("SELECT * FROM tasks WHERE id=?", t1))

View File

@@ -75,6 +75,7 @@ async def test_keiner_atome_nicht_erneut_gefragt():
db.insert("soll", topic=topic, punkt="Punkt", status="bestaetigt") db.insert("soll", topic=topic, punkt="Punkt", status="bestaetigt")
aid = _atom(topic, "Fremdes Atom", "Definition ohne Bezug zum Punkt hier.") aid = _atom(topic, "Fremdes Atom", "Definition ohne Bezug zum Punkt hier.")
task = {"run_id": run_id, "topic": topic, "runde": 1, "item": "z0", task = {"run_id": run_id, "topic": topic, "runde": 1, "item": "z0",
"knoten": "soll_zuordnung",
"payload": db.j({"atom_ids": [aid]})} "payload": db.j({"atom_ids": [aid]})}
# Fake-Zuordnung antwortet KEINER (Titel unbekannt) → Atom bleibt ohne Soll # Fake-Zuordnung antwortet KEINER (Titel unbekannt) → Atom bleibt ohne Soll
erg = await inventar.soll_zuordnung(task) erg = await inventar.soll_zuordnung(task)
@@ -83,7 +84,8 @@ async def test_keiner_atome_nicht_erneut_gefragt():
assert atom["soll_id"] is None and atom["soll_geprueft"] == 1 assert atom["soll_id"] is None and atom["soll_geprueft"] == 1
# Dedup erzeugt für dieses Atom KEINE neue Zuordnungs-Aufgabe mehr # Dedup erzeugt für dieses Atom KEINE neue Zuordnungs-Aufgabe mehr
erg2 = await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1, erg2 = await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"}) "knoten": "dedup", "item": "r1:dedup",
"payload": "{}"})
assert erg2.neue_tasks == [] assert erg2.neue_tasks == []
@@ -127,7 +129,7 @@ async def test_enthalten_merged_in_den_umfassenderen():
"Deutlich längere Definition mit allen Sonderfällen und mehr " "Deutlich längere Definition mit allen Sonderfällen und mehr "
"Kontext für Lernende.", soll_id=sid, quelle=2, start=0, ende=10) "Kontext für Lernende.", soll_id=sid, quelle=2, start=0, ende=10)
await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1, await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"}) "knoten": "dedup", "item": "r1:dedup", "payload": "{}"})
gewinner = db.one("SELECT id FROM atome WHERE topic=? AND status='aktiv'", gewinner = db.one("SELECT id FROM atome WHERE topic=? AND status='aktiv'",
topic) topic)
assert gewinner["id"] == lang # längere Definition gewinnt assert gewinner["id"] == lang # längere Definition gewinnt
@@ -199,7 +201,7 @@ async def test_fehlendes_urteil_nachcall_dann_befund(monkeypatch):
monkeypatch.setattr(llm_mod, "call", ohne_urteil) monkeypatch.setattr(llm_mod, "call", ohne_urteil)
await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1, await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"}) "knoten": "dedup", "item": "r1:dedup", "payload": "{}"})
assert calls["n"] == 4 # 2 Panel-Stimmen + 2 Nach-Call-Stimmen assert calls["n"] == 4 # 2 Panel-Stimmen + 2 Nach-Call-Stimmen
assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='parse'", assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='parse'",
run_id) run_id)
@@ -207,3 +209,42 @@ async def test_fehlendes_urteil_nachcall_dann_befund(monkeypatch):
aktiv = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv'", aktiv = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv'",
topic) topic)
assert {r["id"] for r in aktiv} == {a, b} # nichts still verschmolzen assert {r["id"] for r in aktiv} == {a, b} # nichts still verschmolzen
def test_fakten_kanal_findet_studien_paraphrase():
"""Audit: „Ariga & Lleras 2011" stand 3× im Guide, Wort-Jaccard nur 0.21 —
geteilte Zahlen+Namen machen das Paar jetzt zum Kandidaten."""
topic = topic_anlegen()
_section(topic, 0, "Die Untersuchung von Ariga und Lleras aus dem Jahr "
"2011 zeigte über lange Zeit stabile Aufmerksamkeit "
"bei den Probanden im Labor.")
_section(topic, 1, "Kurze bewusste Unterbrechungen halfen laut Ariga "
"sowie Lleras 2011 den Teilnehmern messbar besser "
"durch anstrengende Arbeitsphasen zu kommen.",
ziel_suffix="2")
paare = redundanz.kandidaten_paare(topic)
assert len(paare) == 1
async def test_wortgleiche_paare_ohne_freispruch(monkeypatch):
"""Fast wörtliche Paare (gemeinsame 5-Wort-Folge) darf das Panel nicht
als „didaktisch gewollt" freisprechen — immer Befund."""
from backend import llm as llm_mod
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
satz = ("Die Pausen sollten proportional zur Arbeitszeit verlängert "
"werden, sagt die Studie aus Maastricht deutlich.")
_section(topic, 0, satz)
_section(topic, 1, satz + " Dazu kommt hier noch ein Zusatz.",
ziel_suffix="2")
async def gewollt(**kw):
return "===URTEIL===\nPAAR: 1\nURTEIL: didaktisch_gewollt"
monkeypatch.setattr(llm_mod, "call", gewollt)
erg = await redundanz.pruefen({"run_id": run_id, "topic": topic,
"knoten": "redundanz", "runde": 1,
"item": "r1:redundanz", "payload": "{}"})
assert erg.daten["befunde"] == 1
assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='redundanz'",
run_id)

View File

@@ -48,7 +48,7 @@ def _pruefe_endzustand(topic: str):
assert "TITEL:" not in guide and "DEFINITION:" not in guide assert "TITEL:" not in guide and "DEFINITION:" not in guide
assert "## Fehlerbehandlung" in guide assert "## Fehlerbehandlung" in guide
# Lücken-Atom kam über Web-Nachrecherche # Lücken-Atom kam über Web-Nachrecherche
assert db.one("SELECT id FROM quellen WHERE topic=? AND zweck LIKE 'luecke:%' " assert db.one("SELECT id FROM quellen WHERE topic=? AND zweck='luecke' "
"AND status='geladen'", topic) is not None "AND status='geladen'", topic) is not None

View File

@@ -119,6 +119,7 @@ async def test_gate_guide_neuschreiben_bei_kaputten_markern():
assert writer_tasks and writer_tasks[0]["payload"]["neu"] is True assert writer_tasks and writer_tasks[0]["payload"]["neu"] is True
# Neuschreiben resettet den Fix-Cap und ersetzt den Text # Neuschreiben resettet den Fix-Cap und ersetzt den Text
await guide.writer({"run_id": run_id, "topic": topic, "runde": 2, await guide.writer({"run_id": run_id, "topic": topic, "runde": 2,
"knoten": "writer",
"item": f"r2:baustein:{bid}", "item": f"r2:baustein:{bid}",
"payload": db.j({"baustein_id": bid, "neu": True})}) "payload": db.j({"baustein_id": bid, "neu": True})})
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid) sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
@@ -201,8 +202,9 @@ async def test_vorwaerts_ignoriert_verlinkte_erwaehnung():
if b["art"] == "vorwaerts"] if b["art"] == "vorwaerts"]
async def test_gate_verwirft_soll_lose_atome(): async def test_gate_verwirft_soll_lose_atome(monkeypatch):
from backend import inventar from backend import config, inventar
monkeypatch.setattr(config, "BEIFANG_MAX_QUOTE", 1.0) # Stop hier nicht Testziel
topic = topic_anlegen() topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running") run_id = db.insert("runs", topic=topic, status="running")
geprueft = db.insert("atome", topic=topic, titel="Beifang", status="aktiv", geprueft = db.insert("atome", topic=topic, titel="Beifang", status="aktiv",
@@ -210,7 +212,8 @@ async def test_gate_verwirft_soll_lose_atome():
frisch = db.insert("atome", topic=topic, titel="Frisch", status="aktiv", frisch = db.insert("atome", topic=topic, titel="Frisch", status="aktiv",
soll_geprueft=0) soll_geprueft=0)
await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1, await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"}) "knoten": "gate_inventar", "item": "runde:1",
"payload": "{}"})
assert db.one("SELECT status FROM atome WHERE id=?", geprueft)[ assert db.one("SELECT status FROM atome WHERE id=?", geprueft)[
"status"] == "verworfen" "status"] == "verworfen"
assert db.one("SELECT status FROM atome WHERE id=?", frisch)[ assert db.one("SELECT status FROM atome WHERE id=?", frisch)[
@@ -280,3 +283,66 @@ async def test_budget_stoppt_lauf():
import pytest import pytest
with pytest.raises(BudgetErschoepft): with pytest.raises(BudgetErschoepft):
budget_pruefen(run["id"]) budget_pruefen(run["id"])
async def test_stop_seed_fehlt(monkeypatch):
"""Vollständigkeit ist DAS Kernziel: ohne nützlich geladene Startquelle
PAUSE statt Mini-Guide (Lektion 103/106)."""
import pytest
from backend import config, korpus, llm
monkeypatch.setattr(config, "SEED_PFLICHT", True)
monkeypatch.setattr(config, "QUELLEN_MIN", 1)
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
db.insert("quellen", topic=topic, url="https://a.de", url_norm="a",
backend="ddgs", status="geladen", zweck="korpus")
for i in range(3):
db.insert("soll", topic=topic, punkt=f"P{i}", status="bestaetigt")
db.insert("tasks", run_id=run_id, topic=topic, knoten="gate_korpus",
item="runde:1", runde=1, status="fertig",
ergebnis=db.j({"bestaetigt": 3}))
with pytest.raises(llm.LaufPause, match="seed_fehlt"):
await korpus.gate({"run_id": run_id, "topic": topic, "runde": 2,
"knoten": "gate_korpus", "item": "runde:2",
"payload": "{}"})
async def test_stop_zu_wenige_quellen(monkeypatch):
import pytest
from backend import config, korpus, llm
monkeypatch.setattr(config, "SEED_PFLICHT", False)
monkeypatch.setattr(config, "QUELLEN_MIN", 5)
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
db.insert("quellen", topic=topic, url="https://a.de", url_norm="a",
backend="ddgs", status="geladen", zweck="korpus")
for i in range(3):
db.insert("soll", topic=topic, punkt=f"P{i}", status="bestaetigt")
db.insert("tasks", run_id=run_id, topic=topic, knoten="gate_korpus",
item="runde:1", runde=1, status="fertig",
ergebnis=db.j({"bestaetigt": 3}))
with pytest.raises(llm.LaufPause, match="quellen"):
await korpus.gate({"run_id": run_id, "topic": topic, "runde": 2,
"knoten": "gate_korpus", "item": "runde:2",
"payload": "{}"})
async def test_stop_beifang_quote():
""">50 % der Atome als Beifang verworfen → Soll zu eng → PAUSE."""
import pytest
from backend import inventar, llm
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
s = db.insert("soll", topic=topic, punkt="P", status="bestaetigt",
belege=db.j([]))
behalten = db.insert("atome", topic=topic, titel="Gut", status="aktiv",
soll_id=s, soll_geprueft=1)
for i in range(2):
db.insert("atome", topic=topic, titel=f"Beifang{i}", status="aktiv",
soll_geprueft=1)
with pytest.raises(llm.LaufPause, match="beifang"):
await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"knoten": "gate_inventar", "item": "runde:1",
"payload": "{}"})
assert db.one("SELECT status FROM atome WHERE id=?", behalten)[
"status"] == "aktiv"

View File

@@ -42,3 +42,19 @@ def test_layout_vollstaendig():
lay = graph.get().layout() lay = graph.get().layout()
assert set(lay["knoten"]) == set(graph.get().knoten) assert set(lay["knoten"]) == set(graph.get().knoten)
assert all("spalte" in v and "zeile" in v for v in lay["knoten"].values()) assert all("spalte" in v and "zeile" in v for v in lay["knoten"].values())
def test_skills_von_eine_wahrheit():
"""Worker-Skills kommen NUR aus pipeline.yaml; nicht deklarierte
extra_skills sind ein harter Fehler (Drift bricht laut, nicht still)."""
assert graph.skills_von("writer")[-1] == "writer"
assert graph.skills_von("fix", extra="klaerung")[-1] == "klaerung"
with pytest.raises(graph.GraphFehler, match="nicht deklariert"):
graph.skills_von("writer", extra="gibtsnicht")
def test_timeout_und_runden_aus_dem_graph():
assert graph.timeout_fuer("writer") == 420
assert graph.timeout_fuer("dedup", n=10) == 120 + 8 * 10
assert graph.timeout_fuer("unbekannter_knoten") == 180 # Default
assert graph.get().knoten["gate_guide"].runden_max == 12

View File

@@ -1,22 +1,21 @@
"""Konsens: 1 kuratierter Wikipedia-Beleg genügt, 1 Blog-Beleg nicht.""" """Konsens ohne 2-Quellen-Regel: 1 wörtlicher Beleg genügt (Zitat-Gate schützt);
ohne Beleg bleibt der Punkt Kandidat."""
from backend import db, korpus from backend import db, korpus
from .conftest import topic_anlegen from .conftest import topic_anlegen
async def test_wiki_einzelbeleg_bestaetigt(): async def test_einzelbeleg_bestaetigt_ohne_beleg_nicht():
topic = topic_anlegen() topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running") run_id = db.insert("runs", topic=topic, status="running")
q_wiki = db.insert("quellen", topic=topic, url="https://de.wikipedia.org/w",
url_norm="u1", backend="wikipedia_de", status="geladen")
q_blog = db.insert("quellen", topic=topic, url="https://blog.de/x", q_blog = db.insert("quellen", topic=topic, url="https://blog.de/x",
url_norm="u2", backend="ddgs", status="geladen") url_norm="u2", backend="ddgs", status="geladen")
db.insert("soll", topic=topic, punkt="Overflow-Pomodoros erklären", db.insert("soll", topic=topic, punkt="Blog-These mit Beleg",
belege=db.j([{"quelle": q_wiki, "zitat": "z1"}]))
db.insert("soll", topic=topic, punkt="Nur Blog-These",
belege=db.j([{"quelle": q_blog, "zitat": "z2"}])) belege=db.j([{"quelle": q_blog, "zitat": "z2"}]))
db.insert("soll", topic=topic, punkt="These ohne Beleg", belege=db.j([]))
await korpus.soll_konsens({"run_id": run_id, "topic": topic, "runde": 1, await korpus.soll_konsens({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:konsens", "payload": "{}"}) "knoten": "soll_konsens", "item": "r1:konsens",
wiki = db.one("SELECT status FROM soll WHERE punkt LIKE 'Overflow%'") "payload": "{}"})
blog = db.one("SELECT status FROM soll WHERE punkt LIKE 'Nur Blog%'") mit = db.one("SELECT status FROM soll WHERE punkt LIKE 'Blog-These%'")
assert wiki["status"] == "bestaetigt" # kuratierte Quelle: 1 Beleg reicht ohne = db.one("SELECT status FROM soll WHERE punkt LIKE 'These ohne%'")
assert blog["status"] == "kandidat" # Blog allein bestätigt nicht assert mit["status"] == "bestaetigt" # 1 wörtlicher Beleg reicht
assert ohne["status"] == "kandidat" # ohne Beleg keine Bestätigung

52
tests/test_lektorat.py Normal file
View File

@@ -0,0 +1,52 @@
"""Lektorat: EIN Blick über den ganzen Guide, Befunde in die Fix-Maschine."""
from backend import db, guide
from .conftest import topic_anlegen
def _guide_anlegen(topic):
kid = db.insert("kapitel", topic=topic, titel="K1", ord=0)
bids = []
for i in range(2):
zid = db.insert("lernziele", topic=topic, text=f"z{i}")
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel=f"B{i}",
kapitel_id=kid, ord=i)
db.insert("sections", baustein_id=bid, text=f"Text der Section {i}.")
bids.append(bid)
return bids
async def test_lektorat_meldet_befund(monkeypatch):
from backend import llm as llm_mod
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
bids = _guide_anlegen(topic)
tid = db.insert("tasks", run_id=run_id, topic=topic, knoten="lektorat",
item="r1:lektorat", status="laufend")
async def antwort(**kw):
assert f"[SECTION {bids[1]}]" in kw["werte"]["guide"]
return (f"===BEFUND===\nSECTION: {bids[1]}\nART: redundanz\n"
f"DETAIL: Fakt doppelt erklärt, kürzen.")
monkeypatch.setattr(llm_mod, "call", antwort)
erg = await guide.lektorat({"id": tid, "run_id": run_id, "topic": topic,
"knoten": "lektorat", "runde": 1,
"item": "r1:lektorat", "payload": "{}"})
assert erg.daten["befunde"] == 1
b = db.one("SELECT * FROM befunde WHERE run_id=? AND knoten='lektorat'",
run_id)
assert b["art"] == "redundanz" and b["item"] == f"baustein:{bids[1]}"
async def test_lektorat_laeuft_nur_einmal(monkeypatch):
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
_guide_anlegen(topic)
db.insert("tasks", run_id=run_id, topic=topic, knoten="lektorat",
item="r1:lektorat", status="fertig")
t2 = db.insert("tasks", run_id=run_id, topic=topic, knoten="lektorat",
item="r2:lektorat", status="laufend")
erg = await guide.lektorat({"id": t2, "run_id": run_id, "topic": topic,
"knoten": "lektorat", "runde": 2,
"item": "r2:lektorat", "payload": "{}"})
assert erg.daten.get("skip")

View File

@@ -59,7 +59,7 @@ async def test_cap_kein_retry(echt_modus, monkeypatch):
async def cap(*a, **k): async def cap(*a, **k):
zaehler["n"] += 1 zaehler["n"] += 1
return llm.ApiErgebnis(1, err="leere Antwort (stop=max_tokens)", return llm.ApiErgebnis(1, err="leere Antwort (stop=max_tokens)",
tokens={"input": 10, "output": 5}) cap=True, tokens={"input": 10, "output": 5})
monkeypatch.setattr(llm, "_api", cap) monkeypatch.setattr(llm, "_api", cap)
assert await _call(run_id) is None assert await _call(run_id) is None
assert zaehler["n"] == 1 # deterministisch — kein Neuversuch assert zaehler["n"] == 1 # deterministisch — kein Neuversuch

View File

@@ -32,3 +32,37 @@ def test_ziele_dedup_verschont_verschiedene():
assert struktur._ziele_dedup(topic) == 0 assert struktur._ziele_dedup(topic) == 0
assert db.one("SELECT COUNT(*) c FROM lernziele WHERE topic=?", assert db.one("SELECT COUNT(*) c FROM lernziele WHERE topic=?",
topic)["c"] == 2 topic)["c"] == 2
def test_ziele_dedup_facetten_paraphrase():
"""Audit: Ganz-Satz-Jaccard kam nie über 0.36 — der Inhaltswort-Kern
(Floskeln raus) merged Paraphrasen desselben Soll-Punkts."""
topic = topic_anlegen()
s = db.insert("soll", topic=topic, punkt="P", status="bestaetigt")
db.insert("lernziele", topic=topic, titel="Wirkung verstehen", soll_id=s,
text="Der Lernende kann die Wirkung der Technik verstehen.")
db.insert("lernziele", topic=topic, titel="Wirkung einordnen", soll_id=s,
text="Der Lernende kann Wirkung und Grenzen der Technik einordnen.")
assert struktur._ziele_dedup(topic) == 1
def test_ziele_dedup_nur_gleicher_soll():
topic = topic_anlegen()
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt")
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt")
db.insert("lernziele", topic=topic, titel="Wirkung verstehen", soll_id=s1,
text="Der Lernende kann die Wirkung der Technik verstehen.")
db.insert("lernziele", topic=topic, titel="Wirkung einordnen", soll_id=s2,
text="Der Lernende kann Wirkung und Grenzen der Technik einordnen.")
assert struktur._ziele_dedup(topic) == 0 # andere Soll-Punkte → kein Merge
def test_baustein_min_wird_erzwungen():
"""Audit: 10 Atome → 8 „Teil"-Gruppen mit 1-2 Atomen. MIN-Merge macht
daraus Gruppen im Band, Quellreihenfolge bleibt."""
from backend import config
gruppen = [[{"id": i}] for i in range(8)]
aus = struktur._min_erzwingen(gruppen)
assert all(len(g) >= config.BAUSTEIN_MIN_ATOME for g in aus)
assert all(len(g) <= config.BAUSTEIN_MAX_ATOME for g in aus)
assert [a["id"] for g in aus for a in g] == list(range(8))

View File

@@ -64,32 +64,20 @@ async def test_suche_dedupliziert_urls():
assert erg2.daten["neu"] == 0 # gleiche URL → kein zweiter Laden-Task assert erg2.daten["neu"] == 0 # gleiche URL → kein zweiter Laden-Task
async def test_relevanz_filter_wirft_listen_raus(monkeypatch): async def test_inhalts_urteil_sortiert_nach_dem_laden_aus():
"""Läuft OHNE Fake-Kurzschluss — genau der Pfad, der im Echtlauf brach.""" """Kein Titel-Filter mehr — das Urteil fällt auf dem geladenen Inhalt
monkeypatch.setattr(config, "FAKE", False) (Lektion 102). Fake-Handler: Titel „Werbeliste" → nein."""
from backend import llm as llm_mod
async def urteil(**kw):
assert "quellen-relevanz" in kw["skill_namen"]
assert "Pasta" in kw["werte"]["treffer"] # Wiki läuft MIT durchs Urteil
return "===RELEVANT===\nIDS: 0,1,3"
monkeypatch.setattr(llm_mod, "call", urteil)
treffer = [
{"titel": "Pomodoro-Technik Wikipedia",
"url": "https://de.wikipedia.org/wiki/X", "backend": "wikipedia_de"},
{"titel": "Guter Artikel", "url": "https://a.de/1", "backend": "ddgs"},
{"titel": "Pasta", "url": "https://en.wikipedia.org/wiki/Pasta",
"backend": "wikipedia_en"},
{"titel": "Anleitung", "url": "https://a.de/3", "backend": "ddgs"},
]
topic = topic_anlegen() topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running") run_id = db.insert("runs", topic=topic, status="running")
task = {"run_id": run_id, "topic": topic, "item": "t", "runde": 1} qid = db.insert("quellen", topic=topic, titel="Werbeliste Tools 2026",
ergebnis = await suche._relevanz_filter(task, "Thema", treffer) url="https://beispiel.de/grundlagen",
urls = [t["url"] for t in ergebnis] url_norm="w1", backend="ddgs", zweck="korpus")
assert "https://de.wikipedia.org/wiki/X" in urls erg = await laden.laden({"run_id": run_id, "topic": topic, "runde": 1,
assert "https://a.de/1" in urls and "https://a.de/3" in urls "knoten": "laden", "payload": db.j({"quelle_id": qid})})
assert "https://en.wikipedia.org/wiki/Pasta" not in urls # Off-Topic-Wiki raus assert erg.daten.get("aussortiert")
q = db.one("SELECT status, grund FROM quellen WHERE id=?", qid)
assert q["status"] == "aussortiert" and q["grund"]
assert erg.neue_tasks == [] # keine Soll-Extraktion für Aussortierte
async def test_wikipedia_seed_umgeht_deckel(monkeypatch): async def test_wikipedia_seed_umgeht_deckel(monkeypatch):
@@ -144,7 +132,7 @@ async def test_laden_snapshot_stabil():
qid = db.insert("quellen", topic=topic, url="https://beispiel.de/grundlagen", qid = db.insert("quellen", topic=topic, url="https://beispiel.de/grundlagen",
url_norm="https://beispiel.de/grundlagen", zweck="korpus") url_norm="https://beispiel.de/grundlagen", zweck="korpus")
t = {"run_id": run_id, "topic": topic, "runde": 1, t = {"run_id": run_id, "topic": topic, "runde": 1,
"payload": db.j({"quelle_id": qid})} "knoten": "laden", "payload": db.j({"quelle_id": qid})}
erg1 = await laden.laden({**t}) erg1 = await laden.laden({**t})
q = db.one("SELECT * FROM quellen WHERE id=?", qid) q = db.one("SELECT * FROM quellen WHERE id=?", qid)
assert q["status"] == "geladen" and q["snapshot"] assert q["status"] == "geladen" and q["snapshot"]
@@ -161,7 +149,30 @@ async def test_laden_leere_quelle_befund():
qid = db.insert("quellen", topic=topic, url="https://unbekannt.example/x", qid = db.insert("quellen", topic=topic, url="https://unbekannt.example/x",
url_norm="https://unbekannt.example/x") url_norm="https://unbekannt.example/x")
erg = await laden.laden({"run_id": run_id, "topic": topic, "runde": 1, erg = await laden.laden({"run_id": run_id, "topic": topic, "runde": 1,
"payload": db.j({"quelle_id": qid})}) "knoten": "laden", "payload": db.j({"quelle_id": qid})})
assert erg.neue_tasks == [] assert erg.neue_tasks == []
assert db.one("SELECT status FROM quellen WHERE id=?", qid)["status"] == "leer" assert db.one("SELECT status FROM quellen WHERE id=?", qid)["status"] == "leer"
assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='leer'", run_id) assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='leer'", run_id)
async def test_seed_loest_wikipedia_titel_auf(monkeypatch):
"""Audit: Seed-Titel „AlpineJS" existiert nicht — Artikel heißt
„Alpine.js". Erst Wiki-Suche, direkter URL-Bau nur als Fallback."""
monkeypatch.setattr(config, "FAKE", False)
async def wiki(q):
assert q == "AlpineJS"
return [{"titel": "Alpine.js", "backend": "wikipedia_de",
"url": "https://de.wikipedia.org/wiki/Alpine.js"},
{"titel": "Nebentreffer", "backend": "wikipedia_de",
"url": "https://de.wikipedia.org/wiki/Nebentreffer"}]
monkeypatch.setattr(suche, "_wikipedia", wiki)
topic = topic_anlegen(name="seedaufl", titel="AlpineJS")
run_id = db.insert("runs", topic=topic, status="running")
await suche.suchen({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:wikiseed", "payload": db.j({"seed": True})})
urls = [q["url"] for q in db.query("SELECT url FROM quellen WHERE topic=?",
topic)]
assert "https://de.wikipedia.org/wiki/Alpine.js" in urls # 1. Treffer zählt
assert any("en.wikipedia.org/wiki/AlpineJS" in u for u in urls) # Fallback

View File

@@ -21,6 +21,7 @@ def _welt(topic, n_vorteile=3):
def _task(run_id, topic, sid): def _task(run_id, topic, sid):
return {"run_id": run_id, "topic": topic, "runde": 1, return {"run_id": run_id, "topic": topic, "runde": 1,
"knoten": "verdichtung",
"item": f"r1:verd:soll:{sid}", "payload": db.j({"soll_id": sid})} "item": f"r1:verd:soll:{sid}", "payload": db.j({"soll_id": sid})}
@@ -90,6 +91,7 @@ async def test_facetten_check_erzeugt_fenster_task(tmp_path):
soll_id=sid) soll_id=sid)
db.insert("anker", atom_id=aid, quelle_id=qid, start=0, ende=20, zitat="z") db.insert("anker", atom_id=aid, quelle_id=qid, start=0, ende=20, zitat="z")
erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1, erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"knoten": "gate_inventar",
"item": "runde:1", "payload": "{}"}) "item": "runde:1", "payload": "{}"})
fac = [t for t in erg.neue_tasks if t["knoten"] == "luecke" fac = [t for t in erg.neue_tasks if t["knoten"] == "luecke"
and t["payload"].get("fenster")] and t["payload"].get("fenster")]
@@ -102,6 +104,7 @@ async def test_facetten_check_erzeugt_fenster_task(tmp_path):
db.insert("tasks", run_id=run_id, topic=topic, knoten="luecke", db.insert("tasks", run_id=run_id, topic=topic, knoten="luecke",
item=f"r{r}:{key}", status="fertig") item=f"r{r}:{key}", status="fertig")
erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2, erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2,
"knoten": "gate_inventar",
"item": "runde:2", "payload": "{}"}) "item": "runde:2", "payload": "{}"})
assert not any(b["art"] == "facette" for b in erg2.befunde) assert not any(b["art"] == "facette" for b in erg2.befunde)
assert db.one("SELECT status FROM befunde WHERE art='facette'")[ assert db.one("SELECT status FROM befunde WHERE art='facette'")[
@@ -112,11 +115,13 @@ async def test_gate_erzeugt_verdichtungs_task():
topic = topic_anlegen() topic = topic_anlegen()
run_id, sid, ids = _welt(topic) run_id, sid, ids = _welt(topic)
erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1, erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"knoten": "gate_inventar",
"item": "runde:1", "payload": "{}"}) "item": "runde:1", "payload": "{}"})
verd = [t for t in erg.neue_tasks if t["knoten"] == "verdichtung"] verd = [t for t in erg.neue_tasks if t["knoten"] == "verdichtung"]
assert verd and verd[0]["payload"]["soll_id"] == sid assert verd and verd[0]["payload"]["soll_id"] == sid
# nach Verdichtung: kein neuer Task mehr # nach Verdichtung: kein neuer Task mehr
db.update("soll", "id=?", (sid,), verdichtet=1) db.update("soll", "id=?", (sid,), verdichtet=1)
erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2, erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2,
"knoten": "gate_inventar",
"item": "runde:2", "payload": "{}"}) "item": "runde:2", "payload": "{}"})
assert not [t for t in erg2.neue_tasks if t["knoten"] == "verdichtung"] assert not [t for t in erg2.neue_tasks if t["knoten"] == "verdichtung"]