From bf7ebe045b18aa76b2ab56674e1eb01cbf1442fe Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 29 Jun 2026 00:09:24 +0200 Subject: [PATCH] update --- backend/bausteine.py | 275 ++++++++++++++++-- backend/config.py | 17 ++ backend/database.py | 34 ++- backend/embedding.py | 123 ++++++++ backend/textkit.py | 12 +- .../Prompt/Bausteine-Block-Gruppieren.md | 20 ++ templates/Prompt/Bausteine-Klaerung.md | 22 +- templates/Prompt/Bausteine-Paar-Filter.md | 20 ++ 8 files changed, 483 insertions(+), 40 deletions(-) create mode 100644 backend/embedding.py create mode 100644 templates/Prompt/Bausteine-Block-Gruppieren.md create mode 100644 templates/Prompt/Bausteine-Paar-Filter.md diff --git a/backend/bausteine.py b/backend/bausteine.py index f004489..7d7638c 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -21,14 +21,15 @@ import time from pathlib import Path import database as db +import embedding from agents import kill_process, cancel_scope, clear_scope, run_agent -from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET +from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV from fsutil import atomic_write_text, atomic_write_json from jsonio import read_json_file as _json_datei from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner from crawl import crawl from pipeline import ( - CANCELLED, FAILED, GenContext, _extra, _gather_fortschritt, _janein_schema, _log, _prompt, _race, + CANCELLED, FAILED, OK, GenContext, _extra, _gather_fortschritt, _janein_schema, _log, _prompt, _race, _relevanz_schema, _runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot, ) from textkit import ( @@ -52,7 +53,9 @@ RECHERCHE_KAPPE = 1800 # Sicherheits-Deckel je Batch-Agent 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 +KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass — nur Fallback-Pfad +DEDUP_MAX_RUNDEN = 3 # finaler Dedup-Pass: max. Iterationen (die kleinere Liste blockt je Runde neu) +DEDUP_MIN_DELTA = 3 # Abbruch, wenn eine Runde weniger als dieses % der Liste entfernt (konvergiert) # Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl. FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster) @@ -204,7 +207,7 @@ def _bausteine_steps(topic: str) -> tuple: laufen alle Pakete parallel; der Schritt bleibt, bis das letzte Paket fertig ist. """ q = lade_quelle(topic) - base = ("Recherche", "Konsolidierung", "Klärung") + base = ("Recherche", "Konsolidierung", "Klärung", "Dedup") rest = ( "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", "Fakten finden", "Fakten prüfen", "Fakten fix", @@ -234,7 +237,7 @@ def _melde_p(set_p, topic: str, schritt: str): # Sonderschritte (Quelle laden, Ergänzung) gehören zur Phase „Inventar". PHASEN = ( ("Quelle", ("Quelle aufbereiten",)), - ("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Ergänzung")), + ("Inventar", ("Recherche", "Konsolidierung", "Klärung", "Dedup", "Ergänzung")), ("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")), ("Fakten", ("Fakten finden", "Fakten prüfen", "Fakten fix")), ("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")), @@ -295,7 +298,7 @@ def _alle_slot_dateien(files: dict) -> list[Path]: dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("fakten-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*")) + list(arbeit.glob("frage-muster-*")) + list(arbeit.glob("gliederung-*")) + list(arbeit.glob("artefakt-*")) + list(arbeit.glob("recherche-*")) + list(arbeit.glob("konsolidierung-*")) - + list(arbeit.glob("klaerung*"))) if arbeit.is_dir() else [] + + list(arbeit.glob("klaerung*")) + list(arbeit.glob("dedup-*"))) if arbeit.is_dir() else [] return [ *files["recherche"], files["recherche_mapping"], *(p for slots in files["auswahl"].values() for p in slots), @@ -1767,18 +1770,18 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, await db.delete_bausteine(topic) # Coverage/inhalt gehört der Sichtung — NICHT löschen await db.set_step_status(topic, "Recherche", "laufend") - async def _ingest(text: str) -> None: + async def _ingest(reader_id: str, 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) + gesehen.add(norm) # ein Reader = eine Stimme je Konzept 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) + await db.upsert_baustein(topic, norm, titel, besch, quelle, reader=reader_id) pages = await db.list_content(topic) # von der Sichtung als Content markierte Seiten if not pages and ordner: @@ -1795,7 +1798,7 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, "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)), + "payload": (lambda result, p=p, rid=f"t{i}": ((rid, t) if (t := _file_payload(p)) else None)), } for i, p in enumerate(paths, 1)] texte = await _race(topic, "Recherche", slots, 3, _timeout("recherche"), provider, cancelled=is_cancelled, grace=RECHERCHE_GRACE) @@ -1804,8 +1807,8 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, if not texte: _bausteine_errors[topic] = "Recherche fehlgeschlagen (Minimum nicht erreicht)" return False - for text in texte: - await _ingest(text) + for rid, text in texte: + await _ingest(rid, text) await db.set_step_status(topic, "Recherche", "fertig") return True @@ -1827,6 +1830,13 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, 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)] + # Reader-Datei-Wiederverwendung: liegen alle Reader-Outputs valide vor (Resume / + # Re-Run ohne Recherche-Änderung), re-ingestieren statt erneut Agenten zu spawnen. + vorhanden = [(f"a{ei}-{i}", t) for i, p in enumerate(paths, 1) if (t := _file_payload(p))] + if len(vorhanden) == len(paths): + for rid, text in vorhanden: + await _ingest(rid, text) + return for p in paths: p.unlink(missing_ok=True) if is_cancelled(): @@ -1835,14 +1845,14 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, "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)), + "payload": (lambda result, p=p, rid=f"a{ei}-{i}": ((rid, t) if (t := _file_payload(p)) else None)), } 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) + for rid, text in (texte or []): + await _ingest(rid, 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")) @@ -1873,12 +1883,12 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, "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)), + "payload": (lambda result, p=p, rid=f"b{bi}-{i}": ((rid, t) if (t := _file_payload(p)) else None)), } 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) + for rid, text in (texte or []): + await _ingest(rid, text) await db.mark_quellen_gelesen(topic, batch) # alle zugeteilten Seiten abhaken (auch ohne Treffer) return not is_cancelled() @@ -1896,11 +1906,168 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, return True +def _grp_schema(data, ids: set[int]): + """{"gruppen": [[1,3],[2], …]} → Partition von `ids` als Liste von Index-Gruppen. + Tolerant: ignoriert Fremd-/Doppel-Nummern; vergessene Kandidaten werden eigenständig + (Singleton-Gruppe) ergänzt. None nur bei strukturell kaputtem JSON.""" + if not isinstance(data, dict) or not isinstance(data.get("gruppen"), list): + return None + gruppen, gesehen = [], set() + for g in data["gruppen"]: + if not isinstance(g, list): + return None + grp = [] + for x in g: + try: + num = int(x) + except (ValueError, TypeError): + continue + if num in ids and num not in gesehen: + gesehen.add(num) + grp.append(num) + if grp: + gruppen.append(grp) + gruppen += [[r] for r in sorted(ids - gesehen)] # vergessene Kandidaten bleiben eigenständig + return gruppen or None + + +_ASPEKT_MARKER = ("∈ np", "∈np", " in np", "np-schwer", "np-vollständig", "verifizierer", + "zertifikat", "ndtm", "nicht-determ", "lower bound", "untere schranke", + "bzgl", "als sprache") + + +def _aspekt_marker(titel: str) -> int: + """Anzahl Eigenschafts-Marker im Titel (∈NP, NP-schwer, Verifizierer, Lower Bound …). + 0 = generisches Hauptkonzept (das Problem selbst); >0 = eine Eigenschaft davon.""" + t = titel.casefold() + return sum(1 for m in _ASPEKT_MARKER if m in t) + + +def _canonical(kandidaten: list[dict], idxs: list[int], gesehen_norm: set[str]) -> dict: + """Repräsentant eines Clusters = das Hauptkonzept (wenigste Eigenschafts-Marker — das Problem + selbst, nicht „… ∈ NP"); Tie → häufigster norm-Titel → meiste Reader. Titel global eindeutig + (Suffix ' (2)'), damit er als Schlüssel taugt.""" + by_norm: dict[str, list[int]] = {} + for k in idxs: + by_norm.setdefault(_norm_titel(kandidaten[k]["titel"]), []).append(k) + + def gewicht(nb: str): + ms = by_norm[nb] + reader = set().union(*[set(kandidaten[m]["reader"]) for m in ms]) if ms else set() + return (-_aspekt_marker(nb), len(ms), len(reader)) # aspekt-arm zuerst = Hauptkonzept + + best = max(by_norm, key=gewicht) + k = max(by_norm[best], key=lambda m: len(kandidaten[m]["beschreibung"])) + titel = kandidaten[k]["titel"] + n = 2 + while _norm_titel(titel) in gesehen_norm: + titel = f"{kandidaten[k]['titel']} ({n})" + n += 1 + gesehen_norm.add(_norm_titel(titel)) + return {"titel": titel, "beschreibung": kandidaten[k]["beschreibung"]} + + +async def _block_gruppieren(ctx: GenContext, set_p, arbeit: Path, kandidaten: list[dict], + blocks: list[list[int]], praefix: str = "konsolidierung", + schritt: str = "Konsolidierung") -> list[list[int]]: + """Je Ähnlichkeits-Block gruppiert ein Judge die Titel in die echten Bausteine (merge + Paraphrasen, split Über-Merges). Singletons direkt. Fehler/Timeout → konservativ jeder + Kandidat einzeln (vermeidet fälschliches Über-Mergen). → finale Gruppen (globale Indizes). + `praefix`/`schritt` trennen Konsolidierung und Dedup (Artefakte, Race-Key, Fortschritt).""" + topic, is_cancelled = ctx.topic, ctx.is_cancelled + multi = [(bi, b) for bi, b in enumerate(blocks) if len(b) > 1] + ergebnis: list[list[int]] = [list(b) for b in blocks if len(b) == 1] # Singletons direkt + + def _zeile(k: int, g: int) -> str: + b = kandidaten[g] + return f"{k}. {b['titel']}" + (f" — {b['beschreibung']}" if b["beschreibung"] else "") + + async def _grp(bi: int, block: list[int]) -> None: + ids = set(range(1, len(block) + 1)) + p = arbeit / f"{praefix}-block-c{bi}.json" + part = _grp_schema(_json_datei(p), ids) + if part is None: # Resume: gültige Datei nicht neu rechnen + p.unlink(missing_ok=True) + if is_cancelled(): + return + zeilen = [_zeile(k, block[k - 1]) for k in range(1, len(block) + 1)] + status, part = await run_single_slot( + ctx, f"Block-Gruppieren {bi}", + key=f"bausteine-{topic}-{praefix}-block-c{bi}", + prompt=_prompt("Bausteine-Block-Gruppieren", topic=topic, eintraege="\n".join(zeilen), out_path=p), + role="judge", capabilities="files", + payload=(lambda result, p=p, ids=ids: _grp_schema(_json_datei(p), ids)), + timeout=_timeout("recherche_mapping", len(block)), + ) + part = part if status == OK else None + if part is None: # Judge gescheitert → einzeln (kein Über-Merge) + ergebnis.extend([idx] for idx in block) + else: # lokale Nummern → globale Kandidaten-Indizes + ergebnis.extend([block[k - 1] for k in g] for g in part) + + await _gather_fortschritt([_grp(bi, b) for bi, b in multi], + len(multi), _melde_p(set_p, topic, schritt)) + return ergebnis + + +async def _konsolidiere_embedding(ctx: GenContext, set_p, files: dict, kandidaten: list[dict]) -> bool: + """Zweistufig: Embeddings → grobe Capped-Blocks (High-Recall) → je Multi-Block ein Judge, + der die Titel in die echten Bausteine gruppiert → Reader-Union (≥2 = Konsens).""" + topic, is_cancelled = ctx.topic, ctx.is_cancelled + arbeit = files["arbeit"] + texts = [f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"] for b in kandidaten] + sims = await asyncio.to_thread(embedding.embed_sims, texts) + if sims is None: # Modell doch nicht verfügbar → Fallback + return await _konsolidiere_llm(ctx, set_p, files, kandidaten) + # Stufe 1: grobe Ähnlichkeits-Blocks (gedeckelt, kein Giant-Component). + blocks = await asyncio.to_thread(embedding.capped_blocks, sims, None, None) + # Stufe 2: ein Judge gruppiert JEDEN Multi-Block in die echten Bausteine. + gruppen = await _block_gruppieren(ctx, set_p, arbeit, kandidaten, blocks) + if is_cancelled(): + return False + + def _min_cos(idxs): # interne Kohärenz zur Kontrolle (Ketten hätten ~0,3) + if len(idxs) < 2: + return 1.0 + return round(min(float(sims[i][j]) for n, i in enumerate(idxs) for j in idxs[n + 1:]), 3) + + # Konsens = ≥2 distinkte Reader pro Cluster. Legacy-DBs ohne Reader-Tracking (Recherche lief + # vor der Migration, kein Re-Ingest) haben leere Reader-Sets → Rückfall auf Titel-Heuristik + # (sonst landete ALLES im Rest). + hat_reader = any(b["reader"] for b in kandidaten) + konsens, rest, debug, gesehen_norm = [], [], [], set() + for idxs in gruppen: + reader = set().union(*[set(kandidaten[k]["reader"]) for k in idxs]) if idxs else set() + if hat_reader: + score = len(reader) + else: # ohne Reader-Daten: max(Nennungen, Anzahl distinkter Titel-Varianten im Cluster) + score = max(max(kandidaten[k]["nennungen"] for k in idxs), + len({kandidaten[k]["titel_norm"] for k in idxs})) + rep = _canonical(kandidaten, idxs, gesehen_norm) + eintrag = f"{rep['titel']} — {rep['beschreibung']}" if rep["beschreibung"] else rep["titel"] + (konsens if score >= 2 else rest).append(eintrag) + debug.append({"titel": rep["titel"], "reader": sorted(reader), "score": score, + "konsens": score >= 2, "min_cos": _min_cos(idxs), + "mitglieder": [kandidaten[k]["titel"] for k in idxs]}) + atomic_write_json(arbeit / "konsolidierung-cluster.json", debug, indent=1) + multi_blocks = sum(1 for b in blocks if len(b) > 1) + _log(topic, f"Konsolidierung (Embedding): {len(blocks)} Blocks ({multi_blocks} per LLM gruppiert) " + f"→ {len(gruppen)} Cluster aus {len(kandidaten)} Kandidaten " + f"→ {len(konsens)} Konsens / {len(rest)} Rest") + + await db.delete_bausteine(topic) + for t in konsens: + await _set_inventar(topic, t, "konsens") + for t in rest: + await _set_inventar(topic, t, "rest") + await db.set_step_status(topic, "Konsolidierung", "fertig") + return True + + async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool: - """Panel (KONSOLIDIERUNG_PANEL Judges) mergt Kandidaten semantisch; ein Reconcile-Judge führt die - Panel-Ausgaben zur finalen Konsens (≥2)/Rest (1×)-Liste zusammen. Status in DB. - Panel statt Einzel-Judge: ein einzelner Judge ist bias-anfällig (Position/Verbosity) und instabil.""" - topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled + """Mergt Roh-Kandidaten zu Konsens (≥2 Reader)/Rest. Deterministisch per Embedding-Clustering; + fehlt das Modell → Rückfall auf das LLM-Panel (`_konsolidiere_llm`). Status in DB.""" + topic = ctx.topic if await db.get_step_status(topic, "Konsolidierung") == "fertig": return True set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung")) @@ -1908,6 +2075,16 @@ async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool: if not kandidaten: _bausteine_errors[topic] = "Konsolidierung: keine Kandidaten" return False + if EMBEDDING_AKTIV and await asyncio.to_thread(embedding.verfuegbar): + return await _konsolidiere_embedding(ctx, set_p, files, kandidaten) + return await _konsolidiere_llm(ctx, set_p, files, kandidaten) + + +async def _konsolidiere_llm(ctx: GenContext, set_p, files: dict, kandidaten: list[dict]) -> bool: + """Fallback (nur ohne Embedding-Modell): Panel (KONSOLIDIERUNG_PANEL Judges) mergt Kandidaten + semantisch; ein Reconcile-Judge führt die Panel-Ausgaben zur finalen Konsens (≥2)/Rest (1×)-Liste + zusammen. Panel statt Einzel-Judge: ein einzelner Judge ist bias-anfällig und instabil.""" + topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled arbeit = files["arbeit"] chunks = _chunk_nums(kandidaten, max(1, math.ceil(len(kandidaten) / KONSOLIDIERUNG_CHUNK))) @@ -2026,7 +2203,6 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool: set_p("Klärung läuft…", step=_step_idx(topic, "Klärung")) rest_rows = await db.list_bausteine(topic, status="rest") if rest_rows: - konsens = [b["titel"] for b in await db.list_bausteine(topic, status="konsens")] arbeit = files["arbeit"] paths = [arbeit / f"klaerung-j{j}.json" for j in range(1, KONSOLIDIERUNG_PANEL + 1)] offen = [(j, p) for j, p in enumerate(paths, 1) if _runde_schema(_json_datei(p), final=True) is None] @@ -2037,7 +2213,6 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool: "key": f"bausteine-{topic}-klaerung-j{j}", "prompt": _prompt( "Bausteine-Klaerung", topic=topic, - konsens="\n".join(f"- {t}" for t in konsens) or "(noch leer)", rest="\n".join(f"- {b['titel']}" for b in rest_rows), final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.", out_path=p, @@ -2066,6 +2241,52 @@ async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool: return True +async def _dedup_inventar(ctx: GenContext, set_p, files: dict) -> bool: + """Finaler iterativer Dedup-Pass über die fertige Konsens-Liste: Embedding-Block + LLM- + Gruppierung. Fängt Dubletten, die Konsolidierung (Block-Grenzen, Cap) und Klärung (lange + Liste, Lost-in-the-Middle) übersehen. Pro Gruppe bleibt EIN Repräsentant, der Rest wird + verworfen. Iteriert, weil die kleinere Liste je Runde neu blockt (Cross-Block-Reste).""" + topic, is_cancelled = ctx.topic, ctx.is_cancelled + if await db.get_step_status(topic, "Dedup") == "fertig": + return True + if not (EMBEDDING_AKTIV and await asyncio.to_thread(embedding.verfuegbar)): + await db.set_step_status(topic, "Dedup", "fertig") # ohne Modell: still überspringen + return True + set_p("Dedup…", step=_step_idx(topic, "Dedup")) + arbeit = files["arbeit"] + for runde in range(1, DEDUP_MAX_RUNDEN + 1): + konsens = await db.list_bausteine(topic, status="konsens") + if len(konsens) < 2: + break + texts = [f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"] for b in konsens] + sims = await asyncio.to_thread(embedding.embed_sims, texts) + if sims is None: + break + blocks = await asyncio.to_thread(embedding.capped_blocks, sims, None, None) + gruppen = await _block_gruppieren(ctx, set_p, arbeit, konsens, blocks, + praefix=f"dedup-r{runde}", schritt="Dedup") + if is_cancelled(): + return False + weg = 0 + for idxs in gruppen: + if len(idxs) < 2: + continue + # Repräsentant = Hauptkonzept (wenigste Eigenschafts-Marker), dann kürzester Titel; Rest verwerfen. + rep = min(idxs, key=lambda k: (_aspekt_marker(konsens[k]["titel"]), len(konsens[k]["titel"]), k)) + for k in idxs: + if k != rep: + await db.set_baustein_status(topic, konsens[k]["titel_norm"], "verworfen") + weg += 1 + atomic_write_json(arbeit / f"dedup-runde-{runde}.json", + {"vorher": len(konsens), "entfernt": weg, + "gruppen": [[konsens[k]["titel"] for k in g] for g in gruppen if len(g) > 1]}, indent=1) + _log(topic, f"Dedup Runde {runde}: {len(konsens)} → {len(konsens) - weg} (−{weg})") + if weg <= max(1, len(konsens) * DEDUP_MIN_DELTA // 100): # konvergiert → Schluss + break + await db.set_step_status(topic, "Dedup", "fertig") + return True + + # --- Gliederung (Bausteine-Artefakt: Kapitel-Struktur, vom Guide nur gelesen) --- def _gliederung_komplett(files: dict) -> bool: @@ -2459,7 +2680,7 @@ async def _reset_db_ab_phase(topic: str, label: str) -> None: await db.delete_subbausteine(topic) if idx <= 1: # Inventar: Inventar + Recherche-Schritte — Sichtung bleibt await db.delete_bausteine(topic) - await db.delete_pipeline_state(topic, ["Recherche", "Konsolidierung", "Klärung"]) + await db.delete_pipeline_state(topic, ["Recherche", "Konsolidierung", "Klärung", "Dedup"]) if idx <= 0: # Quelle: Sichtung neu (Coverage/inhalt + Schritt) await db.delete_coverage(topic) await db.delete_pipeline_state(topic, ["Quelle aufbereiten"]) @@ -2534,6 +2755,10 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = if is_cancelled(): abgebrochen() return + if not await _dedup_inventar(ctx, set_p, files): + if is_cancelled(): + abgebrochen() + return konsens_rows = await db.list_bausteine(topic, status="konsens") entries = { i: (f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"]) diff --git a/backend/config.py b/backend/config.py index 3af6585..de692e4 100644 --- a/backend/config.py +++ b/backend/config.py @@ -21,6 +21,23 @@ LESBARKEIT_MAX = 3.5 # Section zu schwer, wenn der Satz-Schnitt darübe LESBARKEIT_HART = 4.0 # Einzelsatz ab hier „hart" LESBARKEIT_HART_ANTEIL = 0.30 # … ODER wenn dieser Anteil der Sätze hart ist +# Bausteine-Konsolidierung: semantisches Embedding-Clustering statt LLM-Listen-Merge. +# Ein kleines mehrsprachiges Satz-Embedding (mean-pool) bildet die Kandidaten-Cluster +# GLOBAL (kein Chunk-Verlust) per Cosine + Union-Find. Titel-Varianten desselben Konzepts +# ("Vertex Cover" / "Vertex Cover Definition") verschmelzen; der Konsens zählt danach die +# echten Reader pro Cluster (≥2 = Konsens). Fehlen transformers/torch oder lädt das Modell +# nicht → Embedding stumm aus, `_konsolidiere` fällt auf den alten Panel-Judge-Pfad zurück. +EMBEDDING_AKTIV = True +EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # CPU, mehrsprachig, ~470 MB +# Stärkere (größere) CPU-Alternative bei Bedarf: "BAAI/bge-m3". +# Konsolidierung = zweistufig: (1) Embedding bildet GROBE Ähnlichkeits-Blocks (High-Recall), +# (2) ein LLM-Judge gruppiert JEDEN Block in die echten Bausteine (merge Paraphrasen, split +# Über-Merges). Reines Threshold-Blocking erzeugt einen Giant-Component (alles verkettet) → +# darum „Capped-Blocking": greedy nach Cosine mergen, aber Blockgröße deckeln. So bleiben die +# LLM-Listen kurz und stabil (belegt: Embedding-Block + LLM-Judge ≈ 95 % Precision). +EMBEDDING_BLOCK_FLOOR = 0.5 # Mindest-Cosine, damit zwei Kandidaten in EINEN Block dürfen +EMBEDDING_BLOCK_CAP = 25 # max. Titel je Block (LLM-Liste kurz/stabil halten) + # Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg). # Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter # laufenden Writern in der Warteschlange hängen. diff --git a/backend/database.py b/backend/database.py index 94366fb..bf35482 100644 --- a/backend/database.py +++ b/backend/database.py @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS bausteine ( nennungen INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'kandidat', quellen TEXT NOT NULL DEFAULT '[]', + reader TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL, PRIMARY KEY (topic, titel_norm) ) @@ -266,6 +267,10 @@ async def init_db(): await db.execute("ALTER TABLE subbausteine ADD COLUMN fakten TEXT NOT NULL DEFAULT ''") # DEFAULT '' nötig für NOT NULL beim ADD COLUMN except aiosqlite.OperationalError: pass + try: # Migration: bausteine.reader (Reader-Set je Kandidat, JSON) — exakte Konsens-Zählung (≥2 Reader) + await db.execute("ALTER TABLE bausteine ADD COLUMN reader TEXT NOT NULL DEFAULT '[]'") + except aiosqlite.OperationalError: + pass # Migration: alte vertiefungen-Tabelle → baustein_texte (Bestand = lange Form, art 'deepdive') cursor = await db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vertiefungen'") if await cursor.fetchone(): @@ -714,15 +719,31 @@ async def delete_baustein_daten(topic: str) -> None: # --- Bausteine-Pipeline-Inhalt: Inventar / Subbausteine / Frage-Muster / Coverage / State / Quelle --- -async def upsert_baustein(topic: str, titel_norm: str, titel: str, beschreibung: str = "", quellen: list | None = None) -> None: - """Kandidat einfügen oder Nennungszähler erhöhen. Erst-Beschreibung bleibt erhalten.""" +async def upsert_baustein(topic: str, titel_norm: str, titel: str, beschreibung: str = "", + quellen: list | None = None, reader: str | None = None) -> None: + """Kandidat einfügen oder Reader-Set vereinigen. Erst-Beschreibung bleibt erhalten. + + `reader` = ID des Recherche-Readers (z.B. "a5-1"). Die Vereinigung läuft race-frei in + EINEM Statement (json1), weil mehrere Reader-Coroutinen nebenläufig upserten — ein + read-modify-write über `await` würde Mitglieder verlieren. `nennungen` bleibt synchron + zu `len(reader)`. `reader=None` (z.B. aus `_set_inventar`) lässt das Set unverändert.""" db = await get_db() + rid = reader if isinstance(reader, str) and reader else None await db.execute( - """INSERT INTO bausteine (topic, titel_norm, titel, beschreibung, nennungen, status, quellen, updated_at) - VALUES (?, ?, ?, ?, 1, 'kandidat', ?, ?) + """INSERT INTO bausteine (topic, titel_norm, titel, beschreibung, nennungen, status, quellen, reader, updated_at) + VALUES (?, ?, ?, ?, 1, 'kandidat', ?, ?, ?) ON CONFLICT(topic, titel_norm) DO UPDATE SET - nennungen = nennungen + 1, quellen = excluded.quellen, updated_at = excluded.updated_at""", - (topic, titel_norm, titel, beschreibung, json.dumps(quellen or [], ensure_ascii=False), _now()), + reader = (SELECT json_group_array(v) FROM ( + SELECT value AS v FROM json_each(bausteine.reader) + UNION SELECT ? WHERE ? IS NOT NULL)), + nennungen = (SELECT count(*) FROM ( + SELECT value AS v FROM json_each(bausteine.reader) + UNION SELECT ? WHERE ? IS NOT NULL)), + quellen = excluded.quellen, updated_at = excluded.updated_at""", + (topic, titel_norm, titel, beschreibung, + json.dumps(quellen or [], ensure_ascii=False), + json.dumps([rid] if rid else [], ensure_ascii=False), _now(), + rid, rid, rid, rid), ) await db.commit() @@ -738,6 +759,7 @@ async def list_bausteine(topic: str, status: str | None = None) -> list[dict]: for row in rows: d = _row_to_dict(row, cursor) d["quellen"] = json.loads(d.get("quellen") or "[]") + d["reader"] = json.loads(d.get("reader") or "[]") out.append(d) return out diff --git a/backend/embedding.py b/backend/embedding.py new file mode 100644 index 0000000..0e8bffb --- /dev/null +++ b/backend/embedding.py @@ -0,0 +1,123 @@ +"""Semantisches Embedding-Clustering für die Baustein-Konsolidierung. + +Mean-Pool-Embeddings eines mehrsprachigen Satz-Modells bilden über Cosine-Blocking + +Union-Find GLOBALE Kandidaten-Cluster (kein Chunk-Verlust). Sichere Paare (Ähnlichkeit +≥ HART) werden ohne LLM gemergt; Grenz-Paare im Band [BAND_LOW, HART) gibt der Aufrufer +einem LLM-Judge zur ja/nein-Entscheidung. Fehlen `transformers`/`torch` oder lädt das +Modell nicht → `embed_sims()` liefert `None`, der Aufrufer fällt auf den alten +Panel-Judge-Pfad zurück (silente Deaktivierung, wie das Lesbarkeits-Gate). + +CPU genügt; der Aufrufer wrappt die blockierende Inferenz in `asyncio.to_thread`. +`numpy` ist transitiv über torch vorhanden (bewusst nicht in requirements.txt, analog torch). +""" + +import logging + +import numpy as np + +from config import EMBEDDING_AKTIV, EMBEDDING_MODELL, EMBEDDING_BLOCK_FLOOR, EMBEDDING_BLOCK_CAP + +log = logging.getLogger("creator.embedding") + +_modell_cache = None # (tokenizer, model, torch) — Singleton +_ladeversuch = False # schon versucht zu laden? + +EMBEDDING_BATCH = 32 # Inferenz-Batchgröße (CPU) +EMBEDDING_MAX_LEN = 128 # Titel + Kurzbeschreibung sind kurz → kleiner Truncation-Cap genügt + + +def _modell(): + """Lädt das Modell einmalig. None = Clustering aus (deaktiviert oder Lade-Fehler).""" + global _modell_cache, _ladeversuch + if _ladeversuch: + return _modell_cache + _ladeversuch = True + if not EMBEDDING_AKTIV: + return None + try: + import torch + from transformers import AutoModel, AutoTokenizer + tok = AutoTokenizer.from_pretrained(EMBEDDING_MODELL) + model = AutoModel.from_pretrained(EMBEDDING_MODELL) + model.eval() + _modell_cache = (tok, model, torch) + log.info("Embedding-Modell geladen: %s", EMBEDDING_MODELL) + except Exception as e: + log.warning("Embedding-Clustering deaktiviert (Modell nicht ladbar): %s", e) + _modell_cache = None + return _modell_cache + + +def verfuegbar() -> bool: + """True, wenn das Modell geladen werden konnte. Lädt beim ersten Aufruf (blockierend).""" + return _modell() is not None + + +def embed(texts: list[str]) -> "np.ndarray | None": + """Texte → (n, d) L2-normalisierte, mean-gepoolte Embeddings. None = Modell aus.""" + if _modell() is None: + return None + tok, model, torch = _modell_cache + out = [] + for i in range(0, len(texts), EMBEDDING_BATCH): + batch = texts[i:i + EMBEDDING_BATCH] + enc = tok(batch, return_tensors="pt", truncation=True, max_length=EMBEDDING_MAX_LEN, padding=True) + with torch.no_grad(): + hidden = model(**enc).last_hidden_state # (b, t, d) + mask = enc["attention_mask"].unsqueeze(-1).type_as(hidden) + vec = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9) # mean-pool ohne Padding + vec = torch.nn.functional.normalize(vec, p=2, dim=1) # L2 → Cosine = Skalarprodukt + out.append(vec.cpu().numpy()) + return np.vstack(out).astype(np.float32) + + +def _find(parent: list[int], x: int) -> int: + while parent[x] != x: + parent[x] = parent[parent[x]] # Pfad-Kompression + x = parent[x] + return x + + +def _union(parent: list[int], a: int, b: int) -> None: + ra, rb = _find(parent, a), _find(parent, b) + if ra != rb: + parent[max(ra, rb)] = min(ra, rb) # kleinster Index = Wurzel (deterministisch) + + +def embed_sims(texts: list[str]): + """Texte → (n, n) Cosine-Matrix · None = Modell nicht verfügbar (Fallback).""" + embs = embed(texts) + if embs is None: + return None + return embs @ embs.T # (n, n) Cosine, float32 (~2 MB bei n=700) + + +def capped_blocks(sims, floor: float | None = None, cap: int | None = None) -> list[list[int]]: + """Grobe Ähnlichkeits-Blocks für den LLM — High-Recall, aber Größe gedeckelt. + + Greedy: alle Paare mit Cosine ≥ `floor` nach Cosine absteigend; zwei Blocks werden nur + verschmolzen, wenn der resultierende Block ≤ `cap` bleibt. Verhindert den Giant-Component + (reines Threshold-Blocking verkettet sonst fast alles) und hält die LLM-Listen kurz. + → Liste von Blocks (Index-Listen), jeder Knoten in genau einem Block. + """ + fl = EMBEDDING_BLOCK_FLOOR if floor is None else floor + cp = EMBEDDING_BLOCK_CAP if cap is None else cap + n = len(sims) + parent = list(range(n)) + size = [1] * n + if n >= 2: + iu = np.triu_indices(n, k=1) + s = sims[iu] + kept = np.where(s >= fl)[0] + # höchste Cosine zuerst → engste Paare bilden zuerst Blocks + for k in kept[np.argsort(-s[kept])]: + i, j = int(iu[0][k]), int(iu[1][k]) + ri, rj = _find(parent, i), _find(parent, j) + if ri != rj and size[ri] + size[rj] <= cp: + _union(parent, i, j) + r = _find(parent, i) + size[r] = size[ri] + size[rj] + blocks: dict[int, list[int]] = {} + for i in range(n): + blocks.setdefault(_find(parent, i), []).append(i) + return list(blocks.values()) diff --git a/backend/textkit.py b/backend/textkit.py index 9a2bee7..d177d97 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -55,6 +55,14 @@ def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None: return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t))) +def _norm_dash(s: str) -> str: + """Space-umgebene Dash-Varianten (en/em/figure/bar/hyphen) → einheitlicher Trenner ' — '. + Manche Modelle (v.a. nicht-westliche) setzen statt des Em-Dashs einen En-Dash „–"; ohne + Normalisierung scheitert der ` — `-Split komplett und der ganze Eintrag wird zum Titel. + ASCII-Bindestrich „-" bleibt unangetastet (sonst zerlegt es Formeln wie „n - 1").""" + return re.sub(r"\s+[‒–—―‐]\s+", " — ", s) + + def _parse_auswahl(text: str) -> dict[int, str]: """Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile.""" entries: dict[int, str] = {} @@ -63,9 +71,9 @@ def _parse_auswahl(text: str) -> dict[int, str]: m = re.match(r"\s*(\d+)[.)]\s+(.*\S)", line) if m: last = int(m.group(1)) - entries[last] = m.group(2) + entries[last] = _norm_dash(m.group(2)) elif last is not None and line.strip(): - entries[last] += " " + line.strip() + entries[last] += " " + _norm_dash(line.strip()) return entries diff --git a/templates/Prompt/Bausteine-Block-Gruppieren.md b/templates/Prompt/Bausteine-Block-Gruppieren.md new file mode 100644 index 0000000..5b6cb9f --- /dev/null +++ b/templates/Prompt/Bausteine-Block-Gruppieren.md @@ -0,0 +1,20 @@ +Unten stehen nummerierte Baustein-Kandidaten für das Thema "{topic}". Sie stammen aus einem Ähnlichkeits-Block. Manche bezeichnen DENSELBEN Baustein oder eine Eigenschaft davon, andere sind verschieden. Gruppiere sie. + +KANDIDATEN: +{eintraege} + +Regeln: +- Bilde Gruppen: Nummern, die ZUM SELBEN Baustein gehören, kommen in EINE Gruppe. +- **Achte auf die Kern-Entität** (das Problem/Objekt): Clique, Vertex Cover, Set Cover, Knapsack, Dominating Set, LPT/List Scheduling … Verschiedene Entität → verschiedene Gruppen, auch bei ähnlichem Satzbau ("Lower Bound Clique" ≠ "Lower Bound Vertex Cover"). +- Echte Paraphrasen ZUSAMMEN, auch bei anderen Worten ("List Scheduling" = "LPT-Algorithmus"; "Set Cover" = "Mengenüberdeckung"). +- **Eigenschaften eines Problems gehören ZUM Problem-Baustein — nicht eigenständig.** Bündle mit dem Problem: seinen Komplexitäts-Status (∈ NP, NP-schwer, NP-vollständig), seinen Verifizierer / Zertifikat / NDTM, "… als Sprache / Definition", seine einzelnen Lower-Bound-Parameter (k / r / |U|). + - Beispiel: "Knapsack", "Knapsack ∈ NP", "Knapsack NP-schwer", "Knapsack NP-vollständig", "Verifizierer für Knapsack" → EINE Gruppe (Baustein "Knapsack"). + - Beispiel: "Hitting Set Lower Bound (k)", "(r)", "(|U|)" → EINE Gruppe. +- GETRENNT bleiben (eigene Bausteine): verschiedene Probleme (Clique-Member ≠ Clique-Nomember); eine REDUKTION zwischen zwei Problemen ist eine eigene Technik (z.B. "3-SAT ⪯ k-Clique" bleibt eigenständig); verschiedene Verfahren/Sätze mit eigener Aussage. +- Im Zweifel zwischen zwei verschiedenen Problemen → TRENNEN. Bei Problem + seiner Eigenschaft → BÜNDELN. +- JEDE Nummer kommt in GENAU EINE Gruppe. Einzelne Bausteine sind eine Gruppe mit einem Element. + +Schreibe NUR die JSON-Datei nach: {out_path} + +Format (Listen von Kandidaten-Nummern; jede Nummer genau einmal): +{{"gruppen": [[1, 3], [2], [4, 5]]}} diff --git a/templates/Prompt/Bausteine-Klaerung.md b/templates/Prompt/Bausteine-Klaerung.md index 1868a2f..4b4c8e5 100644 --- a/templates/Prompt/Bausteine-Klaerung.md +++ b/templates/Prompt/Bausteine-Klaerung.md @@ -1,15 +1,23 @@ -Zum Thema "{topic}" sind unten Bausteine, die in der Recherche nur EINMAL genannt wurden. Die Quelle wurde vollständig durchsucht. Entscheide jeden Eintrag einzeln. - -BEREITS BESTÄTIGTES INVENTAR (nur Kontext — nicht ändern, nicht duplizieren): -{konsens} +Zum Thema "{topic}" sind unten Bausteine, die in der Recherche nur EINMAL genannt wurden. Die Quelle wurde vollständig durchsucht. Entscheide für JEDEN Eintrag: ist er ein echter, eigenständiger Baustein → `aufnehmen`, oder nicht → verwerfen (in keine Liste). EINZELN GENANNTE EINTRÄGE (jeweils entscheiden): {rest} +Was ein Baustein IST: eine eigenständige LERNEINHEIT — ein Konzept, ein Verfahren, ein Problem, eine Definition, ein benannter Satz MIT inhaltlicher Aussage. + +Was KEIN Baustein ist (→ verwerfen): +- Bloße Verweise/Nummern ohne Inhalt: "Satz 7.18", "Bemerkung 7.22", "Lemma 6.2", "D*". +- Beweis-Fragmente/Zwischenschritte: "Beweis (b) Güte", "Beweis ⊂ Richtung", "Eigenschaft (a)". +- Zu Vages oder erkennbar Erfundenes. + +Beispiele: +- "Clique-Member — k-Clique, die einen festen Knoten v enthält" → **aufnehmen** (eigenständiges Problem). +- "Satz 7.18" → **verwerfen** (bloßer Verweis, keine Aussage). +- "Beweis (b) Güte" → **verwerfen** (Fragment). +- "Satz 7.8: ΔTSP1 hat Güte 2" → **aufnehmen** (benannter Satz MIT Aussage). + Regeln: -- Fachlich valider, eigenständiger Baustein und KEINE Dublette/Variante des Inventars → `aufnehmen`. -- Erfunden, zu vage, oder Dublette/Variante eines Inventar-Bausteins → verwerfen (in keine Liste). -- Doppelt ein Eintrag inhaltlich einen ANDEREN einzeln genannten Eintrag (gleiche Sache, andere Worte) → nur EINEN aufnehmen, den anderen verwerfen. +- Im Zweifel an der Eigenständigkeit → eher aufnehmen. Dubletten werden später separat entfernt; hier zählt nur: echter Baustein oder Müll. - Übernimm aufgenommene Einträge WÖRTLICH ("Titel — Kurzbeschreibung"), nicht umformulieren.{final} Schreibe NUR die JSON-Datei nach: {out_path} diff --git a/templates/Prompt/Bausteine-Paar-Filter.md b/templates/Prompt/Bausteine-Paar-Filter.md new file mode 100644 index 0000000..2c0f35e --- /dev/null +++ b/templates/Prompt/Bausteine-Paar-Filter.md @@ -0,0 +1,20 @@ +Zwei Recherchen haben Bausteine für das Thema "{topic}" notiert. Entscheide für JEDES Paar, ob A und B DENSELBEN Baustein bezeichnen (dasselbe Konzept, nur anders formuliert) → **ja**, oder ob es ZWEI UNTERSCHIEDLICHE Bausteine sind → **nein**. + +PAARE: +{paare} + +Regeln: +- **Achte zuerst auf die KERN-ENTITÄT** (das Problem/Objekt, um das es geht): Clique, Vertex Cover, Independent Set, Dominating Set, Set Cover, FVS, Knapsack … Sind die Entitäten VERSCHIEDEN → **nein**, egal wie gleich der Satzbau ist. +- Gleicher Satzbau täuscht. Diese Paare sind **nein** (verschiedene Entität trotz fast identischer Formulierung): + - "Lower Bound **Clique** bzgl. Knoten" ↔ "Lower Bound **Vertex Cover** bzgl. Knoten" + - "Lower Bound Clique bzgl. **Knoten**" ↔ "Lower Bound Clique bzgl. **Kanten**" + - "Verifizierer für **FVS**" ↔ "Verifizierer für **Knapsack**" + - "**Cliquenproblem**" ↔ "**Vertex-Cover-Problem**" +- **ja** nur bei echter Bedeutungsgleichheit: gleiche Lösung desselben Problems, dieselbe Entität, nur andere Formulierung/Benennung (z. B. "SET COVER" ↔ "Mengenüberdeckungsproblem", "Cliquenproblem" ↔ "k-CLIQUE", "List Scheduling" ↔ "LPT-Algorithmus"). +- **nein** auch bei verschiedenen Aspekten desselben Problems: "Set Cover (Problem)" ↔ "Set Cover ETH-Schranke"; ein Problem ↔ seine Reduktion auf ein anderes; ein Problem ↔ sein Verifizierer. +- Im Zweifel **nein** — lieber zwei getrennte Bausteine als zwei Konzepte fälschlich verschmelzen. + +Schreibe NUR die JSON-Datei nach: {out_path} + +Format (jede Paar-Nummer aus der Liste mit "ja" oder "nein"; kein weiterer Text in der Datei): +{{"paare": {{"1": "ja", "2": "nein"}}}}