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
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)
# „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.

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 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,))

View File

@@ -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):