From 8fe0e8581fbe2089e14e7619521ceaac55711985 Mon Sep 17 00:00:00 2001 From: team3 Date: Tue, 23 Jun 2026 09:58:43 +0200 Subject: [PATCH] update --- backend/bausteine.py | 199 ++++++++++++------ templates/Prompt/Bausteine-Seiten-Sichtung.md | 14 ++ 2 files changed, 153 insertions(+), 60 deletions(-) create mode 100644 templates/Prompt/Bausteine-Seiten-Sichtung.md diff --git a/backend/bausteine.py b/backend/bausteine.py index 053148a..ae70e09 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -41,10 +41,12 @@ SUBBAUSTEIN_MAX = 40 # Einstufen ist billig (kurzes Urteil, keine Websuche) → größere Pakete, weniger Dateien/Agenten. STUFE_CHUNK = 100 -# Recherche-Loop: 5 Agenten je Runde, Runden bis volle Crawl-Abdeckung / 0 neue / Zeit-Kappe. -RECHERCHE_AGENTEN = 5 -RECHERCHE_QUORUM = 3 # je Runde mind. so viele gültige Slot-Ergebnisse -RECHERCHE_KAPPE = 1800 # Loop-Gesamt-Sekunden (Notbremse, 30 min) +# Recherche: feste Datei-Batches statt Such-Loop → jede Crawl-Seite genau einmal zugeteilt. +RECHERCHE_BATCH = 20 # Crawl-Seiten je Batch +RECHERCHE_READERS = 2 # Reader-Agenten je Batch (Konsens ≥2 innerhalb des Batches) +RECHERCHE_THEMA_AGENTEN = 5 # Web-Modus (Quelle „thema", kein Crawl-Ordner) +RECHERCHE_KAPPE = 1800 # Sicherheits-Deckel je Batch-Agent +SICHTUNG_CHUNK = 80 # Crawl-Seiten je Sichtungs-Agent (Content/Noise-Triage) SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min) KONSOLIDIERUNG_CHUNK = 150 # Konsolidierung ab so vielen Kandidaten chunken @@ -1209,78 +1211,155 @@ async def _set_inventar(topic: str, eintrag: str, status: str) -> None: await db.set_baustein_status(topic, norm, status) -async def _recherche_loop(ctx: GenContext, set_p, files: dict, q: dict, ordner, instructions: str) -> bool: - """Füllt DB-Tabelle `bausteine` mit Kandidaten (+ Nennungszähler). Loop: je Runde - RECHERCHE_AGENTEN Agenten; re-promptet mit noch offenen Crawl-Seiten, bis alle gelesen / - 0 neue / Zeit-Kappe. Resume: fertiger Schritt wird übersprungen. → True (ok) / False (Abbruch).""" +def _sichtung_schema(data) -> list[str] | None: + """{"noise": ["datei.txt", …]} → Liste Dateinamen (leer erlaubt) · sonst None.""" + if not isinstance(data, dict) or not isinstance(data.get("noise"), list): + return None + return [s for x in data["noise"] if (s := str(x).strip())] + + +async def _sichte_inhalt(ctx: GenContext, set_p, files: dict, ordner, pages: list[str]) -> set[str]: + """Content/Noise-Triage nach dem Crawl. Ein Agent je Chunk markiert eindeutigen Noise; + alles andere bleibt Content (konservativ). Fehler/Ausfall → Seiten bleiben Content (fail-open). + → Menge der Content-Dateinamen (nie leer, solange pages nicht leer).""" + topic, is_cancelled = ctx.topic, ctx.is_cancelled + pages = list(pages) + if not pages: + return set() + arbeit = files["arbeit"] + ordner = Path(ordner) + + def _snippet(fn: str) -> str: + zeilen = _read(ordner / fn).splitlines() + url = zeilen[0][len("QUELLE:"):].strip() if zeilen and zeilen[0].startswith("QUELLE:") else "" + body = " ".join(z.strip() for z in zeilen[1:] if z.strip())[:200] + return f"- {fn} · {url} · {body}" + + chunks = _chunk_nums(sorted(pages), max(1, math.ceil(len(pages) / SICHTUNG_CHUNK))) + + async def _chunk(ci: int, chunk: list[str]) -> set[str]: + fp = arbeit / f"sichtung-c{ci}.json" + fp.unlink(missing_ok=True) + seiten = "\n".join(_snippet(fn) for fn in chunk) + status, res = await run_single_slot( + ctx, f"Seiten-Sichtung {ci}", + key=f"bausteine-{topic}-sichtung-c{ci}", + prompt=_prompt("Bausteine-Seiten-Sichtung", topic=topic, seiten=seiten, out_path=fp), + role="judge", capabilities="files", + payload=lambda result, p=fp: _sichtung_schema(_json_datei(p)), + timeout=_timeout("recherche_mapping", len(chunk)), + ) + if res: # nur Dateinamen, die wirklich in diesem Chunk sind → Noise + cs = set(chunk) + return {n for n in res if n in cs} + return set() # Fail/kein Noise → Content behalten (fail-open) + + async def melde(d, t): + set_p(f"Sichte Seiten {d}/{t}…") + + set_p("Sichte Seiten…", step=_step_idx(topic, "Recherche")) + teile = await _gather_fortschritt([_chunk(ci, c) for ci, c in enumerate(chunks, 1)], len(chunks), melde) + if is_cancelled(): + return set(pages) + noise = set().union(*(t for t in teile if isinstance(t, set))) if teile else set() + content = set(pages) - noise + _log(topic, f"Sichtung: {len(content)} Content / {len(noise)} Noise von {len(pages)}") + return content or set(pages) # alles als Noise markiert? → fail-open, alle behalten + + +async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, instructions: str) -> bool: + """Befüllt DB-Tabelle `bausteine` mit Kandidaten (+ Nennungszähler). FESTE Datei-Batches: + jede Crawl-Seite wird genau einem Batch zugeteilt und von RECHERCHE_READERS Agenten gelesen + (Konsens ≥2 im Batch). Alle zugeteilten Seiten werden als gelesen markiert → 100 % Abdeckung. + Ohne Crawl-Ordner (Quelle „thema") → freie Web-Recherche, eine Runde. → True/False.""" topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled if await db.get_step_status(topic, "Recherche") == "fertig": return True arbeit = files["arbeit"] - caps = "files" if ordner else "full" - crawl_idx = _crawl_index(ordner) - alle_seiten = set(crawl_idx.values()) - await db.delete_bausteine(topic) await db.delete_coverage(topic) await db.set_step_status(topic, "Recherche", "laufend") - set_p("Recherche läuft (Runde 1)…", step=_step_idx(topic, "Recherche")) - start = time.monotonic() - runde = 0 - while not is_cancelled(): - runde += 1 - vorhandene = {b["titel_norm"] for b in await db.list_bausteine(topic)} - gelesen = set(await db.list_coverage(topic)) - offen = sorted(alle_seiten - gelesen) - if runde > 1 and alle_seiten and not offen: - break # volle Abdeckung erreicht - fokus = _fokus_text([b["titel"] for b in await db.list_bausteine(topic)], offen) - paths = [arbeit / f"recherche-r{runde}-{i}.md" for i in range(1, RECHERCHE_AGENTEN + 1)] + async def _ingest(text: str) -> None: + gesehen = set() + for eintrag in _parse_auswahl(text).values(): + titel = _titel(eintrag) + norm = _norm_titel(titel) + if not norm or norm in gesehen: + continue + gesehen.add(norm) + teile = [t.strip() for t in eintrag.split(" — ")] + besch = teile[1] if len(teile) >= 2 else "" + quelle = [teile[2]] if len(teile) >= 3 and teile[2] else [] + await db.upsert_baustein(topic, norm, titel, besch, quelle) + + pages = sorted(set(_crawl_index(ordner).values())) + + if not pages: + # Quelle „thema" (oder kein Crawl): freie Web-Recherche, eine Runde. + set_p("Recherche läuft…", step=_step_idx(topic, "Recherche")) + caps = "files" if ordner else "full" + paths = [arbeit / f"recherche-{i}.md" for i in range(1, RECHERCHE_THEMA_AGENTEN + 1)] for p in paths: p.unlink(missing_ok=True) slots = [{ - "key": f"bausteine-{topic}-recherche-r{runde}-{i}", - "prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner, fokus=fokus), + "key": f"bausteine-{topic}-recherche-{i}", + "prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner), "role": "quick", "capabilities": caps, "payload": (lambda result, p=p: _file_payload(p)), } for i, p in enumerate(paths, 1)] - set_p(f"Recherche läuft (Runde {runde})…") - texte = await _race(topic, f"Recherche R{runde}", slots, RECHERCHE_QUORUM, - _timeout("recherche"), provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE) + texte = await _race(topic, "Recherche", slots, 3, _timeout("recherche"), provider, + cancelled=is_cancelled, grace=RECHERCHE_GRACE) if is_cancelled(): return False if not texte: - if runde == 1: - _bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)" - return False - break # keine neuen Ergebnisse mehr - neu, zitiert = 0, set() + _bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)" + return False for text in texte: - zitiert |= _cited_sources(text) - gesehen = set() - for eintrag in _parse_auswahl(text).values(): - titel = _titel(eintrag) - norm = _norm_titel(titel) - if not norm or norm in gesehen: - continue - gesehen.add(norm) - teile = [t.strip() for t in eintrag.split(" — ")] - besch = teile[1] if len(teile) >= 2 else "" - quelle = [teile[2]] if len(teile) >= 3 and teile[2] else [] - if norm not in vorhandene: - neu += 1 - vorhandene.add(norm) - await db.upsert_baustein(topic, norm, titel, besch, quelle) - await db.mark_quellen_gelesen(topic, sorted(_abgedeckt(zitiert, crawl_idx))) - gesamt = len(await db.list_bausteine(topic)) - deckung = len(await db.list_coverage(topic)) - _log(topic, f"Recherche R{runde}: +{neu} neu (gesamt {gesamt}), Abdeckung {deckung}/{len(alle_seiten) or '?'}") - if neu == 0: - break - if time.monotonic() - start > RECHERCHE_KAPPE: - _log(topic, f"Recherche: Zeit-Kappe {RECHERCHE_KAPPE}s erreicht (Runde {runde})") - break + await _ingest(text) + await db.set_step_status(topic, "Recherche", "fertig") + return True + + # Content/Noise-Triage: Noise (Events/Clubs/Profile/…) raus, nur Content batchen. + content = await _sichte_inhalt(ctx, set_p, files, ordner, pages) + if is_cancelled(): + return False + pages = sorted(content) + + # Crawl: feste Batches, je Batch RECHERCHE_READERS Reader, die GENAU diese Dateien lesen. + batches = _chunk_nums(pages, max(1, math.ceil(len(pages) / RECHERCHE_BATCH))) + + async def _lese_batch(bi: int, batch: list[str]) -> bool: + liste = "\n".join(f"- {p}" for p in batch) + fokus = ("WICHTIG — feste Zuteilung: Bearbeite AUSSCHLIESSLICH diese Dateien und lies JEDE " + f"vollständig. Ignoriere alle anderen Dateien im Ordner:\n{liste}") + paths = [arbeit / f"recherche-b{bi}-{i}.md" for i in range(1, RECHERCHE_READERS + 1)] + for p in paths: + p.unlink(missing_ok=True) + if not is_cancelled(): + slots = [{ + "key": f"bausteine-{topic}-recherche-b{bi}-{i}", + "prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner, fokus=fokus), + "role": "quick", "capabilities": "files", + "payload": (lambda result, p=p: _file_payload(p)), + } for i, p in enumerate(paths, 1)] + texte = await _race(topic, f"Recherche Batch {bi}", slots, 1, _timeout("recherche", len(batch)), + provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE) + for text in (texte or []): + await _ingest(text) + await db.mark_quellen_gelesen(topic, batch) # alle zugeteilten Seiten abhaken (auch ohne Treffer) + return not is_cancelled() + + await _gather_fortschritt([_lese_batch(bi, b) for bi, b in enumerate(batches, 1)], + len(batches), _melde_p(set_p, topic, "Recherche")) + if is_cancelled(): + return False + gesamt = len(await db.list_bausteine(topic)) + deckung = len(await db.list_coverage(topic)) + _log(topic, f"Recherche: {gesamt} Kandidaten, Abdeckung {deckung}/{len(pages)} Seiten ({len(batches)} Batches)") + if not gesamt: + _bausteine_errors[topic] = "Recherche fehlgeschlagen (keine Bausteine)" + return False await db.set_step_status(topic, "Recherche", "fertig") return True @@ -1307,7 +1386,7 @@ async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool: status, mapping = await run_single_slot( ctx, f"Konsolidierung {c}", key=f"bausteine-{topic}-konsolidierung-c{c}", - prompt=_prompt("Bausteine-Recherche-Mapping", topic=topic, n=RECHERCHE_AGENTEN, eintraege=eintraege, out_path=fp), + prompt=_prompt("Bausteine-Recherche-Mapping", topic=topic, n=RECHERCHE_READERS, eintraege=eintraege, out_path=fp), role="judge", capabilities="files", payload=lambda result, p=fp: _mapping_schema(_json_datei(p)), timeout=_timeout("recherche_mapping", len(chunk)), @@ -1476,7 +1555,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = await db.delete_coverage(topic) # Inventar (DB): Recherche-Loop → Konsolidierung → Klärung. - if not await _recherche_loop(ctx, set_p, files, q, ordner, instructions): + if not await _recherche_batch(ctx, set_p, files, q, ordner, instructions): if is_cancelled(): abgebrochen() return diff --git a/templates/Prompt/Bausteine-Seiten-Sichtung.md b/templates/Prompt/Bausteine-Seiten-Sichtung.md new file mode 100644 index 0000000..3641902 --- /dev/null +++ b/templates/Prompt/Bausteine-Seiten-Sichtung.md @@ -0,0 +1,14 @@ +Du sortierst gecrawlte Seiten zum Lern-Thema "{topic}". Markiere NUR die Seiten, die eindeutig KEINEN fachlichen Lerninhalt tragen. + +SEITEN (Dateiname · URL · Auszug): +{seiten} + +Regeln: +- `noise` = eindeutig kein Lerninhalt. Typisch: Veranstaltungs-/Termin-Seiten, Mitglieder-/Profil-Seiten, Vereins-/Community-Seiten, Podcast-/Video-Verzeichnisse, reine Marketing-/Preis-/Werbeseiten, Login/Impressum, leere Navigations- oder Übersichtsseiten ohne Substanz. +- Im ZWEIFEL NICHT markieren. Alles, was fachlichen Stoff enthalten könnte (Konzepte, Anleitungen, Beispiele, Definitionen), bleibt Content. +- Nutze NUR die exakten Dateinamen aus der Liste oben. + +Schreibe NUR die JSON-Datei nach {out_path} (kein weiterer Text): +{{"noise": ["exakter-dateiname.txt", "…"]}} + +Ist nichts eindeutig themenfremd, schreibe {{"noise": []}}.