From 8c69c44b37ba974dde700508a21251f2f31965d1 Mon Sep 17 00:00:00 2001 From: team3 Date: Sat, 27 Jun 2026 11:08:36 +0200 Subject: [PATCH] update --- backend/bausteine.py | 193 ++++++++++++++++++++---- templates/Prompt/Fakten-Check.md | 6 +- templates/Prompt/Subbaustein-Mapping.md | 6 +- 3 files changed, 172 insertions(+), 33 deletions(-) diff --git a/backend/bausteine.py b/backend/bausteine.py index f158cde..e33a800 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -14,6 +14,7 @@ import asyncio import json import logging import math +import re import shutil import subprocess import time @@ -46,6 +47,9 @@ 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 +# uni/projekt: Skript-Text in Abschnitte ~dieser Größe chunken (gegen Lost-in-the-Middle bei +# großen Dokumenten). ~12k Zeichen ≈ 3k Token → sicher unter der Recall-Abfall-Schwelle. +RECHERCHE_ABSCHNITT_ZEICHEN = 12000 # Sichtung (Content/Noise) ist jetzt ein deterministischer Regel-Filter (config.CRAWL_*). SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min) KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass @@ -478,8 +482,47 @@ def _pdfs_konvertieren(project: Path) -> None: _QUELLE_TEMPLATE = {"projekt": "Bausteine-Quelle-Projekt", "uni": "Bausteine-Quelle-Uni", "link": "Bausteine-Quelle-Link"} -def _build_recherche_prompt(topic: str, out_path: Path, instructions: str, typ: str, ordner: Path | None, fokus: str = "") -> str: - if typ in _QUELLE_TEMPLATE: +def _text_abschnitte(text: str, ziel: int = RECHERCHE_ABSCHNITT_ZEICHEN) -> list[str]: + """Text an Absatz-/Zeilengrenzen in Abschnitte ~`ziel` Zeichen splitten (gegen Lost-in-the-Middle + bei großen Dokumenten). Kleiner Text bleibt EIN Abschnitt. Inhalt bleibt vollständig — nur + Trenn-Whitespace fällt weg.""" + text = text.strip() + if len(text) <= ziel: + return [text] if text else [] + abschnitte: list[str] = [] + buf = "" + + def flush(): + nonlocal buf + if buf.strip(): + abschnitte.append(buf.strip()) + buf = "" + + for block in re.split(r"\n\s*\n", text): # an Absatz-Grenzen + block = block.strip() + if not block: + continue + if len(block) > ziel: # einzelner Riesen-Absatz → hart an Zeilen schneiden + flush() + for zeile in block.split("\n"): + if buf and len(buf) + len(zeile) + 1 > ziel: + flush() + buf += zeile + "\n" + flush() + elif buf and len(buf) + len(block) + 2 > ziel: + flush() + buf = block + else: + buf = (buf + "\n\n" + block) if buf else block + flush() + return abschnitte + + +def _build_recherche_prompt(topic: str, out_path: Path, instructions: str, typ: str, ordner: Path | None, fokus: str = "", abschnitt: str = "") -> str: + if abschnitt: + # Abschnitt-Modus (uni/projekt): Text direkt im Prompt → kleiner Kontext, kein Datei-Lesen. + source = abschnitt + elif typ in _QUELLE_TEMPLATE: source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner) else: source = _prompt("Bausteine-Quelle-Thema", topic=topic) @@ -615,7 +658,11 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict → {Baustein-Titel: [Subbaustein, …]} (Konsens) oder None. Befüllt DB-Tabelle `subbausteine`.""" topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled arbeit = files["arbeit"] - caps = "files" if quelle_ordner(topic) else "full" + ordner = quelle_ordner(topic) + caps = "files" if ordner else "full" + # Quelle für die Beleg-Prüfung im Klär-Schritt (verwirft erfundene/unbelegbare Subs). + _typ = lade_quelle(topic).get("type", "thema") + source = _prompt(_QUELLE_TEMPLATE[_typ], project=ordner) if _typ in _QUELLE_TEMPLATE else _prompt("Bausteine-Quelle-Thema", topic=topic) nums = list(entries) chunks = _chunk_nums(nums, _n_chunks(len(nums))) n = len(chunks) @@ -711,8 +758,8 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict status, _ = await run_single_slot( ctx, f"Subbaustein-Klärung {c}", key=f"bausteine-{topic}-subbaustein-final-c{c}", - prompt=_prompt("Subbaustein-Mapping", topic=topic, bausteine="\n\n".join(bloecke), out_path=fp, extra=_extra(instructions)), - role="judge", capabilities="files", + prompt=_prompt("Subbaustein-Mapping", topic=topic, source=source, bausteine="\n\n".join(bloecke), out_path=fp, extra=_extra(instructions)), + role="judge", capabilities=caps, payload=lambda result, p=fp: _parse_subbausteine(_read(p)) or None, timeout=_timeout("subbaustein_check", len(chunk)), ) @@ -917,8 +964,9 @@ def _fakten_schema(data) -> list[dict] | None: return out or None -def _fakten_check_schema(data) -> list[str] | None: - """Fakten-Check → beanstandete sub_norms · {ok:true}→[] · None bei ungültig.""" +def _fakten_check_schema(data) -> list[tuple[str, bool]] | None: + """Fakten-Check → [(sub_norm, verwerfen)] je Beanstandung · {ok:true}→[] · None bei ungültig. + verwerfen=True: Sub inhaltlich nicht belegbar (entfernen). verwerfen=False: nur Fakt korrigieren.""" if not isinstance(data, dict): return None if data.get("ok") is True: @@ -926,7 +974,8 @@ def _fakten_check_schema(data) -> list[str] | None: pr = data.get("probleme") if not isinstance(pr, list): return None - return [sn for p in pr if isinstance(p, dict) and (sn := _norm_titel(str(p.get("subbaustein", ""))))] + return [(sn, bool(p.get("verwerfen"))) + for p in pr if isinstance(p, dict) and (sn := _norm_titel(str(p.get("subbaustein", ""))))] def _fakten_zeilen(fk: dict) -> str: @@ -950,10 +999,11 @@ def _fakten_komplett(files: dict) -> bool: return isinstance(d, dict) and bool(d) -async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, instructions: str) -> dict | None: - """Block: je Sub Quell-Fakten extrahieren (finden) → verifizieren (prüfen) → korrigieren (fix). +async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, instructions: str) -> tuple | None: + """Block: je Sub Quell-Fakten extrahieren (finden) → verifizieren (prüfen) → korrigieren/verwerfen (fix). Extract-once-Grounding: das Ergebnis nährt Stufe/Relevanz/Fragen/Guide. - → {Baustein-Titel: {sub_norm: fakten-dict}} oder None bei Abbruch/Fehler.""" + → (fakten_map, verworfen_map) — fakten_map {Baustein: {sub_norm: fakten}}, verworfen_map + {Baustein: {sub_norm}} (unbelegbare Subs zum Entfernen) — oder None bei Abbruch/Fehler.""" topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled arbeit = files["arbeit"] caps = "files" if ordner else "full" @@ -961,7 +1011,7 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner) if typ in _QUELLE_TEMPLATE else _prompt("Bausteine-Quelle-Thema", topic=topic) bausteine = [(titel, [str(s).strip() for s in subs if str(s).strip()]) for titel, subs in roh.items() if subs] if not bausteine: - return {} + return {}, {} chunks = _lpt_chunks([len(subs) for _, subs in bausteine], FAKTEN_CHUNK_SUBS) def roh_path(ci): return arbeit / f"fakten-c{ci}.json" @@ -1008,11 +1058,12 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins _bausteine_errors[topic] = "Fakten-Extraktion fehlgeschlagen" return None - # Phase „Fakten prüfen": FAKTEN_CHECK_PANEL Judges je Chunk → beanstandete sub_norms (Mehrheit). + # Phase „Fakten prüfen": FAKTEN_CHECK_PANEL Judges je Chunk. Zwei Mehrheits-Mengen: + # beanstandet (Fakt ungenau → korrigieren) und verwerfen (Sub nicht belegbar → entfernen). async def _pruefe(ci, idxs): per = roh_map(ci, roh_path(ci)) if not per: - return ci, set() + return ci, set(), set() fakten_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_fakten_zeilen(fk)}" for fm in per.values() for fk in fm.values()) offen = [j for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if _fakten_check_schema(_json_datei(chk_path(ci, j))) is None] await asyncio.gather(*[ @@ -1021,29 +1072,45 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins _timeout("inhalt_check", len(per)), provider=provider, role="judge", capabilities=caps) for j in offen], return_exceptions=True) outs = [s for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if (s := _fakten_check_schema(_json_datei(chk_path(ci, j)))) is not None] - votes: dict[str, int] = {} - for s in outs: - for sn in set(s): - votes[sn] = votes.get(sn, 0) + 1 + bvotes: dict[str, int] = {} + vvotes: dict[str, int] = {} + for s in outs: # s = [(sub_norm, verwerfen)] eines Judges + gb, gv = set(), set() + for sn, verw in s: + if sn not in gb: + gb.add(sn); bvotes[sn] = bvotes.get(sn, 0) + 1 + if verw and sn not in gv: + gv.add(sn); vvotes[sn] = vvotes.get(sn, 0) + 1 schwelle = len(outs) / 2 if outs else 99 - return ci, {sn for sn, v in votes.items() if v > schwelle} + beanstandet = {sn for sn, v in bvotes.items() if v > schwelle} + # Verwerfen ist irreversibel → strenger als Beanstanden: Mehrheit UND ≥2 zustimmende Judges + # (verhindert Löschung durch eine Einzelstimme, wenn das Panel degradiert ist). + verwerfen = {sn for sn, v in vvotes.items() if v > schwelle and v >= 2} + return ci, beanstandet, verwerfen pruef = await _gather_fortschritt([_pruefe(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten prüfen")) if is_cancelled(): return None - beanstandet = {ci: subs for r in pruef if isinstance(r, tuple) for ci, subs in [r]} + beanstandet: dict[int, set] = {} + verwerfen: dict[int, set] = {} + for r in pruef: + if isinstance(r, tuple) and len(r) == 3: + ci, b, v = r + beanstandet[ci] = b + verwerfen[ci] = v - # Phase „Fakten fix": je Chunk mit beanstandeten Subs → diese neu extrahieren (frische Quelle). - n_problem = sum(len(s) for s in beanstandet.values()) + # Phase „Fakten fix": nur KORRIGIERBARE (beanstandet ohne verwerfen) neu extrahieren. + korrigieren = {ci: (beanstandet.get(ci, set()) - verwerfen.get(ci, set())) for ci in beanstandet} + n_problem = sum(len(s) for s in korrigieren.values()) if n_problem: set_p(f"Fakten korrigieren ({n_problem})…", step=_step_idx(topic, "Fakten fix")) async def _fix(ci): - subs_norm = beanstandet.get(ci, set()) + subs_norm = korrigieren.get(ci, set()) if not subs_norm or _fakten_schema(_json_datei(fix_path(ci))): return idxs = chunks[ci] rel_by = {bausteine[i][0]: bausteine[i][1] for i in idxs} - # nur die beanstandeten Subs als Block + # nur die korrigierbaren Subs als Block ziel = [] for bt, subs in rel_by.items(): betroffen = [s for s in subs if _norm_titel(s) in subs_norm] @@ -1057,20 +1124,27 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins role="guide", capabilities=caps, payload=lambda result, p=fix_path(ci): _fakten_schema(_json_datei(p)), timeout=_timeout("inhalt", len(subs_norm))) - await _gather_fortschritt([_fix(ci) for ci in beanstandet], len(beanstandet), _melde_p(set_p, topic, "Fakten fix")) + await _gather_fortschritt([_fix(ci) for ci in korrigieren], len(korrigieren), _melde_p(set_p, topic, "Fakten fix")) if is_cancelled(): return None - # Zusammensetzen: roh + Fix-Overrides für beanstandete (Vollständigkeits-Guard: fehlt der Fix, bleibt roh). + # Zusammensetzen: roh + Fix-Overrides für korrigierte. Verworfene Subs raus (+ je Baustein melden). ergebnis: dict[str, dict] = {} + verworfen_map: dict[str, set] = {} for ci in range(len(chunks)): per = roh_map(ci, roh_path(ci)) fix = roh_map(ci, fix_path(ci)) if fix_path(ci).exists() else {} + verw = verwerfen.get(ci, set()) for bt, fm in per.items(): for sn, fk in fm.items(): - gewinner = fix.get(bt, {}).get(sn, fk) if sn in beanstandet.get(ci, set()) else fk + if sn in verw: + verworfen_map.setdefault(bt, set()).add(sn) + continue + gewinner = fix.get(bt, {}).get(sn, fk) if sn in korrigieren.get(ci, set()) else fk ergebnis.setdefault(bt, {})[sn] = {k: gewinner[k] for k in _FAKTEN_FELDER} - return ergebnis + if verworfen_map: + _log(topic, f"Fakten-Check verwirft {sum(len(s) for s in verworfen_map.values())} unbelegbare Subbausteine") + return ergebnis, verworfen_map async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None: @@ -1511,7 +1585,55 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, await db.set_step_status(topic, "Recherche", "fertig") return True - # Content-Seiten stehen schon fest (Sichtung im Schritt „Quelle aufbereiten"). + # uni/projekt: kuratierte, oft GROSSE Dateien (Skript). Statt alle am Stück zu lesen + # (Lost-in-the-Middle), in Abschnitte chunken und JEDEN gründlich von 2 Readern lesen — + # Text direkt im Prompt (kleiner Kontext), Nennungen akkumulieren zu Konsens. + if q["type"] in ("uni", "projekt"): + eintraege: list[tuple[str, str]] = [] # (dateiname, abschnitt-text) + for fn in sorted(pages): + for absch in _text_abschnitte(_read(ordner / fn)): + eintraege.append((fn, absch)) + if not eintraege: + _bausteine_errors[topic] = "Recherche: Quelle leer" + return False + set_p(f"Recherche ({len(eintraege)} Abschnitte)…", step=_step_idx(topic, "Recherche")) + + async def _lese_abschnitt(ei: int, fn: str, absch: str) -> None: + block = (f"ARBEITE AUSSCHLIESSLICH MIT DIESEM TEXTABSCHNITT (Quelle: {fn}). Lies ihn " + f"VOLLSTÄNDIG, überspringe nichts. Notiere `{fn}` als Quelle jedes Bausteins. " + f"Suche NICHT im Web — nur dieser Abschnitt zählt.\n\n-----\n{absch}\n-----") + paths = [arbeit / f"recherche-a{ei}-{i}.md" for i in range(1, RECHERCHE_READERS + 1)] + for p in paths: + p.unlink(missing_ok=True) + if is_cancelled(): + return + slots = [{ + "key": f"bausteine-{topic}-recherche-a{ei}-{i}", + "prompt": _build_recherche_prompt(topic, p, instructions, q["type"], ordner, abschnitt=block), + "role": "quick", "capabilities": "files", + "payload": (lambda result, p=p: _file_payload(p)), + } for i, p in enumerate(paths, 1)] + # Quorum 2: beide Reader pro Abschnitt sollen durch (mehr Augen = mehr Konzepte + + # echter Konsens); nach Timeout fällt _race auf das Vorhandene zurück. + texte = await _race(topic, f"Recherche Abschnitt {ei}", slots, 2, _timeout("recherche", 1), + provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE) + for text in (texte or []): + await _ingest(text) + + await _gather_fortschritt([_lese_abschnitt(ei, fn, a) for ei, (fn, a) in enumerate(eintraege, 1)], + len(eintraege), _melde_p(set_p, topic, "Recherche")) + if is_cancelled(): + return False + await db.mark_quellen_gelesen(topic, sorted(pages)) + gesamt = len(await db.list_bausteine(topic)) + _log(topic, f"Recherche (uni/projekt): {gesamt} Kandidaten aus {len(eintraege)} Abschnitten ({len(pages)} Dateien)") + if not gesamt: + _bausteine_errors[topic] = "Recherche fehlgeschlagen (keine Bausteine)" + return False + await db.set_step_status(topic, "Recherche", "fertig") + return True + + # Crawl/Link: viele kleine Content-Seiten (Sichtung im Schritt „Quelle aufbereiten"). # Feste Batches, je Batch RECHERCHE_READERS Reader, die GENAU diese Dateien lesen. batches = _chunk_nums(sorted(pages), max(1, math.ceil(len(pages) / RECHERCHE_BATCH))) @@ -2040,12 +2162,21 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = # Fakten je Sub (VOR der Stufe): Quell-Fakten extrahieren + verifizieren → fakten.json. # Extract-once-Grounding — Stufe/Relevanz/Fragen/Guide nähren sich daraus. if not _fakten_komplett(files): - fakten_map = await _fakten_block(ctx, set_p, files, roh, q, ordner, instructions) + res = await _fakten_block(ctx, set_p, files, roh, q, ordner, instructions) if is_cancelled(): abgebrochen() return - if fakten_map is None: + if res is None: return # Fehler ist gesetzt + fakten_map, verworfen = res + # Verworfene (unbelegbare) Subs aus roh streichen — ZUERST (Resume-robust), dann + # fakten.json. So sehen Stufen/Relevanz/Gliederung/Fragen/Guide sie nicht mehr. + if verworfen: + for bt, sns in verworfen.items(): + if bt in roh: + roh[bt] = [s for s in roh[bt] if _norm_titel(s) not in sns] + roh = {bt: subs for bt, subs in roh.items() if subs} # leere Bausteine raus (_sub_roh_schema verlangt ≥1) + atomic_write_json(files["sub_roh"], roh, indent=1) atomic_write_json(files["fakten"], fakten_map, indent=1) sidecar = await _stufen_block(ctx, set_p, files, roh, instructions) if is_cancelled(): diff --git a/templates/Prompt/Fakten-Check.md b/templates/Prompt/Fakten-Check.md index 87173dd..d30c7f4 100644 --- a/templates/Prompt/Fakten-Check.md +++ b/templates/Prompt/Fakten-Check.md @@ -13,10 +13,14 @@ Prüfe je Subbaustein: Notiere nur ECHTE Mängel (falscher Fakt, falsche Quelle, Beispiel als Fakt getarnt). Geschmacksfragen nicht. +**Verwerfen vs. korrigieren** — entscheide je Mangel: +- `verwerfen: true` — der Subbaustein ist inhaltlich **nicht belegbar**: eine erfundene Aussage, eine im Material nicht auffindbare Schranke/Formel/Behauptung, oder fachlich schlicht falsch. Der Subbaustein wird dann ENTFERNT. Sei sicher — im Zweifel `false`. +- `verwerfen: false` — der Kern stimmt, nur ein Fakt/Wert/eine Quelle ist ungenau und korrigierbar. + Schreibe NUR die JSON-Datei nach: {out_path} Format — alles in Ordnung: {{"ok": true}} Sonst (Subbaustein-Titel EXAKT wie oben): -{{"probleme": [{{"subbaustein": "", "problem": "…"}}]}} +{{"probleme": [{{"subbaustein": "", "problem": "…", "verwerfen": true}}]}} {extra} diff --git a/templates/Prompt/Subbaustein-Mapping.md b/templates/Prompt/Subbaustein-Mapping.md index 327d5fc..8a6f6ab 100644 --- a/templates/Prompt/Subbaustein-Mapping.md +++ b/templates/Prompt/Subbaustein-Mapping.md @@ -1,10 +1,14 @@ Unten je Baustein des Themas "{topic}" die gefundenen Subbausteine. Gib je Baustein die **bereinigte** finale Liste zurück. +{source} + +GEFUNDENE SUBBAUSTEINE: {bausteine} Regeln: +- **Beleg-Prüfung (wichtig):** Prüfe jeden Subbaustein gegen die Quelle. **Verwirf, was im Material NICHT belegbar oder klar erfunden ist** — erfundene Schranken, Formeln, Werte oder Behauptungen, die so nicht in der Quelle stehen. Mit Quelldatei: gegen die Datei prüfen. Ohne Quelle (reines Thema): nur gesichertes Standardwissen behalten, Zweifelhaftes/Falsches streichen. - **Dubletten zusammenführen:** Subbausteine, die denselben Punkt mit anderen Worten sagen, sind EINER. Behalte den klarsten, streiche die Umformulierungen. (z.B. „Broadcast Mode verfügbar" und „Broadcast-Modus aktivieren" → einer.) -- Behalte alle fachlich **DISTINKTEN** Teilpunkte vollständig — nichts Wesentliches weglassen. +- Behalte alle fachlich **DISTINKTEN** und belegbaren Teilpunkte vollständig — nichts Wesentliches weglassen. - Verwirf, was zu fein granular, am Rand des Themas oder fachlich zweifelhaft ist. - Jeder Punkt atomar (eine Aussage). Behaltene Punkte WÖRTLICH übernehmen, nicht umformulieren, nichts erfinden. - Die Anzahl folgt der Schwierigkeit: lieber wenige distinkte als viele redundante Punkte.