update
This commit is contained in:
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:
|
||||
db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
|
||||
knoten="recherche_plan", item=f"r1:lens:{lens}", runde=1,
|
||||
payload=db.j({"lens": lens}), erzeugt_von="start")
|
||||
"""Initiale Tasks: Knoten der Stufe 1 ohne Normal-Vorgänger (aus dem
|
||||
Graph abgeleitet, keine Knoten-Namen im Code). Idempotent."""
|
||||
g = graph.get()
|
||||
ziele = {k["nach"] for k in g.kanten if k["art"] == "normal"}
|
||||
for kn in g.knoten.values():
|
||||
if kn.stufe == g.stufen[0] and kn.id not in ziele:
|
||||
db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
|
||||
knoten=kn.id, item="r1:start", runde=1,
|
||||
payload="{}", erzeugt_von="start")
|
||||
|
||||
@@ -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,8 +39,10 @@ def _bekannt_kontext(topic: str, baustein: dict) -> str:
|
||||
baustein["id"], topic)
|
||||
if not fruehere:
|
||||
return "-"
|
||||
return "\n".join(f"- {f['titel']} (im Abschnitt '{f['bt']}')"
|
||||
for f in fruehere[:60])
|
||||
return "\n".join(
|
||||
config.TEXTE["abschnitt_verweis"].format(titel=f["titel"],
|
||||
abschnitt=f["bt"])
|
||||
for f in fruehere[:60])
|
||||
|
||||
|
||||
@engine.worker("guide.writer")
|
||||
@@ -64,7 +66,7 @@ async def writer(task: dict) -> engine.Ergebnis:
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="writer", item=task["item"],
|
||||
role="writer",
|
||||
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "writer"],
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"baustein": b["titel"], "atome": atome_text,
|
||||
"belege": belege.fenster_fuer_atome([a["id"] for a in atome])[:40000],
|
||||
"bekannt": _bekannt_kontext(task["topic"], b),
|
||||
@@ -167,10 +169,8 @@ async def det_pruefung(task: dict) -> engine.Ergebnis:
|
||||
frisch = {(b["art"], b["item"], b["detail"][:400]) for b in befunde}
|
||||
for row in db.query(
|
||||
"SELECT id, art, item, detail FROM befunde WHERE item=? AND "
|
||||
"status='offen' AND art IN ('marker_fehlend','marker_fremd',"
|
||||
"'marker_doppelt','vorwaerts','laenge','format_leak',"
|
||||
"'link_im_text','atom_echo','h_ebene')",
|
||||
f"baustein:{bid}"):
|
||||
f"status='offen' AND art IN ({','.join('?' * len(arten.DET_ARTEN))})",
|
||||
f"baustein:{bid}", *arten.DET_ARTEN):
|
||||
if (row["art"], row["item"], row["detail"][:400]) not in frisch:
|
||||
db.update("befunde", "id=?", (row["id"],), status="veraltet")
|
||||
for b in befunde:
|
||||
@@ -210,14 +210,14 @@ async def llm_pruefung(task: dict) -> engine.Ergebnis:
|
||||
text = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="llm_pruefung",
|
||||
item=f"{task['item']}:p{i}", role="judge", n=len(aktive),
|
||||
skill_namen=["richter", "deutsch-praezise", "guide-pruefung"],
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"sections": sections_text})
|
||||
if text is None:
|
||||
return None
|
||||
return {"befunde": [b for b in textkit.bloecke(text, "BEFUND")
|
||||
if b.get("art") in ("falsch", "luecke", "stil")
|
||||
and b.get("section", "").strip().isdigit()],
|
||||
"ok": {int(b["section"]) for b in textkit.bloecke(text, "OK")
|
||||
"ok": {int(b["section"]) for b in textkit.bloecke(text, "LEER")
|
||||
if b.get("section", "").strip().isdigit()}}
|
||||
|
||||
stimmen = await panels.panel(ruf, config.PRUEF_PANEL)
|
||||
@@ -277,7 +277,7 @@ async def fix(task: dict) -> engine.Ergebnis:
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="fix", item=task["item"],
|
||||
role="writer",
|
||||
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "guide-fix"],
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"section": sec["text"],
|
||||
"befunde": "\n".join(f"- [{b['art']}] {b['detail']}" for b in offen),
|
||||
"belege": belege.fenster_fuer_atome(
|
||||
@@ -308,7 +308,7 @@ async def _klaerung(task, bid, sec, offen) -> engine.Ergebnis:
|
||||
text = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="fix",
|
||||
item=f"{task['item']}:k{b['id']}:p{i}", role="judge",
|
||||
skill_namen=["richter", "deutsch-praezise", "klaerung"],
|
||||
skill_namen=graph.skills_von(task["knoten"], extra="klaerung"),
|
||||
werte={"befund": f"[{b['art']}] {b['detail']}", "belege": beleg_text})
|
||||
urteile = textkit.bloecke(text or "", "URTEIL")
|
||||
return urteile[0].get("urteil") if urteile else None
|
||||
@@ -339,7 +339,7 @@ async def kapitel_intro(task: dict) -> engine.Ergebnis:
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="kapitel_intro",
|
||||
item=task["item"], role="writer",
|
||||
skill_namen=["autor", "deutsch-praezise", "kapitel-intro"],
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"kapitel": kap["titel"],
|
||||
"sections": "\n".join(f"- {t['titel']}" for t in titel),
|
||||
"vorherige": "\n".join(f"- {v['titel']}" for v in vorherige)
|
||||
@@ -352,6 +352,46 @@ async def kapitel_intro(task: dict) -> engine.Ergebnis:
|
||||
{"knoten": "redundanz", "item": f"r{task['runde']}:redundanz"}])
|
||||
|
||||
|
||||
@engine.worker("guide.lektorat")
|
||||
async def lektorat(task: dict) -> engine.Ergebnis:
|
||||
"""Gesamt-Wächter (Kurzbuch-Ziel): EIN Blick über den ganzen Guide —
|
||||
section-übergreifende Dopplungen, Splitter-Sections, zähe Übergänge.
|
||||
Läuft einmal je Topic; Befunde laufen durch die normale Fix-Maschine."""
|
||||
topic = task["topic"]
|
||||
if db.one("SELECT id FROM tasks WHERE topic=? AND knoten='lektorat' AND "
|
||||
"status='fertig' AND id!=?", topic, task["id"]):
|
||||
return engine.Ergebnis(daten={"skip": True})
|
||||
teile = []
|
||||
for kap in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord",
|
||||
topic):
|
||||
teile.append(f"## {kap['titel']}")
|
||||
for b in db.query(
|
||||
"SELECT b.id, b.titel, s.text FROM bausteine b JOIN sections s "
|
||||
"ON s.baustein_id=b.id WHERE b.kapitel_id=? AND s.text!='' "
|
||||
"ORDER BY b.ord", kap["id"]):
|
||||
teile.append(f"[SECTION {b['id']}] {b['titel']}\n{b['text']}")
|
||||
if len(teile) < 2:
|
||||
return engine.Ergebnis(daten={"skip": True})
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="guide", knoten="lektorat",
|
||||
item=task["item"], role="judge",
|
||||
skill_namen=graph.skills_von(task["knoten"]),
|
||||
werte={"guide": "\n\n".join(teile)[:120_000]})
|
||||
if antwort is None:
|
||||
raise RuntimeError("lektorat ohne Ergebnis")
|
||||
befunde_bloecke = textkit.bloecke(antwort, "BEFUND")
|
||||
if not befunde_bloecke and not textkit.bloecke(antwort, "LEER"):
|
||||
raise RuntimeError("lektorat: weder BEFUND noch LEER") # Format kaputt
|
||||
n = 0
|
||||
for b in befunde_bloecke:
|
||||
sid, art = b.get("section", "").strip(), b.get("art", "").strip()
|
||||
if sid.isdigit() and art in ("redundanz", "stil") and befund_merken(
|
||||
task["run_id"], "lektorat", art, f"baustein:{sid}",
|
||||
b.get("detail", "")[:300], task["runde"]):
|
||||
n += 1
|
||||
return engine.Ergebnis(daten={"befunde": n})
|
||||
|
||||
|
||||
def befund_merken(run_id: int, knoten: str, art: str, item: str, detail: str,
|
||||
runde: int) -> bool:
|
||||
"""Befund-Dedupe (creator2-Schlüssel art+item+detail): existiert derselbe
|
||||
@@ -392,7 +432,8 @@ async def gate(task: dict) -> engine.Ergebnis:
|
||||
continue
|
||||
alle_marker += [int(m) for m in _MARKER_RE.findall(sec["text"])]
|
||||
for f in _det_befunde(topic, b["id"], sec["text"]):
|
||||
if f["art"] == "laenge": # Länge blockiert nicht hart (nur Befund)
|
||||
if not arten.BEFUND_ARTEN.get(f["art"], {"gate_blockiert": True})[
|
||||
"gate_blockiert"]: # z. B. laenge: nur Kennzahl
|
||||
continue
|
||||
if (f["art"], f["item"], f["detail"][:400]) in final:
|
||||
continue
|
||||
@@ -402,7 +443,10 @@ async def gate(task: dict) -> engine.Ergebnis:
|
||||
"detail": "Atom in mehreren Sections gelehrt"})
|
||||
# Kaputte Marker mit erschöpftem Fix → Section NEU schreiben (max 1×!
|
||||
# sonst resettet jedes Neuschreiben den Fix-Cap → Endlos-Karussell)
|
||||
for f in [f for f in befunde if f["art"].startswith("marker")]:
|
||||
# NUR Section-Befunde (item baustein:*) — marker_global_doppelt trägt
|
||||
# item atom:<marke>, dessen Zahl ist KEINE Baustein-Id (latenter Bug)
|
||||
for f in [f for f in befunde if f["art"].startswith("marker")
|
||||
and f["item"].startswith("baustein:")]:
|
||||
bid = f["item"].split(":")[-1]
|
||||
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid)
|
||||
if sec and (sec["fix_versuche"] or 0) >= config.FIX_MAX_VERSUCHE \
|
||||
@@ -414,7 +458,7 @@ async def gate(task: dict) -> engine.Ergebnis:
|
||||
offene = db.query(
|
||||
"SELECT * FROM befunde b WHERE b.run_id IN (SELECT id FROM runs WHERE "
|
||||
"topic=?) AND b.stufe='guide' AND b.status='offen' AND b.art IN "
|
||||
"('falsch','luecke','redundanz','vorwaerts','format_leak')", topic)
|
||||
f"({','.join('?' * len(arten.FIX_ARTEN))})", topic, *arten.FIX_ARTEN)
|
||||
for b in offene:
|
||||
befunde.append({"art": b["art"], "item": b["item"],
|
||||
"detail": b["detail"][:120]})
|
||||
|
||||
@@ -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,
|
||||
roh: bytes) -> engine.Ergebnis:
|
||||
if len(text) < config.SNAPSHOT_MIN_ZEICHEN or "enable javascript" in text.lower():
|
||||
status = "braucht_js" if text and "javascript" in text.lower() else "leer"
|
||||
db.update("quellen", "id=?", (quelle["id"],), status=status)
|
||||
async def _inhalt_nuetzlich(task: dict, quelle: dict, text: str) -> tuple[bool, str]:
|
||||
"""Inhalts-Urteil statt Titel-Filter (Lektion 102): erst laden, dann auf
|
||||
dem echten Text entscheiden. AUSNAHMSLOS für jede Quelle — auch Seed/
|
||||
Wikipedia (Lektion 106: privilegierte Quellen luden „Kosovo" ungeprüft)."""
|
||||
thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
|
||||
"WHERE name=?", task["topic"])["t"]
|
||||
antwort = await llm.call(
|
||||
run_id=task["run_id"], stufe="korpus", knoten="laden",
|
||||
item=f"quelle:{quelle['id']}:urteil", role="judge",
|
||||
skill_namen=graph.skills_von(task["knoten"], extra="urteil"),
|
||||
werte={"thema": thema, "titel": quelle["titel"],
|
||||
"auszug": text[:config.URTEIL_AUSZUG_ZEICHEN]})
|
||||
bloecke = textkit.bloecke(antwort or "", "URTEIL")
|
||||
urteil = bloecke[0].get("nuetzlich", "").strip().lower() if bloecke else ""
|
||||
if urteil not in ("ja", "nein"): # Ausfall/kaputt → Retry, kein Silent-Keep
|
||||
raise RuntimeError("quellen-urteil ohne verwertbare Antwort")
|
||||
return urteil == "ja", bloecke[0].get("grund", "")[:80]
|
||||
|
||||
|
||||
async def _snapshot_ablegen(task: dict, quelle: dict, text: str,
|
||||
roh: bytes) -> engine.Ergebnis:
|
||||
if len(text) < config.SNAPSHOT_MIN_ZEICHEN:
|
||||
# zu wenig extrahierbarer Text (auch JS-Walls landen hier) — keine
|
||||
# Sprach-/Phrasen-Heuristik (Lektion 106)
|
||||
db.update("quellen", "id=?", (quelle["id"],), status="leer")
|
||||
db.insert("befunde", run_id=task["run_id"], stufe="korpus", knoten="laden",
|
||||
art=status, item=quelle["url"][:120])
|
||||
return engine.Ergebnis(daten={status: True})
|
||||
art="leer", item=quelle["url"][:120])
|
||||
return engine.Ergebnis(daten={"leer": True})
|
||||
|
||||
hash_ = hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
dublette = db.one("SELECT id FROM quellen WHERE topic=? AND hash=? AND id!=?",
|
||||
@@ -171,6 +170,11 @@ def _snapshot_ablegen(task: dict, quelle: dict, text: str,
|
||||
db.update("quellen", "id=?", (quelle["id"],), status="fehler",
|
||||
grund="inhalt_dublette")
|
||||
return engine.Ergebnis(daten={"dublette": True})
|
||||
nuetzlich, grund = await _inhalt_nuetzlich(task, quelle, text)
|
||||
if not nuetzlich:
|
||||
db.update("quellen", "id=?", (quelle["id"],), status="aussortiert",
|
||||
grund=grund)
|
||||
return engine.Ergebnis(daten={"aussortiert": True})
|
||||
pfad = snapshot_pfad(task["topic"], hash_)
|
||||
_atomar_schreiben(pfad, text.encode("utf-8"))
|
||||
roh_pfad = ""
|
||||
@@ -185,16 +189,14 @@ def _snapshot_ablegen(task: dict, quelle: dict, text: str,
|
||||
|
||||
|
||||
def _folge_tasks(task: dict, quelle: dict) -> list:
|
||||
"""korpus-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen →
|
||||
direkte Atom-Extraktion (Soll steht schon fest)."""
|
||||
from . import textkit
|
||||
if quelle["zweck"].startswith("luecke:"):
|
||||
soll_id = int(quelle["zweck"].split(":")[1])
|
||||
"""korpus-/seed-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen
|
||||
(soll_id gesetzt) → direkte Atom-Extraktion (Soll steht schon fest)."""
|
||||
if quelle["zweck"] == "luecke" and quelle.get("soll_id"):
|
||||
text = snapshot_lesen(quelle)
|
||||
return [{"knoten": "atom_extraktion",
|
||||
"item": f"quelle:{quelle['id']}:a{i}:r1",
|
||||
"payload": {"quelle_id": quelle["id"], "offset": o,
|
||||
"chars": len(c), "soll_id": soll_id}}
|
||||
"chars": len(c), "soll_id": quelle["soll_id"]}}
|
||||
for i, (o, c) in enumerate(textkit.abschnitte(text))]
|
||||
text = snapshot_lesen(quelle)
|
||||
chunks = textkit.abschnitte(text, config.SOLL_CHUNK_CHARS)
|
||||
|
||||
@@ -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}",
|
||||
"url": f"https://{s}.wikipedia.org/wiki/"
|
||||
f"{quote(titel.replace(' ', '_'))}",
|
||||
"backend": f"wikipedia_{s}"} for s in ("de", "en")]
|
||||
gefunden = {} if config.FAKE else {
|
||||
t["backend"]: t for t in reversed(await _wikipedia(titel))}
|
||||
treffer = [gefunden.get(f"wikipedia_{s}",
|
||||
{"titel": f"Wikipedia ({s}): {titel}",
|
||||
"url": f"https://{s}.wikipedia.org/wiki/"
|
||||
f"{quote(titel.replace(' ', '_'))}",
|
||||
"backend": f"wikipedia_{s}"})
|
||||
for s in config.SPRACHEN_SUCHE]
|
||||
fehler = []
|
||||
elif config.FAKE:
|
||||
from . import fakes
|
||||
treffer, fehler = fakes.suche(query), []
|
||||
else:
|
||||
treffer, fehler = await web_suchen(query)
|
||||
# Wikipedia zuerst (kuratierter Grundstock schlägt Blog-Treffer)
|
||||
treffer.sort(key=lambda t: not t["backend"].startswith("wikipedia"))
|
||||
if zweck == "korpus" and treffer and not config.FAKE and not seed:
|
||||
thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
|
||||
"WHERE name=?", task["topic"])["t"]
|
||||
treffer = await _relevanz_filter(task, thema, treffer)
|
||||
# Keine Vorsortierung, kein Titel-Filter — beurteilt wird nach dem Laden
|
||||
# auf dem Inhalt (Lektion 102/106).
|
||||
# Quellen-Deckel je Topic (0 = unbegrenzt, dann stoppt nur die Sättigung)
|
||||
einstellung = db.one("SELECT quellen_max FROM topics WHERE name=?",
|
||||
task["topic"]) or {}
|
||||
@@ -179,17 +166,18 @@ async def suchen(task: dict) -> engine.Ergebnis:
|
||||
deckel = config.QUELLEN_MAX
|
||||
neue = []
|
||||
for t in treffer:
|
||||
if zweck == "korpus" and deckel > 0 and not seed:
|
||||
if zweck == "korpus" and deckel > 0:
|
||||
vorhanden = db.one("SELECT COUNT(*) c FROM quellen WHERE topic=? AND "
|
||||
"zweck='korpus' AND status NOT IN "
|
||||
"('fehler','leer','robots','braucht_js')",
|
||||
"('fehler','leer','braucht_js','aussortiert')",
|
||||
task["topic"])["c"]
|
||||
if vorhanden >= deckel:
|
||||
break
|
||||
url_norm = url_normieren(t["url"])
|
||||
qid = db.insert("quellen", ignore=True, topic=task["topic"],
|
||||
titel=t["titel"][:200], url=t["url"], url_norm=url_norm,
|
||||
backend=t["backend"], runde=task["runde"], zweck=zweck)
|
||||
backend=t["backend"], runde=task["runde"], zweck=zweck,
|
||||
soll_id=payload.get("soll_id"))
|
||||
if qid:
|
||||
neue.append({"knoten": "laden", "item": f"quelle:{qid}",
|
||||
"payload": {"quelle_id": qid}})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user