diff --git a/LEKTIONEN.md b/LEKTIONEN.md index 2576de0..6c1216b 100644 --- a/LEKTIONEN.md +++ b/LEKTIONEN.md @@ -85,3 +85,37 @@ Lektion 50 (API statt CLI) ist die Gründungsentscheidung von creator3. parse-Befund statt stiller „verwandt"-Kante. Stille None-Pfade (kapitel_intro, lernziele, bausteine, anker_fix, luecke) sind jetzt 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. diff --git a/backend/arten.py b/backend/arten.py new file mode 100644 index 0000000..5d7a39c --- /dev/null +++ b/backend/arten.py @@ -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") diff --git a/backend/config.py b/backend/config.py index d01fdd7..1c5fedf 100644 --- a/backend/config.py +++ b/backend/config.py @@ -30,16 +30,22 @@ KORPUS_DIR = ROOT / "storage" / "korpus" SKILLS_DIR = ROOT / "skills" PIPELINE_YAML = ROOT / "pipeline.yaml" -TOPIC_NAME_RE = re.compile(r"^[a-zA-Z0-9äöüÄÖÜß][a-zA-Z0-9äöüÄÖÜß _-]{0,63}$") +TOPIC_NAME_RE = re.compile(r"^[a-zA-Z0-9äöüÄÖÜß][a-zA-Z0-9äöüÄÖÜß ._-]{0,63}$") -# ── LLM / MiniMax (R3/R9) — alles M3; M2.x kann Thinking nicht abschalten ── -LLM_ENDPOINT = "https://api.minimax.io/anthropic/v1/messages" +# ── LLM (R3/R9) — Anbieter/Modell per env überschreibbar, nicht im Code ── +LLM_ENDPOINT = os.getenv("LLM_ENDPOINT", + "https://api.minimax.io/anthropic/v1/messages") +LLM_MODEL = os.getenv("LLM_MODEL", "MiniMax-M3") ROLLEN = { # thinking IMMER explizit ("enabled" gibt 400; Default widersprüchlich dokumentiert) - "extraktion": dict(model="MiniMax-M3", thinking="disabled", temperature=0.2), - "judge": dict(model="MiniMax-M3", thinking="disabled", temperature=0.1), - "writer": dict(model="MiniMax-M3", thinking="adaptive", temperature=0.7), + "extraktion": dict(model=LLM_MODEL, thinking="disabled", temperature=0.2), + "judge": dict(model=LLM_MODEL, thinking="disabled", temperature=0.1), + "writer": dict(model=LLM_MODEL, thinking="adaptive", temperature=0.7), } +# Anbieter-spezifische Infra-Marker (MiniMax-Statuscodes) getrennt von generischen +INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out", + "connection", "network", "overloaded", "unavailable", + '"status_code":1002', '"status_code":1041', '"status_code":2045') LLM_MAX_TOKENS = 32_000 MAX_PARALLEL_LLM = int(os.getenv("MAX_PARALLEL_LLM", "12")) # M3: 200 RPM → 8-16 sicher TAKT_SEKUNDEN = float(os.getenv("TAKT_SEKUNDEN", "3")) # 1 Call-Start je Takt; 0 = Breiten-Drossel @@ -53,16 +59,8 @@ HEDGE_NACH_S = int(os.getenv("HEDGE_NACH_S", "90")) # Zwilling nach max(90, tim RUN_BUDGET_TOKENS = int(os.getenv("RUN_BUDGET_TOKENS", "10000000")) # DEFAULT AN; 0 = aus PREIS_IN, PREIS_OUT = 0.60, 2.40 # $/1M konservativ (R9), real messen -# Timeouts je Knoten: (basis_s, pro_item_s) — creator2-Lektion 14: nie fix; -# pro_item skaliert mit der Bündel-Größe (n in llm.call) -TIMEOUTS = { - "recherche_plan": (60, 20), "soll_extraktion": (180, 60), "soll_konsens": (240, 2), - "atom_extraktion": (240, 60), "anker_fix": (180, 4), "dedup": (120, 8), - "soll_zuordnung": (120, 3), "luecke": (180, 45), "lernziele": (120, 3), - "bausteine": (120, 3), "ordnung": (180, 3), "writer": (420, 0), - "kapitel_intro": (90, 0), "llm_pruefung": (240, 60), "fix": (300, 0), - "redundanz": (120, 8), "klaerung": (180, 0), -} +# Timeouts stehen als Knoten-Property in pipeline.yaml (graph.timeout_fuer); +# hier nur der Default für Knoten ohne Angabe TIMEOUT_DEFAULT = (180, 0) # ── Suche (R2) ── @@ -78,18 +76,25 @@ FETCH_CONNECT_S = 10 FETCH_READ_S = 30 DOMAIN_RATE_S = 1.0 SNAPSHOT_MIN_ZEICHEN = 500 +# Privates Lese-Tool, ~20 Seiten/Lauf mit Domain-Bremse — kein robots.txt-Check +# mehr (Lektion 101); Browser-UA, sonst sperren Bot-Filter trotzdem +BROWSER_UA = ("Mozilla/5.0 (X11; Linux x86_64; rv:128.0) " + "Gecko/20100101 Firefox/128.0") +URTEIL_AUSZUG_ZEICHEN = 3_000 # Inhalts-Urteil je geladener Quelle # ── Korpus ── RECHERCHE_LENSES = ("ueberblick", "tutorial", "referenz", "praxis") # generisch! -RECHERCHE_RUNDEN_MAX = int(os.getenv("RECHERCHE_RUNDEN_MAX", "4")) QUERIES_JE_LENS = 3 SOLL_CHUNK_CHARS = 40_000 -SOLL_MIN_BELEGE = 2 # ≥2 unabhängige Quellen je bestätigtem Punkt SOLL_PUNKTE_MIN = int(os.getenv("SOLL_PUNKTE_MIN", "5")) SOLL_BAND_FAKTOR = 1.0 # Ziel ≈ √Kandidaten (creator2-Band — Lektion 90) SOLL_BAND_TOLERANZ = 1.5 # mehr als Ziel×1.5 bestätigt → nochmal falten QUELLEN_MAX = int(os.getenv("QUELLEN_MAX", "24")) # harter Korpus-Deckel EXTRAKT_FENSTER = 4_000 # Atome nur ±Fenster um Soll-Belege (belegnah) +# Harte Vollständigkeits-Stops (Lektion 103): lieber PAUSE als Mini-Guide +SEED_PFLICHT = True # keine Startquelle nützlich geladen → PAUSE +QUELLEN_MIN = 5 # weniger geladene nützliche Quellen → PAUSE +BEIFANG_MAX_QUOTE = 0.5 # >50 % Atome als Beifang verworfen → PAUSE # ── Inventar ── ABSCHNITT_CHARS = 6_000 # 12k riss das 32k-Output-Cap (creator2 empirisch) @@ -127,22 +132,34 @@ REDUNDANZ_SHINGLE = 5 # gemeinsame 5-Wort-Folge = Kandidat REDUNDANZ_SATZ_MIN_TOKEN = 6 ZIEL_DEDUP_JACCARD = 0.5 # synonyme Lernziele deterministisch mergen ECHO_JACCARD = 0.7 # 1. Satz nach Marker ≈ Atom-Definition = Echo -SOLL_MIN_BELEGE_WIKI = 1 # kuratierte Quelle (Wikipedia) = 1 Beleg genügt +FAKTEN_SIGNATUR_MIN = 2 # geteilte Zahlen+Namen je Satzpaar = Kandidat # ── Engine (R8) ── POLL_SEKUNDEN = 2.0 MAX_VERSUCHE = 2 # Inhalts-Versuche je Task; Infra zählt separat STILLSTAND_N = 2 # identischer Befund-Fingerprint n× → PAUSE -GATE_RUNDEN_MAX = {"gate_korpus": 4, "gate_inventar": 8, "gate_struktur": 3, "gate_guide": 12} -# gate_inventar 6: Anker-Fix, 2× Dedup/Lücke, Web-Nachrecherche, Nachzügler-Dedup -# gate_guide 8: Echtlauf konvergierte 45→28→10 — der Stillstand-Fingerprint -# fängt echte Schleifen, das Runden-Limit ist nur die Notbremse dahinter +# Gate-Runden-Caps stehen als runden_max am Gate-Knoten in pipeline.yaml MAX_PARALLEL_GLOBAL = 24 - -def timeout_fuer(knoten: str, n: int = 0) -> float: - basis, pro = TIMEOUTS.get(knoten, TIMEOUT_DEFAULT) - return basis + pro * n +# ── Sprache: EIN Parameter, alles referenziert ihn (Lektion 106) ── +SPRACHE = os.getenv("SPRACHE", "de") +SPRACHEN_SUCHE = tuple(os.getenv("SPRACHEN_SUCHE", "de,en").split(",")) +STEMMER_NAME = {"de": "german", "en": "english", "fr": "french", + "es": "spanish", "it": "italian"} # sonst: kein Stemming +NEGATIONEN = {"de": {"nicht", "kein", "keine", "keinen", "keiner", "ohne", + "nie", "niemals"}, + "en": {"not", "no", "none", "never", "without"}} +NEGATIONS_PRAEFIX = {"de": "nicht", "en": "non"} +SUCHE_LOCALE = {"de": {"country": "DE", "search_lang": "de", "region": "de-de"}, + "en": {"country": "US", "search_lang": "en", "region": "us-en"}} +# Ausgabe-Literale, die im Guide landen — eine Stelle, pro Sprache tauschbar +TEXTE = { + "sammel_titel": "Grundlagen", + "sammel_punkt": "Weitere Grundlagen zum Thema", + "sammel_ziel": "Der Lernende kann Grundlagen zu '{punkt}' erklären.", + "teil": "{titel} (Teil {n})", + "abschnitt_verweis": "- {titel} (im Abschnitt '{abschnitt}')", +} def user_agent() -> str: diff --git a/backend/db.py b/backend/db.py index f391501..3fabe1d 100644 --- a/backend/db.py +++ b/backend/db.py @@ -2,6 +2,7 @@ architektonisch, R8), WAL, kurze Transaktionen. Zustand liegt NUR hier — Resume = Prozess neu starten, fertige Tasks überspringen.""" import json +import re import sqlite3 import threading from datetime import datetime, timezone @@ -14,6 +15,13 @@ _lock = threading.RLock() on_change: Callable[[str, dict], None] | None = None # ws.Hub.push _LIVE_TABELLEN = {"topics", "runs", "tasks", "gate_laeufe", "befunde"} +_RUN_TABELLEN = {"topics", "runs"} # ws-Bereich "run", Rest "graph" +# Topic-skopierte Pipeline-Tabellen — EINE Wahrheit für Reset (main.py) +PIPELINE_TABELLEN = ("tasks", "gate_laeufe", "quellen", "soll", "atome", + "kanten", "lernziele", "bausteine", "kapitel") +_TABELLE_RE = re.compile( + r"^\s*(?:UPDATE|DELETE\s+FROM|INSERT(?:\s+OR\s+\w+)?\s+INTO)\s+(\w+)", + re.IGNORECASE) SCHEMA = """ CREATE TABLE IF NOT EXISTS topics( @@ -50,8 +58,8 @@ CREATE TABLE IF NOT EXISTS quellen( id INTEGER PRIMARY KEY, topic TEXT, titel TEXT DEFAULT '', url TEXT DEFAULT '', url_norm TEXT DEFAULT '', backend TEXT DEFAULT '', snapshot TEXT DEFAULT '', roh TEXT DEFAULT '', hash TEXT DEFAULT '', runde INTEGER DEFAULT 0, - zweck TEXT DEFAULT 'korpus', status TEXT DEFAULT 'neu', grund TEXT DEFAULT '', - atome_stand TEXT DEFAULT '', UNIQUE(topic, url_norm)); + zweck TEXT DEFAULT 'korpus', soll_id INTEGER, status TEXT DEFAULT 'neu', + grund TEXT DEFAULT '', atome_stand TEXT DEFAULT '', UNIQUE(topic, url_norm)); CREATE TABLE IF NOT EXISTS soll( id INTEGER PRIMARY KEY, topic TEXT, punkt TEXT, status TEXT DEFAULT 'kandidat', belege TEXT DEFAULT '[]', kapitel_id INTEGER, geprueft TEXT DEFAULT '[]', @@ -109,7 +117,8 @@ def connect(pfad: str | None = None) -> None: for migration in ( # Mini-Migrationen für Bestands-DBs "ALTER TABLE atome ADD COLUMN soll_geprueft INTEGER DEFAULT 0", "ALTER TABLE topics ADD COLUMN quellen_max INTEGER", - "ALTER TABLE soll ADD COLUMN verdichtet INTEGER DEFAULT 0"): + "ALTER TABLE soll ADD COLUMN verdichtet INTEGER DEFAULT 0", + "ALTER TABLE quellen ADD COLUMN soll_id INTEGER"): try: _conn.execute(migration) except sqlite3.OperationalError: @@ -156,8 +165,8 @@ def execute(sql: str, *args) -> int: with _lock: cur = _conn.execute(sql, args) _conn.commit() - tabelle = sql.split()[2] if sql.lstrip().upper().startswith(("UPDATE",)) else "" - _notify(tabelle.lower(), {}) + m = _TABELLE_RE.match(sql) # UPDATE/DELETE/INSERT — nicht Positions-Raten + _notify(m.group(1).lower() if m else "", {}) return cur.rowcount diff --git a/backend/dedup.py b/backend/dedup.py index a8cf7fb..1bc6ed7 100644 --- a/backend/dedup.py +++ b/backend/dedup.py @@ -4,7 +4,7 @@ Merge NUR einstimmig; geprüfte Paare als 'verwandt'-Kante — nie zweimal Tokens.""" from rapidfuzz import fuzz -from . import config, db, engine, llm, panels, textkit +from . import config, db, engine, graph, llm, panels, textkit def _aktive(topic: str) -> list[dict]: @@ -74,11 +74,13 @@ def _kandidaten(topic: str) -> list[tuple[dict, dict, str]]: def _signatur(text: str) -> set: - """Zahlen + Eigennamen — dieselbe Studie in Paraphrase teilt sie - (a20≈a78: 'Biwer', '2023', '25', '35'), auch über Soll-Grenzen.""" + """Zahlen + großgeschriebene Wörter — dieselbe Studie/Zahl in Paraphrase + teilt sie (a20≈a78: 'Biwer', '2023'). Unicode-Groß statt [A-ZÄÖÜ]-Klasse + (Lektion 106); Schriften ohne Großschreibung liefern nur Zahlen.""" import re zahlen = set(re.findall(r"\b\d{2,4}\b", text)) - namen = {w for w in re.findall(r"\b[A-ZÄÖÜ][a-zäöü]{3,}\b", text)} + namen = {w for w in re.findall(r"\w{4,}", text, re.UNICODE) + if w[0].isupper() and not w[0].isdigit()} return zahlen | namen @@ -106,16 +108,16 @@ async def _chunk_urteilen(task: dict, chunk: list, tag: str) -> tuple[int, list] in ALLEN gültigen Stimmen vorkommen; fehlende → Rückgabe zum Nachprüfen (nie still als „keine Dublette" werten).""" topic = task["topic"] - liste = "\n\n".join( - f"PAAR {j + 1}:\nA[{a['id']}] {a['titel']}: {a['definition'][:200]}\n" - f"B[{b['id']}] {b['titel']}: {b['definition'][:200]}" - for j, (a, b, _) in enumerate(chunk)) + liste = textkit.paar_liste( + [(f"[{a['id']}] {a['titel']}: {a['definition'][:200]}", + f"[{b['id']}] {b['titel']}: {b['definition'][:200]}") + for a, b, _ in chunk]) async def ruf(p): text = await llm.call( run_id=task["run_id"], stufe="inventar", knoten="dedup", item=f"{task['item']}:{tag}:p{p}", role="judge", n=len(chunk), - skill_namen=["richter", "deutsch-praezise", "dedup-urteil"], + skill_namen=graph.skills_von(task["knoten"]), werte={"paare": liste}) if text is None: return None diff --git a/backend/engine.py b/backend/engine.py index 608c9ee..75c9f02 100644 --- a/backend/engine.py +++ b/backend/engine.py @@ -36,9 +36,10 @@ class Ergebnis: def module_laden() -> None: - """Worker-Module importieren → Registrierung; danach Graph validieren.""" - for mod in ("suche", "laden", "korpus", "inventar", "dedup", - "struktur", "guide", "redundanz", "montage"): + """Worker-Module aus dem Graph ableiten (eine Wahrheit: pipeline.yaml), + importieren → Registrierung; danach voll validieren.""" + g = graph.laden(None) + for mod in sorted({k.worker.split(".")[0] for k in g.knoten.values()}): importlib.import_module(f"backend.{mod}") graph.laden(set(WORKER)) @@ -218,7 +219,7 @@ def _gate_pruefen(g: graph.Graph, topic: str, run_id: int, stufe_i: int) -> None return runde = (db.one("SELECT COUNT(*) c FROM gate_laeufe WHERE topic=? AND knoten=?", topic, gate.id) or {"c": 0})["c"] + 1 - if runde > config.GATE_RUNDEN_MAX.get(gate.id, 3): + if runde > gate.runden_max: _pausieren(run_id, f"gate_cap:{gate.id}:{runde}") return db.insert("tasks", ignore=True, run_id=run_id, topic=topic, knoten=gate.id, @@ -340,21 +341,27 @@ def _pausieren(run_id: int, grund: str, status: str = "paused") -> None: def _abschliessen(g: graph.Graph, topic: str, run_id: int) -> None: - """Kein Task mehr offen: done, wenn alle Gates grün + Montage fertig.""" - fertig = db.one("SELECT id FROM tasks WHERE topic=? AND knoten='montage' " - "AND status='fertig'", topic) + """Kein Task mehr offen: done, wenn die letzte Stufe fertig ist.""" + terminal = [k.id for k in g.knoten.values() if k.stufe == g.stufen[-1]] + qs = ",".join("?" * len(terminal)) + fertig = db.one(f"SELECT id FROM tasks WHERE topic=? AND knoten IN ({qs}) " + "AND status='fertig'", topic, *terminal) offen_fehler = db.one("SELECT id FROM tasks WHERE topic=? AND status='fehler' " "LIMIT 1", topic) if fertig and not offen_fehler: db.update("runs", "id=?", (run_id,), status="done", beendet=db.now()) else: - grund = "tasks_mit_fehler" if offen_fehler else "leerlauf_ohne_montage" + grund = "tasks_mit_fehler" if offen_fehler else "leerlauf_ohne_abschluss" _pausieren(run_id, grund) def _start_tasks(topic: str, run_id: int) -> None: - """Initiale Tasks der Stufe 1 (idempotent — Resume ist ein No-Op).""" - for lens in config.RECHERCHE_LENSES: - db.insert("tasks", ignore=True, run_id=run_id, topic=topic, - knoten="recherche_plan", item=f"r1:lens:{lens}", runde=1, - payload=db.j({"lens": lens}), erzeugt_von="start") + """Initiale Tasks: Knoten der Stufe 1 ohne Normal-Vorgänger (aus dem + Graph abgeleitet, keine Knoten-Namen im Code). Idempotent.""" + g = graph.get() + ziele = {k["nach"] for k in g.kanten if k["art"] == "normal"} + for kn in g.knoten.values(): + if kn.stufe == g.stufen[0] and kn.id not in ziele: + db.insert("tasks", ignore=True, run_id=run_id, topic=topic, + knoten=kn.id, item="r1:start", runde=1, + payload="{}", erzeugt_von="start") diff --git a/backend/fakes.py b/backend/fakes.py index 8a2739e..7e4823c 100644 --- a/backend/fakes.py +++ b/backend/fakes.py @@ -106,7 +106,7 @@ def _nummerierte_abschnitte(p: str) -> dict[int, str]: def _recherche_plan(p: str) -> str: runde = re.search(r"\(Runde (\d+)\)", p) r = runde.group(1) if runde else "1" - m = re.search(r"BLICKWINKEL:\n(.*?)\n\nBereits", p, re.DOTALL) + m = re.search(r"BLICKWINKEL:\n(.*?)\n\nBISHERIGE", p, re.DOTALL) lenses = re.findall(r"^- (\w+)$", m.group(1) if m else "", re.MULTILINE) return "\n".join(f"===QUERY===\nLENS: {l}\nTEXT: thema {l} r{r} nr{i}" for l in (lenses or ["x"]) for i in (1, 2)) @@ -150,13 +150,13 @@ def _atom_extraktion(p: str) -> str: def _anker_fix(p: str) -> str: - return "\n".join(f"===FIX===\nID: {i}\nZITAT: VERWERFEN" + return "\n".join(f"===FIX===\nID: {i}\nZITAT: KEINER" for i, _ in _ids_liste(_abschnitt(p, "ATOME"))) def _dedup_urteil(p: str) -> str: out = [] - for m in re.finditer(r"PAAR (\d+):\nA\[\d+\] ([^:]+):.*?\nB\[\d+\] ([^:]+):", + for m in re.finditer(r"PAAR (\d+):\nA: \[\d+\] ([^:]+):.*?\nB: \[\d+\] ([^:]+):", p, re.DOTALL): nr, ta, tb = m.group(1), m.group(2).strip(), m.group(3).strip() paar = frozenset({ta, tb}) @@ -189,7 +189,7 @@ def _luecke_nachextraktion(p: str) -> str: out.append(f"===ATOM===\nSOLL: {sid}\nTITEL: {t}\nTYP: {typ}\n" f"DEFINITION: {d}\nZITAT: {s}") else: - out.append(f"===NICHTS===\nSOLL: {sid}") + out.append(f"===LEER===\nSOLL: {sid}") return "\n".join(out) @@ -217,7 +217,7 @@ def _lernziele(p: str) -> str: def _bausteine(p: str) -> str: - ids = [str(i) for i, _ in _ids_liste(_abschnitt(p, "ATOME (in Quellreihenfolge)"))] + ids = [str(i) for i, _ in _ids_liste(_abschnitt(p, "ATOME"))] return f"===BAUSTEIN===\nTITEL: Baustein\nATOME: {','.join(ids)}" @@ -234,7 +234,7 @@ def _themen_schnitt(p: str) -> str: def _ordnung(p: str) -> str: - prior = re.search(r"übernehmen\): ([\d,]+)", p) + prior = re.search(r"^PRIOR: ([\d,]+)$", p, re.MULTILINE) return f"===ORDNUNG===\nIDS: {prior.group(1) if prior else ''}" @@ -251,15 +251,15 @@ def _writer(p: str) -> str: def _kapitel_intro(p: str) -> str: - kap = re.search(r"Kapitel „(.+?)\"", p) + kap = re.search(r"^KAPITEL: (.+)$", p, re.MULTILINE) return (f"===INTRO===\nDieses Kapitel behandelt {kap.group(1) if kap else 'X'}. " "Es baut auf dem bisher Gelernten auf.") def _guide_pruefung(p: str) -> str: ids = re.findall(r"^=== SECTION (\d+) ===$", p, re.MULTILINE) - return "\n".join(f"===OK===\nSECTION: {i}" - for i in dict.fromkeys(ids)) or "===OK===\nSECTION: 0" + return "\n".join(f"===LEER===\nSECTION: {i}" + for i in dict.fromkeys(ids)) or "===LEER===\nSECTION: 0" def _guide_fix(p: str) -> str: @@ -273,7 +273,7 @@ def _guide_fix(p: str) -> str: def _redundanz_urteil(p: str) -> str: out = [] - for m in re.finditer(r"PAAR (\d+):\nKapitel A: (.*)\nKapitel B: (.*)", p): + for m in re.finditer(r"PAAR (\d+):\nA: (.*)\nB: (.*)", p): redundant = "Fehler lassen sich nie ganz vermeiden" in m.group(2) out.append(f"===URTEIL===\nPAAR: {m.group(1)}\n" f"URTEIL: {'redundant' if redundant else 'didaktisch_gewollt'}") @@ -284,9 +284,10 @@ _HANDLER = { "recherche-plan": _recherche_plan, "soll-extraktion": _soll_extraktion, "soll-konsens": _soll_konsens, - "soll-beleg": lambda p: "===BELEG===\nID: 0\nBELEG: KEIN BELEG", - "quellen-relevanz": lambda p: "===RELEVANT===\nIDS: " + ",".join( - m for m in re.findall(r"^\[(\d+)\]", p, re.MULTILINE)), + "quellen-urteil": lambda p: ("===URTEIL===\nNUETZLICH: nein\nGRUND: Werbung" + if "Werbeliste" in p else + "===URTEIL===\nNUETZLICH: ja\nGRUND: lehrt"), + "lektorat": lambda p: "===LEER===\nOK: ja", "atom-extraktion": _atom_extraktion, "anker-fix": _anker_fix, "dedup-urteil": _dedup_urteil, diff --git a/backend/graph.py b/backend/graph.py index 19f7e03..2c9e80b 100644 --- a/backend/graph.py +++ b/backend/graph.py @@ -23,7 +23,10 @@ class Knoten: titel: str = "" beschreibung: str = "" skills: list = field(default_factory=list) + extra_skills: dict = field(default_factory=dict) # name → Skill-Liste klasse: str = "lang" # lang | mittel | kurz → Bündel-Größe je Call + timeout: list | None = None # [basis_s, pro_item_s] + runden_max: int = 3 # nur Gates: Runden-Cap max_parallel: int = 4 max_versuche: int = config.MAX_VERSUCHE barriere: bool = False @@ -139,7 +142,8 @@ def laden(worker_namen: set[str] | None = None, pfad=None) -> Graph: for kn in knoten.values(): if kn.typ == "llm" and not kn.skills: raise GraphFehler(f"{kn.id}: llm-Knoten ohne Skills") - for s in kn.skills: + for s in kn.skills + [s for liste in kn.extra_skills.values() + for s in liste]: skills.lade(s) # wirft SkillFehler, wenn Datei fehlt/kaputt if worker_namen is not None and kn.worker not in worker_namen: raise GraphFehler(f"{kn.id}: Worker '{kn.worker}' nicht registriert") @@ -152,3 +156,21 @@ def get() -> Graph: if _graph is None: raise GraphFehler("Graph nicht geladen") return _graph + + +def skills_von(knoten_id: str, extra: str | None = None) -> list: + """EINE Wahrheit für Skill-Kompositionen: pipeline.yaml. Worker fragen + hier nach, statt Listen zu hardcoden (Drift bricht sonst erst zur + Laufzeit).""" + kn = get().knoten[knoten_id] + if extra is None: + return kn.skills + if extra not in kn.extra_skills: + raise GraphFehler(f"{knoten_id}: extra_skills '{extra}' nicht deklariert") + return kn.extra_skills[extra] + + +def timeout_fuer(knoten_id: str, n: int = 0) -> float: + kn = _graph.knoten.get(knoten_id) if _graph else None + basis, pro = (kn.timeout if kn and kn.timeout else config.TIMEOUT_DEFAULT) + return basis + pro * n diff --git a/backend/guide.py b/backend/guide.py index 1c5a64f..0e7b446 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -4,7 +4,7 @@ import hashlib import re -from . import belege, config, db, engine, llm, panels, textkit +from . import arten, belege, config, db, engine, graph, llm, panels, textkit _MARKER_RE = re.compile(r"") @@ -25,7 +25,7 @@ def _band(n_atome: int) -> tuple[int, int]: def _slug(titel: str) -> str: s = textkit.norm(titel).replace(" ", "-") - return re.sub(r"[^a-z0-9äöüß-]", "", s) + return "".join(c for c in s if c.isalnum() or c == "-") # Unicode-fähig def _bekannt_kontext(topic: str, baustein: dict) -> str: @@ -39,8 +39,10 @@ def _bekannt_kontext(topic: str, baustein: dict) -> str: baustein["id"], topic) if not fruehere: return "-" - return "\n".join(f"- {f['titel']} (im Abschnitt '{f['bt']}')" - for f in fruehere[:60]) + return "\n".join( + config.TEXTE["abschnitt_verweis"].format(titel=f["titel"], + abschnitt=f["bt"]) + for f in fruehere[:60]) @engine.worker("guide.writer") @@ -64,7 +66,7 @@ async def writer(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="guide", knoten="writer", item=task["item"], role="writer", - skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "writer"], + skill_namen=graph.skills_von(task["knoten"]), werte={"baustein": b["titel"], "atome": atome_text, "belege": belege.fenster_fuer_atome([a["id"] for a in atome])[:40000], "bekannt": _bekannt_kontext(task["topic"], b), @@ -167,10 +169,8 @@ async def det_pruefung(task: dict) -> engine.Ergebnis: frisch = {(b["art"], b["item"], b["detail"][:400]) for b in befunde} for row in db.query( "SELECT id, art, item, detail FROM befunde WHERE item=? AND " - "status='offen' AND art IN ('marker_fehlend','marker_fremd'," - "'marker_doppelt','vorwaerts','laenge','format_leak'," - "'link_im_text','atom_echo','h_ebene')", - f"baustein:{bid}"): + f"status='offen' AND art IN ({','.join('?' * len(arten.DET_ARTEN))})", + f"baustein:{bid}", *arten.DET_ARTEN): if (row["art"], row["item"], row["detail"][:400]) not in frisch: db.update("befunde", "id=?", (row["id"],), status="veraltet") for b in befunde: @@ -210,14 +210,14 @@ async def llm_pruefung(task: dict) -> engine.Ergebnis: text = await llm.call( run_id=task["run_id"], stufe="guide", knoten="llm_pruefung", item=f"{task['item']}:p{i}", role="judge", n=len(aktive), - skill_namen=["richter", "deutsch-praezise", "guide-pruefung"], + skill_namen=graph.skills_von(task["knoten"]), werte={"sections": sections_text}) if text is None: return None return {"befunde": [b for b in textkit.bloecke(text, "BEFUND") if b.get("art") in ("falsch", "luecke", "stil") and b.get("section", "").strip().isdigit()], - "ok": {int(b["section"]) for b in textkit.bloecke(text, "OK") + "ok": {int(b["section"]) for b in textkit.bloecke(text, "LEER") if b.get("section", "").strip().isdigit()}} stimmen = await panels.panel(ruf, config.PRUEF_PANEL) @@ -277,7 +277,7 @@ async def fix(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="guide", knoten="fix", item=task["item"], role="writer", - skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "guide-fix"], + skill_namen=graph.skills_von(task["knoten"]), werte={"section": sec["text"], "befunde": "\n".join(f"- [{b['art']}] {b['detail']}" for b in offen), "belege": belege.fenster_fuer_atome( @@ -308,7 +308,7 @@ async def _klaerung(task, bid, sec, offen) -> engine.Ergebnis: text = await llm.call( run_id=task["run_id"], stufe="guide", knoten="fix", item=f"{task['item']}:k{b['id']}:p{i}", role="judge", - skill_namen=["richter", "deutsch-praezise", "klaerung"], + skill_namen=graph.skills_von(task["knoten"], extra="klaerung"), werte={"befund": f"[{b['art']}] {b['detail']}", "belege": beleg_text}) urteile = textkit.bloecke(text or "", "URTEIL") return urteile[0].get("urteil") if urteile else None @@ -339,7 +339,7 @@ async def kapitel_intro(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="guide", knoten="kapitel_intro", item=task["item"], role="writer", - skill_namen=["autor", "deutsch-praezise", "kapitel-intro"], + skill_namen=graph.skills_von(task["knoten"]), werte={"kapitel": kap["titel"], "sections": "\n".join(f"- {t['titel']}" for t in titel), "vorherige": "\n".join(f"- {v['titel']}" for v in vorherige) @@ -352,6 +352,46 @@ async def kapitel_intro(task: dict) -> engine.Ergebnis: {"knoten": "redundanz", "item": f"r{task['runde']}:redundanz"}]) +@engine.worker("guide.lektorat") +async def lektorat(task: dict) -> engine.Ergebnis: + """Gesamt-Wächter (Kurzbuch-Ziel): EIN Blick über den ganzen Guide — + section-übergreifende Dopplungen, Splitter-Sections, zähe Übergänge. + Läuft einmal je Topic; Befunde laufen durch die normale Fix-Maschine.""" + topic = task["topic"] + if db.one("SELECT id FROM tasks WHERE topic=? AND knoten='lektorat' AND " + "status='fertig' AND id!=?", topic, task["id"]): + return engine.Ergebnis(daten={"skip": True}) + teile = [] + for kap in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", + topic): + teile.append(f"## {kap['titel']}") + for b in db.query( + "SELECT b.id, b.titel, s.text FROM bausteine b JOIN sections s " + "ON s.baustein_id=b.id WHERE b.kapitel_id=? AND s.text!='' " + "ORDER BY b.ord", kap["id"]): + teile.append(f"[SECTION {b['id']}] {b['titel']}\n{b['text']}") + if len(teile) < 2: + return engine.Ergebnis(daten={"skip": True}) + antwort = await llm.call( + run_id=task["run_id"], stufe="guide", knoten="lektorat", + item=task["item"], role="judge", + skill_namen=graph.skills_von(task["knoten"]), + werte={"guide": "\n\n".join(teile)[:120_000]}) + if antwort is None: + raise RuntimeError("lektorat ohne Ergebnis") + befunde_bloecke = textkit.bloecke(antwort, "BEFUND") + if not befunde_bloecke and not textkit.bloecke(antwort, "LEER"): + raise RuntimeError("lektorat: weder BEFUND noch LEER") # Format kaputt + n = 0 + for b in befunde_bloecke: + sid, art = b.get("section", "").strip(), b.get("art", "").strip() + if sid.isdigit() and art in ("redundanz", "stil") and befund_merken( + task["run_id"], "lektorat", art, f"baustein:{sid}", + b.get("detail", "")[:300], task["runde"]): + n += 1 + return engine.Ergebnis(daten={"befunde": n}) + + def befund_merken(run_id: int, knoten: str, art: str, item: str, detail: str, runde: int) -> bool: """Befund-Dedupe (creator2-Schlüssel art+item+detail): existiert derselbe @@ -392,7 +432,8 @@ async def gate(task: dict) -> engine.Ergebnis: continue alle_marker += [int(m) for m in _MARKER_RE.findall(sec["text"])] for f in _det_befunde(topic, b["id"], sec["text"]): - if f["art"] == "laenge": # Länge blockiert nicht hart (nur Befund) + if not arten.BEFUND_ARTEN.get(f["art"], {"gate_blockiert": True})[ + "gate_blockiert"]: # z. B. laenge: nur Kennzahl continue if (f["art"], f["item"], f["detail"][:400]) in final: continue @@ -402,7 +443,10 @@ async def gate(task: dict) -> engine.Ergebnis: "detail": "Atom in mehreren Sections gelehrt"}) # Kaputte Marker mit erschöpftem Fix → Section NEU schreiben (max 1×! # sonst resettet jedes Neuschreiben den Fix-Cap → Endlos-Karussell) - for f in [f for f in befunde if f["art"].startswith("marker")]: + # NUR Section-Befunde (item baustein:*) — marker_global_doppelt trägt + # item atom:, dessen Zahl ist KEINE Baustein-Id (latenter Bug) + for f in [f for f in befunde if f["art"].startswith("marker") + and f["item"].startswith("baustein:")]: bid = f["item"].split(":")[-1] sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid) if sec and (sec["fix_versuche"] or 0) >= config.FIX_MAX_VERSUCHE \ @@ -414,7 +458,7 @@ async def gate(task: dict) -> engine.Ergebnis: offene = db.query( "SELECT * FROM befunde b WHERE b.run_id IN (SELECT id FROM runs WHERE " "topic=?) AND b.stufe='guide' AND b.status='offen' AND b.art IN " - "('falsch','luecke','redundanz','vorwaerts','format_leak')", topic) + f"({','.join('?' * len(arten.FIX_ARTEN))})", topic, *arten.FIX_ARTEN) for b in offene: befunde.append({"art": b["art"], "item": b["item"], "detail": b["detail"][:120]}) diff --git a/backend/inventar.py b/backend/inventar.py index 0e1aad1..a7f5a1d 100644 --- a/backend/inventar.py +++ b/backend/inventar.py @@ -1,7 +1,7 @@ """Stufe 2: Atome extrahieren (2 Reader, Anker-Pflicht), Anker-Fix (deterministisch vor LLM), Soll-Zuordnung, Lücken schließen (Nachextraktion → Web-Nachrecherche → Freispruch-Panel). Gate zählt Coverage deterministisch.""" -from . import config, db, engine, laden, llm, panels, textkit +from . import config, db, engine, graph, laden, llm, panels, textkit def _quelle(qid: int) -> dict: @@ -50,7 +50,7 @@ async def atom_extraktion(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="inventar", knoten="atom_extraktion", item=task["item"], role="extraktion", n=len(aktive), - skill_namen=["extraktor", "deutsch-praezise", "atom-extraktion"], + skill_namen=graph.skills_von(task["knoten"]), werte={"thema": thema, "abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}" for i, (_, _, c) in aktive.items())}) @@ -124,7 +124,7 @@ async def anker_fix(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="inventar", knoten="anker_fix", item=f"{task['item']}:b{i}", role="extraktion", n=len(batch), - skill_namen=["extraktor", "deutsch-praezise", "anker-fix"], + skill_namen=graph.skills_von(task["knoten"]), werte={"atome": liste, "quelltext": text[:config.SOLL_CHUNK_CHARS]}) if antwort is None: # Call-Ausfall: neuer Versuch statt stilles Auslassen raise RuntimeError("anker_fix ohne Ergebnis") @@ -132,12 +132,12 @@ async def anker_fix(task: dict) -> engine.Ergebnis: textkit.bloecke(antwort, "FIX") if b.get("id", "").isdigit()} for z in batch: neu = antworten.get(z["atom_id"], "") - span = textkit.finde_zitat(text, neu) if neu and neu != "VERWERFEN" else None + span = textkit.finde_zitat(text, neu) if neu and neu != "KEINER" else None if span: db.update("anker", "id=?", (z["anker_id"],), start=span[0], ende=span[1], zitat=neu[:600]) llm_fix += 1 - elif neu == "VERWERFEN": + elif neu == "KEINER": db.update("atome", "id=?", (z["atom_id"],), status="verworfen") verworfen += 1 # Verankerte sofort aktivieren — Dedup/Zuordnung sehen sonst nichts @@ -162,7 +162,7 @@ async def soll_zuordnung(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="inventar", knoten="soll_zuordnung", item=task["item"], role="judge", n=len(atome), - skill_namen=["richter", "deutsch-praezise", "soll-zuordnung"], + skill_namen=graph.skills_von(task["knoten"]), werte={"atome": "\n".join(f"[{a['id']}] {a['titel']}: {a['definition'][:150]}" for a in atome), "soll": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)}) @@ -191,7 +191,7 @@ async def _freispruch(task: dict, soll: dict) -> bool: text = await llm.call( run_id=task["run_id"], stufe="inventar", knoten="luecke", item=f"{task['item']}:p{i}", role="judge", - skill_namen=["richter", "deutsch-praezise", "luecke-freispruch"], + skill_namen=graph.skills_von(task["knoten"], extra="freispruch"), werte={"soll_punkt": soll["punkt"], "auszuege": auszuege[:20000]}) urteile = textkit.bloecke(text or "", "URTEIL") return urteile[0].get("urteil", "") if urteile else None @@ -239,7 +239,7 @@ async def luecke(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="inventar", knoten="luecke", item=task["item"], role="extraktion", n=len(aktive), - skill_namen=["extraktor", "deutsch-praezise", "luecke-nachextraktion"], + skill_namen=graph.skills_von(task["knoten"]), werte={"luecken": "\n\n".join( f"=== LUECKE ===\nSOLL: {sid}\nPUNKT: {s['punkt']}\nFENSTER:\n{f}" for sid, (_, s, f) in aktive.items())}) @@ -250,7 +250,7 @@ async def luecke(task: dict) -> engine.Ergebnis: s = block.get("soll", "").strip() if s.isdigit(): je_soll.setdefault(int(s), []).append(block) - nichts = {int(b["soll"]) for b in textkit.bloecke(antwort, "NICHTS") + nichts = {int(b["soll"]) for b in textkit.bloecke(antwort, "LEER") if b.get("soll", "").strip().isdigit()} gesamt = 0 for sid, (m, soll, _) in aktive.items(): @@ -306,7 +306,7 @@ async def verdichtung(task: dict) -> engine.Ergebnis: text = await llm.call( run_id=task["run_id"], stufe="inventar", knoten="verdichtung", item=f"{task['item']}:p{i}", role="judge", n=len(atome), - skill_namen=["richter", "deutsch-praezise", "verdichtung"], + skill_namen=graph.skills_von(task["knoten"]), werte={"soll_punkt": soll["punkt"], "atome": liste, "ziel": config.VERDICHTUNG_ZIEL}) if text is None: @@ -450,7 +450,8 @@ async def gate(task: dict) -> engine.Ergebnis: sorted(textkit.tokens(zitat))[:3]) neue.append({"knoten": "suche", "item": f"luecke:{s['id']}:r{runde + 1}", "art": "ruecklauf", "runde": runde + 1, - "payload": {"query": query, "zweck": f"luecke:{s['id']}"}}) + "payload": {"query": query, "zweck": "luecke", + "soll_id": s["id"]}}) else: neue.append({"knoten": "luecke", "item": f"r{runde + 1}:frei:soll:{s['id']}", @@ -461,6 +462,16 @@ async def gate(task: dict) -> engine.Ergebnis: # misst sich am Soll; alles andere bläht nur (32-Bausteine-Lektion) db.execute("UPDATE atome SET status='verworfen' WHERE topic=? AND " "status='aktiv' AND soll_id IS NULL AND soll_geprueft=1", topic) + # Harter Vollständigkeits-Stop (Lektion 103): kippt über die Hälfte der + # extrahierten Atome als Beifang, ist das Soll zu eng → PAUSE statt + # löchriger Guide. + geprueft = db.one("SELECT COUNT(*) c FROM atome WHERE topic=? AND " + "soll_geprueft=1 AND merged_into IS NULL", topic)["c"] + beifang = db.one("SELECT COUNT(*) c FROM atome WHERE topic=? AND " + "status='verworfen' AND soll_id IS NULL AND " + "soll_geprueft=1 AND merged_into IS NULL", topic)["c"] + if geprueft and beifang / geprueft > config.BEIFANG_MAX_QUOTE: + raise llm.LaufPause(f"vollstaendigkeit:beifang {beifang}/{geprueft}") # (d2) Facetten HART: jede Beleg-Stelle braucht ein Atom in der Nähe # (Gutachten: Namensherkunft fehlte trotz 7 Belegen). Cap: 2 gezielte diff --git a/backend/korpus.py b/backend/korpus.py index a6770ed..2d35a82 100644 --- a/backend/korpus.py +++ b/backend/korpus.py @@ -3,7 +3,7 @@ mit Sättigungslogik (Nullrunde nur mit neuen Queries gültig, R11).""" import hashlib -from . import config, db, engine, laden, llm, textkit +from . import config, db, engine, graph, laden, llm, textkit def _thema(topic: str) -> str: @@ -11,25 +11,25 @@ def _thema(topic: str) -> str: return (t.get("titel") or t["name"]) if t else topic -@engine.worker("korpus.recherche_plan", buendel=True) +@engine.worker("korpus.recherche_plan") async def recherche_plan(task: dict) -> engine.Ergebnis: - mitglieder = [task] + task.get("_buendel", []) - lens_von = {m["id"]: db.uj(m["payload"], {}).get("lens", "") - for m in mitglieder} + """EIN Task je Runde plant alle Blickwinkel (das Lens-Auffächern gehört + hierher, nicht in die Engine).""" + lenses = config.RECHERCHE_LENSES seed = [] if task["runde"] == 1: - # Wikipedia-Seed: kuratierter Grundstock ist Pflicht, nicht Suchglück - # (Audit Run 7: wikipedia_de fehlte komplett) — einmalig, idempotent - seed = [{"knoten": "suche", "item": "r1:wikiseed", + # Startquellen-Seed: kuratierter Grundstock ist Pflicht, nicht + # Suchglück — einmalig, idempotent; läuft durchs Inhalts-Urteil + seed = [{"knoten": "suche", "item": "r1:seed", "payload": {"seed": True}}] bisherige = [db.uj(t["ergebnis"], {}).get("query", "") for t in db.query( "SELECT ergebnis FROM tasks WHERE topic=? AND knoten='suche'", task["topic"])] text = await llm.call( run_id=task["run_id"], stufe="korpus", knoten="recherche_plan", - item=task["item"], role="judge", n=len(mitglieder), - skill_namen=["planer", "deutsch-praezise", "recherche-plan"], + item=task["item"], role="judge", n=len(lenses), + skill_namen=graph.skills_von(task["knoten"]), werte={"thema": _thema(task["topic"]), - "lenses": "\n".join(f"- {l}" for l in lens_von.values()), + "lenses": "\n".join(f"- {l}" for l in lenses), "runde": task["runde"], "bisherige_queries": "\n".join(f"- {q}" for q in bisherige if q) or "-"}) if text is None: @@ -39,19 +39,16 @@ async def recherche_plan(task: dict) -> engine.Ergebnis: q = block.get("text", "").strip() if q: je_lens.setdefault(block.get("lens", "").strip(), []).append(q) - neue, teil = [], {} - for m in mitglieder: - queries = je_lens.get(lens_von[m["id"]], [])[:config.QUERIES_JE_LENS] - if not queries: # Lens fehlt in der Antwort → neuer Versuch, nie stumm - teil[m["id"]] = "neu" - continue - teil[m["id"]] = "fertig" + neue = [] + for lens in lenses: + queries = je_lens.get(lens, [])[:config.QUERIES_JE_LENS] + if not queries: # Blickwinkel fehlt in der Antwort → Retry, nie stumm + raise RuntimeError(f"recherche_plan: Blickwinkel '{lens}' ohne Query") for q in queries: h = hashlib.sha256(q.encode()).hexdigest()[:8] - neue.append({"knoten": "suche", "item": f"r{m['runde']}:q:{h}", + neue.append({"knoten": "suche", "item": f"r{task['runde']}:q:{h}", "payload": {"query": q}}) - return engine.Ergebnis(daten={"queries": len(neue)}, - neue_tasks=seed + neue, teil_status=teil) + return engine.Ergebnis(daten={"queries": len(neue)}, neue_tasks=seed + neue) @engine.worker("korpus.soll_extraktion", buendel=True) @@ -66,7 +63,7 @@ async def soll_extraktion(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="korpus", knoten="soll_extraktion", item=task["item"], role="extraktion", n=len(mitglieder), - skill_namen=["extraktor", "deutsch-praezise", "soll-extraktion"], + skill_namen=graph.skills_von(task["knoten"]), werte={"thema": _thema(task["topic"]), "abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}" for i, (_, _, c) in chunks.items())}) @@ -117,7 +114,7 @@ async def soll_konsens(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="korpus", knoten="soll_konsens", item=f"{task['item']}:n{len(punkte)}", role="judge", n=len(punkte), - skill_namen=["richter", "deutsch-praezise", "soll-konsens"], + skill_namen=graph.skills_von(task["knoten"]), werte={"punkte": liste, "ziel": ziel}) if antwort is None: raise RuntimeError("soll_konsens ohne Ergebnis") @@ -131,17 +128,11 @@ async def soll_konsens(task: dict) -> engine.Ergebnis: belege = [] for i in ids: belege += db.uj(bekannt[i]["belege"], []) - quellen = {b["quelle"] for b in belege} - # DER CODE zählt — ≥2 unabhängige Quellen ODER 1 kuratierte - # (Wikipedia = vertrauenswürdig, creator2-uni-Prinzip; sonst - # verlieren Einzelquellen-Facetten wie „Overflow-Pomodoros") - kuratiert = db.one( - "SELECT id FROM quellen WHERE id IN ({}) AND backend LIKE " - "'wikipedia%'".format(",".join("?" * len(quellen))), - *quellen) if quellen else None - noetig = config.SOLL_MIN_BELEGE_WIKI if kuratiert \ - else config.SOLL_MIN_BELEGE - if len(quellen) >= noetig: + # Vollständigkeit vor Vorsicht: 1 wörtlicher Beleg genügt (das + # Zitat-Gate schützt vor Halluzination; die ≥2-Quellen-Regel + # verwarf echte Facetten — Lektion 104). Dedup/Verdichtung/ + # Prüfungen fangen Überschuss hinten ab. + if belege: kopf = ids[0] db.update("soll", "id=?", (kopf,), status="bestaetigt", punkt=gruppe.get("text", bekannt[kopf]["punkt"])[:300], @@ -173,25 +164,31 @@ async def gate(task: dict) -> engine.Ergebnis: if bestaetigt < config.SOLL_PUNKTE_MIN: befunde.append({"art": "soll_zu_wenig", "item": topic, "detail": f"{bestaetigt} < {config.SOLL_PUNKTE_MIN}"}) + runden_max = graph.get().knoten[task["knoten"]].runden_max gesaettigt = bestaetigt == vorher # Nullrunde (mit frischen Queries gelaufen) - if not gesaettigt and runde < config.RECHERCHE_RUNDEN_MAX: + if not gesaettigt and runde < runden_max: befunde.append({"art": "unsaettigt", "item": topic, "detail": f"Runde {runde}: {vorher}→{bestaetigt}"}) if befunde: - if runde < config.RECHERCHE_RUNDEN_MAX: - neue = [{"knoten": "recherche_plan", "item": f"r{runde + 1}:lens:{lens}", - "runde": runde + 1, "art": "ruecklauf", - "payload": {"lens": lens}} - for lens in config.RECHERCHE_LENSES] + if runde < runden_max: + neue = [{"knoten": "recherche_plan", "item": f"r{runde + 1}:plan", + "runde": runde + 1, "art": "ruecklauf"}] return engine.Ergebnis(gate_status="rot", befunde=befunde, neue_tasks=neue, daten={"bestaetigt": bestaetigt}) - # grün → Rest-Kandidaten sind entschieden (Sättigung): verwerfen statt - # als unadjudizierter Ballast liegen zu lassen - db.execute("UPDATE soll SET status='verworfen' WHERE topic=? AND " - "status='kandidat'", topic) + # Harte Vollständigkeits-Stops (Lektion 103): Vollständigkeit ist DAS + # Kernziel — fehlt die Basis, ist die Pipeline kaputt → PAUSE statt + # kleiner Guide. Sichtbar als Run-Grund, Resume prüft erneut. + if config.SEED_PFLICHT and not db.one( + "SELECT id FROM quellen WHERE topic=? AND zweck='seed' AND " + "status='geladen'", topic): + raise llm.LaufPause("vollstaendigkeit:seed_fehlt") + if quellen < config.QUELLEN_MIN: + raise llm.LaufPause( + f"vollstaendigkeit:quellen {quellen}<{config.QUELLEN_MIN}") + # Stufe inventar: Extraktion NUR belegnah (Lektion 92) — 2 Reader je # Fenster um die Soll-Belege. Quellen ohne Beleg liefern keine Atome. db.update("topics", "name=?", (topic,), status="korpus_fertig") @@ -201,7 +198,7 @@ async def gate(task: dict) -> engine.Ergebnis: for b in db.uj(s["belege"], []): belege_je_quelle.setdefault(b["quelle"], []).append(b["zitat"]) for q in db.query("SELECT * FROM quellen WHERE topic=? AND status='geladen' " - "AND zweck='korpus'", topic): + "AND zweck IN ('korpus','seed')", topic): zitate = belege_je_quelle.get(q["id"]) if not zitate: continue diff --git a/backend/laden.py b/backend/laden.py index 2414a01..97d76e4 100644 --- a/backend/laden.py +++ b/backend/laden.py @@ -6,14 +6,12 @@ import hashlib import os import re import time -import urllib.robotparser from urllib.parse import urlsplit import httpx -from . import config, db, engine +from . import config, db, engine, graph, llm, textkit -_robots: dict[str, urllib.robotparser.RobotFileParser | None] = {} _letzter_fetch: dict[str, float] = {} @@ -27,20 +25,6 @@ def _text_reparieren(text: str) -> str: return unicodedata.normalize("NFC", text) -def _robots_erlaubt(url: str) -> bool: - domain = urlsplit(url).netloc - if domain not in _robots: - rp = urllib.robotparser.RobotFileParser() - try: - rp.set_url(f"https://{domain}/robots.txt") - rp.read() - _robots[domain] = rp - except Exception: - _robots[domain] = None # nicht erreichbar → fail-open - rp = _robots[domain] - return rp is None or rp.can_fetch(config.user_agent(), url) - - async def _domain_bremse(url: str) -> None: domain = urlsplit(url).netloc delta = time.monotonic() - _letzter_fetch.get(domain, 0) @@ -75,7 +59,7 @@ async def _fetch(url: str) -> tuple[bytes, str]: async with httpx.AsyncClient( follow_redirects=True, timeout=httpx.Timeout(config.FETCH_READ_S, connect=config.FETCH_CONNECT_S), - headers={"User-Agent": config.user_agent()}, + headers={"User-Agent": config.BROWSER_UA}, transport=httpx.AsyncHTTPTransport(retries=2)) as client: r = await client.get(url) r.raise_for_status() @@ -121,13 +105,6 @@ async def laden(task: dict) -> engine.Ergebnis: text = fakes.laden(quelle["url"]) roh = b"" else: - if quelle["url"].lower().endswith(".pdf"): - db.update("quellen", "id=?", (quelle["id"],), status="fehler", - grund="pdf_uebersprungen") - db.insert("befunde", run_id=task["run_id"], stufe="korpus", - knoten="laden", art="pdf", item=quelle["url"][:120], - detail="PDF in v1 übersprungen") - return engine.Ergebnis(daten={"pdf": True}) if _WIKI_RE.match(quelle["url"]): try: text = await _wikipedia_api(quelle["url"]) @@ -135,34 +112,56 @@ async def laden(task: dict) -> engine.Ergebnis: text = "" if text: text = _text_reparieren(text) - return _snapshot_ablegen(task, quelle, text, b"") - # API-Fehler → normaler Weg (inkl. robots-Check) als Fallback - if not _robots_erlaubt(quelle["url"]): - db.update("quellen", "id=?", (quelle["id"],), status="robots") - db.insert("befunde", run_id=task["run_id"], stufe="korpus", - knoten="laden", art="robots", item=quelle["url"][:120]) - return engine.Ergebnis(daten={"robots": True}) + return await _snapshot_ablegen(task, quelle, text, b"") + # API-Fehler/Titel unbekannt → normaler Seiten-Weg als Fallback await _domain_bremse(quelle["url"]) try: - roh, _ = await _fetch(quelle["url"]) + roh, ctype = await _fetch(quelle["url"]) except Exception as e: db.update("quellen", "id=?", (quelle["id"],), status="fehler", grund=f"{type(e).__name__}"[:80]) return engine.Ergebnis(daten={"fetch_fehler": str(e)[:120]}) + if "pdf" in ctype.lower(): # Content-Type, nicht URL-Suffix (Lektion 106) + db.update("quellen", "id=?", (quelle["id"],), status="fehler", + grund="pdf_uebersprungen") + db.insert("befunde", run_id=task["run_id"], stufe="korpus", + knoten="laden", art="pdf", item=quelle["url"][:120], + detail="PDF in v1 übersprungen") + return engine.Ergebnis(daten={"pdf": True}) text = _extrahieren(roh) text = _text_reparieren(text) - return _snapshot_ablegen(task, quelle, text, roh) + return await _snapshot_ablegen(task, quelle, text, roh) -def _snapshot_ablegen(task: dict, quelle: dict, text: str, - roh: bytes) -> engine.Ergebnis: - if len(text) < config.SNAPSHOT_MIN_ZEICHEN or "enable javascript" in text.lower(): - status = "braucht_js" if text and "javascript" in text.lower() else "leer" - db.update("quellen", "id=?", (quelle["id"],), status=status) +async def _inhalt_nuetzlich(task: dict, quelle: dict, text: str) -> tuple[bool, str]: + """Inhalts-Urteil statt Titel-Filter (Lektion 102): erst laden, dann auf + dem echten Text entscheiden. AUSNAHMSLOS für jede Quelle — auch Seed/ + Wikipedia (Lektion 106: privilegierte Quellen luden „Kosovo" ungeprüft).""" + thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics " + "WHERE name=?", task["topic"])["t"] + antwort = await llm.call( + run_id=task["run_id"], stufe="korpus", knoten="laden", + item=f"quelle:{quelle['id']}:urteil", role="judge", + skill_namen=graph.skills_von(task["knoten"], extra="urteil"), + werte={"thema": thema, "titel": quelle["titel"], + "auszug": text[:config.URTEIL_AUSZUG_ZEICHEN]}) + bloecke = textkit.bloecke(antwort or "", "URTEIL") + urteil = bloecke[0].get("nuetzlich", "").strip().lower() if bloecke else "" + if urteil not in ("ja", "nein"): # Ausfall/kaputt → Retry, kein Silent-Keep + raise RuntimeError("quellen-urteil ohne verwertbare Antwort") + return urteil == "ja", bloecke[0].get("grund", "")[:80] + + +async def _snapshot_ablegen(task: dict, quelle: dict, text: str, + roh: bytes) -> engine.Ergebnis: + if len(text) < config.SNAPSHOT_MIN_ZEICHEN: + # zu wenig extrahierbarer Text (auch JS-Walls landen hier) — keine + # Sprach-/Phrasen-Heuristik (Lektion 106) + db.update("quellen", "id=?", (quelle["id"],), status="leer") db.insert("befunde", run_id=task["run_id"], stufe="korpus", knoten="laden", - art=status, item=quelle["url"][:120]) - return engine.Ergebnis(daten={status: True}) + art="leer", item=quelle["url"][:120]) + return engine.Ergebnis(daten={"leer": True}) hash_ = hashlib.sha256(text.encode()).hexdigest()[:16] dublette = db.one("SELECT id FROM quellen WHERE topic=? AND hash=? AND id!=?", @@ -171,6 +170,11 @@ def _snapshot_ablegen(task: dict, quelle: dict, text: str, db.update("quellen", "id=?", (quelle["id"],), status="fehler", grund="inhalt_dublette") return engine.Ergebnis(daten={"dublette": True}) + nuetzlich, grund = await _inhalt_nuetzlich(task, quelle, text) + if not nuetzlich: + db.update("quellen", "id=?", (quelle["id"],), status="aussortiert", + grund=grund) + return engine.Ergebnis(daten={"aussortiert": True}) pfad = snapshot_pfad(task["topic"], hash_) _atomar_schreiben(pfad, text.encode("utf-8")) roh_pfad = "" @@ -185,16 +189,14 @@ def _snapshot_ablegen(task: dict, quelle: dict, text: str, def _folge_tasks(task: dict, quelle: dict) -> list: - """korpus-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen → - direkte Atom-Extraktion (Soll steht schon fest).""" - from . import textkit - if quelle["zweck"].startswith("luecke:"): - soll_id = int(quelle["zweck"].split(":")[1]) + """korpus-/seed-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen + (soll_id gesetzt) → direkte Atom-Extraktion (Soll steht schon fest).""" + if quelle["zweck"] == "luecke" and quelle.get("soll_id"): text = snapshot_lesen(quelle) return [{"knoten": "atom_extraktion", "item": f"quelle:{quelle['id']}:a{i}:r1", "payload": {"quelle_id": quelle["id"], "offset": o, - "chars": len(c), "soll_id": soll_id}} + "chars": len(c), "soll_id": quelle["soll_id"]}} for i, (o, c) in enumerate(textkit.abschnitte(text))] text = snapshot_lesen(quelle) chunks = textkit.abschnitte(text, config.SOLL_CHUNK_CHARS) diff --git a/backend/ledger.py b/backend/ledger.py index c04f32c..e45fb1a 100644 --- a/backend/ledger.py +++ b/backend/ledger.py @@ -1,6 +1,6 @@ """Event-Ledger: eine Zeile je LLM-Versuch (im finally — jeder Ausgang zählt, auch Hedge-Verlierer). Budget = harte Grenze, geprüft VOR und NACH jedem Call.""" -from . import config, db +from . import arten, config, db class BudgetErschoepft(Exception): @@ -43,9 +43,10 @@ def kennzahlen(run_id: int) -> dict: zeilen = db.query( "SELECT stufe, knoten, COUNT(*) calls, SUM(tok_in) tok_in, " "SUM(tok_out) tok_out, SUM(tok_cache_read) cache_read, " - "SUM(dur_ms) dur_ms, SUM(wait_ms) wait_ms, " - "SUM(status='ok') ok, SUM(status IN ('error','infra','parse','cap')) fehler " - "FROM events WHERE run_id=? GROUP BY stufe, knoten ORDER BY MIN(id)", run_id) + "SUM(dur_ms) dur_ms, SUM(wait_ms) wait_ms, SUM(status='ok') ok, " + f"SUM(status IN ({','.join('?' * len(arten.EVENT_FEHLER))})) fehler " + "FROM events WHERE run_id=? GROUP BY stufe, knoten ORDER BY MIN(id)", + *arten.EVENT_FEHLER, run_id) gesamt = verbraucht(run_id) kosten = sum(z["tok_in"] or 0 for z in zeilen) / 1e6 * config.PREIS_IN + \ sum(z["tok_out"] or 0 for z in zeilen) / 1e6 * config.PREIS_OUT diff --git a/backend/llm.py b/backend/llm.py index 357ff1f..35eca81 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -10,13 +10,9 @@ from dataclasses import dataclass, field import httpx -from . import config, db, skills +from . import config, db, graph, skills from .ledger import BudgetErschoepft, budget_pruefen, log_call # noqa: F401 -_INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out", - "connection", "network", "overloaded", "unavailable", - '"status_code":1002', '"status_code":1041', '"status_code":2045') - class LaufPause(Exception): """Infrastruktur erschöpft — Lauf pausieren, nie fail-open weiterrechnen.""" @@ -31,6 +27,7 @@ class ApiErgebnis: rc: int text: str = "" err: str = "" + cap: bool = False # stop=max_tokens — strukturiert, kein String-Match tokens: dict = field(default_factory=dict) @@ -88,7 +85,7 @@ def _slot_frei() -> None: def _ist_infra(err: str) -> bool: e = err.lower() - return any(m in e for m in _INFRA_MARKER) + return any(m in e for m in config.INFRA_MARKER) async def _api(prompt: str, role: str, timeout: float) -> ApiErgebnis: @@ -130,6 +127,7 @@ async def _api(prompt: str, role: str, timeout: float) -> ApiErgebnis: return ApiErgebnis(1, err=resp.text[:300], tokens=tokens) if not text.strip(): return ApiErgebnis(1, err=f"leere Antwort (stop={daten.get('stop_reason')})", + cap=daten.get("stop_reason") == "max_tokens", tokens=tokens) _erfolg_melden() return ApiErgebnis(0, text=text, tokens=tokens) @@ -165,7 +163,7 @@ async def call(*, run_id: int, stufe: str, knoten: str, item: str, """Zentraler LLM-Engpass. Rückgabe: Antworttext oder None (Inhaltsfehler/cap). Wirft LaufPause (Infra erschöpft/manuell) oder BudgetErschoepft.""" prompt, skill_hash, paare = skills.komponieren(skill_namen, werte) - timeout = config.timeout_fuer(knoten, n) + timeout = graph.timeout_fuer(knoten, n) infra_rest = config.INFRA_MAX_RETRIES inhalt_rest = config.INHALT_MAX_RESTARTS @@ -197,7 +195,7 @@ async def call(*, run_id: int, stufe: str, knoten: str, item: str, tokens, err, text = res.tokens, res.err, res.text if res.rc == 0: status = "ok" - elif "stop=max_tokens" in err: + elif res.cap: status = "cap" elif _ist_infra(err): status = "infra" diff --git a/backend/main.py b/backend/main.py index c5ab70c..3d059ef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -73,8 +73,7 @@ def topic_anlegen(auftrag: TopicNeu): def _pipeline_daten_loeschen(topic: str) -> None: """Alle Pipeline-Daten eines Topics räumen (Snapshots inklusive).""" - for tabelle in ("tasks", "gate_laeufe", "quellen", "soll", "atome", "kanten", - "lernziele", "bausteine", "kapitel"): + for tabelle in db.PIPELINE_TABELLEN: db.execute(f"DELETE FROM {tabelle} WHERE topic=?", topic) db.execute("DELETE FROM befunde WHERE run_id IN " "(SELECT id FROM runs WHERE topic=?)", topic) diff --git a/backend/redundanz.py b/backend/redundanz.py index e9f83b9..0d54df2 100644 --- a/backend/redundanz.py +++ b/backend/redundanz.py @@ -2,7 +2,8 @@ VERSCHIEDENEN Kapiteln mit Jaccard ≥0.6 → 2er-Panel entscheidet „redundant vs. didaktisch gewollt" (Recap ist keine Dopplung). Befunde behebt der Fix-Knoten per Ein-Satz-Refresher + Anker-Link.""" -from . import config, db, engine, llm, panels, textkit +from . import config, db, engine, graph, llm, panels, textkit +from .dedup import _signatur def kandidaten_paare(topic: str) -> list[dict]: @@ -32,8 +33,14 @@ def kandidaten_paare(topic: str) -> list[dict]: >= config.REDUNDANZ_JACCARD # Shingle-Kanal: identische Wort-Folge in längeren Sätzen — # Jaccard übersieht sie, wenn der Restsatz verschieden ist - if jaccard_treffer or (shingles(a["satz"]) & shingles(b["satz"])): - paare.append({"a": a, "b": b}) + wortgleich = bool(shingles(a["satz"]) & shingles(b["satz"])) + # Fakten-Kanal: dieselbe Studie/Zahl in Paraphrase teilt Zahlen + + # Eigennamen (Audit: „Ariga & Lleras 2011" stand 3× im Guide, + # Wort-Jaccard nur 0.21) + fakten = len(_signatur(a["satz"]) & _signatur(b["satz"])) \ + >= config.FAKTEN_SIGNATUR_MIN + if jaccard_treffer or wortgleich or fakten: + paare.append({"a": a, "b": b, "wortgleich": wortgleich}) return paare @@ -43,15 +50,14 @@ async def pruefen(task: dict) -> engine.Ergebnis: befunde = 0 for i in range(0, len(paare), config.DEDUP_PAARE_PRO_CALL): chunk = paare[i:i + config.DEDUP_PAARE_PRO_CALL] - liste = "\n\n".join( - f"PAAR {j + 1}:\nKapitel A: {p['a']['satz']}\nKapitel B: {p['b']['satz']}" - for j, p in enumerate(chunk)) + liste = textkit.paar_liste( + [(q["a"]["satz"], q["b"]["satz"]) for q in chunk]) async def ruf(p, liste=liste, n=len(chunk)): text = await llm.call( run_id=task["run_id"], stufe="guide", knoten="redundanz", item=f"{task['item']}:c{i}:p{p}", role="judge", n=n, - skill_namen=["richter", "deutsch-praezise", "redundanz-urteil"], + skill_namen=graph.skills_von(task["knoten"]), werte={"paare": liste}) if text is None: return None @@ -67,7 +73,11 @@ async def pruefen(task: dict) -> engine.Ergebnis: u = s[j + 1] return True if u == "redundant" else ( False if u == "didaktisch_gewollt" else None) - if panels.einstimmig(stimmen, config.PRUEF_PANEL, urteil): + # Fast wörtliche Paare (gemeinsame 5-Wort-Folge) sind NIE + # „didaktisch gewollt" — das Panel darf sie nicht freisprechen + # (Audit: 94-Studierende-Satz stand 2× nahezu wortgleich im Text) + if p.get("wortgleich") or panels.einstimmig( + stimmen, config.PRUEF_PANEL, urteil): # Der SPÄTERE Baustein trägt den Befund (dort wird verwiesen) spaeter = max(p["a"], p["b"], key=lambda x: x["pos"]) schon = db.one( @@ -80,4 +90,9 @@ async def pruefen(task: dict) -> engine.Ergebnis: item=f"baustein:{spaeter['bid']}", detail=spaeter["satz"][:400], runde=task["runde"]) befunde += 1 - return engine.Ergebnis(daten={"paare": len(paare), "befunde": befunde}) + neue = [] + if not db.one("SELECT id FROM tasks WHERE topic=? AND knoten='lektorat' " + "AND status='fertig'", task["topic"]): + neue = [{"knoten": "lektorat", "item": f"r{task['runde']}:lektorat"}] + return engine.Ergebnis(daten={"paare": len(paare), "befunde": befunde}, + neue_tasks=neue) diff --git a/backend/struktur.py b/backend/struktur.py index 4750637..b236538 100644 --- a/backend/struktur.py +++ b/backend/struktur.py @@ -3,7 +3,7 @@ Gate: exakte Partition + Coverage (jeder Soll-Punkt ≥1 Baustein).""" import math -from . import config, db, engine, llm, textkit +from . import config, db, engine, graph, llm, textkit def _atom_rang(atom_id: int) -> tuple: @@ -27,7 +27,7 @@ async def lernziele(task: dict) -> engine.Ergebnis: else: atome = db.query("SELECT * FROM atome WHERE topic=? AND " "status='aktiv' AND soll_id IS NULL", task["topic"]) - punkt = "Weitere Grundlagen zum Thema" + punkt = config.TEXTE["sammel_punkt"] if not atome: teil[m["id"]] = "fertig" continue @@ -45,7 +45,7 @@ async def lernziele(task: dict) -> engine.Ergebnis: run_id=task["run_id"], stufe="struktur", knoten="lernziele", item=task["item"], role="judge", n=sum(len(a) for (_, _, _, a) in aktive.values()), - skill_namen=["planer", "deutsch-praezise", "lernziele"], + skill_namen=graph.skills_von(task["knoten"]), werte={"soll_punkte": "\n\n".join( f"=== SOLL {i} ===\nPUNKT: {punkt}\nATOME:\n" + "\n".join(f"[{a['id']}] {a['titel']}" for a in atome) @@ -78,9 +78,9 @@ async def lernziele(task: dict) -> engine.Ergebnis: rest = ids_alle - zugeordnet # deterministisches Sammelziel — nichts verlieren if rest: ziel_id = db.insert( - "lernziele", topic=task["topic"], titel="Grundlagen", - soll_id=soll_id, - text=f"Der Lernende kann Grundlagen zu '{punkt}' erklären.") + "lernziele", topic=task["topic"], + titel=config.TEXTE["sammel_titel"], soll_id=soll_id, + text=config.TEXTE["sammel_ziel"].format(punkt=punkt)) for aid in rest: db.update("atome", "id=?", (aid,), ziel_id=ziel_id) teil[m["id"]] = "fertig" @@ -115,17 +115,17 @@ async def bausteine(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="struktur", knoten="bausteine", item=task["item"], role="judge", n=len(atome), - skill_namen=["planer", "deutsch-praezise", "bausteine"], + skill_namen=graph.skills_von(task["knoten"]), werte={"ziel": ziel["text"], "band": f"{config.BAUSTEIN_MIN_ATOME}-{config.BAUSTEIN_MAX_ATOME}", "atome": "\n".join(f"[{a['id']}] {a['titel']}" for a in atome)}) if antwort is None: # Call-Ausfall: neuer Versuch statt stiller Fallback raise RuntimeError("bausteine ohne Ergebnis") - gruppen = _gruppen_validieren(antwort, atome) + gruppen = _min_erzwingen(_gruppen_validieren(antwort, atome)) for i, gruppe in enumerate(gruppen): titel = ziel["titel"] or gruppe[0]["titel"] if len(gruppen) > 1: - titel = f"{titel} (Teil {i + 1})" + titel = config.TEXTE["teil"].format(titel=titel, n=i + 1) bid = db.insert("bausteine", topic=task["topic"], ziel_id=ziel["id"], titel=titel[:120], ord=i) for j, a in enumerate(gruppe): @@ -134,6 +134,31 @@ async def bausteine(task: dict) -> engine.Ergebnis: neue_tasks=[{"knoten": "ordnung", "item": "ordnung"}]) +def _min_erzwingen(gruppen: list[list[dict]]) -> list[list[dict]]: + """Splitter-Bremse (Audit: 8 „Teil"-Sections mit 1-2 Atomen): Gruppen + unter BAUSTEIN_MIN_ATOME mit dem kleineren Nachbarn mergen, solange das + MAX hält. Quellreihenfolge bleibt erhalten.""" + geaendert = True + while geaendert and len(gruppen) > 1: + geaendert = False + for i, g in enumerate(gruppen): + if len(g) >= config.BAUSTEIN_MIN_ATOME: + continue + nachbarn = sorted((j for j in (i - 1, i + 1) + if 0 <= j < len(gruppen)), + key=lambda j: len(gruppen[j])) + for j in nachbarn: + if len(g) + len(gruppen[j]) <= config.BAUSTEIN_MAX_ATOME: + a, b = min(i, j), max(i, j) + gruppen[a] = gruppen[a] + gruppen[b] + del gruppen[b] + geaendert = True + break + if geaendert: + break + return gruppen + + def _gruppen_validieren(antwort: str | None, atome: list[dict]) -> list[list[dict]]: """LLM-Split validieren (jede ID genau einmal); sonst deterministischer divmod-Split entlang der Quellreihenfolge.""" @@ -159,22 +184,38 @@ def _gruppen_validieren(antwort: str | None, atome: list[dict]) -> list[list[dic return gruppen +def _ziel_kerne(ziele: list[dict]) -> list[set]: + """Inhaltswort-Kerne aller Lernziele: Boilerplate wird GEMESSEN statt + gelistet (Lektion 106 — die alte deutsche Floskel-Liste wäre in jeder + anderen Sprache ein No-op). Stämme, die in der Mehrheit der Ziele + vorkommen, sind Schablone („Der Lernende kann …") und fliegen raus.""" + stamm_je_ziel = [textkit.stamm_tokens(z["text"]) for z in ziele] + n = len(stamm_je_ziel) + alle = set().union(*stamm_je_ziel) if stamm_je_ziel else set() + boilerplate = {t for t in alle + if sum(t in s for s in stamm_je_ziel) > max(2, n * 0.5)} + return [{t for t in s if len(t) > 3} - boilerplate for s in stamm_je_ziel] + + def _ziele_dedup(topic: str) -> int: """Synonyme Lernziele deterministisch mergen (Audit: „einordnen" vs. - „verorten" erzeugte Doppel-Bausteine → Writer-Echos). Falsch-Merge ist - harmlos: Atome bleiben erhalten, hängen nur am selben Ziel.""" + „verorten" erzeugte Doppel-Bausteine → Writer-Echos). Nur Paare mit + gleichem Soll-Punkt; Falsch-Merge ist harmlos: Atome bleiben erhalten, + hängen nur am selben Ziel.""" ziele = db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", topic) + kerne = _ziel_kerne(ziele) merges = 0 for i, a in enumerate(ziele): if a.get("_weg"): continue - for b in ziele[i + 1:]: - if b.get("_weg"): + for j, b in enumerate(ziele[i + 1:], i + 1): + if b.get("_weg") or a["soll_id"] != b["soll_id"]: continue + ka, kb = kerne[i], kerne[j] + kern_jaccard = len(ka & kb) / len(ka | kb) if ka and kb else 0.0 if textkit.titel_kern(a["titel"] or a["text"]) == \ textkit.titel_kern(b["titel"] or b["text"]) or \ - textkit.jaccard(a["text"], b["text"]) >= \ - config.ZIEL_DEDUP_JACCARD: + kern_jaccard >= config.ZIEL_DEDUP_JACCARD: db.update("atome", "ziel_id=?", (b["id"],), ziel_id=a["id"]) db.execute("DELETE FROM bausteine WHERE ziel_id=?", b["id"]) db.execute("DELETE FROM lernziele WHERE id=?", b["id"]) @@ -223,7 +264,7 @@ async def ordnung(task: dict) -> engine.Ergebnis: antwort = await llm.call( run_id=task["run_id"], stufe="struktur", knoten="ordnung", item=f"perm:kapitel:{kap['id']}", role="judge", n=len(bs), - skill_namen=["planer", "deutsch-praezise", "ordnung"], + skill_namen=graph.skills_von(task["knoten"]), werte={"art": "Bausteine", "liste": "\n".join(f"[{b['id']}] {b['titel']}" for b in prior), "prior": ",".join(map(str, ids))}) @@ -251,7 +292,7 @@ async def _kapitel_schnitt(task: dict, soll: list[dict]) -> list[tuple[str, list antwort = await llm.call( run_id=task["run_id"], stufe="struktur", knoten="ordnung", item="themen_schnitt", role="judge", n=len(ids), - skill_namen=["planer", "deutsch-praezise", "themen-schnitt"], + skill_namen=graph.skills_von(task["knoten"], extra="schnitt"), werte={"thema": thema, "richtwert": config.KAPITEL_SOLL_PUNKTE, "punkte": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)}) if antwort: @@ -269,7 +310,8 @@ async def _kapitel_schnitt(task: dict, soll: list[dict]) -> list[tuple[str, list detail="Schnitt ungültig, √n-Fallback") n_kap = max(1, round(math.sqrt(len(ids)))) groesse = math.ceil(len(ids) / n_kap) - return [(f"Teil {i + 1}", ids[i * groesse:(i + 1) * groesse]) + return [(config.TEXTE["teil"].format(titel=thema, n=i + 1), + ids[i * groesse:(i + 1) * groesse]) for i in range(n_kap) if ids[i * groesse:(i + 1) * groesse]] diff --git a/backend/suche.py b/backend/suche.py index 410088d..640bb28 100644 --- a/backend/suche.py +++ b/backend/suche.py @@ -2,12 +2,11 @@ Circuit Breaker je Backend; Teilausfall = degraded-Befund, nie Lauf-Crash.""" import asyncio import os -import re from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit import httpx -from . import config, db, engine, llm, textkit +from . import config, db, engine _TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid", "ref"} @@ -42,7 +41,7 @@ async def _http_json(url: str, **kw) -> dict | list | None: async def _wikipedia(query: str) -> list[dict]: treffer = [] - for sprache in ("de", "en"): + for sprache in config.SPRACHEN_SUCHE: backend = f"wikipedia_{sprache}" if _tot(backend): continue @@ -79,9 +78,11 @@ async def _brave(query: str) -> list[dict]: key = os.getenv("BRAVE_API_KEY") if not key: return [] + locale = config.SUCHE_LOCALE.get(config.SPRACHE, {}) daten = await _http_json("https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": config.SUCHE_MAX_TREFFER, - "country": "DE", "search_lang": "de"}, + "country": locale.get("country", "US"), + "search_lang": locale.get("search_lang", "en")}, headers={"X-Subscription-Token": key}) return [{"titel": t.get("title", ""), "url": t["url"], "backend": "brave"} for t in (daten or {}).get("web", {}).get("results", [])] @@ -90,7 +91,8 @@ async def _brave(query: str) -> list[dict]: async def _ddgs(query: str) -> list[dict]: def _sync(): from ddgs import DDGS - return DDGS().text(query, region="de-de", + region = config.SUCHE_LOCALE.get(config.SPRACHE, {}).get("region", "us-en") + return DDGS().text(query, region=region, max_results=config.SUCHE_MAX_TREFFER) await asyncio.sleep(config.DDGS_PAUSE_S) # Drosselung (ToS-Grauzone) rows = await asyncio.to_thread(_sync) @@ -102,9 +104,11 @@ _KETTE = (("tavily", _tavily), ("brave", _brave), ("ddgs", _ddgs)) async def web_suchen(query: str) -> tuple[list[dict], list[str]]: - """→ (treffer, fehler). Wikipedia immer; Kette bis zum ersten Backend mit - Ergebnissen (Fallback statt Fan-out, R2).""" - treffer = await _wikipedia(query) + """→ (treffer, fehler). Kette bis zum ersten Backend mit Ergebnissen + (Fallback statt Fan-out, R2). Wikipedia ist KEINE privilegierte + Extra-Quelle mehr — sie kommt über die Startquellen (Seed) und über + normale Web-Treffer herein (Lektion 106).""" + treffer: list[dict] = [] fehler = [] for name, fn in _KETTE: if _tot(name): @@ -123,26 +127,6 @@ async def web_suchen(query: str) -> tuple[list[dict], list[str]]: return treffer, fehler -async def _relevanz_filter(task: dict, thema: str, treffer: list) -> list: - """Kurzurteil je Trefferliste: Werbe-/App-Listen und Off-Topic raus - (Lektion 91). AUCH Wikipedia-Treffer — Run 7 lud „Pasta" und „Xinjiang".""" - web = treffer - if len(web) <= 2: - return treffer - liste = "\n".join(f"[{i}] {t['titel']} — {t['url']}" for i, t in enumerate(web)) - antwort = await llm.call( - run_id=task["run_id"], stufe="korpus", knoten="suche", - item=f"{task['item']}:relevanz", role="judge", - skill_namen=["richter", "deutsch-praezise", "quellen-relevanz"], - werte={"thema": thema, "treffer": liste}) - if antwort is None: - return treffer # Urteil ausgefallen → lieber behalten als verlieren - bloecke = textkit.bloecke(antwort, "RELEVANT") - ids = {int(i) for i in (bloecke[0].get("ids", "") if bloecke else "") - .replace(" ", "").split(",") if i.isdigit()} - return [t for i, t in enumerate(web) if i in ids] - - @engine.worker("suche.suchen") async def suchen(task: dict) -> engine.Ergebnis: payload = db.uj(task["payload"], {}) @@ -150,27 +134,30 @@ async def suchen(task: dict) -> engine.Ergebnis: zweck = payload.get("zweck", "korpus") seed = bool(payload.get("seed")) if seed: - # Wikipedia-Seed: deterministisch de+en zur Topic-Bezeichnung — - # umgeht Suche, Relevanz-Judge und Quellen-Deckel (2 Pflichtquellen) + # Startquellen-Seed: Pflicht-Kandidaten (umgehen den Deckel), aber + # OHNE weitere Privilegien — das Inhalts-Urteil läuft wie überall. + # Titel erst über die Wiki-Suche auflösen („AlpineJS" heißt dort + # „Alpine.js"); der direkte URL-Bau ist nur Fallback. from urllib.parse import quote + zweck = "seed" titel = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics " "WHERE name=?", task["topic"])["t"] - treffer = [{"titel": f"Wikipedia ({s}): {titel}", - "url": f"https://{s}.wikipedia.org/wiki/" - f"{quote(titel.replace(' ', '_'))}", - "backend": f"wikipedia_{s}"} for s in ("de", "en")] + gefunden = {} if config.FAKE else { + t["backend"]: t for t in reversed(await _wikipedia(titel))} + treffer = [gefunden.get(f"wikipedia_{s}", + {"titel": f"Wikipedia ({s}): {titel}", + "url": f"https://{s}.wikipedia.org/wiki/" + f"{quote(titel.replace(' ', '_'))}", + "backend": f"wikipedia_{s}"}) + for s in config.SPRACHEN_SUCHE] fehler = [] elif config.FAKE: from . import fakes treffer, fehler = fakes.suche(query), [] else: treffer, fehler = await web_suchen(query) - # Wikipedia zuerst (kuratierter Grundstock schlägt Blog-Treffer) - treffer.sort(key=lambda t: not t["backend"].startswith("wikipedia")) - if zweck == "korpus" and treffer and not config.FAKE and not seed: - thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics " - "WHERE name=?", task["topic"])["t"] - treffer = await _relevanz_filter(task, thema, treffer) + # Keine Vorsortierung, kein Titel-Filter — beurteilt wird nach dem Laden + # auf dem Inhalt (Lektion 102/106). # Quellen-Deckel je Topic (0 = unbegrenzt, dann stoppt nur die Sättigung) einstellung = db.one("SELECT quellen_max FROM topics WHERE name=?", task["topic"]) or {} @@ -179,17 +166,18 @@ async def suchen(task: dict) -> engine.Ergebnis: deckel = config.QUELLEN_MAX neue = [] for t in treffer: - if zweck == "korpus" and deckel > 0 and not seed: + if zweck == "korpus" and deckel > 0: vorhanden = db.one("SELECT COUNT(*) c FROM quellen WHERE topic=? AND " "zweck='korpus' AND status NOT IN " - "('fehler','leer','robots','braucht_js')", + "('fehler','leer','braucht_js','aussortiert')", task["topic"])["c"] if vorhanden >= deckel: break url_norm = url_normieren(t["url"]) qid = db.insert("quellen", ignore=True, topic=task["topic"], titel=t["titel"][:200], url=t["url"], url_norm=url_norm, - backend=t["backend"], runde=task["runde"], zweck=zweck) + backend=t["backend"], runde=task["runde"], zweck=zweck, + soll_id=payload.get("soll_id")) if qid: neue.append({"knoten": "laden", "item": f"quelle:{qid}", "payload": {"quelle_id": qid}}) diff --git a/backend/textkit.py b/backend/textkit.py index b27c569..533aea6 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -6,14 +6,17 @@ import unicodedata from . import config +# Sprache kommt aus config.SPRACHE (Lektion 106) — kein hartkodiertes Deutsch try: import Stemmer - _stemmer = Stemmer.Stemmer("german") + _name = config.STEMMER_NAME.get(config.SPRACHE) + _stemmer = Stemmer.Stemmer(_name) if _name else None except ImportError: # Fallback ohne C-Extension: ungestemmt _stemmer = None -_WORT_RE = re.compile(r"[a-zäöüß0-9]+") -_NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne"} +_WORT_RE = re.compile(r"\w+", re.UNICODE) # alle Schriften, nicht nur Latein +_NEGATION = config.NEGATIONEN.get(config.SPRACHE, set()) +_NEG_PRAEFIX = config.NEGATIONS_PRAEFIX.get(config.SPRACHE, "\0") def norm(text: str) -> str: @@ -37,9 +40,10 @@ def jaccard(a: str, b: str, gestemmt: bool = True) -> float: def negations_menge(text: str) -> frozenset: - """Antonyme messen 0.91-0.95 Cosinus (Lektion 31) — harte Merge-Vorbedingung.""" + """Antonyme messen 0.91-0.95 Cosinus (Lektion 31) — harte Merge-Vorbedingung. + Lexikon je Sprache; ohne Eintrag ist der Guard bewusst aus (dokumentiert).""" t = tokens(text) - return frozenset(w for w in t if w in _NEGATION or w.startswith("nicht")) + return frozenset(w for w in t if w in _NEGATION or w.startswith(_NEG_PRAEFIX)) def titel_kern(titel: str) -> str: @@ -81,19 +85,34 @@ def ueberlappung(a: tuple[int, int], b: tuple[int, int]) -> float: return schnitt / kuerzer if kuerzer > 0 else 0.0 -_SATZ_RE = re.compile(r"(?<=[.!?])\s+(?=[A-ZÄÖÜ])") +_SATZ_RE = re.compile(r"(?<=[.!?])\s+") def satz_split(text: str) -> list[str]: + """Terminator + Whitespace; Teile, die klein weitergehen (Abkürzungen, + „z. B."), werden wieder angefügt — Unicode-Groß statt [A-ZÄÖÜ]-Klasse.""" saetze = [] for absatz in text.split("\n"): absatz = absatz.strip() if not absatz or absatz.startswith(("#", "```", "|", "` 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 `` 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: ===TEXT=== diff --git a/skills/aufgabe/guide-pruefung.md b/skills/aufgabe/guide-pruefung.md index e89b5ed..a92c5d6 100644 --- a/skills/aufgabe/guide-pruefung.md +++ b/skills/aufgabe/guide-pruefung.md @@ -9,13 +9,13 @@ Prüfe jede Guide-Section gegen ihre Belege. {{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=== SECTION: ART: falsch | luecke | stil STELLE: DETAIL: -===OK=== +===LEER=== SECTION: diff --git a/skills/aufgabe/kapitel-intro.md b/skills/aufgabe/kapitel-intro.md index 3c97df1..27dd62d 100644 --- a/skills/aufgabe/kapitel-intro.md +++ b/skills/aufgabe/kapitel-intro.md @@ -5,7 +5,9 @@ beschreibung: Inhaltsfreien 2-3-Satz-Einstieg für ein Kapitel schreiben 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: {{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): {{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: ===INTRO=== diff --git a/skills/aufgabe/lektorat.md b/skills/aufgabe/lektorat.md new file mode 100644 index 0000000..177daa5 --- /dev/null +++ b/skills/aufgabe/lektorat.md @@ -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 ] 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: +ART: redundanz | stil +DETAIL: +===LEER=== +OK: ja diff --git a/skills/aufgabe/luecke-nachextraktion.md b/skills/aufgabe/luecke-nachextraktion.md index ef8c7f3..285ad5f 100644 --- a/skills/aufgabe/luecke-nachextraktion.md +++ b/skills/aufgabe/luecke-nachextraktion.md @@ -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. -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=== SOLL: TITEL: … TYP: … DEFINITION: … ZITAT: … -===NICHTS=== +===LEER=== SOLL: diff --git a/skills/aufgabe/ordnung.md b/skills/aufgabe/ordnung.md index 868aaa2..3c2d255 100644 --- a/skills/aufgabe/ordnung.md +++ b/skills/aufgabe/ordnung.md @@ -10,7 +10,9 @@ Ordne die folgenden {{art}} in die beste Lernreihenfolge: Definition/Begriff/Her ELEMENTE: {{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. diff --git a/skills/aufgabe/quellen-relevanz.md b/skills/aufgabe/quellen-relevanz.md deleted file mode 100644 index bbe0f0d..0000000 --- a/skills/aufgabe/quellen-relevanz.md +++ /dev/null @@ -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: diff --git a/skills/aufgabe/quellen-urteil.md b/skills/aufgabe/quellen-urteil.md new file mode 100644 index 0000000..150a27b --- /dev/null +++ b/skills/aufgabe/quellen-urteil.md @@ -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: diff --git a/skills/aufgabe/recherche-plan.md b/skills/aufgabe/recherche-plan.md index 820fd40..27ca030 100644 --- a/skills/aufgabe/recherche-plan.md +++ b/skills/aufgabe/recherche-plan.md @@ -10,10 +10,10 @@ Erzeuge je Blickwinkel 3 Web-Suchanfragen zum Thema „{{thema}}" (Runde {{runde BLICKWINKEL: {{lenses}} -Bereits verwendete Suchanfragen (NICHT wiederholen, neue Winkel finden): +BISHERIGE SUCHANFRAGEN (nicht wiederholen, neue Winkel finden): {{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: ===QUERY=== diff --git a/skills/aufgabe/soll-beleg.md b/skills/aufgabe/soll-beleg.md deleted file mode 100644 index d281abe..0000000 --- a/skills/aufgabe/soll-beleg.md +++ /dev/null @@ -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: -BELEG: diff --git a/skills/aufgabe/writer.md b/skills/aufgabe/writer.md index 1195522..a2e7f5e 100644 --- a/skills/aufgabe/writer.md +++ b/skills/aufgabe/writer.md @@ -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): {{bekannt}} -Regeln: Länge {{band}} Wörter; jeder Marker `` 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 `` 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: ===TEXT=== diff --git a/skills/stil/darstellung.md b/skills/stil/darstellung.md new file mode 100644 index 0000000..c8e6039 --- /dev/null +++ b/skills/stil/darstellung.md @@ -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. diff --git a/skills/stil/guide-lesbarkeit.md b/skills/stil/guide-lesbarkeit.md index 80f9423..9824290 100644 --- a/skills/stil/guide-lesbarkeit.md +++ b/skills/stil/guide-lesbarkeit.md @@ -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. -- 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"). - 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. - 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. diff --git a/tests/conftest.py b/tests/conftest.py index f42f31f..b56b714 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ from backend import config, db, engine, llm # Kleine Fake-Welt → kleine Schwellen config.SOLL_PUNKTE_MIN = 3 -config.RECHERCHE_RUNDEN_MAX = 2 + config.KAPITEL_SOLL_PUNKTE = 2 config.LUECKEN_RUNDEN_MAX = 1 config.POLL_SEKUNDEN = 0.05 @@ -20,8 +20,12 @@ config.DDGS_PAUSE_S = 0 config.QUERIES_JE_LENS = 2 config.VERDICHTUNG_ZIEL = 1 # zwingt den Verdichtungs-Pfad in der Fake-Welt 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() +from backend import graph as _graph +_graph.get().knoten["gate_korpus"].runden_max = 2 # kleine Fake-Welt @pytest.fixture(autouse=True) diff --git a/tests/test_buendel.py b/tests/test_buendel.py index 8a92fe5..2603369 100644 --- a/tests/test_buendel.py +++ b/tests/test_buendel.py @@ -79,7 +79,7 @@ async def test_llm_pruefung_buendel_teilantwort(monkeypatch): async def antwort(**kw): 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) task = dict(db.one("SELECT * FROM tasks WHERE id=?", t1)) diff --git a/tests/test_dedup_redundanz.py b/tests/test_dedup_redundanz.py index 1317266..12cb388 100644 --- a/tests/test_dedup_redundanz.py +++ b/tests/test_dedup_redundanz.py @@ -75,6 +75,7 @@ async def test_keiner_atome_nicht_erneut_gefragt(): db.insert("soll", topic=topic, punkt="Punkt", status="bestaetigt") aid = _atom(topic, "Fremdes Atom", "Definition ohne Bezug zum Punkt hier.") task = {"run_id": run_id, "topic": topic, "runde": 1, "item": "z0", + "knoten": "soll_zuordnung", "payload": db.j({"atom_ids": [aid]})} # Fake-Zuordnung antwortet KEINER (Titel unbekannt) → Atom bleibt ohne Soll 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 # Dedup erzeugt für dieses Atom KEINE neue Zuordnungs-Aufgabe mehr 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 == [] @@ -127,7 +129,7 @@ async def test_enthalten_merged_in_den_umfassenderen(): "Deutlich längere Definition mit allen Sonderfällen und mehr " "Kontext für Lernende.", soll_id=sid, quelle=2, start=0, ende=10) 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'", topic) 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) 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 db.one("SELECT id FROM befunde WHERE run_id=? AND art='parse'", 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'", topic) 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) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 669960f..7f71b62 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -48,7 +48,7 @@ def _pruefe_endzustand(topic: str): assert "TITEL:" not in guide and "DEFINITION:" not in guide assert "## Fehlerbehandlung" in guide # 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 diff --git a/tests/test_gates.py b/tests/test_gates.py index 86a607a..ecf0ac2 100644 --- a/tests/test_gates.py +++ b/tests/test_gates.py @@ -119,6 +119,7 @@ async def test_gate_guide_neuschreiben_bei_kaputten_markern(): assert writer_tasks and writer_tasks[0]["payload"]["neu"] is True # Neuschreiben resettet den Fix-Cap und ersetzt den Text await guide.writer({"run_id": run_id, "topic": topic, "runde": 2, + "knoten": "writer", "item": f"r2:baustein:{bid}", "payload": db.j({"baustein_id": bid, "neu": True})}) 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"] -async def test_gate_verwirft_soll_lose_atome(): - from backend import inventar +async def test_gate_verwirft_soll_lose_atome(monkeypatch): + from backend import config, inventar + monkeypatch.setattr(config, "BEIFANG_MAX_QUOTE", 1.0) # Stop hier nicht Testziel topic = topic_anlegen() run_id = db.insert("runs", topic=topic, status="running") 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", soll_geprueft=0) 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)[ "status"] == "verworfen" assert db.one("SELECT status FROM atome WHERE id=?", frisch)[ @@ -280,3 +283,66 @@ async def test_budget_stoppt_lauf(): import pytest with pytest.raises(BudgetErschoepft): 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" diff --git a/tests/test_graph.py b/tests/test_graph.py index b467644..7eb1489 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -42,3 +42,19 @@ def test_layout_vollstaendig(): lay = graph.get().layout() assert set(lay["knoten"]) == set(graph.get().knoten) 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 diff --git a/tests/test_korpus.py b/tests/test_korpus.py index 3071ca8..c84b5b2 100644 --- a/tests/test_korpus.py +++ b/tests/test_korpus.py @@ -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 .conftest import topic_anlegen -async def test_wiki_einzelbeleg_bestaetigt(): +async def test_einzelbeleg_bestaetigt_ohne_beleg_nicht(): topic = topic_anlegen() 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", url_norm="u2", backend="ddgs", status="geladen") - db.insert("soll", topic=topic, punkt="Overflow-Pomodoros erklären", - belege=db.j([{"quelle": q_wiki, "zitat": "z1"}])) - db.insert("soll", topic=topic, punkt="Nur Blog-These", + db.insert("soll", topic=topic, punkt="Blog-These mit Beleg", 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, - "item": "r1:konsens", "payload": "{}"}) - wiki = db.one("SELECT status FROM soll WHERE punkt LIKE 'Overflow%'") - blog = db.one("SELECT status FROM soll WHERE punkt LIKE 'Nur Blog%'") - assert wiki["status"] == "bestaetigt" # kuratierte Quelle: 1 Beleg reicht - assert blog["status"] == "kandidat" # Blog allein bestätigt nicht + "knoten": "soll_konsens", "item": "r1:konsens", + "payload": "{}"}) + mit = db.one("SELECT status FROM soll WHERE punkt LIKE 'Blog-These%'") + ohne = db.one("SELECT status FROM soll WHERE punkt LIKE 'These ohne%'") + assert mit["status"] == "bestaetigt" # 1 wörtlicher Beleg reicht + assert ohne["status"] == "kandidat" # ohne Beleg keine Bestätigung diff --git a/tests/test_lektorat.py b/tests/test_lektorat.py new file mode 100644 index 0000000..21afb85 --- /dev/null +++ b/tests/test_lektorat.py @@ -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") diff --git a/tests/test_llm.py b/tests/test_llm.py index 574ff92..618fec9 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -59,7 +59,7 @@ async def test_cap_kein_retry(echt_modus, monkeypatch): async def cap(*a, **k): zaehler["n"] += 1 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) assert await _call(run_id) is None assert zaehler["n"] == 1 # deterministisch — kein Neuversuch diff --git a/tests/test_struktur.py b/tests/test_struktur.py index 3214681..f2b2bad 100644 --- a/tests/test_struktur.py +++ b/tests/test_struktur.py @@ -32,3 +32,37 @@ def test_ziele_dedup_verschont_verschiedene(): assert struktur._ziele_dedup(topic) == 0 assert db.one("SELECT COUNT(*) c FROM lernziele WHERE topic=?", 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)) diff --git a/tests/test_suche_laden.py b/tests/test_suche_laden.py index 6ef6980..a6ac508 100644 --- a/tests/test_suche_laden.py +++ b/tests/test_suche_laden.py @@ -64,32 +64,20 @@ async def test_suche_dedupliziert_urls(): assert erg2.daten["neu"] == 0 # gleiche URL → kein zweiter Laden-Task -async def test_relevanz_filter_wirft_listen_raus(monkeypatch): - """Läuft OHNE Fake-Kurzschluss — genau der Pfad, der im Echtlauf brach.""" - monkeypatch.setattr(config, "FAKE", False) - 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"}, - ] +async def test_inhalts_urteil_sortiert_nach_dem_laden_aus(): + """Kein Titel-Filter mehr — das Urteil fällt auf dem geladenen Inhalt + (Lektion 102). Fake-Handler: Titel „Werbeliste" → nein.""" topic = topic_anlegen() run_id = db.insert("runs", topic=topic, status="running") - task = {"run_id": run_id, "topic": topic, "item": "t", "runde": 1} - ergebnis = await suche._relevanz_filter(task, "Thema", treffer) - urls = [t["url"] for t in ergebnis] - assert "https://de.wikipedia.org/wiki/X" in urls - assert "https://a.de/1" in urls and "https://a.de/3" in urls - assert "https://en.wikipedia.org/wiki/Pasta" not in urls # Off-Topic-Wiki raus + qid = db.insert("quellen", topic=topic, titel="Werbeliste Tools 2026", + url="https://beispiel.de/grundlagen", + url_norm="w1", backend="ddgs", zweck="korpus") + erg = await laden.laden({"run_id": run_id, "topic": topic, "runde": 1, + "knoten": "laden", "payload": db.j({"quelle_id": qid})}) + 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): @@ -144,7 +132,7 @@ async def test_laden_snapshot_stabil(): qid = db.insert("quellen", topic=topic, url="https://beispiel.de/grundlagen", url_norm="https://beispiel.de/grundlagen", zweck="korpus") 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}) q = db.one("SELECT * FROM quellen WHERE id=?", qid) 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", url_norm="https://unbekannt.example/x") 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 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) + + +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 diff --git a/tests/test_verdichtung.py b/tests/test_verdichtung.py index e34802f..fe12b05 100644 --- a/tests/test_verdichtung.py +++ b/tests/test_verdichtung.py @@ -21,6 +21,7 @@ def _welt(topic, n_vorteile=3): def _task(run_id, topic, sid): return {"run_id": run_id, "topic": topic, "runde": 1, + "knoten": "verdichtung", "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) 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, + "knoten": "gate_inventar", "item": "runde:1", "payload": "{}"}) fac = [t for t in erg.neue_tasks if t["knoten"] == "luecke" 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", item=f"r{r}:{key}", status="fertig") erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2, + "knoten": "gate_inventar", "item": "runde:2", "payload": "{}"}) assert not any(b["art"] == "facette" for b in erg2.befunde) assert db.one("SELECT status FROM befunde WHERE art='facette'")[ @@ -112,11 +115,13 @@ async def test_gate_erzeugt_verdichtungs_task(): topic = topic_anlegen() run_id, sid, ids = _welt(topic) erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1, + "knoten": "gate_inventar", "item": "runde:1", "payload": "{}"}) verd = [t for t in erg.neue_tasks if t["knoten"] == "verdichtung"] assert verd and verd[0]["payload"]["soll_id"] == sid # nach Verdichtung: kein neuer Task mehr db.update("soll", "id=?", (sid,), verdichtet=1) erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2, + "knoten": "gate_inventar", "item": "runde:2", "payload": "{}"}) assert not [t for t in erg2.neue_tasks if t["knoten"] == "verdichtung"]