This commit is contained in:
team3
2026-06-28 17:05:18 +02:00
parent 5b71fcbcd7
commit 07b6736ced
20 changed files with 699 additions and 90 deletions

View File

@@ -12,7 +12,7 @@ auth:
@echo "Verzeichnisse angelegt und auf uid 1000 chowned." @echo "Verzeichnisse angelegt und auf uid 1000 chowned."
install: install:
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers trafilatura
pip install --break-system-packages torch --index-url https://download.pytorch.org/whl/cpu pip install --break-system-packages torch --index-url https://download.pytorch.org/whl/cpu
python3 -m playwright install chromium python3 -m playwright install chromium
@echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen." @echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen."

View File

@@ -22,14 +22,14 @@ from pathlib import Path
import database as db import database as db
from agents import kill_process, cancel_scope, clear_scope, run_agent from agents import kill_process, cancel_scope, clear_scope, run_agent
from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS from config import KONSENS_GRACE, RECHERCHE_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET
from fsutil import atomic_write_text, atomic_write_json from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_datei from jsonio import read_json_file as _json_datei
from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
from crawl import crawl from crawl import crawl
from pipeline import ( from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _gather_fortschritt, _log, _prompt, _race, _relevanz_schema, CANCELLED, FAILED, GenContext, _extra, _gather_fortschritt, _janein_schema, _log, _prompt, _race,
_runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot, _relevanz_schema, _runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot,
) )
from textkit import ( from textkit import (
_eindeutige_titel, _lade_bausteine, _norm_titel, _parse_auswahl, _parse_subbausteine, _titel, _eindeutige_titel, _lade_bausteine, _norm_titel, _parse_auswahl, _parse_subbausteine, _titel,
@@ -57,6 +57,8 @@ KONSOLIDIERUNG_CHUNK = 600 # bis hierher EIN globaler Judge (dedupt alles); dar
FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk FRAGE_CHUNK_SUBS = 50 # Ziel-Summe relevanter Subs je Chunk
FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster) FAKTEN_CHUNK_SUBS = 25 # Fakten-Extraktion: kleinere Chunks (Fakten sind umfangreicher als Muster)
FAKTEN_CHECK_PANEL = 3 # Judges je Chunk im Fakten-Check (Mehrheit beanstandet) FAKTEN_CHECK_PANEL = 3 # Judges je Chunk im Fakten-Check (Mehrheit beanstandet)
KONSOLIDIERUNG_PANEL = 3 # Mapping-Judges je Chunk (Panel → Reconcile statt Einzel-Judge)
SUBBAUSTEIN_PANEL = 3 # Source-Judges in der Subbaustein-Klärung (Mehrheit statt Einzel-Judge)
log = logging.getLogger("creator.bausteine") log = logging.getLogger("creator.bausteine")
@@ -678,7 +680,9 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict
bl.append(f"<!-- baustein: {titel_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in subs)) bl.append(f"<!-- baustein: {titel_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in subs))
if not bl: if not bl:
return "" return ""
return "\n\nBEREITS GEFUNDEN — bestätige diese Subbausteine erneut UND ergänze fehlende:\n" + "\n".join(bl) # Bekanntes NICHT erneut auflisten lassen (sonst bläht Re-Bestätigung den Mention-Count auf,
# Self-Bias/Echo) — nur Fehlendes ergänzen. Der Zähler bleibt so ein ehrliches Konsens-Signal.
return ("\n\nBEREITS ERFASST — liste diese NICHT erneut. Finde nur, was FEHLT:\n" + "\n".join(bl))
# Phase „Subbausteine finden": je Paket Loop bis 0 neue Subs / Zeit-Kappe. # Phase „Subbausteine finden": je Paket Loop bis 0 neue Subs / Zeit-Kappe.
async def _finde(c, chunk): async def _finde(c, chunk):
@@ -741,30 +745,74 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict
await db.set_subbaustein_felder(topic, norm_by_num[num], s["sub_norm"], await db.set_subbaustein_felder(topic, norm_by_num[num], s["sub_norm"],
status=("konsens" if s["nennungen"] >= 2 else "verworfen")) status=("konsens" if s["nennungen"] >= 2 else "verworfen"))
# Phase „Subbausteine klären": Judge je Paket bereinigt die Konsens-Liste. # Phase „Subbausteine klären": Source-Panel (SUBBAUSTEIN_PANEL Judges) prüft Konsens + Unsicher (1×)
# gegen die Quelle; Code-Mehrheit je Sub. Externes, mehrstimmiges Gate gegen Einzel-Judge-Bias + Echo.
async def _klaere(c, chunk): async def _klaere(c, chunk):
fp = arbeit / f"subbaustein-final-c{c}.md" fp = arbeit / f"subbaustein-final-c{c}.md"
if _parse_subbausteine(_read(fp)): if _parse_subbausteine(_read(fp)):
return return
bloecke, hat = [], False bloecke, hat = [], False
konsens_by_num: dict[int, list[str]] = {}
for num in chunk: for num in chunk:
subs = [s["sub_titel"] for s in await db.list_subbausteine(topic, norm_by_num[num]) if s["status"] == "konsens"] rows = await db.list_subbausteine(topic, norm_by_num[num])
zeilen = "\n".join(f"- {s}" for s in subs) if subs else "- (keiner)" kon = [s["sub_titel"] for s in rows if s["status"] == "konsens"]
bloecke.append(f"BAUSTEIN: {titel_by_num[num]}\nKonsens:\n{zeilen}") uns = [s["sub_titel"] for s in rows if s["status"] != "konsens" and s["nennungen"] == 1]
if subs: konsens_by_num[num] = kon
if not kon and not uns:
continue
hat = True hat = True
k_zeilen = "\n".join(f"- {s}" for s in kon) if kon else "- (keiner)"
u_zeilen = "\n".join(f"- {s}" for s in uns) if uns else "- (keiner)"
bloecke.append(f"BAUSTEIN: {titel_by_num[num]}\nKonsens (≥2 Finder):\n{k_zeilen}\nUnsicher (1× — streng gegen Quelle prüfen):\n{u_zeilen}")
if not hat: if not hat:
return return
status, _ = await run_single_slot(
ctx, f"Subbaustein-Klärung {c}", chunk_idx = _titel_index({num: titel_by_num[num] for num in chunk})
key=f"bausteine-{topic}-subbaustein-final-c{c}", paths = [arbeit / f"subbaustein-final-c{c}-j{j}.md" for j in range(1, SUBBAUSTEIN_PANEL + 1)]
prompt=_prompt("Subbaustein-Mapping", topic=topic, source=source, bausteine="\n\n".join(bloecke), out_path=fp, extra=_extra(instructions)), offen = [(j, p) for j, p in enumerate(paths, 1) if _parse_subbausteine(_read(p)) is None]
role="judge", capabilities=caps, for _, p in offen:
payload=lambda result, p=fp: _parse_subbausteine(_read(p)) or None, p.unlink(missing_ok=True)
timeout=_timeout("subbaustein_check", len(chunk)), if offen:
) slots = [{
if status == FAILED: "key": f"bausteine-{topic}-subbaustein-final-c{c}-j{j}",
"prompt": _prompt("Subbaustein-Mapping", topic=topic, source=source, bausteine="\n\n".join(bloecke), out_path=p, extra=_extra(instructions)),
"role": "judge", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subbausteine(_read(p)) or None),
} for j, p in offen]
vorhanden = SUBBAUSTEIN_PANEL - len(offen)
await _race(topic, f"Subbaustein-Klärung {c}", slots, max(1, 2 - vorhanden),
_timeout("subbaustein_check", len(chunk)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE)
if is_cancelled():
return
outs = [d for p in paths if (d := _parse_subbausteine(_read(p)))]
if not outs: # Panel komplett gescheitert → Konsens übernehmen (wie bisher der Fallback)
_log(topic, f"Subbaustein-Klärung Paket {c} fehlgeschlagen — Konsens übernommen") _log(topic, f"Subbaustein-Klärung Paket {c} fehlgeschlagen — Konsens übernommen")
text = "\n\n".join(f"<!-- baustein: {titel_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in konsens_by_num[num])
for num in chunk if konsens_by_num[num])
atomic_write_text(fp, text)
return
# Code-Mehrheit je Baustein/Sub-Norm: behalten wenn Mehrheit der Judges ihn führt (Tie → behalten).
bloecke_out = []
for num in chunk:
votes: dict[str, int] = {}
form: dict[str, str] = {}
for d in outs:
seen = set()
for marker, subs in d.items():
if _titel_aufloesen(chunk_idx, marker) != num:
continue
for sub in subs:
sn = _norm_titel(sub)
if not sn or sn in seen:
continue
seen.add(sn)
form.setdefault(sn, sub)
votes[sn] = votes.get(sn, 0) + 1
kept = [form[sn] for sn in form if votes[sn] * 2 >= len(outs)]
if kept:
bloecke_out.append(f"<!-- baustein: {titel_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in kept))
atomic_write_text(fp, "\n\n".join(bloecke_out))
await _gather_fortschritt([_klaere(c, chunk) for c, chunk in enumerate(chunks, 1)], n, _melde_p(set_p, topic, "Subbausteine klären")) await _gather_fortschritt([_klaere(c, chunk) for c, chunk in enumerate(chunks, 1)], n, _melde_p(set_p, topic, "Subbausteine klären"))
if is_cancelled(): if is_cancelled():
@@ -806,6 +854,9 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
{Baustein-Titel: [{titel, stufe}, …]} oder None.""" {Baustein-Titel: [{titel, stufe}, …]} oder None."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
arbeit = files["arbeit"] arbeit = files["arbeit"]
# Kernpunkte je Sub als knapper Kontext (fundiertere Einstufung; Klassifikation braucht wenig).
fakten_map = _json_datei(files["fakten"])
fakten_map = fakten_map if isinstance(fakten_map, dict) else {}
items = [(titel, sub) for titel, subs in roh.items() for sub in subs] # globale id = index+1 items = [(titel, sub) for titel, subs in roh.items() for sub in subs] # globale id = index+1
if not items: if not items:
return {titel: [] for titel in roh} return {titel: [] for titel in roh}
@@ -843,6 +894,8 @@ async def _stufen_block(ctx: GenContext, set_p, files: dict, roh: dict, instruct
enum_zeilen.append(f"\nBAUSTEIN: {b}") enum_zeilen.append(f"\nBAUSTEIN: {b}")
cur_b = b cur_b = b
enum_zeilen.append(f"{k}. {sub}") enum_zeilen.append(f"{k}. {sub}")
if (kz := _kern_zeile(fakten_map.get(b, {}).get(_norm_titel(sub)))):
enum_zeilen.append(f" {kz}")
enum = "\n".join(enum_zeilen).strip() 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)] offen = [(i, p) for i, p in enumerate(paths, 1) if not _stufen_schema(_json_datei(p), local_set)]
slots = [{ slots = [{
@@ -978,10 +1031,18 @@ def _fakten_check_schema(data) -> list[tuple[str, bool]] | None:
for p in pr if isinstance(p, dict) and (sn := _norm_titel(str(p.get("subbaustein", ""))))] for p in pr if isinstance(p, dict) and (sn := _norm_titel(str(p.get("subbaustein", ""))))]
def _kern_zeile(fk) -> str:
"""Knappe Kernpunkt-Zeile für Klassifikation (Stufe/Relevanz) — weniger Kontext genügt dort.
Leer, wenn keine Fakten/Kernpunkte (Altbestand)."""
if not isinstance(fk, dict) or not fk.get("kernpunkte"):
return ""
return "Kern: " + " · ".join(str(k) for k in fk["kernpunkte"])
def _fakten_zeilen(fk: dict) -> str: def _fakten_zeilen(fk: dict) -> str:
z = [] z = []
if fk.get("kernpunkte"): if fk.get("kernpunkte"):
z.append("Kernpunkte: " + " · ".join(fk["kernpunkte"])) z.append("Kernpunkte: " + " · ".join(str(k) for k in fk["kernpunkte"]))
if fk.get("voraussetzungen"): if fk.get("voraussetzungen"):
z.append("Voraussetzung: " + fk["voraussetzungen"]) z.append("Voraussetzung: " + fk["voraussetzungen"])
if fk.get("huerden"): if fk.get("huerden"):
@@ -1015,6 +1076,7 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins
chunks = _lpt_chunks([len(subs) for _, subs in bausteine], FAKTEN_CHUNK_SUBS) chunks = _lpt_chunks([len(subs) for _, subs in bausteine], FAKTEN_CHUNK_SUBS)
def roh_path(ci): return arbeit / f"fakten-c{ci}.json" def roh_path(ci): return arbeit / f"fakten-c{ci}.json"
def erg_path(ci): return arbeit / f"fakten-erg-c{ci}.json"
def chk_path(ci, j): return arbeit / f"fakten-check-c{ci}-j{j}.json" def chk_path(ci, j): return arbeit / f"fakten-check-c{ci}-j{j}.json"
def fix_path(ci): return arbeit / f"fakten-fix-c{ci}.json" def fix_path(ci): return arbeit / f"fakten-fix-c{ci}.json"
def ctitel(idxs): return [bausteine[i][0] for i in idxs] def ctitel(idxs): return [bausteine[i][0] for i in idxs]
@@ -1037,6 +1099,33 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins
out.setdefault(bt, {})[_norm_titel(sub)] = {"sub": sub, **{k: e[k] for k in _FAKTEN_FELDER}} out.setdefault(bt, {})[_norm_titel(sub)] = {"sub": sub, **{k: e[k] for k in _FAKTEN_FELDER}}
return out return out
# Roh-Fakten + Completeness-Ergänzungen vereinigen (Recall): nur Subs, die in roh existieren.
def _chunk_fakten(ci):
roh = roh_map(ci, roh_path(ci))
erg = roh_map(ci, erg_path(ci)) if erg_path(ci).exists() else {}
if not erg:
return roh
for bt, fm in roh.items():
ebt = erg.get(bt, {})
for sn, fk in fm.items():
ek = ebt.get(sn)
if not ek:
continue
seen = {str(k).strip().casefold() for k in fk.get("kernpunkte", [])}
for k in ek.get("kernpunkte", []):
if str(k).strip().casefold() not in seen:
seen.add(str(k).strip().casefold())
fk["kernpunkte"].append(k)
seent = {bf["text"].strip().casefold() for bf in fk.get("belegte_fakten", [])}
for bf in ek.get("belegte_fakten", []):
if bf["text"].strip().casefold() not in seent:
seent.add(bf["text"].strip().casefold())
fk["belegte_fakten"].append(bf)
for f in ("voraussetzungen", "huerden", "beispiel_idee"):
if not fk.get(f) and ek.get(f):
fk[f] = ek[f]
return roh
# Phase „Fakten finden": 1 Generator je Chunk. # Phase „Fakten finden": 1 Generator je Chunk.
async def _finde(ci, idxs): async def _finde(ci, idxs):
fp = roh_path(ci) fp = roh_path(ci)
@@ -1058,10 +1147,38 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins
_bausteine_errors[topic] = "Fakten-Extraktion fehlgeschlagen" _bausteine_errors[topic] = "Fakten-Extraktion fehlgeschlagen"
return None return None
# Phase „Fakten ergänzen" (Recall): ein gezielter Gap-Hunt je Chunk sucht source-belegte Fakten, die
# der Single-Pass übersah. Best-effort — schlägt nie fehl (keine erg-Datei → Merge nutzt nur roh).
async def _ergaenze(ci, idxs):
ep = erg_path(ci)
if _fakten_schema(_json_datei(ep)):
return
per = roh_map(ci, roh_path(ci))
if not per:
return
block = "\n\n".join(
f"BAUSTEIN: {bt}\nSUBBAUSTEINE (mit bereits erfassten Fakten):\n" + "\n".join(
f"- {fk['sub']}\n Erfasst: " + ("; ".join(
list(fk.get("kernpunkte", [])) + [bf["text"] for bf in fk.get("belegte_fakten", [])]) or "(nichts)")
for fk in fm.values())
for bt, fm in per.items())
subs_total = sum(len(bausteine[i][1]) for i in idxs)
await run_single_slot(
ctx, f"Fakten ergänzen {ci}", key=f"bausteine-{topic}-fakten-erg-c{ci}",
prompt=_prompt("Fakten-Ergaenzung", topic=topic, source=source, bausteine=block, out_path=ep, extra=_extra(instructions)),
role="guide", capabilities=caps,
payload=lambda result, p=ep: _fakten_schema(_json_datei(p)),
timeout=_timeout("inhalt", subs_total))
set_p("Fakten ergänzen…", step=_step_idx(topic, "Fakten finden"))
await _gather_fortschritt([_ergaenze(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _melde_p(set_p, topic, "Fakten finden"))
if is_cancelled():
return None
# Phase „Fakten prüfen": FAKTEN_CHECK_PANEL Judges je Chunk. Zwei Mehrheits-Mengen: # Phase „Fakten prüfen": FAKTEN_CHECK_PANEL Judges je Chunk. Zwei Mehrheits-Mengen:
# beanstandet (Fakt ungenau → korrigieren) und verwerfen (Sub nicht belegbar → entfernen). # beanstandet (Fakt ungenau → korrigieren) und verwerfen (Sub nicht belegbar → entfernen).
async def _pruefe(ci, idxs): async def _pruefe(ci, idxs):
per = roh_map(ci, roh_path(ci)) per = _chunk_fakten(ci) # roh + Ergänzungen → Panel verifiziert die Vereinigung
if not per: if not per:
return ci, set(), set() return ci, set(), set()
fakten_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_fakten_zeilen(fk)}" for fm in per.values() for fk in fm.values()) fakten_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_fakten_zeilen(fk)}" for fm in per.values() for fk in fm.values())
@@ -1132,7 +1249,7 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins
ergebnis: dict[str, dict] = {} ergebnis: dict[str, dict] = {}
verworfen_map: dict[str, set] = {} verworfen_map: dict[str, set] = {}
for ci in range(len(chunks)): for ci in range(len(chunks)):
per = roh_map(ci, roh_path(ci)) per = _chunk_fakten(ci) # roh + Ergänzungen (Recall); Fix überschreibt nur Korrigierte
fix = roh_map(ci, fix_path(ci)) if fix_path(ci).exists() else {} fix = roh_map(ci, fix_path(ci)) if fix_path(ci).exists() else {}
verw = verwerfen.get(ci, set()) verw = verwerfen.get(ci, set())
for bt, fm in per.items(): for bt, fm in per.items():
@@ -1153,7 +1270,7 @@ async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, in
{gid: relevanz} oder None bei Abbruch/Recherche-Fehler. Default bei Lücke/Streit: 'relevant'.""" {gid: relevanz} oder None bei Abbruch/Recherche-Fehler. Default bei Lücke/Streit: 'relevant'."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
arbeit = files["arbeit"] arbeit = files["arbeit"]
items = [(titel, sub["titel"]) for titel, subs in sidecar.items() for sub in subs] # globale id = index+1 items = [(titel, sub["titel"], sub.get("fakten")) for titel, subs in sidecar.items() for sub in subs] # globale id = index+1
if not items: if not items:
return {} return {}
chunks = _chunk_nums(list(range(len(items))), _n_chunks(len(items), STUFE_CHUNK)) chunks = _chunk_nums(list(range(len(items))), _n_chunks(len(items), STUFE_CHUNK))
@@ -1172,7 +1289,12 @@ async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, in
vorhanden = sum(1 for p in paths if _relevanz_schema(_json_datei(p), local_set)) vorhanden = sum(1 for p in paths if _relevanz_schema(_json_datei(p), local_set))
if vorhanden >= 2: if vorhanden >= 2:
return True return True
enum = "\n".join(f"{k}. [{items[j][0]}] {items[j][1]}" for k, j in enumerate(item_idxs, 1)) enum_zeilen = []
for k, j in enumerate(item_idxs, 1):
enum_zeilen.append(f"{k}. [{items[j][0]}] {items[j][1]}")
if (kz := _kern_zeile(items[j][2])):
enum_zeilen.append(f" {kz}")
enum = "\n".join(enum_zeilen)
offen = [(i, p) for i, p in enumerate(paths, 1) if not _relevanz_schema(_json_datei(p), local_set)] offen = [(i, p) for i, p in enumerate(paths, 1) if not _relevanz_schema(_json_datei(p), local_set)]
slots = [{ slots = [{
"key": f"bausteine-{topic}-relevanz-c{c}-{i}", "key": f"bausteine-{topic}-relevanz-c{c}-{i}",
@@ -1280,9 +1402,16 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
topic, is_cancelled = ctx.topic, ctx.is_cancelled topic, is_cancelled = ctx.topic, ctx.is_cancelled
arbeit = files["arbeit"] arbeit = files["arbeit"]
# ALLE Subbausteine (auch rand) bekommen ein Muster — Rand ist in der FGuide-Ebene prüfbar. # ALLE Subbausteine (auch rand) bekommen ein Muster — Rand ist in der FGuide-Ebene prüfbar.
# fakten_by: voller Fakten-Kontext je Sub (Generierung profitiert davon — bessere Fragen).
bausteine = [] bausteine = []
fakten_by: dict[tuple, dict] = {}
for titel, subs in sidecar.items(): for titel, subs in sidecar.items():
alle = [s["titel"] for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()] alle = []
for s in subs:
if isinstance(s, dict) and (st := str(s.get("titel", "")).strip()):
alle.append(st)
if isinstance(s.get("fakten"), dict):
fakten_by[(titel, _norm_titel(st))] = s["fakten"]
if alle: if alle:
bausteine.append((titel, alle)) bausteine.append((titel, alle))
if not bausteine: if not bausteine:
@@ -1303,8 +1432,14 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict
fp = roh_path(ci) fp = roh_path(ci)
if _frage_muster_chunk_schema(_json_datei(fp)): if _frage_muster_chunk_schema(_json_datei(fp)):
return # Resume return # Resume
def _sub_zeile(bi, s):
zeile = f"- {s}"
fk = fakten_by.get((bausteine[bi][0], _norm_titel(s)))
if fk and (ft := _fakten_zeilen(fk)):
zeile += "\n" + "\n".join(" " + l for l in ft.split("\n"))
return zeile
block = "\n\n".join( block = "\n\n".join(
f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in bausteine[i][1]) f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(_sub_zeile(i, s) for s in bausteine[i][1])
for i in idxs for i in idxs
) )
subs_total = sum(len(bausteine[i][1]) for i in idxs) subs_total = sum(len(bausteine[i][1]) for i in idxs)
@@ -1499,7 +1634,89 @@ def _sichte_regeln(ordner, pages: list[str]) -> tuple[list[str], list[str]]:
return content, noise return content, noise
async def _quelle_aufbereiten(ctx: GenContext, set_p, files: dict, q: dict, ordner) -> bool: def _seite_snippet(ordner, fn: str) -> tuple[str, str]:
"""(url, snippet) einer Crawl-Seite für das Relevanz-Gate. url aus der QUELLE:-Zeile;
snippet = Body-Auszug (Navigations-Boilerplate steht vorn — der Prompt ignoriert es).
URL ist das Primärsignal (sprechender Slug), der Snippet stützt nur."""
zeilen = _read(Path(ordner) / fn).splitlines()
url = zeilen[0][len("QUELLE:"):].strip() if zeilen and zeilen[0].startswith("QUELLE:") else ""
body = "\n".join(zeilen[1:]).strip()
snippet = " ".join(body.split())[:QUELLE_RELEVANZ_SNIPPET]
return (url or fn), snippet
async def _relevanz_sichtung(ctx: GenContext, set_p, files: dict, ordner, content: list[str], spec: str, instructions: str) -> tuple[list[str], list[str]]:
"""LLM-Themen-Gate nach dem Regel-Filter: jede Content-Seite ja/nein gegen die Spec.
Off-topic (anderes Fachgebiet) → raus. Muster wie `_relevanz_block`: kleine Pakete, 3 Rater
(`fast`), 2-von-3-Konsens. KONSERVATIV: nur bei klarer „nein"-Mehrheit droppen; Streit/Lücke/
Race-Fehler → behalten. SAFETY: würde das Gate ≥80 % (oder alles) droppen, bleibt alles
(Spec-Mismatch/Bug soll die Quelle nicht leeren). → (behalten, raus) als Dateinamen."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
arbeit = files["arbeit"]
pages = sorted(content)
if not pages:
return content, []
items = [_seite_snippet(ordner, fn) for fn in pages] # Index deckt sich mit `pages`
chunks = _chunk_nums(list(range(len(pages))), _n_chunks(len(pages), QUELLE_RELEVANZ_CHUNK))
n = len(chunks)
def rater_paths(c):
return [arbeit / f"quelle-relevanz-c{c}-{i}.json" for i in (1, 2, 3)]
def lset(idxs):
return set(range(1, len(idxs) + 1))
async def _rate(c, idxs):
local_set = lset(idxs)
paths = rater_paths(c)
vorhanden = sum(1 for p in paths if _janein_schema(_json_datei(p), local_set))
if vorhanden >= 2:
return True
enum_zeilen = []
for k, j in enumerate(idxs, 1):
url, snip = items[j]
enum_zeilen.append(f"{k}. {url}")
if snip:
enum_zeilen.append(f" {snip}")
enum = "\n".join(enum_zeilen)
offen = [(i, p) for i, p in enumerate(paths, 1) if not _janein_schema(_json_datei(p), local_set)]
slots = [{
"key": f"bausteine-{topic}-quelle-relevanz-c{c}-{i}",
"prompt": _prompt("Quelle-Relevanz", topic=topic, spec=spec, seiten=enum, out_path=p, extra=_extra(instructions)),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=p, ids=local_set: _janein_schema(_json_datei(p), ids)),
} for i, p in offen]
neu = await _race(topic, f"Relevanz-Sichtung Paket {c}", slots, 2 - vorhanden, _timeout("relevanz", len(idxs)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE)
return not is_cancelled() and neu is not None
_qidx = _step_idx(topic, "Quelle aufbereiten") # Gate läuft im Quelle-Schritt (kein eigener Step)
set_p(f"Prüfe Relevanz zur Spec ({n} Pakete)…", step=_qidx)
async def _melde_sichtung(d, t):
set_p(f"Prüfe Relevanz zur Spec {d}/{t}", step=_qidx)
await _gather_fortschritt([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], n, _melde_sichtung)
if is_cancelled():
return content, [] # Abbruch → nichts droppen (Caller bricht ab)
# Vote je Seite: nur eine klare „nein"-Mehrheit (≥2 und mehr als „ja") wirft raus.
raus: list[str] = []
for c, idxs in enumerate(chunks, 1):
local_set = lset(idxs)
rater = [d for p in rater_paths(c) if (d := _janein_schema(_json_datei(p), local_set))]
for k in range(1, len(idxs) + 1):
stimmen = [d[k] for d in rater if k in d]
nein, ja = stimmen.count("nein"), stimmen.count("ja")
if nein >= 2 and nein > ja:
raus.append(pages[idxs[k - 1]])
if raus and len(raus) >= max(1, int(len(pages) * 0.8)):
_log(topic, f"Relevanz-Sichtung: würde {len(raus)}/{len(pages)} droppen — verworfen (Spec-Mismatch?), alles behalten")
return content, []
raus_set = set(raus)
behalten = [fn for fn in pages if fn not in raus_set]
return behalten, raus
async def _quelle_aufbereiten(ctx: GenContext, set_p, files: dict, q: dict, ordner, instructions: str) -> bool:
"""Schritt „Quelle aufbereiten": Crawl (link) + PDF-Konvert + Content/Noise-Sichtung. """Schritt „Quelle aufbereiten": Crawl (link) + PDF-Konvert + Content/Noise-Sichtung.
Persistiert die Sichtung in der Coverage-Tabelle (inhalt). → True (ok) / False (Abbruch/Fehler). Persistiert die Sichtung in der Coverage-Tabelle (inhalt). → True (ok) / False (Abbruch/Fehler).
thema: nichts. projekt/uni: nur PDFs (kuratierter Ordner, keine Sichtung).""" thema: nichts. projekt/uni: nur PDFs (kuratierter Ordner, keine Sichtung)."""
@@ -1525,8 +1742,15 @@ async def _quelle_aufbereiten(ctx: GenContext, set_p, files: dict, q: dict, ordn
set_p("Sichte Seiten…", step=_step_idx(topic, "Quelle aufbereiten")) set_p("Sichte Seiten…", step=_step_idx(topic, "Quelle aufbereiten"))
await db.delete_coverage(topic) await db.delete_coverage(topic)
content, noise = _sichte_regeln(ordner, pages) # deterministischer Regel-Filter content, noise = _sichte_regeln(ordner, pages) # deterministischer Regel-Filter
if q.get("spec") and content: # Themen-Gate: trennt das Fachgebiet (Regeln können das nicht)
content, raus = await _relevanz_sichtung(ctx, set_p, files, ordner, content, q["spec"], instructions)
if is_cancelled():
return False
if raus:
noise = sorted(set(noise) | set(raus))
_log(topic, f"LLM-Relevanz: {len(raus)} Seiten off-topic → Noise")
await db.mark_inhalt(topic, sorted(content), sorted(noise)) await db.mark_inhalt(topic, sorted(content), sorted(noise))
_log(topic, f"Sichtung: {len(content)} Content / {len(noise)} Noise von {len(pages)} (Regeln)") _log(topic, f"Sichtung: {len(content)} Content / {len(noise)} Noise von {len(pages)} (Regeln + LLM-Gate)")
await db.set_step_status(topic, "Quelle aufbereiten", "fertig") await db.set_step_status(topic, "Quelle aufbereiten", "fertig")
return True return True
@@ -1673,8 +1897,10 @@ async def _recherche_batch(ctx: GenContext, set_p, files: dict, q: dict, ordner,
async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool: async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool:
"""Judge mergt Kandidaten semantisch + teilt in Konsens (≥2)/Rest (1×); Status in DB.""" """Panel (KONSOLIDIERUNG_PANEL Judges) mergt Kandidaten semantisch; ein Reconcile-Judge führt die
topic, is_cancelled = ctx.topic, ctx.is_cancelled Panel-Ausgaben zur finalen Konsens (≥2)/Rest (1×)-Liste zusammen. Status in DB.
Panel statt Einzel-Judge: ein einzelner Judge ist bias-anfällig (Position/Verbosity) und instabil."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
if await db.get_step_status(topic, "Konsolidierung") == "fertig": if await db.get_step_status(topic, "Konsolidierung") == "fertig":
return True return True
set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung")) set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung"))
@@ -1684,27 +1910,81 @@ async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool:
return False return False
arbeit = files["arbeit"] arbeit = files["arbeit"]
chunks = _chunk_nums(kandidaten, max(1, math.ceil(len(kandidaten) / KONSOLIDIERUNG_CHUNK))) chunks = _chunk_nums(kandidaten, max(1, math.ceil(len(kandidaten) / KONSOLIDIERUNG_CHUNK)))
async def _map_panel(c: int, eintraege: str, anzahl: int):
"""3 Mapping-Judges über `eintraege` → Reconcile-Judge → (konsens, rest). None bei Abbruch/Fehler."""
paths = [arbeit / f"konsolidierung-c{c}-j{j}.json" for j in range(1, KONSOLIDIERUNG_PANEL + 1)]
offen = [(j, p) for j, p in enumerate(paths, 1) if _mapping_schema(_json_datei(p)) is None]
for _, p in offen:
p.unlink(missing_ok=True)
if offen:
slots = [{
"key": f"bausteine-{topic}-konsolidierung-c{c}-j{j}",
"prompt": _prompt("Bausteine-Recherche-Mapping", topic=topic, n=RECHERCHE_READERS, eintraege=eintraege, out_path=p),
"role": "judge", "capabilities": "files",
"payload": (lambda result, p=p: _mapping_schema(_json_datei(p))),
} for j, p in offen]
vorhanden = KONSOLIDIERUNG_PANEL - len(offen)
await _race(topic, f"Konsolidierung {c}", slots, max(1, 2 - vorhanden),
_timeout("recherche_mapping", anzahl), provider, cancelled=is_cancelled, grace=KONSENS_GRACE)
if is_cancelled():
return None
outs = [m for p in paths if (m := _mapping_schema(_json_datei(p)))]
if not outs:
return None
# Union der Panel-Titel; je Titel zählen, wie viele Judges ihn als Konsens führen.
kvotes: dict[str, int] = {}
form: dict[str, str] = {} # norm → Anzeigetitel (erstes Vorkommen)
order: list[str] = []
for kk, rr in outs:
for t in kk + rr:
nt = _norm_titel(_titel(t))
if not nt:
continue
if nt not in form:
form[nt] = t
order.append(nt)
kvotes.setdefault(nt, 0)
for t in kk:
nt = _norm_titel(_titel(t))
if nt:
kvotes[nt] = kvotes.get(nt, 0) + 1
# Reconcile: ein Merge-Judge über die Union, annotiert mit Judge-Stimmen ("k× genannt").
rp = arbeit / f"konsolidierung-c{c}-reconcile.json"
recon = _mapping_schema(_json_datei(rp))
if recon is None:
rp.unlink(missing_ok=True)
eintraege_r = "\n".join(f"{i}. {form[nt]} ({max(1, kvotes[nt])}× genannt)" for i, nt in enumerate(order, 1))
status, recon = await run_single_slot(
ctx, f"Konsolidierung Reconcile {c}",
key=f"bausteine-{topic}-konsolidierung-c{c}-reconcile",
prompt=_prompt("Bausteine-Recherche-Mapping", topic=topic, n=KONSOLIDIERUNG_PANEL, eintraege=eintraege_r, out_path=rp),
role="judge", capabilities="files",
payload=lambda result, p=rp: _mapping_schema(_json_datei(p)),
timeout=_timeout("recherche_mapping", len(order)),
)
if status == CANCELLED:
return None
recon = recon if status != FAILED else None
if recon:
return recon
# Fallback (Reconcile gescheitert): Code-Mehrheit — Konsens, wenn Mehrheit der Judges Konsens sagt.
konsens = [form[nt] for nt in order if kvotes[nt] * 2 >= len(outs) and kvotes[nt] > 0]
kset = {_norm_titel(_titel(t)) for t in konsens}
return konsens, [form[nt] for nt in order if nt not in kset]
konsens, rest = [], [] konsens, rest = [], []
for c, chunk in enumerate(chunks, 1): for c, chunk in enumerate(chunks, 1):
fp = arbeit / f"konsolidierung-c{c}.json"
fp.unlink(missing_ok=True)
eintraege = "\n".join( eintraege = "\n".join(
f"{i}. {b['titel']}{b['beschreibung']} ({b['nennungen']}× genannt)" for i, b in enumerate(chunk, 1) f"{i}. {b['titel']}{b['beschreibung']} ({b['nennungen']}× genannt)" for i, b in enumerate(chunk, 1)
) )
status, mapping = await run_single_slot( res = await _map_panel(c, eintraege, len(chunk))
ctx, f"Konsolidierung {c}", if res is None:
key=f"bausteine-{topic}-konsolidierung-c{c}", if is_cancelled():
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(chunk)),
)
if status == CANCELLED:
return False return False
if status == FAILED:
_bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen" _bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen"
return False return False
k, r = mapping k, r = res
konsens += k konsens += k
rest += r rest += r
# Bei mehreren Chunks: ein globaler Merge-Pass über die vereinten Konsens-Einträge, # Bei mehreren Chunks: ein globaler Merge-Pass über die vereinten Konsens-Einträge,
@@ -1737,39 +2017,51 @@ async def _konsolidiere(ctx: GenContext, set_p, files: dict) -> bool:
async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool: async def _klaere_inventar(ctx: GenContext, set_p, files: dict) -> bool:
"""1 Judge entscheidet über den Rest (1×-Genannte): aufnehmen → Konsens, sonst verworfen.""" """Panel (KONSOLIDIERUNG_PANEL Judges) entscheidet über den Rest (1×-Genannte): Mehrheit `aufnehmen`
topic, is_cancelled = ctx.topic, ctx.is_cancelled → Konsens, sonst verworfen. Panel statt Einzel-Judge — der Rest-Schnitt ist der schärfste Eingriff;
ein einzelner Judge ist hier zu instabil. Konservativer Tie → behalten (nie ein Konzept verlieren)."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
if await db.get_step_status(topic, "Klärung") == "fertig": if await db.get_step_status(topic, "Klärung") == "fertig":
return True return True
set_p("Klärung läuft…", step=_step_idx(topic, "Klärung")) set_p("Klärung läuft…", step=_step_idx(topic, "Klärung"))
rest_rows = await db.list_bausteine(topic, status="rest") rest_rows = await db.list_bausteine(topic, status="rest")
if rest_rows: if rest_rows:
konsens = [b["titel"] for b in await db.list_bausteine(topic, status="konsens")] konsens = [b["titel"] for b in await db.list_bausteine(topic, status="konsens")]
fp = files["arbeit"] / "klaerung.json" arbeit = files["arbeit"]
fp.unlink(missing_ok=True) paths = [arbeit / f"klaerung-j{j}.json" for j in range(1, KONSOLIDIERUNG_PANEL + 1)]
status, ergebnis = await run_single_slot( offen = [(j, p) for j, p in enumerate(paths, 1) if _runde_schema(_json_datei(p), final=True) is None]
ctx, "Klärung", for _, p in offen:
key=f"bausteine-{topic}-klaerung", p.unlink(missing_ok=True)
prompt=_prompt( if offen:
slots = [{
"key": f"bausteine-{topic}-klaerung-j{j}",
"prompt": _prompt(
"Bausteine-Klaerung", topic=topic, "Bausteine-Klaerung", topic=topic,
konsens="\n".join(f"- {t}" for t in konsens) or "(noch leer)", konsens="\n".join(f"- {t}" for t in konsens) or "(noch leer)",
rest="\n".join(f"- {b['titel']}" for b in rest_rows), rest="\n".join(f"- {b['titel']}" for b in rest_rows),
final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.", final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.",
out_path=fp, out_path=p,
), ),
role="judge", capabilities="files", "role": "judge", "capabilities": "files",
payload=lambda result, p=fp: _runde_schema(_json_datei(p), final=True), "payload": (lambda result, p=p: _runde_schema(_json_datei(p), final=True)),
timeout=_timeout("auswahl_mapping", len(rest_rows)), } for j, p in offen]
) vorhanden = KONSOLIDIERUNG_PANEL - len(offen)
if status == CANCELLED: await _race(topic, "Klärung", slots, max(1, 2 - vorhanden),
_timeout("auswahl_mapping", len(rest_rows)), provider, cancelled=is_cancelled, grace=KONSENS_GRACE)
if is_cancelled():
return False return False
if status == FAILED: outs = [r for p in paths if (r := _runde_schema(_json_datei(p), final=True))]
if not outs:
_bausteine_errors[topic] = "Klärung fehlgeschlagen" _bausteine_errors[topic] = "Klärung fehlgeschlagen"
return False return False
aufnehmen, _ = ergebnis # Mehrheit je Rest-Eintrag (per Norm-Titel). Tie → behalten (votes*2 >= n).
auf_norm = {_norm_titel(_titel(t)) for t in aufnehmen} votes: dict[str, int] = {}
for aufnehmen, _ in outs:
for nt in {_norm_titel(_titel(t)) for t in aufnehmen}:
votes[nt] = votes.get(nt, 0) + 1
for b in rest_rows: for b in rest_rows:
await db.set_baustein_status(topic, b["titel_norm"], "konsens" if b["titel_norm"] in auf_norm else "verworfen") auf = votes.get(b["titel_norm"], 0) * 2 >= len(outs)
await db.set_baustein_status(topic, b["titel_norm"], "konsens" if auf else "verworfen")
await db.set_step_status(topic, "Klärung", "fertig") await db.set_step_status(topic, "Klärung", "fertig")
return True return True
@@ -1808,14 +2100,99 @@ def _gliederung_schema(data, valid: set[int]):
return {"kapitel": out} return {"kapitel": out}
def _prereq_schema(data, valid: set[int]) -> dict[int, list[int]]:
"""{"prereqs": {"3": [1, 7]}} → {num: [prereq-nums]} · nur Nummern aus `valid`, ohne Selbstkante.
Ungültig/leer → {} (Best-effort: dann Original-Reihenfolge)."""
if not isinstance(data, dict) or not isinstance(data.get("prereqs"), dict):
return {}
out: dict[int, list[int]] = {}
for k, v in data["prereqs"].items():
try:
num = int(k)
except (ValueError, TypeError):
continue
if num not in valid or not isinstance(v, list):
continue
pres = []
for p in v:
try:
p = int(p)
except (ValueError, TypeError):
continue
if p in valid and p != num and p not in pres:
pres.append(p)
if pres:
out[num] = pres
return out
def _topo_order(nums: list[int], edges: dict[int, list[int]]) -> list[int]:
"""Kahn-Topo-Sort: Voraussetzungen zuerst. `edges[num]` = Nummern, die VOR num kommen müssen.
Stabiler Tie-Break (Original-Reihenfolge von `nums`); Zyklen werden gebrochen (nie Deadlock)."""
pos = {n: i for i, n in enumerate(nums)}
# Resteingangsgrad nur über gültige Knoten; Selbst-/Fremdkanten ignoriert.
pre = {n: [p for p in edges.get(n, []) if p in pos and p != n] for n in nums}
fertig: list[int] = []
erledigt: set[int] = set()
rest = list(nums)
while rest:
bereit = [n for n in rest if all(p in erledigt for p in pre[n])]
if not bereit: # Zyklus → den in Original-Reihenfolge frühesten Rest-Knoten erzwingen
bereit = [min(rest, key=lambda n: pos[n])]
nxt = min(bereit, key=lambda n: pos[n]) # stabil: kleinste Original-Position zuerst
fertig.append(nxt)
erledigt.add(nxt)
rest.remove(nxt)
return fertig
async def _lernreihenfolge(ctx: GenContext, set_p, files: dict, entries: dict, valid: set[int], instructions: str) -> dict:
"""entries (num→titel) in Lernreihenfolge bringen: LLM extrahiert Prereq-Kanten aus den
extrahierten `voraussetzungen`, Code löst per Topo-Sort. Best-effort → sonst entries unverändert."""
if len(entries) < 3:
return entries
topic = ctx.topic
fakten_map = _json_datei(files["fakten"])
fakten_map = fakten_map if isinstance(fakten_map, dict) else {}
def _hint(titel):
fm = fakten_map.get(titel) or {}
vs = [v for fk in fm.values() if isinstance(fk, dict) and (v := str(fk.get("voraussetzungen", "")).strip())]
return " · ".join(dict.fromkeys(vs))
pp = files["arbeit"] / "gliederung-prereqs.json"
def _payload(result, p=pp):
d = _json_datei(p)
return d if isinstance(d, dict) and "prereqs" in d else None
vorhanden = _json_datei(pp)
if not (isinstance(vorhanden, dict) and "prereqs" in vorhanden):
zeilen = [f"{n}. {t}" + (f"\n braucht vorher: {h}" if (h := _hint(t)) else "") for n, t in entries.items()]
set_p("Gliederung — Lernreihenfolge…", step=_step_idx(topic, "Gliederung"))
await run_single_slot(
ctx, "Gliederung-Voraussetzungen", key=f"bausteine-{topic}-gliederung-prereqs",
prompt=_prompt("Gliederung-Voraussetzungen", topic=topic, bausteine="\n".join(zeilen), out_path=pp, extra=_extra(instructions)),
role="guide", capabilities="files", payload=_payload, timeout=_timeout("plan", len(entries)))
edges = _prereq_schema(_json_datei(pp), valid)
if not edges:
return entries # keine/ungültige Kanten → Original-Reihenfolge (kein Regress)
ordered = _topo_order(list(entries), edges)
return {n: entries[n] for n in ordered}
async def _gliederung_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str) -> dict: async def _gliederung_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str) -> dict:
"""Format-agnostische Gliederung über ALLE Bausteine — 3 Vorschläge → Judge merged. """Format-agnostische Gliederung über ALLE Bausteine — 3 Vorschläge → Judge merged.
Bricht nie ab: 0 gültige → ein Kapitel mit allem; fehlende Bausteine landen in „Weitere". Bricht nie ab: 0 gültige → ein Kapitel mit allem; fehlende Bausteine landen in „Weitere".
{"kapitel":[{titel,nummern}]} (auch in files["gliederung"]).""" {"kapitel":[{titel,nummern}]} (auch in files["gliederung"])."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled topic, is_cancelled = ctx.topic, ctx.is_cancelled
valid = set(entries) valid = set(entries)
liste = "\n".join(f"{n}. {t}" for n, t in entries.items())
step = _step_idx(topic, "Gliederung") step = _step_idx(topic, "Gliederung")
# Lernreihenfolge gründen (LLM-Modulo): LLM extrahiert Prereq-Kanten aus den extrahierten
# `voraussetzungen`, Code löst per Topo-Sort. Best-effort → sonst Original-Reihenfolge.
entries = await _lernreihenfolge(ctx, set_p, files, entries, valid, instructions)
liste = "\n".join(f"{n}. {t}" for n, t in entries.items())
set_p("Gliederung — Vorschläge…", step=step) set_p("Gliederung — Vorschläge…", step=step)
async def _vorschlag(i, path): async def _vorschlag(i, path):
@@ -1894,6 +2271,26 @@ def _beispiel_schema(data):
return out return out
def _beispiel_check_schema(data):
"""Worked-Example-Check → {"ok": true} → set() (alles korrekt); {"probleme":[{"index":N}]} →
{N, …} (1-basierte beanstandete Indizes); None bei kaputt."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return set()
pr = data.get("probleme")
if not isinstance(pr, list):
return None
out: set[int] = set()
for p in pr:
if isinstance(p, dict):
try:
out.add(int(p.get("index")))
except (ValueError, TypeError):
continue
return out
_ARTEFAKT_SCHEMA = {"karteikarte": _karten_schema, "beispiel": _beispiel_schema} _ARTEFAKT_SCHEMA = {"karteikarte": _karten_schema, "beispiel": _beispiel_schema}
_ARTEFAKT_PROMPT = {"karteikarte": "Artefakt-Karteikarte", "beispiel": "Artefakt-Beispiel"} _ARTEFAKT_PROMPT = {"karteikarte": "Artefakt-Karteikarte", "beispiel": "Artefakt-Beispiel"}
_ARTEFAKT_SCHRITT = {"karteikarte": "Karteikarten", "beispiel": "Beispiele"} _ARTEFAKT_SCHRITT = {"karteikarte": "Karteikarten", "beispiel": "Beispiele"}
@@ -1907,9 +2304,11 @@ def _artefakte_komplett(files: dict) -> bool:
async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None: async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
"""Lern-Artefakte je Typ aus den gespeicherten Fakten erzeugen — ein Generierungs-Durchlauf """Lern-Artefakte je Typ aus den gespeicherten Fakten erzeugen — ein Generierungs-Durchlauf
je Typ über Chunks, keine Verifikation (Bonus-Material). → {typ: [eintraege]} (auch in files).""" je Typ über Chunks. Worked Examples werden gegen die Fakten verifiziert (falsche verworfen);
topic, is_cancelled = ctx.topic, ctx.is_cancelled Karteikarten sind risikoarm und bleiben ungeprüft. → {typ: [eintraege]} (auch in files)."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
arbeit = files["arbeit"] arbeit = files["arbeit"]
caps = "files"
# Bausteine mit Subs + Fakten-Zeilen als Input-Block (extract-once aus den Fakten). # Bausteine mit Subs + Fakten-Zeilen als Input-Block (extract-once aus den Fakten).
bausteine = [] bausteine = []
for btitel, subs in sidecar.items(): for btitel, subs in sidecar.items():
@@ -1935,6 +2334,36 @@ async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
def block_text(idxs): def block_text(idxs):
return "\n\n".join(f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(bausteine[i][1]) for i in idxs) return "\n\n".join(f"BAUSTEIN: {bausteine[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(bausteine[i][1]) for i in idxs)
# Worked Examples gegen die Fakten prüfen (Panel-Mehrheit) — falsche verwerfen. CoT-Schritte sind
# fehleranfällig; ein falsches Beispiel prägt ein fehlerhaftes Schema ein → kein Beispiel > falsches.
async def _pruefe_beispiele(ci, idxs, items):
if is_cancelled() or not items:
return items
def cpath(j): return arbeit / f"artefakt-beispiel-check-c{ci}-j{j}.json"
beispiele_txt = "\n\n".join(
f"{k}. PROBLEM: {e['problem']}\n SCHRITTE: " + " | ".join(e.get("schritte", []))
+ (f"\n ERGEBNIS: {e['ergebnis']}" if e.get("ergebnis") else "")
for k, e in enumerate(items, 1))
offen = [j for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if _beispiel_check_schema(_json_datei(cpath(j))) is None]
if offen:
await asyncio.gather(*[
run_agent(f"bausteine-{topic}-artefakt-beispiel-check-c{ci}-j{j}",
_prompt("Artefakt-Beispiel-Check", topic=topic, fakten=block_text(idxs), beispiele=beispiele_txt, out_path=cpath(j), extra=_extra(instructions)),
_timeout("inhalt_check", len(items)), provider=provider, role="judge", capabilities=caps)
for j in offen], return_exceptions=True)
outs = [s for j in (1, 2, 3)[:FAKTEN_CHECK_PANEL] if (s := _beispiel_check_schema(_json_datei(cpath(j)))) is not None]
if not outs:
return items # keine Prüfung möglich → behalten (Best-effort)
votes: dict[int, int] = {}
for s in outs:
for idx in s:
votes[idx] = votes.get(idx, 0) + 1
schwelle = len(outs) / 2
raus = {idx for idx, v in votes.items() if v > schwelle} # Mehrheit (≥2 von 3) beanstandet → raus
if raus:
_log(topic, f"Worked-Example-Check Chunk {ci}: {len(raus)}/{len(items)} verworfen")
return [e for k, e in enumerate(items, 1) if k not in raus]
ergebnis: dict[str, list] = {} ergebnis: dict[str, list] = {}
for typ in ARTEFAKT_TYPEN: for typ in ARTEFAKT_TYPEN:
schema = _ARTEFAKT_SCHEMA[typ] schema = _ARTEFAKT_SCHEMA[typ]
@@ -1957,7 +2386,10 @@ async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
return None return None
eintraege: list = [] eintraege: list = []
for ci in range(len(chunks)): for ci in range(len(chunks)):
eintraege += schema(_json_datei(apath(ci))) or [] chunk_items = schema(_json_datei(apath(ci))) or []
if typ == "beispiel" and chunk_items:
chunk_items = await _pruefe_beispiele(ci, chunks[ci], chunk_items)
eintraege += chunk_items
ergebnis[typ] = eintraege ergebnis[typ] = eintraege
atomic_write_json(files["artefakte"], ergebnis, indent=1) atomic_write_json(files["artefakte"], ergebnis, indent=1)
return ergebnis return ergebnis
@@ -2069,7 +2501,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
_reset_ab_phase(topic, label) _reset_ab_phase(topic, label)
await _reset_db_ab_phase(topic, label) await _reset_db_ab_phase(topic, label)
# Schritt „Quelle aufbereiten": Crawl (link) + PDFs + Content/Noise-Sichtung. # Schritt „Quelle aufbereiten": Crawl (link) + PDFs + Content/Noise-Sichtung.
if not await _quelle_aufbereiten(ctx, set_p, files, q, ordner): if not await _quelle_aufbereiten(ctx, set_p, files, q, ordner, instructions):
if is_cancelled(): if is_cancelled():
abgebrochen() abgebrochen()
return return

View File

@@ -52,6 +52,11 @@ CRAWL_NOISE_PATTERNS = [ # eindeutig the
] ]
CRAWL_MIN_CHARS = 400 # zu wenig Text → raus CRAWL_MIN_CHARS = 400 # zu wenig Text → raus
# LLM-Themen-Relevanz-Gate (nach dem Regel-Filter): je Content-Seite ja/nein gegen die Spec.
# Trennt das Fachgebiet (z.B. Backend vs Frontend), was die globalen CRAWL_*-Regeln nicht können.
QUELLE_RELEVANZ_CHUNK = 12 # Seiten je Rater-Paket (klein, da je Seite ein Snippet mitgeht)
QUELLE_RELEVANZ_SNIPPET = 800 # Body-Zeichen je Seite im Prompt (URL ist Primärsignal)
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section). # 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. # Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
TIMEOUTS = { TIMEOUTS = {

View File

@@ -20,6 +20,7 @@ log = logging.getLogger("creator.crawl")
MAX_TIEFE = 3 MAX_TIEFE = 3
MAX_SEITEN = 500 MAX_SEITEN = 500
SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download) SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download)
CRAWL_SETTLE_MS = 3000 # gedeckelter Settle nach domcontentloaded (SPA-Render); kein 30s-networkidle-Hang
MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF
_UA = "Mozilla/5.0 (creator-lernbot)" _UA = "Mozilla/5.0 (creator-lernbot)"
@@ -46,6 +47,37 @@ def _is_pdf(url: str) -> bool:
return url.lower().split("?")[0].rstrip("/").endswith(".pdf") return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
def _scope_prefix(start_url: str) -> str:
"""Erstes nicht-leeres Pfad-Segment der Start-URL als Crawl-Scope, z.B.
`/learn/path/x` → `/learn`. Ohne Pfad-Segment → `""` (ganze Domain, kein Regress)."""
seg = [s for s in urlparse(start_url).path.split("/") if s]
return f"/{seg[0]}" if seg else ""
def _in_scope(url: str, prefix: str) -> bool:
"""Segment-genauer Prefix-Match (kein `/learn` ⊃ `/learning-x`). Leerer Prefix → alles erlaubt."""
if not prefix:
return True
p = urlparse(url).path
return p == prefix or p.startswith(prefix + "/")
def _seiten_text(page) -> str:
"""Haupttext der gerenderten Seite — Nav/Footer/Boilerplate per trafilatura entfernt.
Fallback auf den rohen Body-Text, wenn die Extraktion leer/zu kurz ausfällt (Nicht-Artikel-Seiten)."""
try:
from trafilatura import extract # lazy: Backend startet auch ohne das Paket
text = extract(page.content(), include_comments=False, include_tables=True) or ""
except Exception:
text = ""
if len(text.strip()) >= 200:
return text.strip()
try:
return page.inner_text("body").strip()
except Exception:
return text.strip()
def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int: def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int:
"""Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab. """Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab.
@@ -58,6 +90,7 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten:
ziel.mkdir(parents=True, exist_ok=True) ziel.mkdir(parents=True, exist_ok=True)
domain = urlparse(start_url).netloc domain = urlparse(start_url).netloc
prefix = _scope_prefix(start_url) # nur Links unter diesem Pfad-Segment folgen
gesehen: set[str] = set() gesehen: set[str] = set()
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)] queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
gespeichert = 0 gespeichert = 0
@@ -85,13 +118,14 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten:
continue continue
try: try:
page.goto(url, wait_until="networkidle", timeout=SEITE_TIMEOUT * 1000) page.goto(url, wait_until="domcontentloaded", timeout=SEITE_TIMEOUT * 1000)
except Exception as e: except Exception as e:
log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen
try: try:
text = page.inner_text("body").strip() page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
except Exception: except Exception:
text = "" pass # SPA mit Dauer-Traffic erreicht nie idle → nach Settle weiter, kein 30s-Hang
text = _seiten_text(page) # Haupttext, Nav/Footer entfernt (Fallback: roher Body)
if text: if text:
atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}") atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
gespeichert += 1 gespeichert += 1
@@ -103,7 +137,8 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten:
for href in hrefs: for href in hrefs:
nxt = urldefrag(href)[0] nxt = urldefrag(href)[0]
if (nxt.startswith(("http://", "https://")) if (nxt.startswith(("http://", "https://"))
and urlparse(nxt).netloc == domain and nxt not in gesehen): and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
and nxt not in gesehen):
queue.append((nxt, tiefe + 1)) queue.append((nxt, tiefe + 1))
finally: finally:
browser.close() browser.close()

View File

@@ -346,11 +346,13 @@ def _vermeide_block(vermeide: list[str] | None) -> str:
# Lerner-Niveau (aus dem Score abgeleitet) → Adressaten-Rolle für die Frage. So entsteht die # Lerner-Niveau (aus dem Score abgeleitet) → Adressaten-Rolle für die Frage. So entsteht die
# Schwierigkeit: nicht „extra schwer machen", sondern „für einen Anfänger/Experten fragen". # Schwierigkeit: nicht „extra schwer machen", sondern „für einen Anfänger/Experten fragen".
# Je Stufe: Adressaten-Rolle + kognitive Anforderung (Bloom) + „frag so"-Cue. Ohne explizite Stufe nimmt
# das Modell den leichten Pfad (bloßer Abruf) — die Cues heben höhere Niveaus auf Anwenden/Analysieren/Transfer.
NIVEAU_ROLLE = { NIVEAU_ROLLE = {
"anfaenger": "Der Lerner ist ANFÄNGER. Frage nach dem Grundverständnis — das Kernkonzept, einfach und direkt.", "anfaenger": "Der Lerner ist ANFÄNGER. Kognitiv: ERINNERN/VERSTEHEN. Frage nach dem Grundverständnis — das Kernkonzept, einfach und direkt.",
"fortgeschritten": "Der Lerner ist FORTGESCHRITTEN. Frage etwas tiefer — Zusammenhänge und die typische Anwendung.", "fortgeschritten": "Der Lerner ist FORTGESCHRITTEN. Kognitiv: ANWENDEN. Stelle eine kleine konkrete Situation und lass das Konzept darauf anwenden — frage nicht bloß die Definition ab.",
"experte": "Der Lerner ist EXPERTE. Frage anspruchsvoll — Sonderfälle, Abgrenzungen, typische Tücken.", "experte": "Der Lerner ist EXPERTE. Kognitiv: ANALYSIEREN. Lass abgrenzen/vergleichen, einen Sonderfall einordnen oder eine typische Tücke (Hürde) aufdecken — nicht das Lehrbuch-Wissen abfragen.",
"meister": "Der Lerner ist auf MEISTER-Niveau. Frage sehr anspruchsvoll — Edge-Cases, präzise Begründung, Transfer auf Neues.", "meister": "Der Lerner ist auf MEISTER-Niveau. Kognitiv: BEWERTEN/TRANSFER. Lass das Konzept auf ein NEUES Problem übertragen, eine Entscheidung begründen oder einen Trade-off abwägen.",
} }

View File

@@ -181,6 +181,32 @@ def _relevanz_schema(data, ids: set[int] | None = None):
return out return out
_JANEIN = ("ja", "nein")
def _janein_schema(data, ids: set[int] | None = None):
"""{"relevant": {"1": "ja", …}} → {id: ja/nein} · sonst None.
Binäres ja/nein — das Themen-Relevanz-Gate der Sichtung. Wie `_relevanz_schema`:
sind `ids` gegeben, müssen mindestens diese abgedeckt sein (Extras erlaubt).
"""
if not isinstance(data, dict) or not isinstance(data.get("relevant"), dict) or not data["relevant"]:
return None
out: dict[int, str] = {}
for k, v in data["relevant"].items():
try:
num = int(k)
except (ValueError, TypeError):
return None
wert = str(v).strip().casefold()
if wert not in _JANEIN:
return None
out[num] = wert
if ids is not None and not ids <= set(out):
return None
return out
_MAX_RESTARTS = 2 _MAX_RESTARTS = 2

View File

@@ -2,6 +2,7 @@ fastapi
uvicorn[standard] uvicorn[standard]
aiosqlite aiosqlite
playwright playwright
trafilatura
transformers transformers
# torch NICHT hier listen — sonst zieht pip die CUDA-Variante (~2,5 GB). # torch NICHT hier listen — sonst zieht pip die CUDA-Variante (~2,5 GB).
# Es wird separat als CPU-Build installiert (Dockerfile + Makefile-Target `install`). # Es wird separat als CPU-Build installiert (Dockerfile + Makefile-Target `install`).

View File

@@ -0,0 +1,25 @@
Du bist Korrektheits-Prüfer für durchgerechnete Beispiele (Worked Examples) zum Thema "{topic}". Ein anderer Agent hat sie erzeugt — sie sollen dem Lerner eine **korrekte** Lösung vormachen. Ein falsches Beispiel prägt einen falschen Lösungsweg ein. Finde die fehlerhaften.
GRUNDWAHRHEIT — die belegten Fakten (nur hieran messen):
{fakten}
ZU PRÜFENDE BEISPIELE (nummeriert; PROBLEM / SCHRITTE / ERGEBNIS):
{beispiele}
Beanstande ein Beispiel, wenn EINES davon zutrifft:
- **Rechenfehler** in einem Schritt (Zahl, Umformung, Einheit).
- **Falsche Folgerung:** ein Schritt folgt nicht aus dem vorigen; das ERGEBNIS folgt nicht aus den Schritten.
- **Nicht gedeckt:** ein Schritt/Wert widerspricht den Fakten oder erfindet etwas, das dort nicht steht.
- **Unvollständig/irreführend:** der Weg ließe den Lerner einen falschen Schluss ziehen.
Regeln:
- Miss NUR an den Fakten + an interner Logik. Rechne selbst nach.
- **Konservativ:** beanstande nur, was **klar** falsch ist. Im Zweifel behalten.
- Gib die 1-basierte Nummer (`index`) jedes fehlerhaften Beispiels an.
Schreibe NUR die JSON-Datei nach: {out_path} — eines von beiden:
{{"ok": true}}
{{"probleme": [{{"index": 2}}, {{"index": 5}}]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -9,6 +9,7 @@ EINZELN GENANNTE EINTRÄGE (jeweils entscheiden):
Regeln: Regeln:
- Fachlich valider, eigenständiger Baustein und KEINE Dublette/Variante des Inventars → `aufnehmen`. - Fachlich valider, eigenständiger Baustein und KEINE Dublette/Variante des Inventars → `aufnehmen`.
- Erfunden, zu vage, oder Dublette/Variante eines Inventar-Bausteins → verwerfen (in keine Liste). - Erfunden, zu vage, oder Dublette/Variante eines Inventar-Bausteins → verwerfen (in keine Liste).
- Doppelt ein Eintrag inhaltlich einen ANDEREN einzeln genannten Eintrag (gleiche Sache, andere Worte) → nur EINEN aufnehmen, den anderen verwerfen.
- Übernimm aufgenommene Einträge WÖRTLICH ("Titel — Kurzbeschreibung"), nicht umformulieren.{final} - Übernimm aufgenommene Einträge WÖRTLICH ("Titel — Kurzbeschreibung"), nicht umformulieren.{final}
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}

View File

@@ -0,0 +1,24 @@
Du füllst **Lücken**. Für jeden Subbaustein des Themas "{topic}" sind unten die bereits erfassten Fakten gelistet. Ein erster Durchlauf hat sie extrahiert — dabei fällt erfahrungsgemäß Belegbares durch. Deine Aufgabe: **nur das Fehlende** aus der Quelle nachtragen.
{source}
SUBBAUSTEINE MIT BEREITS ERFASSTEN FAKTEN:
{bausteine}
Regeln:
- Gib pro Subbaustein **nur NEUE** Punkte aus, die in der Quelle belegt sind und oben **noch fehlen**.
- **Wiederhole nichts** aus „Erfasst" — auch nicht umformuliert. Steht es schon da → weglassen.
- **Nur Belegbares.** Mit Quelldatei/Skript: sinngemäß-genau zitieren + Stelle angeben. Erfinde keine Werte, rechne nichts selbst aus. Ohne Quelldatei (reines Thema): nur gesichertes Standardwissen.
- **HARTE TRENNUNG:** `belegte_fakten` = nur Belegbares. Ein selbst gerechnetes/erdachtes Beispiel gehört in `beispiel_idee`, NIE in `belegte_fakten`.
- `kernpunkte` nur ergänzen, wenn ein **wesentlicher** Aspekt fehlt — keine Dopplungen, keine Trivialitäten.
- **Findest du für einen Subbaustein nichts Neues → lass ihn weg.** Findest du gar nichts → leere Liste. Lieber nichts als Erfundenes.
Schreibe NUR die NEUEN Fakten als EIN JSON in die Datei {out_path}, GENAU in diesem Format:
{{"fakten": [
{{"baustein": "<exakter Baustein-Titel>", "subbaustein": "<exakter Subbaustein-Titel>",
"kernpunkte": ["…"], "voraussetzungen": "…", "huerden": "…",
"belegte_fakten": [{{"text": "…", "quelle": "…"}}], "beispiel_idee": "…"}}
]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -1,13 +1,13 @@
Du baust den Fragen-Vorrat für eine Lern-Prüfung zum Thema "{topic}". Erzeuge **Frage-Muster****genau eine** konkrete Beispielfrage **pro Subbaustein**. Aus jedem Muster wird später bei der Prüfung eine leicht abgewandelte Frage formuliert — passend zum Niveau des Lerners (Anfänger bis Experte). Die Schwierigkeit steckt also NICHT im Muster, sondern kommt erst bei der Prüfung. Frag deshalb nach dem **Kernkonzept** des Subbausteins, neutral formuliert. Du baust den Fragen-Vorrat für eine Lern-Prüfung zum Thema "{topic}". Erzeuge **Frage-Muster****genau eine** konkrete Beispielfrage **pro Subbaustein**. Aus jedem Muster wird später bei der Prüfung eine leicht abgewandelte Frage formuliert — passend zum Niveau des Lerners (Anfänger bis Experte). Die Schwierigkeit steckt also NICHT im Muster, sondern kommt erst bei der Prüfung. Frag deshalb nach dem **Kernkonzept** des Subbausteins, neutral formuliert.
Du bearbeitest **mehrere Bausteine**. Jeder Baustein hat seine eigenen Subbausteine: Du bearbeitest **mehrere Bausteine**. Jeder Baustein hat seine eigenen Subbausteine — viele mit Kernpunkten, belegten Fakten und einem Beispiel:
{bausteine} {bausteine}
AUFGABE (für **jeden** der Bausteine oben): AUFGABE (für **jeden** der Bausteine oben):
- Erzeuge für **jeden** seiner Subbausteine **genau ein** Muster — eine konkrete, knappe Frage zum Kernkonzept dieses Subbausteins. - Erzeuge für **jeden** seiner Subbausteine **genau ein** Muster — eine konkrete, knappe Frage zum Kernkonzept dieses Subbausteins.
- Jedes Muster ist EINE konkrete Frage (12 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung. - Jedes Muster ist EINE konkrete Frage (12 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung.
- Die Frage muss aus dem Baustein-Wissen beantwortbar sein. Erfinde keine Zusatzannahmen. - Die Frage muss aus dem Baustein-Wissen beantwortbar sein. Erfinde keine Zusatzannahmen.
- WICHTIG: Du siehst nur Titel, NICHT den späteren Guide-Text. Frag deshalb nur nach dem KERNKONZEPT des Subbausteins. Setze NICHTS voraus, was evtl. nicht im Guide steht: keine „gezeigten" Code-Ausschnitte/Beispiele/Diagramme, keine konkreten Werte/Listen/Zahlen. - Stütze die Frage auf die gelieferten **Kernpunkte und belegten Fakten** (die verlässliche Grundlage). Triff das KERNKONZEPT des Subbausteins, nicht ein auswendig zu lernendes Detail. Setze NICHTS voraus, was nicht in den Fakten steht: keine „gezeigten" Code-Ausschnitte/Diagramme, keine konkreten Werte/Listen/Zahlen aus einem späteren Guide-Text.
- Die Frage trifft den **Kern** des Subbausteins — den zentralen Punkt, den man verstanden haben muss. Nicht zu eng auf ein Detail, nicht zu breit über den ganzen Baustein. - Die Frage trifft den **Kern** des Subbausteins — den zentralen Punkt, den man verstanden haben muss. Nicht zu eng auf ein Detail, nicht zu breit über den ganzen Baustein.
- `baustein` ist exakt einer der Baustein-Titel oben. `subbaustein` ist exakt einer der Subbaustein-Titel des jeweiligen Bausteins. - `baustein` ist exakt einer der Baustein-Titel oben. `subbaustein` ist exakt einer der Subbaustein-Titel des jeweiligen Bausteins.

View File

@@ -0,0 +1,17 @@
Ermittle die **Lern-Voraussetzungen** zwischen den Bausteinen des Themas "{topic}". Daraus wird die Reihenfolge des Guides berechnet.
BAUSTEINE (Nummer, Titel; manche nennen unter „braucht vorher" die Voraussetzungen ihrer Inhalte):
{bausteine}
AUFGABE:
- Für **jeden** Baustein: Welche **anderen Bausteine aus der Liste** muss man **vorher** verstanden haben?
- Nur **echte fachliche Voraussetzungen** — „B baut direkt auf A auf", nicht „passt thematisch dazu".
- Nutze die „braucht vorher"-Hinweise, ordne sie den passenden Baustein-Nummern zu.
- Nummern **nur aus der Liste**. **Keine Selbstbezüge.** Im Zweifel **leer** — lieber keine Kante als eine erfundene.
- Halte es sparsam: die meisten Bausteine haben 02 direkte Voraussetzungen, nicht mehr.
Schreibe NUR die JSON-Datei nach: {out_path}, GENAU in diesem Format (Schlüssel = Baustein-Nummer, Wert = Liste der Voraussetzungs-Nummern):
{{"prereqs": {{"3": [1, 7], "5": []}}}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -1,6 +1,6 @@
{n} Agenten haben die feste Baustein-Auswahl für {format_name} zum Thema "{topic}" unabhängig gegliedert ({zweck}). Führe die Vorschläge zu EINER kohärenten Gliederung zusammen. {n} Agenten haben die feste Baustein-Auswahl für {format_name} zum Thema "{topic}" unabhängig gegliedert ({zweck}). Führe die Vorschläge zu EINER kohärenten Gliederung zusammen.
GEWÄHLTE BAUSTEINE (jeder mit seiner NUMMER — die Auswahl steht fest): GEWÄHLTE BAUSTEINE (jeder mit seiner NUMMER — die Auswahl steht fest; die Liste steht in **Lernreihenfolge**, Voraussetzungen zuerst — halte sie grob ein):
{bausteine} {bausteine}
DIE VORSCHLÄGE (je Kapitel die Baustein-Nummern): DIE VORSCHLÄGE (je Kapitel die Baustein-Nummern):

View File

@@ -3,6 +3,8 @@ Plane die Gliederung eines Lern-Guides zum Thema "{topic}".
BAUSTEINE (jeder mit seiner NUMMER — die Liste steht fest, du ordnest nur): BAUSTEINE (jeder mit seiner NUMMER — die Liste steht fest, du ordnest nur):
{bausteine} {bausteine}
Die Liste steht bereits in **Lernreihenfolge** (Voraussetzungen zuerst, per Abhängigkeits-Sortierung). Halte sie **grob** ein; gruppiere benachbarte verwandte Bausteine zu Kapiteln.
REIHENFOLGE — vom Bekannten zum Unbekannten: REIHENFOLGE — vom Bekannten zum Unbekannten:
- Kapitel 1 führt zum schnellsten sichtbaren Ergebnis (erster Erfolg), nicht zur Theorie. - Kapitel 1 führt zum schnellsten sichtbaren Ergebnis (erster Erfolg), nicht zur Theorie.
- Konkretes vor Abstraktem, Einfaches vor Komplexem, Voraussetzungen vor dem, was auf ihnen aufbaut. - Konkretes vor Abstraktem, Einfaches vor Komplexem, Voraussetzungen vor dem, was auf ihnen aufbaut.

View File

@@ -0,0 +1,23 @@
Du sichtest gecrawlte Seiten für das Lern-Thema "{topic}". Entscheide je Seite: **ja** (gehört zum Thema) oder **nein** (themenfremd). Die Auswahl steuert, welche Seiten überhaupt gelesen werden — themenfremde Seiten blähen das Inventar mit Fremdstoff auf.
LERNZIEL / SPEC (das ist der Maßstab):
{spec}
SEITEN (Nummer, dann die URL, darunter ein Inhalts-Auszug):
{seiten}
So entscheidest du:
- **ja**: Die Seite behandelt genau das Fachgebiet der Spec. Kern-, Standard- oder Vertiefungsstoff dazu. Auch Randthemen des RICHTIGEN Gebiets sind `ja`.
- **nein**: Anderes Fachgebiet als die Spec. Beispiel: Spec ist Backend-Entwicklung, die Seite behandelt Frontend, Storefront, Theming, Payment-Bedienung oder einen ganz anderen Rollen-/Produktbereich. Auch reine Marketing-, Community-, Event- oder Konto-Seiten ohne Lernstoff sind `nein`.
Wichtig:
- Urteile auf URL **und** Inhalts-Auszug zusammen. Der URL-Slug ist oft am aussagekräftigsten.
- **Ignoriere das Navigations-Menü / Boilerplate** im Auszug (Login, „Sign up", Menüpunkte, Footer) — das steht auf jeder Seite und sagt nichts über das Thema.
- **Im Zweifel `ja`** — lieber eine Seite zu viel lesen als Wichtiges ausschließen. Sage nur `nein`, wenn die Seite klar in ein anderes Fachgebiet gehört.
- Bewerte jede Seite einzeln. Erfinde nichts dazu.
{extra}
Schreibe NUR die JSON-Datei nach: {out_path}
Format (für JEDE Nummer genau ein Wert; kein weiterer Text):
{{"relevant": {{"1": "ja", "2": "nein", "3": "ja"}}}}

View File

@@ -8,7 +8,7 @@ Relevanz:
- **rand**: Randnotiz, Nischen-/Spezialfall, „nice to know", Deprecated/Exotik, rein historische Details, konkrete Zahlenbeispiele, bloße Querverweise. NICHT rand: ein zentrales Theorem/eine fundamentale Eigenschaft. Nur für den Komplett-Guide. - **rand**: Randnotiz, Nischen-/Spezialfall, „nice to know", Deprecated/Exotik, rein historische Details, konkrete Zahlenbeispiele, bloße Querverweise. NICHT rand: ein zentrales Theorem/eine fundamentale Eigenschaft. Nur für den Komplett-Guide.
Regeln: Regeln:
- Wäge die Stimmen ab, entscheide fachlich. Im Zweifel **relevant**. - Wäge die Stimmen ab, entscheide am Kriterium **Kern vs Rand SEINES Bausteins**. `rand` ist eine echte Kategorie — markiere Randständiges bewusst so. Nur echte Kernkonzepte/zentrale Sätze sind nie `rand`.
- Für JEDE strittige Nummer genau einen Wert. - Für JEDE strittige Nummer genau einen Wert.
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}

View File

@@ -1,15 +1,18 @@
Bewerte jeden Subbaustein des Themas "{topic}" nach Relevanz: relevant oder rand. Die Relevanz steuert, ob ein Punkt in die fokussierten Guides kommt oder nur in den Komplett-Guide. Bewerte jeden Subbaustein des Themas "{topic}" nach Relevanz: relevant oder rand. Die Relevanz steuert, ob ein Punkt in die fokussierten Guides kommt oder nur in den Komplett-Guide.
SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein): SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein; viele nennen ihre Kernpunkte mit „Kern:"):
{subbausteine} {subbausteine}
Nutze die Kernpunkte zur fundierten Bewertung — urteile auf dem Inhalt, nicht nur dem Titel.
Relevanz: Relevanz:
- **relevant**: Kern-/Standardwissen, das das Thema TRÄGT — zentrale Konzepte, Definitionen, Eigenschaften, gängige Praxis. Bei praktischen Themen: was man im Gebrauch braucht. Bei theoretischen Themen: zentrale Sätze/Eigenschaften, AUCH ohne praktischen Gebrauch. Was man wirklich kennen sollte. - **relevant**: Kern-/Standardwissen, das das Thema TRÄGT — zentrale Konzepte, Definitionen, Eigenschaften, gängige Praxis. Bei praktischen Themen: was man im Gebrauch braucht. Bei theoretischen Themen: zentrale Sätze/Eigenschaften, AUCH ohne praktischen Gebrauch. Was man wirklich kennen sollte.
- **rand**: Randnotiz, Spezial-/Nischenfall, „nice to know", Deprecated/Exotik — PLUS rein historische Details (wer/wann), konkrete Zahlenbeispiele eines allgemeinen Prinzips, bloße Querverweise. NICHT rand: ein zentrales Theorem oder eine fundamentale Eigenschaft, nur weil sie „theoretisch" wirkt. Gehört nur in einen Komplett-Guide. - **rand**: Randnotiz, Spezial-/Nischenfall, „nice to know", Deprecated/Exotik — PLUS rein historische Details (wer/wann), konkrete Zahlenbeispiele eines allgemeinen Prinzips, bloße Querverweise. NICHT rand: ein zentrales Theorem oder eine fundamentale Eigenschaft, nur weil sie „theoretisch" wirkt. Gehört nur in einen Komplett-Guide.
Regeln: Regeln:
- Bewerte jeden Punkt einzeln, gemessen an SEINEM Baustein und am Thema. - Diese Subbausteine gehören **alle schon** zum Thema — die Frage ist nicht *ob*, sondern *wie zentral*. Bewerte **Kern vs Rand SEINES Bausteins**, einzeln.
- Im Zweifel **relevant** — lieber zu viel als versehentlich Wichtiges weglassen. - `rand` ist eine **echte Kategorie**, kein Rest. Markiere Randständiges (nice-to-know, Spezial-/Nischenfälle, Detail-/Zahlenbeispiele, historische Notizen, Querverweise) **bewusst** als `rand`.
- Stufe nicht aus Vorsicht hoch. Nur **echte Kernkonzepte / zentrale Sätze** sind nie `rand`.
- Urteile nur — erfinde nichts dazu, ändere keine Subbausteine. - Urteile nur — erfinde nichts dazu, ändere keine Subbausteine.
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}

View File

@@ -11,6 +11,7 @@ Stufe = Lernpfad-Position (WANN man den Punkt braucht), NICHT Schwierigkeit:
Regeln: Regeln:
- Ein einfaches Nischen-Detail ist `experte`; ein fundamentales komplexes Konzept ist `anfaenger`. - Ein einfaches Nischen-Detail ist `experte`; ein fundamentales komplexes Konzept ist `anfaenger`.
- Wäge die Stimmen ab, entscheide fachlich nach Lernpfad-Position. - Wäge die Stimmen ab, entscheide fachlich nach Lernpfad-Position.
- **`fortgeschritten` ist keine Verlegenheits-Wahl.** Wähle die Mitte nicht, weil die Stimmen streuen — entscheide am Kriterium „Wann braucht man das?". `anfaenger`/`experte` klar treffen, wo sie zutreffen.
- Für JEDE strittige Nummer genau eine Stufe. - Für JEDE strittige Nummer genau eine Stufe.
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}

View File

@@ -1,8 +1,10 @@
Ordne jeden Subbaustein des Themas "{topic}" seiner **Lernpfad-Position** zu: anfaenger, fortgeschritten oder experte. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „anfaenger", Fortgeschrittene mehr). Ordne jeden Subbaustein des Themas "{topic}" seiner **Lernpfad-Position** zu: anfaenger, fortgeschritten oder experte. Die Stufe steuert, wann ein Lerner den Punkt sieht (Anfänger sehen nur „anfaenger", Fortgeschrittene mehr).
SUBBAUSTEINE (nach Baustein gruppiert): SUBBAUSTEINE (nach Baustein gruppiert; viele nennen ihre Kernpunkte mit „Kern:"):
{subbausteine} {subbausteine}
Nutze die Kernpunkte zur fundierten Einstufung — urteile auf dem Inhalt, nicht nur dem Titel.
ENTSCHEIDEND — die Stufe misst, WANN man den Punkt im Lernpfad braucht, NICHT wie schwer er zu verstehen ist: ENTSCHEIDEND — die Stufe misst, WANN man den Punkt im Lernpfad braucht, NICHT wie schwer er zu verstehen ist:
- **anfaenger**: Fundament. Was man als Erstes/unbedingt braucht, um den Baustein überhaupt zu verstehen. Voraussetzung für den Rest. Auch wenn das Konzept gedanklich anspruchsvoll ist — ist es das Fundament, gehört es hierher. - **anfaenger**: Fundament. Was man als Erstes/unbedingt braucht, um den Baustein überhaupt zu verstehen. Voraussetzung für den Rest. Auch wenn das Konzept gedanklich anspruchsvoll ist — ist es das Fundament, gehört es hierher.
- **fortgeschritten**: baut auf dem Fundament auf. Gängige Varianten, typische Anwendung, der nächste Schritt. - **fortgeschritten**: baut auf dem Fundament auf. Gängige Varianten, typische Anwendung, der nächste Schritt.
@@ -18,6 +20,12 @@ Regeln:
- Nicht jeder Baustein braucht einen `anfaenger`-Punkt. Manche Themen setzen Vorwissen voraus und sind ganz `fortgeschritten`/`experte` — das ist gewollt. Stufe ehrlich nach Lernpfad-Position, ohne künstlichen Einstieg zu erfinden. - Nicht jeder Baustein braucht einen `anfaenger`-Punkt. Manche Themen setzen Vorwissen voraus und sind ganz `fortgeschritten`/`experte` — das ist gewollt. Stufe ehrlich nach Lernpfad-Position, ohne künstlichen Einstieg zu erfinden.
- Urteile nur — erfinde nichts dazu, ändere keine Subbausteine. - Urteile nur — erfinde nichts dazu, ändere keine Subbausteine.
NICHT zur Mitte flüchten:
- **`fortgeschritten` ist keine Verlegenheits-Wahl.** Wähle die Mitte nicht, weil du unsicher bist.
- Entscheide jede Stufe an der Frage **„Wann im Lernpfad braucht man das?"** — nicht nach der sichersten Mittel-Option.
- **Differenziere innerhalb des Bausteins:** Was ist Fundament (anfaenger), was Aufbau (fortgeschritten), was Feinheit (experte)? Nicht alles ist Mitte.
- Triff `anfaenger` und `experte` klar, wo sie zutreffen — kein Bonus für die Mitte.
Schreibe NUR die JSON-Datei nach: {out_path} Schreibe NUR die JSON-Datei nach: {out_path}
Format (für JEDE Nummer genau eine Stufe; kein weiterer Text): Format (für JEDE Nummer genau eine Stufe; kein weiterer Text):

View File

@@ -2,9 +2,13 @@ Unten je Baustein des Themas "{topic}" die gefundenen Subbausteine. Gib je Baust
{source} {source}
GEFUNDENE SUBBAUSTEINE: GEFUNDENE SUBBAUSTEINE (je Baustein zwei Gruppen):
{bausteine} {bausteine}
Die Gruppen:
- **Konsens (≥2 Finder):** mehrfach unabhängig gefunden — standardmäßig behalten. Nur Erfundenes/Dubletten raus.
- **Unsicher (1×):** nur von einem Finder genannt — **streng prüfen**. Nimm einen Unsicher-Eintrag NUR auf, wenn er **klar in der Quelle belegt UND ein eigenständiger Punkt** ist. Im Zweifel weglassen.
Regeln: Regeln:
- **Beleg-Prüfung (wichtig):** Prüfe jeden Subbaustein gegen die Quelle. **Verwirf, was im Material NICHT belegbar oder klar erfunden ist** — erfundene Schranken, Formeln, Werte oder Behauptungen, die so nicht in der Quelle stehen. Mit Quelldatei: gegen die Datei prüfen. Ohne Quelle (reines Thema): nur gesichertes Standardwissen behalten, Zweifelhaftes/Falsches streichen. - **Beleg-Prüfung (wichtig):** Prüfe jeden Subbaustein gegen die Quelle. **Verwirf, was im Material NICHT belegbar oder klar erfunden ist** — erfundene Schranken, Formeln, Werte oder Behauptungen, die so nicht in der Quelle stehen. Mit Quelldatei: gegen die Datei prüfen. Ohne Quelle (reines Thema): nur gesichertes Standardwissen behalten, Zweifelhaftes/Falsches streichen.
- **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.) - **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.)