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

View File

@@ -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)