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

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