update
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,))
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user