update
This commit is contained in:
34
LEKTIONEN.md
34
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.
|
||||
|
||||
28
backend/arten.py
Normal file
28
backend/arten.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Zentrale Status-/Arten-Registry (Lektion 106): EINE Wahrheit statt
|
||||
verstreuter Literal-Listen, die still auseinanderdriften."""
|
||||
|
||||
# Quellen-Status, die nicht (mehr) zum Korpus beitragen — geteilt von
|
||||
# suche (Deckel-Zählung), laden (Erzeuger) und Frontend-Anzeige
|
||||
QUELLEN_TOT = ("fehler", "leer", "braucht_js", "aussortiert")
|
||||
|
||||
# Event-Status, die in den Kennzahlen als Fehler zählen (ledger)
|
||||
EVENT_FEHLER = ("error", "infra", "cap")
|
||||
|
||||
# Guide-Befund-Arten mit Verhalten. gate_blockiert: Befund hält das Gate rot;
|
||||
# fix_route: Gate erzeugt Fix→Klärung→Neuschreiben-Kette dafür.
|
||||
BEFUND_ARTEN = {
|
||||
"falsch": {"gate_blockiert": True, "fix_route": True},
|
||||
"luecke": {"gate_blockiert": True, "fix_route": True},
|
||||
"redundanz": {"gate_blockiert": True, "fix_route": True},
|
||||
"vorwaerts": {"gate_blockiert": True, "fix_route": True},
|
||||
"format_leak": {"gate_blockiert": True, "fix_route": True},
|
||||
"stil": {"gate_blockiert": True, "fix_route": True},
|
||||
"laenge": {"gate_blockiert": False, "fix_route": False}, # nur Kennzahl
|
||||
}
|
||||
|
||||
FIX_ARTEN = tuple(a for a, f in BEFUND_ARTEN.items() if f["fix_route"])
|
||||
|
||||
# det-Check-Arten, deren offene Alt-Befunde bei jedem Gate-Lauf neu erhoben
|
||||
# werden (Live-Checks ersetzen die Historie)
|
||||
DET_ARTEN = ("marker_fehlend", "marker_fremd", "marker_doppelt", "vorwaerts",
|
||||
"laenge", "format_leak", "link_im_text", "atom_echo", "h_ebene")
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""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="recherche_plan", item=f"r1:lens:{lens}", runde=1,
|
||||
payload=db.j({"lens": lens}), erzeugt_von="start")
|
||||
knoten=kn.id, item="r1:start", runde=1,
|
||||
payload="{}", erzeugt_von="start")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from . import belege, config, db, engine, llm, panels, textkit
|
||||
from . import arten, belege, config, db, engine, graph, llm, panels, textkit
|
||||
|
||||
_MARKER_RE = re.compile(r"<!--\s*atom:\s*(\d+)\s*-->")
|
||||
|
||||
@@ -25,7 +25,7 @@ def _band(n_atome: int) -> tuple[int, int]:
|
||||
|
||||
def _slug(titel: str) -> str:
|
||||
s = textkit.norm(titel).replace(" ", "-")
|
||||
return re.sub(r"[^a-z0-9äöüß-]", "", s)
|
||||
return "".join(c for c in s if c.isalnum() or c == "-") # Unicode-fähig
|
||||
|
||||
|
||||
def _bekannt_kontext(topic: str, baustein: dict) -> str:
|
||||
@@ -39,7 +39,9 @@ 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']}')"
|
||||
return "\n".join(
|
||||
config.TEXTE["abschnitt_verweis"].format(titel=f["titel"],
|
||||
abschnitt=f["bt"])
|
||||
for f in fruehere[:60])
|
||||
|
||||
|
||||
@@ -64,7 +66,7 @@ async def writer(task: dict) -> engine.Ergebnis:
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="writer", item=task["item"],
|
||||
role="writer",
|
||||
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "writer"],
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"baustein": b["titel"], "atome": atome_text,
|
||||
"belege": belege.fenster_fuer_atome([a["id"] for a in atome])[:40000],
|
||||
"bekannt": _bekannt_kontext(task["topic"], b),
|
||||
@@ -167,10 +169,8 @@ async def det_pruefung(task: dict) -> engine.Ergebnis:
|
||||
frisch = {(b["art"], b["item"], b["detail"][:400]) for b in befunde}
|
||||
for row in db.query(
|
||||
"SELECT id, art, item, detail FROM befunde WHERE item=? AND "
|
||||
"status='offen' AND art IN ('marker_fehlend','marker_fremd',"
|
||||
"'marker_doppelt','vorwaerts','laenge','format_leak',"
|
||||
"'link_im_text','atom_echo','h_ebene')",
|
||||
f"baustein:{bid}"):
|
||||
f"status='offen' AND art IN ({','.join('?' * len(arten.DET_ARTEN))})",
|
||||
f"baustein:{bid}", *arten.DET_ARTEN):
|
||||
if (row["art"], row["item"], row["detail"][:400]) not in frisch:
|
||||
db.update("befunde", "id=?", (row["id"],), status="veraltet")
|
||||
for b in befunde:
|
||||
@@ -210,14 +210,14 @@ async def llm_pruefung(task: dict) -> engine.Ergebnis:
|
||||
text = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="llm_pruefung",
|
||||
item=f"{task['item']}:p{i}", role="judge", n=len(aktive),
|
||||
skill_namen=["richter", "deutsch-praezise", "guide-pruefung"],
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"sections": sections_text})
|
||||
if text is None:
|
||||
return None
|
||||
return {"befunde": [b for b in textkit.bloecke(text, "BEFUND")
|
||||
if b.get("art") in ("falsch", "luecke", "stil")
|
||||
and b.get("section", "").strip().isdigit()],
|
||||
"ok": {int(b["section"]) for b in textkit.bloecke(text, "OK")
|
||||
"ok": {int(b["section"]) for b in textkit.bloecke(text, "LEER")
|
||||
if b.get("section", "").strip().isdigit()}}
|
||||
|
||||
stimmen = await panels.panel(ruf, config.PRUEF_PANEL)
|
||||
@@ -277,7 +277,7 @@ async def fix(task: dict) -> engine.Ergebnis:
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="fix", item=task["item"],
|
||||
role="writer",
|
||||
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "guide-fix"],
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"section": sec["text"],
|
||||
"befunde": "\n".join(f"- [{b['art']}] {b['detail']}" for b in offen),
|
||||
"belege": belege.fenster_fuer_atome(
|
||||
@@ -308,7 +308,7 @@ async def _klaerung(task, bid, sec, offen) -> engine.Ergebnis:
|
||||
text = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="fix",
|
||||
item=f"{task['item']}:k{b['id']}:p{i}", role="judge",
|
||||
skill_namen=["richter", "deutsch-praezise", "klaerung"],
|
||||
skill_namen=graph.skills_von(task["knoten"], extra="klaerung"),
|
||||
werte={"befund": f"[{b['art']}] {b['detail']}", "belege": beleg_text})
|
||||
urteile = textkit.bloecke(text or "", "URTEIL")
|
||||
return urteile[0].get("urteil") if urteile else None
|
||||
@@ -339,7 +339,7 @@ async def kapitel_intro(task: dict) -> engine.Ergebnis:
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="kapitel_intro",
|
||||
item=task["item"], role="writer",
|
||||
skill_namen=["autor", "deutsch-praezise", "kapitel-intro"],
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"kapitel": kap["titel"],
|
||||
"sections": "\n".join(f"- {t['titel']}" for t in titel),
|
||||
"vorherige": "\n".join(f"- {v['titel']}" for v in vorherige)
|
||||
@@ -352,6 +352,46 @@ async def kapitel_intro(task: dict) -> engine.Ergebnis:
|
||||
{"knoten": "redundanz", "item": f"r{task['runde']}:redundanz"}])
|
||||
|
||||
|
||||
@engine.worker("guide.lektorat")
|
||||
async def lektorat(task: dict) -> engine.Ergebnis:
|
||||
"""Gesamt-Wächter (Kurzbuch-Ziel): EIN Blick über den ganzen Guide —
|
||||
section-übergreifende Dopplungen, Splitter-Sections, zähe Übergänge.
|
||||
Läuft einmal je Topic; Befunde laufen durch die normale Fix-Maschine."""
|
||||
topic = task["topic"]
|
||||
if db.one("SELECT id FROM tasks WHERE topic=? AND knoten='lektorat' AND "
|
||||
"status='fertig' AND id!=?", topic, task["id"]):
|
||||
return engine.Ergebnis(daten={"skip": True})
|
||||
teile = []
|
||||
for kap in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord",
|
||||
topic):
|
||||
teile.append(f"## {kap['titel']}")
|
||||
for b in db.query(
|
||||
"SELECT b.id, b.titel, s.text FROM bausteine b JOIN sections s "
|
||||
"ON s.baustein_id=b.id WHERE b.kapitel_id=? AND s.text!='' "
|
||||
"ORDER BY b.ord", kap["id"]):
|
||||
teile.append(f"[SECTION {b['id']}] {b['titel']}\n{b['text']}")
|
||||
if len(teile) < 2:
|
||||
return engine.Ergebnis(daten={"skip": True})
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="lektorat",
|
||||
item=task["item"], role="judge",
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"guide": "\n\n".join(teile)[:120_000]})
|
||||
if antwort is None:
|
||||
raise RuntimeError("lektorat ohne Ergebnis")
|
||||
befunde_bloecke = textkit.bloecke(antwort, "BEFUND")
|
||||
if not befunde_bloecke and not textkit.bloecke(antwort, "LEER"):
|
||||
raise RuntimeError("lektorat: weder BEFUND noch LEER") # Format kaputt
|
||||
n = 0
|
||||
for b in befunde_bloecke:
|
||||
sid, art = b.get("section", "").strip(), b.get("art", "").strip()
|
||||
if sid.isdigit() and art in ("redundanz", "stil") and befund_merken(
|
||||
task["run_id"], "lektorat", art, f"baustein:{sid}",
|
||||
b.get("detail", "")[:300], task["runde"]):
|
||||
n += 1
|
||||
return engine.Ergebnis(daten={"befunde": n})
|
||||
|
||||
|
||||
def befund_merken(run_id: int, knoten: str, art: str, item: str, detail: str,
|
||||
runde: int) -> bool:
|
||||
"""Befund-Dedupe (creator2-Schlüssel art+item+detail): existiert derselbe
|
||||
@@ -392,7 +432,8 @@ async def gate(task: dict) -> engine.Ergebnis:
|
||||
continue
|
||||
alle_marker += [int(m) for m in _MARKER_RE.findall(sec["text"])]
|
||||
for f in _det_befunde(topic, b["id"], sec["text"]):
|
||||
if f["art"] == "laenge": # Länge blockiert nicht hart (nur Befund)
|
||||
if not arten.BEFUND_ARTEN.get(f["art"], {"gate_blockiert": True})[
|
||||
"gate_blockiert"]: # z. B. laenge: nur Kennzahl
|
||||
continue
|
||||
if (f["art"], f["item"], f["detail"][:400]) in final:
|
||||
continue
|
||||
@@ -402,7 +443,10 @@ async def gate(task: dict) -> engine.Ergebnis:
|
||||
"detail": "Atom in mehreren Sections gelehrt"})
|
||||
# Kaputte Marker mit erschöpftem Fix → Section NEU schreiben (max 1×!
|
||||
# sonst resettet jedes Neuschreiben den Fix-Cap → Endlos-Karussell)
|
||||
for f in [f for f in befunde if f["art"].startswith("marker")]:
|
||||
# NUR Section-Befunde (item baustein:*) — marker_global_doppelt trägt
|
||||
# item atom:<marke>, dessen Zahl ist KEINE Baustein-Id (latenter Bug)
|
||||
for f in [f for f in befunde if f["art"].startswith("marker")
|
||||
and f["item"].startswith("baustein:")]:
|
||||
bid = f["item"].split(":")[-1]
|
||||
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid)
|
||||
if sec and (sec["fix_versuche"] or 0) >= config.FIX_MAX_VERSUCHE \
|
||||
@@ -414,7 +458,7 @@ async def gate(task: dict) -> engine.Ergebnis:
|
||||
offene = db.query(
|
||||
"SELECT * FROM befunde b WHERE b.run_id IN (SELECT id FROM runs WHERE "
|
||||
"topic=?) AND b.stufe='guide' AND b.status='offen' AND b.art IN "
|
||||
"('falsch','luecke','redundanz','vorwaerts','format_leak')", topic)
|
||||
f"({','.join('?' * len(arten.FIX_ARTEN))})", topic, *arten.FIX_ARTEN)
|
||||
for b in offene:
|
||||
befunde.append({"art": b["art"], "item": b["item"],
|
||||
"detail": b["detail"][:120]})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
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 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)
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]]
|
||||
|
||||
|
||||
|
||||
@@ -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}",
|
||||
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 ("de", "en")]
|
||||
"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}})
|
||||
|
||||
@@ -6,14 +6,17 @@ import unicodedata
|
||||
|
||||
from . import config
|
||||
|
||||
# Sprache kommt aus config.SPRACHE (Lektion 106) — kein hartkodiertes Deutsch
|
||||
try:
|
||||
import Stemmer
|
||||
_stemmer = Stemmer.Stemmer("german")
|
||||
_name = config.STEMMER_NAME.get(config.SPRACHE)
|
||||
_stemmer = Stemmer.Stemmer(_name) if _name else None
|
||||
except ImportError: # Fallback ohne C-Extension: ungestemmt
|
||||
_stemmer = None
|
||||
|
||||
_WORT_RE = re.compile(r"[a-zäöüß0-9]+")
|
||||
_NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne"}
|
||||
_WORT_RE = re.compile(r"\w+", re.UNICODE) # alle Schriften, nicht nur Latein
|
||||
_NEGATION = config.NEGATIONEN.get(config.SPRACHE, set())
|
||||
_NEG_PRAEFIX = config.NEGATIONS_PRAEFIX.get(config.SPRACHE, "\0")
|
||||
|
||||
|
||||
def norm(text: str) -> str:
|
||||
@@ -37,9 +40,10 @@ def jaccard(a: str, b: str, gestemmt: bool = True) -> float:
|
||||
|
||||
|
||||
def negations_menge(text: str) -> frozenset:
|
||||
"""Antonyme messen 0.91-0.95 Cosinus (Lektion 31) — harte Merge-Vorbedingung."""
|
||||
"""Antonyme messen 0.91-0.95 Cosinus (Lektion 31) — harte Merge-Vorbedingung.
|
||||
Lexikon je Sprache; ohne Eintrag ist der Guard bewusst aus (dokumentiert)."""
|
||||
t = tokens(text)
|
||||
return frozenset(w for w in t if w in _NEGATION or w.startswith("nicht"))
|
||||
return frozenset(w for w in t if w in _NEGATION or w.startswith(_NEG_PRAEFIX))
|
||||
|
||||
|
||||
def titel_kern(titel: str) -> str:
|
||||
@@ -81,19 +85,34 @@ def ueberlappung(a: tuple[int, int], b: tuple[int, int]) -> float:
|
||||
return schnitt / kuerzer if kuerzer > 0 else 0.0
|
||||
|
||||
|
||||
_SATZ_RE = re.compile(r"(?<=[.!?])\s+(?=[A-ZÄÖÜ])")
|
||||
_SATZ_RE = re.compile(r"(?<=[.!?])\s+")
|
||||
|
||||
|
||||
def satz_split(text: str) -> list[str]:
|
||||
"""Terminator + Whitespace; Teile, die klein weitergehen (Abkürzungen,
|
||||
„z. B."), werden wieder angefügt — Unicode-Groß statt [A-ZÄÖÜ]-Klasse."""
|
||||
saetze = []
|
||||
for absatz in text.split("\n"):
|
||||
absatz = absatz.strip()
|
||||
if not absatz or absatz.startswith(("#", "```", "|", "<!--")):
|
||||
continue
|
||||
saetze += [s.strip() for s in _SATZ_RE.split(absatz) if s.strip()]
|
||||
for teil in _SATZ_RE.split(absatz):
|
||||
teil = teil.strip()
|
||||
if not teil:
|
||||
continue
|
||||
if saetze and teil[0].islower():
|
||||
saetze[-1] += " " + teil
|
||||
else:
|
||||
saetze.append(teil)
|
||||
return saetze
|
||||
|
||||
|
||||
def paar_liste(paare: list[tuple[str, str]]) -> str:
|
||||
"""EIN Paar-Rendering für alle Paar-Urteils-Aufgaben (dedup, redundanz)."""
|
||||
return "\n\n".join(f"PAAR {i + 1}:\nA: {a}\nB: {b}"
|
||||
for i, (a, b) in enumerate(paare))
|
||||
|
||||
|
||||
# ── Positions-Maps für Anker-Suche ──
|
||||
|
||||
def _map_bauen(text: str, wandler) -> tuple[str, list[int]]:
|
||||
|
||||
@@ -19,8 +19,8 @@ class Hub:
|
||||
"""Thread-sicher aus der DB-Schicht aufrufbar."""
|
||||
if self._loop is None or self._queue is None:
|
||||
return
|
||||
bereich = {"tasks": "graph", "gate_laeufe": "graph", "befunde": "graph",
|
||||
"runs": "run", "topics": "run"}.get(tabelle, "graph")
|
||||
from . import db
|
||||
bereich = "run" if tabelle in db._RUN_TABELLEN else "graph"
|
||||
msg = json.dumps({"typ": "dirty", "bereich": bereich}, ensure_ascii=False)
|
||||
self._loop.call_soon_threadsafe(self._queue.put_nowait, msg)
|
||||
|
||||
|
||||
@@ -6,72 +6,82 @@ stufen: [korpus, inventar, struktur, guide, montage]
|
||||
knoten:
|
||||
- {id: recherche_plan, typ: llm, worker: korpus.recherche_plan, stufe: korpus,
|
||||
titel: "Recherche-Plan", beschreibung: "Plant die Suchanfragen zum Thema",
|
||||
skills: [planer, deutsch-praezise, recherche-plan], max_parallel: 16, klasse: kurz}
|
||||
skills: [planer, deutsch-praezise, recherche-plan], max_parallel: 16, timeout: [60, 20]}
|
||||
- {id: suche, typ: system, worker: suche.suchen, stufe: korpus, max_parallel: 4,
|
||||
titel: "Websuche", beschreibung: "Sucht Quellen im Web, filtert Off-Topic raus"}
|
||||
titel: "Websuche", beschreibung: "Sucht Quellen im Web"}
|
||||
- {id: laden, typ: system, worker: laden.laden, stufe: korpus, max_parallel: 4,
|
||||
titel: "Quellen laden", beschreibung: "Lädt Quellen und friert den Text ein"}
|
||||
titel: "Quellen laden", beschreibung: "Lädt Quellen, friert den Text ein, beurteilt Nützlichkeit",
|
||||
extra_skills: {urteil: [richter, deutsch-praezise, quellen-urteil]}}
|
||||
- {id: soll_extraktion, typ: llm, worker: korpus.soll_extraktion, stufe: korpus,
|
||||
titel: "Soll-Punkte sammeln", beschreibung: "Zieht aus jeder Quelle, was der Guide abdecken soll",
|
||||
skills: [extraktor, deutsch-praezise, soll-extraktion], max_parallel: 16, klasse: mittel}
|
||||
skills: [extraktor, deutsch-praezise, soll-extraktion], max_parallel: 16, klasse: mittel, timeout: [180, 60]}
|
||||
- {id: soll_konsens, typ: llm, worker: korpus.soll_konsens, stufe: korpus,
|
||||
titel: "Soll-Konsens", beschreibung: "Bestätigt Punkte mit genug unabhängigen Belegen",
|
||||
skills: [richter, deutsch-praezise, soll-konsens], barriere: true}
|
||||
titel: "Soll-Konsens", beschreibung: "Fasst gleiche Punkte zusammen, bestätigt wörtlich belegte",
|
||||
skills: [richter, deutsch-praezise, soll-konsens], barriere: true, timeout: [240, 2]}
|
||||
- {id: gate_korpus, typ: gate, worker: korpus.gate, stufe: korpus,
|
||||
titel: "Gate Korpus", beschreibung: "Prüft Quellen und Soll, lässt sonst nachsuchen"}
|
||||
titel: "Gate Korpus", beschreibung: "Prüft Quellen und Soll, lässt sonst nachsuchen",
|
||||
runden_max: 4}
|
||||
|
||||
- {id: atom_extraktion, typ: llm, worker: inventar.atom_extraktion, stufe: inventar,
|
||||
titel: "Atome extrahieren", beschreibung: "Holt die kleinsten Lerneinheiten aus den Quellen",
|
||||
skills: [extraktor, deutsch-praezise, atom-extraktion], max_parallel: 16, klasse: mittel}
|
||||
skills: [extraktor, deutsch-praezise, atom-extraktion], max_parallel: 16, klasse: mittel, timeout: [240, 60]}
|
||||
- {id: anker_fix, typ: llm, worker: inventar.anker_fix, stufe: inventar,
|
||||
titel: "Anker reparieren", beschreibung: "Repariert Zitate, die nicht zur Quelle passen",
|
||||
skills: [extraktor, deutsch-praezise, anker-fix], max_parallel: 16}
|
||||
skills: [extraktor, deutsch-praezise, anker-fix], max_parallel: 16, timeout: [180, 4]}
|
||||
- {id: dedup, typ: llm, worker: dedup.dedup, stufe: inventar,
|
||||
titel: "Dubletten-Urteil", beschreibung: "Findet doppelte Atome und verschmilzt sie",
|
||||
skills: [richter, deutsch-praezise, dedup-urteil], barriere: true, klasse: kurz}
|
||||
skills: [richter, deutsch-praezise, dedup-urteil], barriere: true, klasse: kurz, timeout: [120, 8]}
|
||||
- {id: soll_zuordnung, typ: llm, worker: inventar.soll_zuordnung, stufe: inventar,
|
||||
titel: "Soll-Zuordnung", beschreibung: "Ordnet jedes Atom einem Soll-Punkt zu",
|
||||
skills: [richter, deutsch-praezise, soll-zuordnung], max_parallel: 16, klasse: kurz}
|
||||
skills: [richter, deutsch-praezise, soll-zuordnung], max_parallel: 16, klasse: kurz, timeout: [120, 3]}
|
||||
- {id: luecke, typ: llm, worker: inventar.luecke, stufe: inventar,
|
||||
titel: "Lücken füllen", beschreibung: "Extrahiert nach, wo Soll-Punkte ohne Atom sind",
|
||||
skills: [extraktor, deutsch-praezise, luecke-nachextraktion], max_parallel: 16, klasse: mittel}
|
||||
skills: [extraktor, deutsch-praezise, luecke-nachextraktion], max_parallel: 16, klasse: mittel,
|
||||
timeout: [180, 45], extra_skills: {freispruch: [richter, deutsch-praezise, luecke-freispruch]}}
|
||||
- {id: verdichtung, typ: llm, worker: inventar.verdichtung, stufe: inventar,
|
||||
titel: "Verdichtung", beschreibung: "Verschmilzt Splitter-Atome zum minimalen Set",
|
||||
skills: [richter, deutsch-praezise, verdichtung], max_parallel: 16, klasse: kurz}
|
||||
skills: [richter, deutsch-praezise, verdichtung], max_parallel: 16, klasse: kurz, timeout: [180, 0]}
|
||||
- {id: gate_inventar, typ: gate, worker: inventar.gate, stufe: inventar,
|
||||
titel: "Gate Atome", beschreibung: "Prüft Abdeckung und Dubletten, stößt Reparaturen an"}
|
||||
titel: "Gate Atome", beschreibung: "Prüft Abdeckung und Dubletten, stößt Reparaturen an",
|
||||
runden_max: 8}
|
||||
|
||||
- {id: lernziele, typ: llm, worker: struktur.lernziele, stufe: struktur,
|
||||
titel: "Lernziele", beschreibung: "Formuliert Lernziele aus den Atomen",
|
||||
skills: [planer, deutsch-praezise, lernziele], max_parallel: 16, klasse: kurz}
|
||||
skills: [planer, deutsch-praezise, lernziele], max_parallel: 16, klasse: kurz, timeout: [120, 3]}
|
||||
- {id: bausteine, typ: llm, worker: struktur.bausteine, stufe: struktur,
|
||||
titel: "Bausteine", beschreibung: "Bündelt Atome je Lernziel zu Bausteinen",
|
||||
skills: [planer, deutsch-praezise, bausteine], max_parallel: 16}
|
||||
skills: [planer, deutsch-praezise, bausteine], max_parallel: 16, timeout: [120, 3]}
|
||||
- {id: ordnung, typ: llm, worker: struktur.ordnung, stufe: struktur,
|
||||
titel: "Reihenfolge", beschreibung: "Bringt Bausteine in Lernreihenfolge und Kapitel",
|
||||
skills: [planer, deutsch-praezise, ordnung], barriere: true}
|
||||
skills: [planer, deutsch-praezise, ordnung], barriere: true, timeout: [180, 3],
|
||||
extra_skills: {schnitt: [planer, deutsch-praezise, themen-schnitt]}}
|
||||
- {id: gate_struktur, typ: gate, worker: struktur.gate, stufe: struktur,
|
||||
titel: "Gate Struktur", beschreibung: "Prüft die Gliederung, lässt Bausteine nachbessern"}
|
||||
|
||||
- {id: writer, typ: llm, worker: guide.writer, stufe: guide,
|
||||
titel: "Schreiben", beschreibung: "Schreibt eine Guide-Section je Baustein",
|
||||
skills: [autor, deutsch-praezise, guide-lesbarkeit, writer], max_parallel: 16}
|
||||
skills: [autor, deutsch-praezise, guide-lesbarkeit, darstellung, writer], max_parallel: 16, timeout: [420, 0]}
|
||||
- {id: kapitel_intro, typ: llm, worker: guide.kapitel_intro, stufe: guide, max_parallel: 16,
|
||||
titel: "Kapitel-Intros", beschreibung: "Schreibt die Einleitung je Kapitel",
|
||||
skills: [autor, deutsch-praezise, kapitel-intro]}
|
||||
skills: [autor, deutsch-praezise, kapitel-intro], timeout: [90, 0]}
|
||||
- {id: det_pruefung, typ: system, worker: guide.det_pruefung, stufe: guide, max_parallel: 8,
|
||||
titel: "Format-Prüfung", beschreibung: "Prüft Sections deterministisch auf Regelverstöße"}
|
||||
- {id: llm_pruefung, typ: llm, worker: guide.llm_pruefung, stufe: guide,
|
||||
titel: "Inhalts-Prüfung", beschreibung: "Prüft Sections auf Fehler gegen die Quellen-Anker",
|
||||
skills: [richter, deutsch-praezise, guide-pruefung], max_parallel: 16, klasse: mittel}
|
||||
skills: [richter, deutsch-praezise, guide-pruefung], max_parallel: 16, klasse: mittel, timeout: [240, 60]}
|
||||
- {id: fix, typ: llm, worker: guide.fix, stufe: guide,
|
||||
titel: "Korrektur", beschreibung: "Behebt gefundene Befunde in den Sections",
|
||||
skills: [autor, deutsch-praezise, guide-lesbarkeit, guide-fix], max_parallel: 16}
|
||||
skills: [autor, deutsch-praezise, guide-lesbarkeit, darstellung, guide-fix], max_parallel: 16,
|
||||
timeout: [300, 0], extra_skills: {klaerung: [richter, deutsch-praezise, klaerung]}}
|
||||
- {id: redundanz, typ: llm, worker: redundanz.pruefen, stufe: guide,
|
||||
titel: "Dopplungs-Check", beschreibung: "Sucht Wiederholungen zwischen den Sections",
|
||||
skills: [richter, deutsch-praezise, redundanz-urteil], barriere: true, klasse: kurz}
|
||||
skills: [richter, deutsch-praezise, redundanz-urteil], barriere: true, klasse: kurz, timeout: [120, 8]}
|
||||
- {id: lektorat, typ: llm, worker: guide.lektorat, stufe: guide,
|
||||
titel: "Lektorat", beschreibung: "Liest den ganzen Guide: Dopplungen, Splitter, zähe Übergänge",
|
||||
skills: [richter, deutsch-praezise, lektorat], max_parallel: 1, timeout: [300, 0]}
|
||||
- {id: gate_guide, typ: gate, worker: guide.gate, stufe: guide,
|
||||
titel: "Gate Guide", beschreibung: "Prüft den Text, stößt Fixes und Re-Prüfung an"}
|
||||
titel: "Gate Guide", beschreibung: "Prüft den Text, stößt Fixes und Re-Prüfung an",
|
||||
runden_max: 12}
|
||||
|
||||
- {id: montage, typ: system, worker: montage.montieren, stufe: montage,
|
||||
titel: "Montage", beschreibung: "Fügt alle Teile zum fertigen Guide zusammen"}
|
||||
@@ -102,6 +112,7 @@ kanten:
|
||||
- {von: fix, nach: redundanz, art: normal}
|
||||
- {von: llm_pruefung, nach: redundanz, art: normal}
|
||||
- {von: kapitel_intro, nach: redundanz, art: normal}
|
||||
- {von: redundanz, nach: lektorat, art: normal}
|
||||
- {von: redundanz, nach: gate_guide, art: normal}
|
||||
- {von: gate_guide, nach: montage, art: normal}
|
||||
|
||||
|
||||
28
skills/README.md
Normal file
28
skills/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Skill-Vertrag (Lektion 106: ein Protokoll, keine Sonderfälle)
|
||||
|
||||
Komposition: 1 Rolle + 0..n Stile + 1 Aufgabe. Nur die Aufgabe definiert das
|
||||
Ausgabeformat. Welche Skills ein Knoten nutzt, steht AUSSCHLIESSLICH in
|
||||
pipeline.yaml (`skills:` + `extra_skills:`) — nie im Worker-Code.
|
||||
|
||||
## Prompt-Struktur (Parser und Fakes verlassen sich darauf)
|
||||
|
||||
- Eingabe-Abschnitte: `NAME:` in GROSSBUCHSTABEN am Zeilenanfang, Inhalt in
|
||||
den Folgezeilen. Erlaubte Klammer-Zusätze klein dahinter.
|
||||
- Nummerierte Mehrfach-Eingaben: `=== NAME <n> ===`-Zeilen.
|
||||
- Der Format-Abschnitt beginnt mit einer Zeile, die mit `Ausgabeformat`
|
||||
anfängt. Regeln stehen unter `Regeln:`.
|
||||
|
||||
## Ausgabe-Protokoll
|
||||
|
||||
- Blöcke: `===MARKE===`, Felder `FELD: wert` (GROSS, 2-20 Zeichen).
|
||||
- Leere pro Eingabe-Element: genau EIN Marker projektweit — `===LEER===`
|
||||
mit dem ID-Feld des Elements. Kein NICHTS, kein OK, kein Freitext.
|
||||
- Leeres Einzel-Feld: genau EIN Wert projektweit — `KEINER`.
|
||||
- Paar-Aufgaben rendern Eingaben einheitlich über `textkit.paar_liste`
|
||||
(`PAAR n:` / `A:` / `B:`).
|
||||
- Protokoll-Tokens (Urteile wie `dublette`, `mangel`) sind sprachneutrale
|
||||
Enum-Marker und ändern sich bei Übersetzungen NICHT.
|
||||
|
||||
Sprachvorgabe für Prosa-Ausgaben liegt allein im Stil-Skill
|
||||
(deutsch-praezise) — Aufgaben-Prompts enthalten keine sprachspezifischen
|
||||
Beispiel-Floskeln.
|
||||
@@ -13,9 +13,9 @@ ATOME:
|
||||
QUELLTEXT:
|
||||
{{quelltext}}
|
||||
|
||||
Regeln: ZITAT zeichengetreu aus dem Quelltext (mindestens ein voller Satz); gibt es keine passende Stelle, schreibe exakt VERWERFEN.
|
||||
Regeln: ZITAT zeichengetreu aus dem Quelltext (mindestens ein voller Satz); gibt es keine passende Stelle, schreibe exakt KEINER.
|
||||
|
||||
Ausgabeformat — GENAU so, ein Block pro Atom:
|
||||
===FIX===
|
||||
ID: <Atom-ID>
|
||||
ZITAT: <wörtliches Zitat oder VERWERFEN>
|
||||
ZITAT: <wörtliches Zitat oder KEINER>
|
||||
|
||||
@@ -7,10 +7,10 @@ platzhalter: [ziel, atome, band]
|
||||
|
||||
Teile die Atome des Lernziels „{{ziel}}" in Bausteine mit je {{band}} Atomen. Ein Baustein ist eine didaktische Einheit, die man am Stück lernt.
|
||||
|
||||
ATOME (in Quellreihenfolge):
|
||||
ATOME:
|
||||
{{atome}}
|
||||
|
||||
Regeln: jede Atom-ID genau einmal; Reihenfolge innerhalb eines Bausteins beibehalten; TITEL beschreibt den Baustein-Inhalt.
|
||||
Regeln: die Atome stehen in Quellreihenfolge; jede Atom-ID genau einmal; Reihenfolge innerhalb eines Bausteins beibehalten; TITEL beschreibt den Baustein-Inhalt.
|
||||
|
||||
Ausgabeformat — GENAU so, ein Block pro Baustein:
|
||||
===BAUSTEIN===
|
||||
|
||||
@@ -16,7 +16,7 @@ BEFUNDE:
|
||||
BELEGE (Faktenbasis):
|
||||
{{belege}}
|
||||
|
||||
Regeln: Marker-Zeilen `<!-- atom: id -->` unverändert lassen; bei Redundanz-Befunden die Wiederholung ersatzlos streichen oder durch einen knappen Prosa-Halbsatz ersetzen („wie bereits beim … beschrieben") — KEINE Links; keine H1-H3, keine Fettzeilen-Definitionen; Zahlen nur mit ehrlicher Quellenangabe.
|
||||
Regeln: Marker-Zeilen `<!-- atom: id -->` unverändert lassen; bei Redundanz-Befunden die Wiederholung ersatzlos streichen oder durch einen knappen Prosa-Halbsatz ersetzen („wie bereits beim … beschrieben") — KEINE Links; Zahlen nur mit benannter Quelle. Vorhandene Tabellen, Listen und Code-Blöcke strukturell erhalten — nur ihren Inhalt korrigieren.
|
||||
|
||||
Ausgabeformat:
|
||||
===TEXT===
|
||||
|
||||
@@ -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: <Section-ID>
|
||||
ART: falsch | luecke | stil
|
||||
STELLE: <wörtliches Kurzzitat aus der Section>
|
||||
DETAIL: <ein Satz>
|
||||
===OK===
|
||||
===LEER===
|
||||
SECTION: <Section-ID>
|
||||
|
||||
@@ -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===
|
||||
|
||||
25
skills/aufgabe/lektorat.md
Normal file
25
skills/aufgabe/lektorat.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: lektorat
|
||||
art: aufgabe
|
||||
beschreibung: Den ganzen Guide als Lektor lesen — Dopplungen, Splitter, zähe Übergänge
|
||||
platzhalter: [guide]
|
||||
---
|
||||
|
||||
Lies den kompletten Lern-Guide wie ein Lektor eines Kurzbuchs. Die Sections sind mit [SECTION <id>] markiert.
|
||||
|
||||
GUIDE:
|
||||
{{guide}}
|
||||
|
||||
Suche NUR die drei größten Probleme je Art:
|
||||
(a) redundanz = derselbe Fakt, dieselbe Studie oder Aussage wird in mehreren Sections erneut erklärt (Rückverweis in einem Halbsatz ist okay);
|
||||
(b) stil = eine Section ist ein Splitter (nur 1-2 Sätze) oder ein Übergang ist zäh/formelhaft und bremst den Lesefluss.
|
||||
|
||||
Nenne höchstens 8 Befunde, die wichtigsten zuerst. SECTION ist die ID der Section, die geändert werden soll (bei Dopplung: die spätere).
|
||||
|
||||
Ausgabeformat — GENAU so, ein Block pro Befund; keine Befunde → als einzige Ausgabe ein LEER-Block:
|
||||
===BEFUND===
|
||||
SECTION: <id>
|
||||
ART: redundanz | stil
|
||||
DETAIL: <ein Satz: was doppelt/zäh ist und was stattdessen>
|
||||
===LEER===
|
||||
OK: ja
|
||||
@@ -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: <Soll-ID>
|
||||
TITEL: …
|
||||
TYP: …
|
||||
DEFINITION: …
|
||||
ZITAT: …
|
||||
===NICHTS===
|
||||
===LEER===
|
||||
SOLL: <Soll-ID>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: quellen-relevanz
|
||||
art: aufgabe
|
||||
beschreibung: Suchtreffer auf inhaltliche Relevanz für ein Lernthema filtern
|
||||
platzhalter: [thema, treffer]
|
||||
---
|
||||
|
||||
Unten stehen Web-Suchtreffer für einen Lern-Guide zum Thema „{{thema}}". Behalte nur Treffer, die inhaltlich LEHREN (Erklärungen, Anleitungen, Hintergründe).
|
||||
|
||||
TREFFER:
|
||||
{{treffer}}
|
||||
|
||||
Aussortieren: Produkt-/App-Listen, Shops, Werbeseiten, Foren-Schnipsel, Off-Topic — auch Job-, HR-, Karriere- und Ratgeberportale, deren Kern ein ANDERES Thema ist (die das Thema nur beiläufig erwähnen).
|
||||
|
||||
Ausgabeformat — GENAU so:
|
||||
===RELEVANT===
|
||||
IDS: <kommagetrennte Nummern der behaltenen Treffer>
|
||||
21
skills/aufgabe/quellen-urteil.md
Normal file
21
skills/aufgabe/quellen-urteil.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: quellen-urteil
|
||||
art: aufgabe
|
||||
beschreibung: Geladene Quelle auf Nützlichkeit für ein Lernthema beurteilen
|
||||
platzhalter: [thema, titel, auszug]
|
||||
---
|
||||
|
||||
Entscheide, ob diese geladene Quelle für einen Lern-Guide zum Thema „{{thema}}" nützlich ist.
|
||||
|
||||
TITEL: {{titel}}
|
||||
|
||||
AUSZUG (Anfang des Textes):
|
||||
{{auszug}}
|
||||
|
||||
Nützlich = der Text LEHRT etwas zum Thema (Erklärungen, Anleitungen, Hintergründe, Referenz).
|
||||
Nicht nützlich = Produkt-/App-Listen, Shops, Kurs-Werbung, Login-/Fehlerseiten, reine Linklisten, oder der Kern ist ein ANDERES Thema.
|
||||
|
||||
Ausgabeformat — GENAU so, genau ein Block:
|
||||
===URTEIL===
|
||||
NUETZLICH: ja | nein
|
||||
GRUND: <ein kurzer Satz>
|
||||
@@ -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===
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: soll-beleg
|
||||
art: aufgabe
|
||||
beschreibung: Gebündelte Beleg-Nachsuche für offene Soll-Punkte in einer Quelle
|
||||
platzhalter: [punkte, quelltext]
|
||||
---
|
||||
|
||||
Suche für jeden der folgenden Soll-Punkte ein wörtliches Beleg-Zitat im Quelltext.
|
||||
|
||||
PUNKTE:
|
||||
{{punkte}}
|
||||
|
||||
QUELLTEXT:
|
||||
{{quelltext}}
|
||||
|
||||
Regeln: BELEG zeichengetreu aus dem Quelltext; findet sich keiner, schreibe exakt KEIN BELEG.
|
||||
|
||||
Ausgabeformat — GENAU so, ein Block pro Punkt:
|
||||
===BELEG===
|
||||
ID: <Punkt-ID>
|
||||
BELEG: <wörtliches Zitat oder KEIN BELEG>
|
||||
@@ -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 `<!-- atom: id -->` genau einmal; KEINE Markdown-Links; keine Überschrift für den Baustein-Titel und keine H1-H3 (Unterpunkte als ####); Feldnamen/Rohformate aus der Eingabe nie übernehmen; Zahlen und Statistiken nur mit ehrlicher Quellenangabe aus den Belegen („laut Anbieter X", „eine Studie der Universität Y") — nie pauschal „Studien zufolge", nichts dazuerfinden.
|
||||
Regeln: Länge {{band}} Wörter; jeder Marker `<!-- atom: id -->` genau einmal; KEINE Markdown-Links; keine Überschrift für den Baustein-Titel; Feldnamen/Rohformate aus der Eingabe nie übernehmen; Zahlen und Quantitäten nur mit benannter Quelle aus den Belegen — nie pauschal zugeschrieben, nichts dazuerfinden.
|
||||
|
||||
Ausgabeformat — GENAU so, keine Prosa außerhalb:
|
||||
===TEXT===
|
||||
|
||||
13
skills/stil/darstellung.md
Normal file
13
skills/stil/darstellung.md
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: darstellung
|
||||
art: stil
|
||||
beschreibung: Darstellungsform folgt dem Inhaltstyp — Liste, Tabelle, Code statt Schachtelsatz
|
||||
---
|
||||
|
||||
Wähle je Inhalt die am schnellsten lesbare Form:
|
||||
- Abläufe und Verfahren mit festen Schritten → nummerierte Liste (1., 2., …), ein Schritt pro Zeile.
|
||||
- Gegenüberstellungen, Varianten, Kennwerte → Tabelle mit maximal 4 Spalten und kurzen Zellen.
|
||||
- Formale Notation (Code, Formeln, Konfiguration, strukturierte Auszüge) → umschlossener Block mit passender Kennzeichnung; kurze Bezeichner im Fließtext als `Inline-Code`.
|
||||
- Alles andere → knappe Prosa.
|
||||
|
||||
Grenzen: Form ersetzt Prosa, sie ergänzt sie nicht — kein Inhalt doppelt als Text UND Tabelle. Keine zusätzlichen Beispiele, Analogien oder Merksätze erfinden. Höchstens zwei ####-Zwischentitel pro Section.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
52
tests/test_lektorat.py
Normal file
52
tests/test_lektorat.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Lektorat: EIN Blick über den ganzen Guide, Befunde in die Fix-Maschine."""
|
||||
from backend import db, guide
|
||||
from .conftest import topic_anlegen
|
||||
|
||||
|
||||
def _guide_anlegen(topic):
|
||||
kid = db.insert("kapitel", topic=topic, titel="K1", ord=0)
|
||||
bids = []
|
||||
for i in range(2):
|
||||
zid = db.insert("lernziele", topic=topic, text=f"z{i}")
|
||||
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel=f"B{i}",
|
||||
kapitel_id=kid, ord=i)
|
||||
db.insert("sections", baustein_id=bid, text=f"Text der Section {i}.")
|
||||
bids.append(bid)
|
||||
return bids
|
||||
|
||||
|
||||
async def test_lektorat_meldet_befund(monkeypatch):
|
||||
from backend import llm as llm_mod
|
||||
topic = topic_anlegen()
|
||||
run_id = db.insert("runs", topic=topic, status="running")
|
||||
bids = _guide_anlegen(topic)
|
||||
tid = db.insert("tasks", run_id=run_id, topic=topic, knoten="lektorat",
|
||||
item="r1:lektorat", status="laufend")
|
||||
|
||||
async def antwort(**kw):
|
||||
assert f"[SECTION {bids[1]}]" in kw["werte"]["guide"]
|
||||
return (f"===BEFUND===\nSECTION: {bids[1]}\nART: redundanz\n"
|
||||
f"DETAIL: Fakt doppelt erklärt, kürzen.")
|
||||
|
||||
monkeypatch.setattr(llm_mod, "call", antwort)
|
||||
erg = await guide.lektorat({"id": tid, "run_id": run_id, "topic": topic,
|
||||
"knoten": "lektorat", "runde": 1,
|
||||
"item": "r1:lektorat", "payload": "{}"})
|
||||
assert erg.daten["befunde"] == 1
|
||||
b = db.one("SELECT * FROM befunde WHERE run_id=? AND knoten='lektorat'",
|
||||
run_id)
|
||||
assert b["art"] == "redundanz" and b["item"] == f"baustein:{bids[1]}"
|
||||
|
||||
|
||||
async def test_lektorat_laeuft_nur_einmal(monkeypatch):
|
||||
topic = topic_anlegen()
|
||||
run_id = db.insert("runs", topic=topic, status="running")
|
||||
_guide_anlegen(topic)
|
||||
db.insert("tasks", run_id=run_id, topic=topic, knoten="lektorat",
|
||||
item="r1:lektorat", status="fertig")
|
||||
t2 = db.insert("tasks", run_id=run_id, topic=topic, knoten="lektorat",
|
||||
item="r2:lektorat", status="laufend")
|
||||
erg = await guide.lektorat({"id": t2, "run_id": run_id, "topic": topic,
|
||||
"knoten": "lektorat", "runde": 2,
|
||||
"item": "r2:lektorat", "payload": "{}"})
|
||||
assert erg.daten.get("skip")
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user