diff --git a/backend/bausteine.py b/backend/bausteine.py index c60e0ab..4e43e53 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -20,7 +20,7 @@ from pathlib import Path import database as db from agents import kill_process, cancel_scope, clear_scope -from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER +from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS from fsutil import atomic_write_text, atomic_write_json from jsonio import read_json_file as _json_datei from lernen import FRAGETYPEN @@ -46,10 +46,11 @@ 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-Chunk (Content/Noise-Triage) -SICHTUNG_READERS = 3 # Sichter je Chunk; Merge per Mehrheit, Tie-Break = Content +# 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 +# Frage-Muster-Chunks per LPT nach Sub-Last balancieren (Makespan), statt nach Baustein-Anzahl. +FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk log = logging.getLogger("creator.bausteine") @@ -324,16 +325,17 @@ def active_bausteine() -> list[dict]: def reset_bausteine(topic: str) -> None: - """„Löschen": räumt nur die generierten Bausteine weg (Dateien ab Inventar). BEHÄLT - Quelle (quelle.json), Crawl-Ordner und Sichtung. Voller Wipe inkl. Quelle/Crawl nur - über „Thema löschen" (DELETE /topics → rmtree(topic_dir)).""" - _reset_ab_phase(topic, "Inventar") + """„Entfernen": löscht den GESAMTEN Bausteine-Bereich — Crawl, Sichtung, Inventar … Fragen. + BEHÄLT nur die Themen-Config `quelle.json` (Typ/Link/Spec). Re-Generieren crawlt neu. + (Crawl/Sichtung gehören zu den Bausteinen; nur die Config ist „Thema".)""" + files = _bausteine_files(topic) + files["final"].unlink(missing_ok=True) + files["sidecar"].unlink(missing_ok=True) + files["frage_muster"].unlink(missing_ok=True) + shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # Crawl gehört zu Bausteinen + shutil.rmtree(files["arbeit"], ignore_errors=True) _bausteine_errors.pop(topic, None) - - -async def reset_bausteine_db(topic: str) -> None: - """DB-Pendant zu reset_bausteine: Inventar…Fragen aus der DB; Coverage/Sichtung + Quelle bleiben.""" - await _reset_db_ab_phase(topic, "Inventar") + # quelle.json bleibt bewusst stehen — das ist die Themen-Config. def _phase_idx(label: str) -> int: @@ -550,6 +552,21 @@ def _n_chunks(count: int, size: int = SUBBAUSTEIN_CHUNK) -> int: return min(SUBBAUSTEIN_MAX, max(1, math.ceil(count / size))) +def _lpt_chunks(gewichte: list[int], target: int) -> list[list[int]]: + """Indizes lastbalanciert auf Chunks verteilen (LPT, Makespan-minimal). Gewicht = Kosten je Index. + K = ceil(Gesamtgewicht/target); schwerste zuerst in den jeweils leichtesten Bin. → Index-Listen.""" + if not gewichte: + return [] + K = max(1, math.ceil(sum(gewichte) / max(1, target))) + bins: list[list[int]] = [[] for _ in range(K)] + last = [0] * K + for i in sorted(range(len(gewichte)), key=lambda x: gewichte[x], reverse=True): + j = min(range(K), key=lambda b: last[b]) + bins[j].append(i) + last[j] += gewichte[i] + return [b for b in bins if b] + + def _merge_finder(num: int, idx: dict, finder: list[dict]) -> tuple[list[str], list[str]]: """Subbausteine eines Bausteins über die Finder mergen → (konsens ≥2, rest ==1).""" counts: dict[str, int] = {} @@ -753,7 +770,18 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct items = [(titel, sub) for titel, subs in roh.items() for sub in subs] # globale id = index+1 if not items: return {titel: [] for titel in roh} - chunks = _chunk_nums(list(range(len(items))), _n_chunks(len(items), STUFE_CHUNK)) + # Chunks aus GANZEN Bausteinen packen (keinen Baustein splitten) → Rater sieht je Baustein + # alle Subs und kann relativ einstufen. Item-Indizes je Baustein in roh-Reihenfolge. + chunks, cur, i = [], [], 0 + for _titel_b, subs in roh.items(): + g = list(range(i, i + len(subs))) + i += len(subs) + if cur and len(cur) + len(g) > STUFE_CHUNK: + chunks.append(cur) + cur = [] + cur.extend(g) + if cur: + chunks.append(cur) n = len(chunks) def rater_paths(c): @@ -769,7 +797,14 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct vorhanden = sum(1 for p in paths if _stufen_schema(_json_datei(p), local_set)) if vorhanden >= 2: return True - enum = "\n".join(f"{k}. [{items[j][0]}] {items[j][1]}" for k, j in enumerate(item_idxs, 1)) + enum_zeilen, cur_b = [], None + for k, j in enumerate(item_idxs, 1): + b, sub = items[j] + if b != cur_b: + enum_zeilen.append(f"\nBAUSTEIN: {b}") + cur_b = b + enum_zeilen.append(f"{k}. {sub}") + enum = "\n".join(enum_zeilen).strip() offen = [(i, p) for i, p in enumerate(paths, 1) if not _stufen_schema(_json_datei(p), local_set)] slots = [{ "key": f"bausteine-{topic}-stufe-c{c}-{i}", @@ -1002,7 +1037,7 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict if not bausteine: return {} typen_block = "\n".join(f"- {k}: {v}" for k, v in FRAGETYPEN.items()) - chunks = _chunk_nums(list(range(len(bausteine))), _n_chunks(len(bausteine))) # ~10 Bausteine/Chunk + chunks = _lpt_chunks([len(rel) for _, rel in bausteine], FRAGE_CHUNK_SUBS) # lastbalanciert nach Sub-Zahl def roh_path(ci): return arbeit / f"frage-muster-c{ci}.json" @@ -1223,65 +1258,25 @@ async def _set_inventar(topic: str, eintrag: str, status: str) -> None: await db.set_baustein_status(topic, norm, status) -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. Je Chunk SICHTUNG_READERS Sichter; Merge je Seite - per Mehrheit, Tie-Break = Content (konservativ). Fehler/Ausfall → Content (fail-open). - → Menge der Content-Dateinamen (nie leer, solange pages nicht leer).""" - topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled - pages = list(pages) - if not pages: - return set() - arbeit = files["arbeit"] +def _sichte_regeln(ordner, pages: list[str]) -> tuple[list[str], list[str]]: + """Deterministischer Content/Noise-Filter (config.CRAWL_*). Substring-Match (klein) gegen + URL + Dateiname. Reihenfolge: keep > noise > min_chars > behalten. → (content, noise).""" ordner = Path(ordner) - - def _snippet(fn: str) -> str: + content, noise = [], [] + for fn in pages: 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]: - seiten = "\n".join(_snippet(fn) for fn in chunk) - paths = [arbeit / f"sichtung-c{ci}-{i}.json" for i in range(1, SICHTUNG_READERS + 1)] - for p in paths: - p.unlink(missing_ok=True) - slots = [{ - "key": f"bausteine-{topic}-sichtung-c{ci}-{i}", - "prompt": _prompt("Bausteine-Seiten-Sichtung", topic=topic, seiten=seiten, out_path=p), - "role": "judge", "capabilities": "files", - "payload": (lambda result, p=p: _sichtung_schema(_json_datei(p))), - } for i, p in enumerate(paths, 1)] - ergebnisse = await _race(topic, f"Seiten-Sichtung {ci}", slots, 1, - _timeout("recherche_mapping", len(chunk)), provider, - cancelled=is_cancelled, grace=KONSENS_GRACE) - cs = set(chunk) - stimmen = [set(r) & cs for r in (ergebnisse or [])] # Noise-Menge je Reader (nur Chunk-Seiten) - R = len(stimmen) - if not R: - return set() # fail-open: kein Urteil → alles Content - # Seite = Noise nur bei strenger Mehrheit; Gleichstand/Minderheit → Content. - return {page for page in chunk if 2 * sum(page in s for s in stimmen) > R} - - async def melde(d, t): - set_p(f"Sichte Seiten {d}/{t}…") - - set_p("Sichte Seiten…", step=_step_idx(topic, "Quelle aufbereiten")) - 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)} ({len(chunks)} Chunks × {SICHTUNG_READERS} Sichter)") - return content or set(pages) # alles Noise? → fail-open, alle behalten + body = "\n".join(zeilen[1:]).strip() + hay = f"{url}\n{fn}".lower() + if any(p in hay for p in CRAWL_KEEP_PATTERNS): + content.append(fn) + elif any(p in hay for p in CRAWL_NOISE_PATTERNS): + noise.append(fn) + elif len(body) < CRAWL_MIN_CHARS: + noise.append(fn) + else: + content.append(fn) # Default: behalten — alles mit Inhalt bleibt + return content, noise async def _quelle_aufbereiten(ctx: GenContext, set_p, files: dict, q: dict, ordner) -> bool: @@ -1307,12 +1302,11 @@ async def _quelle_aufbereiten(ctx: GenContext, set_p, files: dict, q: dict, ordn await asyncio.to_thread(_pdfs_konvertieren, ordner) pages = sorted(set(_crawl_index(ordner).values())) if pages: + set_p("Sichte Seiten…", step=_step_idx(topic, "Quelle aufbereiten")) await db.delete_coverage(topic) - content = await _sichte_inhalt(ctx, set_p, files, ordner, pages) - if is_cancelled(): - return False - noise = [p for p in pages if p not in content] - await db.mark_inhalt(topic, sorted(content), noise) + content, noise = _sichte_regeln(ordner, pages) # deterministischer Regel-Filter + await db.mark_inhalt(topic, sorted(content), sorted(noise)) + _log(topic, f"Sichtung: {len(content)} Content / {len(noise)} Noise von {len(pages)} (Regeln)") await db.set_step_status(topic, "Quelle aufbereiten", "fertig") return True diff --git a/backend/config.py b/backend/config.py index 45d77cc..0f1bc69 100644 --- a/backend/config.py +++ b/backend/config.py @@ -42,6 +42,16 @@ RECHERCHE_GRACE = 900 # Prüf-Loops lassen Rest-Beanstandungen danach stehen. KONSENS_MAX_RUNDEN = 3 +# Crawler-Sichtung (Content/Noise) — deterministischer Regel-Filter statt LLM. +# Match: Substring (klein) gegen URL UND Dateiname. Reihenfolge: keep > noise > min_chars > behalten. +# Sonderregeln einfach hier ergänzen. +CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # immer Content +CRAWL_NOISE_PATTERNS = [ # eindeutig themenfremd → raus + "clubs", "events", "podcasts", "resources", "-u-", + "academy", "pricing", "/plans", "career", "newsletter", "impressum", "login", +] +CRAWL_MIN_CHARS = 400 # zu wenig Text → raus + # Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section). # Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt. TIMEOUTS = { diff --git a/backend/database.py b/backend/database.py index c2a3380..512b127 100644 --- a/backend/database.py +++ b/backend/database.py @@ -864,8 +864,9 @@ async def delete_guide_content(topic: str, format: str | None = None) -> None: async def delete_topic_pipeline(topic: str) -> None: - """Alle Pipeline-Inhalte eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State/Quelle).""" + """Bausteine-Bereich eines Themas verwerfen (Inventar/Subs/Muster/Coverage/State). + NICHT die Themen-Config `quelle` — die wird separat verwaltet (delete_quelle).""" db = await get_db() - for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", "pipeline_state", "quelle"): + for tab in ("bausteine", "subbausteine", "frage_muster", "recherche_coverage", "pipeline_state"): await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,)) await db.commit() diff --git a/backend/routes.py b/backend/routes.py index 657eb75..3fa35f6 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -15,9 +15,9 @@ from database import ( create_element, list_elements, get_element, update_element, delete_element, list_baustein_progress, get_baustein_progress, set_offene_frage, set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten, - delete_topic_pipeline, get_guide_content, delete_guide_content, + delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content, ) -from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, reset_bausteine_db, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster +from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion from lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_meilensteinen, _zufall_malus from guide import generate_guide, guide_slot_dateien, guide_fertig_step @@ -88,6 +88,7 @@ async def remove_topic(topic: str): await delete_topic(topic) await delete_baustein_daten(topic) await delete_topic_pipeline(topic) + await delete_quelle(topic) # Themen-Config (DB) — beim Thema-Löschen mit weg await delete_guide_content(topic) shutil.rmtree(topic_dir(topic), ignore_errors=True) return {"ok": True} @@ -172,8 +173,8 @@ async def cancel_bausteine_route(topic: str): @router.delete("/bausteine") async def remove_bausteine(topic: str): - reset_bausteine(topic) # Dateien ab Inventar — Quelle/Crawl bleiben - await reset_bausteine_db(topic) # DB ab Inventar — Coverage/Sichtung + Quelle bleiben + reset_bausteine(topic) # Dateien: Crawl + Sichtung + Inventar…Fragen weg; quelle.json bleibt + await delete_topic_pipeline(topic) # DB: Bausteine-Bereich weg; Themen-Config (quelle) bleibt return {"ok": True} diff --git a/templates/Prompt/Bausteine-Seiten-Sichtung.md b/templates/Prompt/Bausteine-Seiten-Sichtung.md deleted file mode 100644 index d75dba5..0000000 --- a/templates/Prompt/Bausteine-Seiten-Sichtung.md +++ /dev/null @@ -1,18 +0,0 @@ -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 FACHLICHER Lerninhalt zum Thema. Markiere insbesondere: - - Veranstaltungs-/Termin-, Mitglieder-/Profil-, Vereins-/Community-, Podcast-/Video-Verzeichnisse, Login/Impressum/Karriere. - - **Marketing/Vertrieb:** Positionierung, ICP/Zielgruppe, Editionen/Preise/Pläne, Plattform-/Produkt-Werbung ohne How-to. - - **Meta/Navigation:** Übersichts-/Index-/Landing-Seiten ohne eigenen Stoff, Lernpfad-/Kurs-Intros, Voraussetzungs-/„Was du lernst"-Seiten. - - **Fremde Tracks:** Seiten eines ANDEREN Lern-Pfads/Tracks oder Levels als dieses Thema (z.B. Frontend statt Backend, „Intermediate"/„Advanced" wenn das Thema „Essentials" ist). -- Im ZWEIFEL bei ECHTER Technik NICHT markieren: konkrete Konzepte, Anleitungen, Code, Konfiguration, Verfahren bleiben Content — auch Deployment/PaaS/Betrieb. -- 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": []}}. diff --git a/templates/Prompt/Stufen-Mapping.md b/templates/Prompt/Stufen-Mapping.md index a839c2a..f88f5c1 100644 --- a/templates/Prompt/Stufen-Mapping.md +++ b/templates/Prompt/Stufen-Mapping.md @@ -3,13 +3,13 @@ Mehrere Agenten haben die Subbausteine des Themas "{topic}" nach Schwierigkeit e STRITTIGE SUBBAUSTEINE (mit den abgegebenen Stimmen): {strittig} -Stufen: -- **einfach**: Grundlegend, Anfänger versteht es sofort, Kernfunktion. -- **mittel**: Braucht etwas Erklärung, gängige Variante oder wichtiges Detail. -- **schwer**: Sonderfall, Tücke, fortgeschritten, leicht falsch zu machen. +Stufe = Lern-/Verständnisaufwand (nicht Seltenheit/Obskurität): +- **einfach**: Basis-Fakt/Definition, sofort verständlich. Auch Versions-Details, Pfade, Konstanten, einzelne Fakten. +- **mittel**: braucht Erklärung — mehrere Teile, Zusammenhang, typische Anwendung. +- **schwer**: komplexes Zusammenspiel mehrerer Konzepte, tieferes Verständnis nötig. NICHT „schwer" nur weil selten/neu/Edge-Case. Regeln: -- Wäge die Stimmen ab, entscheide fachlich. Im Zweifel die niedrigere Stufe. +- Wäge die Stimmen ab, entscheide fachlich nach Lernaufwand. Im Zweifel die niedrigere Stufe. - Für JEDE strittige Nummer genau eine Stufe. Schreibe NUR die JSON-Datei nach: {out_path} diff --git a/templates/Prompt/Stufen-Recherche.md b/templates/Prompt/Stufen-Recherche.md index 4e6d172..1c7d392 100644 --- a/templates/Prompt/Stufen-Recherche.md +++ b/templates/Prompt/Stufen-Recherche.md @@ -1,16 +1,23 @@ -Stufe jeden Subbaustein des Themas "{topic}" nach Schwierigkeit ein: einfach, mittel oder schwer. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „einfach", Fortgeschrittene alles). +Stufe jeden Subbaustein des Themas "{topic}" nach **Lern-/Verständnisaufwand** ein: einfach, mittel oder schwer. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „einfach", Fortgeschrittene alles). -SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein): +SUBBAUSTEINE (nach Baustein gruppiert): {subbausteine} -Stufen: -- **einfach**: Grundlegend. Ein Anfänger versteht es sofort. Die Kernfunktion, ohne die der Baustein nicht nutzbar ist. -- **mittel**: Braucht etwas Erklärung. Eine gängige Variante, ein wichtiges Detail, ein zweiter Schritt. -- **schwer**: Sonderfall, Tücke, fortgeschritten. Leicht falsch zu machen, selten gebraucht, oder setzt mehr Verständnis voraus. +Was die Stufe misst — wie viel Aufwand, den Punkt zu VERSTEHEN und ANZUWENDEN: +- **einfach**: Basis-Fakt oder Definition. Sofort verständlich. Der Einstieg in den Baustein. +- **mittel**: braucht etwas Erklärung. Mehrere Teile, ein Zusammenhang, eine typische Anwendung. +- **schwer**: komplexes Zusammenspiel mehrerer Konzepte. Setzt tieferes Verständnis voraus, ist gedanklich anspruchsvoll. + +WICHTIG — was NICHT die Stufe bestimmt: +- Seltenheit ist KEINE Schwierigkeit. Ein Nischen-Feature kann trotzdem einfach sein. +- Versions-Details (z.B. „seit 6.4.12.0"), Konstanten, Pfade, einzelne Fakten → meist **einfach**, nicht schwer. +- Edge-Cases/Gotchas, die nur eine simple Regel sind (z.B. „Name muss dem Ordner entsprechen") → **einfach/mittel**, nicht schwer. +- „schwer" NUR, wenn der Punkt selbst gedanklich schwer ist — nicht weil er obskur oder neu ist. Regeln: -- Stufe jeden Punkt einzeln, gemessen an SEINEM Baustein. Im Zweifel die niedrigere Stufe. -- Jeder Baustein sollte mindestens einen „einfach"-Punkt haben (den Einstieg). +- Stufe RELATIV im jeweiligen Baustein: vergleiche die Punkte eines Bausteins miteinander. +- Die meisten Punkte sind einfach/mittel. „schwer" nur für die echten Brocken. +- Jeder Baustein hat mindestens einen „einfach"-Punkt (den Einstieg). Im Zweifel die niedrigere Stufe. - Urteile nur — erfinde nichts dazu, ändere keine Subbausteine. Schreibe NUR die JSON-Datei nach: {out_path} diff --git a/templates/Prompt/Subbaustein-Mapping.md b/templates/Prompt/Subbaustein-Mapping.md index 0a83899..327d5fc 100644 --- a/templates/Prompt/Subbaustein-Mapping.md +++ b/templates/Prompt/Subbaustein-Mapping.md @@ -1,13 +1,13 @@ -Mehrere Agenten haben unabhängig die Subbausteine je Baustein des Themas "{topic}" ermittelt. Pro Baustein ist der KONSENS (von mindestens zwei Agenten genannt) und der strittige REST (nur einmal genannt) gelistet. Entscheide je Baustein die finale Subbaustein-Liste. +Unten je Baustein des Themas "{topic}" die gefundenen Subbausteine. Gib je Baustein die **bereinigte** finale Liste zurück. {bausteine} Regeln: -- Übernimm alle Konsens-Subbausteine. -- Einen strittigen Rest-Punkt AUFNEHMEN, wenn er ein eigener, korrekter Teilpunkt des Bausteins ist, den der Konsens nicht abdeckt. -- VERWERFEN, wenn er eine Dublette/Variante eines vorhandenen Punkts ist, zu fein granular, am Rand des Themas oder fachlich zweifelhaft. -- Die Anzahl folgt der Schwierigkeit: trivialer Baustein wenige Punkte, komplexer viele. Blähe nichts auf, lass nichts Wesentliches weg. -- Subbausteine wörtlich übernehmen, nicht umformulieren. Jeder Punkt atomar (eine Aussage). +- **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. +- 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. Schreibe NUR die Datei {out_path} — pro Baustein ein baustein-Marker (Titel EXAKT wie oben), darunter die finale Subbaustein-Liste: