This commit is contained in:
team3
2026-06-23 11:12:31 +02:00
parent 8fe0e8581f
commit c07e1e9e30
3 changed files with 103 additions and 29 deletions

View File

@@ -48,7 +48,7 @@ RECHERCHE_THEMA_AGENTEN = 5 # Web-Modus (Quelle „thema", kein Crawl-Ordner)
RECHERCHE_KAPPE = 1800 # Sicherheits-Deckel je Batch-Agent RECHERCHE_KAPPE = 1800 # Sicherheits-Deckel je Batch-Agent
SICHTUNG_CHUNK = 80 # Crawl-Seiten je Sichtungs-Agent (Content/Noise-Triage) SICHTUNG_CHUNK = 80 # Crawl-Seiten je Sichtungs-Agent (Content/Noise-Triage)
SUBBAUSTEIN_KAPPE = 900 # Subbaustein-Finde-Loop je Chunk (15 min) 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") 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", "Fragen finden", "Fragen wählen", "Fragen klären", "Fragen prüfen",
) )
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest 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: 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). # Grobe Anzeige-Phasen: bündeln die Feinschritte (intern bleibt alles feingranular).
# Sonderschritte (Quelle laden, Ergänzung) gehören zur Phase „Inventar". # Sonderschritte (Quelle laden, Ergänzung) gehören zur Phase „Inventar".
PHASEN = ( 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")), ("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")),
("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")), ("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")),
("Relevanz", ("Relevanz finden", "Relevanz wählen", "Relevanz 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) files = _bausteine_files(topic)
q = lade_quelle(topic) q = lade_quelle(topic)
if q["type"] == "link" and not _crawl_fertig(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 if not files["final"].exists(): # Inventar (DB-Loop) noch offen
return _step_idx(topic, "Recherche") return _step_idx(topic, "Recherche")
if q["type"] == "projekt" and not files["ergaenzung"].exists(): 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 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: 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: """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 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": if await db.get_step_status(topic, "Recherche") == "fertig":
return True return True
arbeit = files["arbeit"] arbeit = files["arbeit"]
await db.delete_bausteine(topic) await db.delete_bausteine(topic) # Coverage/inhalt gehört der Sichtung — NICHT löschen
await db.delete_coverage(topic)
await db.set_step_status(topic, "Recherche", "laufend") await db.set_step_status(topic, "Recherche", "laufend")
async def _ingest(text: str) -> None: 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 [] 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)
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: if not pages:
# Quelle „thema" (oder kein Crawl): freie Web-Recherche, eine Runde. # 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") await db.set_step_status(topic, "Recherche", "fertig")
return True return True
# Content/Noise-Triage: Noise (Events/Clubs/Profile/…) raus, nur Content batchen. # Content-Seiten stehen schon fest (Sichtung im Schritt „Quelle aufbereiten").
content = await _sichte_inhalt(ctx, set_p, files, ordner, pages) # Feste Batches, je Batch RECHERCHE_READERS Reader, die GENAU diese Dateien lesen.
if is_cancelled(): batches = _chunk_nums(sorted(pages), max(1, math.ceil(len(pages) / RECHERCHE_BATCH)))
return False
pages = sorted(content)
# Crawl: feste Batches, je Batch RECHERCHE_READERS Reader, die GENAU diese Dateien lesen.
batches = _chunk_nums(pages, max(1, math.ceil(len(pages) / RECHERCHE_BATCH)))
async def _lese_batch(bi: int, batch: list[str]) -> bool: async def _lese_batch(bi: int, batch: list[str]) -> bool:
liste = "\n".join(f"- {p}" for p in batch) 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 k, r = mapping
konsens += k konsens += k
rest += r 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. # Judge-Ausgabe ist maßgeblich → Inventar in der DB neu setzen.
await db.delete_bausteine(topic) await db.delete_bausteine(topic)
for t in konsens: for t in konsens:
@@ -1488,7 +1536,7 @@ async def _reset_db_ab_phase(topic: str, ab_phase: int) -> None:
if ab_phase <= 1: if ab_phase <= 1:
await db.delete_bausteine(topic) await db.delete_bausteine(topic)
await db.delete_coverage(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: if ab_phase <= 2:
await db.delete_subbausteine(topic) await db.delete_subbausteine(topic)
if ab_phase <= 5: if ab_phase <= 5:
@@ -1528,18 +1576,11 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
if ab_phase is not None: if ab_phase is not None:
_reset_ab_phase(topic, ab_phase) _reset_ab_phase(topic, ab_phase)
await _reset_db_ab_phase(topic, ab_phase) await _reset_db_ab_phase(topic, ab_phase)
# Link-Quelle: erst crawlen (gleiche Domain, begrenzt) → wird zur Ordner-Quelle. # Schritt „Quelle aufbereiten": Crawl (link) + PDFs + Content/Noise-Sichtung.
if q["type"] == "link" and not _crawl_fertig(topic): if not await _quelle_aufbereiten(ctx, set_p, files, q, ordner):
set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle laden"))
n = await asyncio.to_thread(crawl, q["ort"], ordner, cancelled=is_cancelled)
if is_cancelled(): if is_cancelled():
abgebrochen() abgebrochen()
return 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)
# „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND # „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND
# Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor, # Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor,
# ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen. # ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen.

View File

@@ -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_RECHERCHE_COVERAGE = """
CREATE TABLE IF NOT EXISTS recherche_coverage ( CREATE TABLE IF NOT EXISTS recherche_coverage (
topic TEXT NOT NULL, topic TEXT NOT NULL,
quelle TEXT NOT NULL, quelle TEXT NOT NULL,
gelesen INTEGER NOT NULL DEFAULT 0, gelesen INTEGER NOT NULL DEFAULT 0,
inhalt INTEGER,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (topic, quelle) PRIMARY KEY (topic, quelle)
) )
@@ -202,6 +203,10 @@ async def init_db():
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER") await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError: except aiosqlite.OperationalError:
pass 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) try: # Migration für Bestands-DBs ohne verstanden-Spalte (Mastery-Stufe)
await db.execute("ALTER TABLE baustein_progress ADD COLUMN verstanden TEXT") await db.execute("ALTER TABLE baustein_progress ADD COLUMN verstanden TEXT")
except aiosqlite.OperationalError: except aiosqlite.OperationalError:
@@ -739,6 +744,30 @@ async def list_coverage(topic: str) -> dict[str, int]:
return {q: g for q, g in rows} 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: async def delete_coverage(topic: str) -> None:
db = await get_db() db = await get_db()
await db.execute("DELETE FROM recherche_coverage WHERE topic = ?", (topic,)) await db.execute("DELETE FROM recherche_coverage WHERE topic = ?", (topic,))

View File

@@ -4,8 +4,12 @@ SEITEN (Dateiname · URL · Auszug):
{seiten} {seiten}
Regeln: 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. - `noise` = eindeutig kein FACHLICHER Lerninhalt zum Thema. Markiere insbesondere:
- Im ZWEIFEL NICHT markieren. Alles, was fachlichen Stoff enthalten könnte (Konzepte, Anleitungen, Beispiele, Definitionen), bleibt Content. - 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. - Nutze NUR die exakten Dateinamen aus der Liste oben.
Schreibe NUR die JSON-Datei nach {out_path} (kein weiterer Text): Schreibe NUR die JSON-Datei nach {out_path} (kein weiterer Text):