From c07e1e9e307a25e8d0dc4f1e87a20da50da98c12 Mon Sep 17 00:00:00 2001 From: team3 Date: Tue, 23 Jun 2026 11:12:31 +0200 Subject: [PATCH] update --- backend/bausteine.py | 93 +++++++++++++------ backend/database.py | 31 ++++++- templates/Prompt/Bausteine-Seiten-Sichtung.md | 8 +- 3 files changed, 103 insertions(+), 29 deletions(-) diff --git a/backend/bausteine.py b/backend/bausteine.py index ae70e09..66ad311 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -48,7 +48,7 @@ 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 +KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); darüber chunked + Merge-Pass log = logging.getLogger("creator.bausteine") @@ -180,7 +180,7 @@ def _bausteine_steps(topic: str) -> tuple: "Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen", ) mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest - return (("Quelle laden",) if q["type"] == "link" else ()) + mitte + return (("Quelle aufbereiten",) if q["type"] == "link" else ()) + mitte def _step_idx(topic: str, name: str) -> int: @@ -198,7 +198,7 @@ def _melde_p(set_p, topic: str, schritt: str): # Grobe Anzeige-Phasen: bündeln die Feinschritte (intern bleibt alles feingranular). # Sonderschritte (Quelle laden, Ergänzung) gehören zur Phase „Inventar". PHASEN = ( - ("Inventar", ("Quelle laden", "Recherche", "Konsolidierung", "Klärung", "Ergänzung")), + ("Inventar", ("Quelle aufbereiten", "Recherche", "Konsolidierung", "Klärung", "Ergänzung")), ("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")), ("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")), ("Relevanz", ("Relevanz finden", "Relevanz wählen", "Relevanz klären")), @@ -273,7 +273,7 @@ def _resume_step(topic: str) -> int: files = _bausteine_files(topic) q = lade_quelle(topic) if q["type"] == "link" and not _crawl_fertig(topic): - return _step_idx(topic, "Quelle laden") + return _step_idx(topic, "Quelle aufbereiten") if not files["final"].exists(): # Inventar (DB-Loop) noch offen return _step_idx(topic, "Recherche") if q["type"] == "projekt" and not files["ergaenzung"].exists(): @@ -1267,6 +1267,39 @@ async def _sichte_inhalt(ctx: GenContext, set_p, files: dict, ordner, pages: lis return content or set(pages) # alles als Noise markiert? → fail-open, alle behalten +async def _quelle_aufbereiten(ctx: GenContext, set_p, files: dict, q: dict, ordner) -> bool: + """Schritt „Quelle aufbereiten": Crawl (link) + PDF-Konvert + Content/Noise-Sichtung. + Persistiert die Sichtung in der Coverage-Tabelle (inhalt). → True (ok) / False (Abbruch/Fehler). + thema: nichts. projekt/uni: nur PDFs (kuratierter Ordner, keine Sichtung).""" + topic, is_cancelled = ctx.topic, ctx.is_cancelled + if not ordner: + return True # thema → keine Quelle aufzubereiten + if q["type"] != "link": + await asyncio.to_thread(_pdfs_konvertieren, ordner) # projekt/uni: nur PDFs, keine Sichtung + return True + if await db.get_step_status(topic, "Quelle aufbereiten") == "fertig": + return True + if not _crawl_fertig(topic): + set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle aufbereiten")) + n = await asyncio.to_thread(crawl, q["ort"], ordner, cancelled=is_cancelled) + if is_cancelled(): + return False + if not n: + _bausteine_errors[topic] = "Crawl ergab keine Inhalte — Link/Domain prüfen" + return False + await asyncio.to_thread(_pdfs_konvertieren, ordner) + pages = sorted(set(_crawl_index(ordner).values())) + if pages: + 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) + await db.set_step_status(topic, "Quelle aufbereiten", "fertig") + return True + + 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 @@ -1276,8 +1309,7 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, if await db.get_step_status(topic, "Recherche") == "fertig": return True arbeit = files["arbeit"] - await db.delete_bausteine(topic) - await db.delete_coverage(topic) + 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: @@ -1293,7 +1325,9 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, 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())) + pages = await db.list_content(topic) # von der Sichtung als Content markierte Seiten + if not pages and ordner: + pages = sorted(set(_crawl_index(ordner).values())) # Fallback (projekt/uni: keine Sichtung) if not pages: # Quelle „thema" (oder kein Crawl): freie Web-Recherche, eine Runde. @@ -1320,14 +1354,9 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner, 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))) + # Content-Seiten stehen schon fest (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))) async def _lese_batch(bi: int, batch: list[str]) -> bool: liste = "\n".join(f"- {p}" for p in batch) @@ -1399,6 +1428,25 @@ async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool: k, r = mapping konsens += k rest += r + # Bei mehreren Chunks: ein globaler Merge-Pass über die vereinten Konsens-Einträge, + # damit Dubletten über Chunk-Grenzen (DAL×4, PHPUnit×5 …) verschmelzen. + if len(chunks) > 1 and konsens: + fp = arbeit / "konsolidierung-merge.json" + fp.unlink(missing_ok=True) + eintraege = "\n".join(f"{i}. {t} (2× genannt)" for i, t in enumerate(konsens, 1)) + status, mapping = await run_single_slot( + ctx, "Konsolidierung Merge", + key=f"bausteine-{topic}-konsolidierung-merge", + 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(konsens)), + ) + if status == CANCELLED: + return False + if status != FAILED and mapping: + konsens, r2 = mapping + rest += r2 # vom Merge zurückgestufte Einträge in den Rest # Judge-Ausgabe ist maßgeblich → Inventar in der DB neu setzen. await db.delete_bausteine(topic) for t in konsens: @@ -1488,7 +1536,7 @@ async def _reset_db_ab_phase(topic: str, ab_phase: int) -> None: if ab_phase <= 1: await db.delete_bausteine(topic) await db.delete_coverage(topic) - await db.delete_pipeline_state(topic, ["Recherche", "Konsolidierung", "Klärung"]) + await db.delete_pipeline_state(topic, ["Quelle aufbereiten", "Recherche", "Konsolidierung", "Klärung"]) if ab_phase <= 2: await db.delete_subbausteine(topic) if ab_phase <= 5: @@ -1528,18 +1576,11 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = if ab_phase is not None: _reset_ab_phase(topic, ab_phase) await _reset_db_ab_phase(topic, ab_phase) - # Link-Quelle: erst crawlen (gleiche Domain, begrenzt) → wird zur Ordner-Quelle. - if q["type"] == "link" and not _crawl_fertig(topic): - set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle laden")) - n = await asyncio.to_thread(crawl, q["ort"], ordner, cancelled=is_cancelled) + # Schritt „Quelle aufbereiten": Crawl (link) + PDFs + Content/Noise-Sichtung. + if not await _quelle_aufbereiten(ctx, set_p, files, q, ordner): if is_cancelled(): abgebrochen() - return - if not n: - _bausteine_errors[topic] = "Crawl ergab keine Inhalte — Link/Domain prüfen" - return - if ordner: - await asyncio.to_thread(_pdfs_konvertieren, ordner) + return # „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND # Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor, # ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen. diff --git a/backend/database.py b/backend/database.py index aafa93a..c2a3380 100644 --- a/backend/database.py +++ b/backend/database.py @@ -124,12 +124,13 @@ CREATE TABLE IF NOT EXISTS frage_muster ( ) """ -# Crawl-Seiten-Abdeckung: treibt den Recherche-Loop (welche Quelle wurde zitiert?). +# Crawl-Seiten je Thema: inhalt (1=Content/0=Noise, von der Sichtung) + gelesen (von der Recherche). CREATE_RECHERCHE_COVERAGE = """ CREATE TABLE IF NOT EXISTS recherche_coverage ( topic TEXT NOT NULL, quelle TEXT NOT NULL, gelesen INTEGER NOT NULL DEFAULT 0, + inhalt INTEGER, updated_at TEXT NOT NULL, PRIMARY KEY (topic, quelle) ) @@ -202,6 +203,10 @@ async def init_db(): await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER") except aiosqlite.OperationalError: pass + try: # Migration: recherche_coverage.inhalt (Content/Noise aus der Sichtung) + await db.execute("ALTER TABLE recherche_coverage ADD COLUMN inhalt INTEGER") + except aiosqlite.OperationalError: + pass try: # Migration für Bestands-DBs ohne verstanden-Spalte (Mastery-Stufe) await db.execute("ALTER TABLE baustein_progress ADD COLUMN verstanden TEXT") except aiosqlite.OperationalError: @@ -739,6 +744,30 @@ async def list_coverage(topic: str) -> dict[str, int]: return {q: g for q, g in rows} +async def mark_inhalt(topic: str, content: list[str], noise: list[str]) -> None: + """Sichtungs-Ergebnis je Crawl-Seite ablegen: inhalt=1 (Content) bzw. 0 (Noise).""" + db = await get_db() + now = _now() + rows = [(topic, q, 1, now) for q in content] + [(topic, q, 0, now) for q in noise] + if not rows: + return + await db.executemany( + """INSERT INTO recherche_coverage (topic, quelle, inhalt, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(topic, quelle) DO UPDATE SET inhalt = excluded.inhalt, updated_at = excluded.updated_at""", + rows, + ) + await db.commit() + + +async def list_content(topic: str) -> list[str]: + """Crawl-Seiten, die die Sichtung als Content markiert hat (inhalt=1).""" + db = await get_db() + cursor = await db.execute( + "SELECT quelle FROM recherche_coverage WHERE topic = ? AND inhalt = 1 ORDER BY quelle", (topic,) + ) + return [r[0] for r in await cursor.fetchall()] + + async def delete_coverage(topic: str) -> None: db = await get_db() await db.execute("DELETE FROM recherche_coverage WHERE topic = ?", (topic,)) diff --git a/templates/Prompt/Bausteine-Seiten-Sichtung.md b/templates/Prompt/Bausteine-Seiten-Sichtung.md index 3641902..d75dba5 100644 --- a/templates/Prompt/Bausteine-Seiten-Sichtung.md +++ b/templates/Prompt/Bausteine-Seiten-Sichtung.md @@ -4,8 +4,12 @@ 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. +- `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):