diff --git a/Makefile b/Makefile index c196539..ecdb53f 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ auth: @echo "Verzeichnisse angelegt und auf uid 1000 chowned." 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 python3 -m playwright install chromium @echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen." diff --git a/backend/bausteine.py b/backend/bausteine.py index e33a800..f004489 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -22,14 +22,14 @@ from pathlib import Path import database as db 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 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 crawl import crawl from pipeline import ( - CANCELLED, FAILED, GenContext, _extra, _gather_fortschritt, _log, _prompt, _race, _relevanz_schema, - _runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot, + CANCELLED, FAILED, GenContext, _extra, _gather_fortschritt, _janein_schema, _log, _prompt, _race, + _relevanz_schema, _runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot, ) from textkit import ( _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 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) +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") @@ -678,7 +680,9 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict bl.append(f"\n" + "\n".join(f"- {s}" for s in subs)) if not bl: 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. 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"], 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): fp = arbeit / f"subbaustein-final-c{c}.md" if _parse_subbausteine(_read(fp)): return bloecke, hat = [], False + konsens_by_num: dict[int, list[str]] = {} for num in chunk: - subs = [s["sub_titel"] for s in await db.list_subbausteine(topic, norm_by_num[num]) if s["status"] == "konsens"] - zeilen = "\n".join(f"- {s}" for s in subs) if subs else "- (keiner)" - bloecke.append(f"BAUSTEIN: {titel_by_num[num]}\nKonsens:\n{zeilen}") - if subs: - hat = True + rows = await db.list_subbausteine(topic, norm_by_num[num]) + kon = [s["sub_titel"] for s in rows if s["status"] == "konsens"] + uns = [s["sub_titel"] for s in rows if s["status"] != "konsens" and s["nennungen"] == 1] + konsens_by_num[num] = kon + if not kon and not uns: + continue + 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: return - status, _ = await run_single_slot( - ctx, f"Subbaustein-Klärung {c}", - key=f"bausteine-{topic}-subbaustein-final-c{c}", - prompt=_prompt("Subbaustein-Mapping", topic=topic, source=source, bausteine="\n\n".join(bloecke), out_path=fp, extra=_extra(instructions)), - role="judge", capabilities=caps, - payload=lambda result, p=fp: _parse_subbausteine(_read(p)) or None, - timeout=_timeout("subbaustein_check", len(chunk)), - ) - if status == FAILED: + + chunk_idx = _titel_index({num: titel_by_num[num] for num in chunk}) + paths = [arbeit / f"subbaustein-final-c{c}-j{j}.md" for j in range(1, SUBBAUSTEIN_PANEL + 1)] + offen = [(j, p) for j, p in enumerate(paths, 1) if _parse_subbausteine(_read(p)) is None] + for _, p in offen: + p.unlink(missing_ok=True) + if offen: + slots = [{ + "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") + text = "\n\n".join(f"\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"\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")) 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.""" topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled 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 if not items: 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}") cur_b = b 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() offen = [(i, p) for i, p in enumerate(paths, 1) if not _stufen_schema(_json_datei(p), local_set)] 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", ""))))] +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: z = [] 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"): z.append("Voraussetzung: " + fk["voraussetzungen"]) 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) 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 fix_path(ci): return arbeit / f"fakten-fix-c{ci}.json" 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}} 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. async def _finde(ci, idxs): 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" 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: # beanstandet (Fakt ungenau → korrigieren) und verwerfen (Sub nicht belegbar → entfernen). 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: 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()) @@ -1132,7 +1249,7 @@ async def _fakten_block(ctx, set_p, files: dict, roh: dict, q: dict, ordner, ins ergebnis: dict[str, dict] = {} verworfen_map: dict[str, set] = {} 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 {} verw = verwerfen.get(ci, set()) 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'.""" topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled 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: return {} 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)) if vorhanden >= 2: 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)] slots = [{ "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 arbeit = files["arbeit"] # 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 = [] + fakten_by: dict[tuple, dict] = {} 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: bausteine.append((titel, alle)) if not bausteine: @@ -1303,8 +1432,14 @@ async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict fp = roh_path(ci) if _frage_muster_chunk_schema(_json_datei(fp)): 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( - 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 ) 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 -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. Persistiert die Sichtung in der Coverage-Tabelle (inhalt). → True (ok) / False (Abbruch/Fehler). 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")) await db.delete_coverage(topic) 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)) - _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") 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: - """Judge mergt Kandidaten semantisch + teilt in Konsens (≥2)/Rest (1×); Status in DB.""" - topic, is_cancelled = ctx.topic, ctx.is_cancelled + """Panel (KONSOLIDIERUNG_PANEL Judges) mergt Kandidaten semantisch; ein Reconcile-Judge führt die + 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": return True 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 arbeit = files["arbeit"] 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 = [], [] for c, chunk in enumerate(chunks, 1): - fp = arbeit / f"konsolidierung-c{c}.json" - fp.unlink(missing_ok=True) eintraege = "\n".join( f"{i}. {b['titel']} — {b['beschreibung']} ({b['nennungen']}× genannt)" for i, b in enumerate(chunk, 1) ) - status, mapping = await run_single_slot( - ctx, f"Konsolidierung {c}", - key=f"bausteine-{topic}-konsolidierung-c{c}", - 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 - if status == FAILED: + res = await _map_panel(c, eintraege, len(chunk)) + if res is None: + if is_cancelled(): + return False _bausteine_errors[topic] = "Recherche-Mapping fehlgeschlagen" return False - k, r = mapping + k, r = res konsens += k rest += r # 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: - """1 Judge entscheidet über den Rest (1×-Genannte): aufnehmen → Konsens, sonst verworfen.""" - topic, is_cancelled = ctx.topic, ctx.is_cancelled + """Panel (KONSOLIDIERUNG_PANEL Judges) entscheidet über den Rest (1×-Genannte): Mehrheit `aufnehmen` + → 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": return True set_p("Klärung läuft…", step=_step_idx(topic, "Klärung")) rest_rows = await db.list_bausteine(topic, status="rest") if rest_rows: konsens = [b["titel"] for b in await db.list_bausteine(topic, status="konsens")] - fp = files["arbeit"] / "klaerung.json" - fp.unlink(missing_ok=True) - status, ergebnis = await run_single_slot( - ctx, "Klärung", - key=f"bausteine-{topic}-klaerung", - prompt=_prompt( - "Bausteine-Klaerung", topic=topic, - konsens="\n".join(f"- {t}" for t in konsens) or "(noch leer)", - rest="\n".join(f"- {b['titel']}" for b in rest_rows), - final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.", - out_path=fp, - ), - role="judge", capabilities="files", - payload=lambda result, p=fp: _runde_schema(_json_datei(p), final=True), - timeout=_timeout("auswahl_mapping", len(rest_rows)), - ) - if status == CANCELLED: + arbeit = files["arbeit"] + paths = [arbeit / f"klaerung-j{j}.json" for j in range(1, KONSOLIDIERUNG_PANEL + 1)] + offen = [(j, p) for j, p in enumerate(paths, 1) if _runde_schema(_json_datei(p), final=True) is None] + for _, p in offen: + p.unlink(missing_ok=True) + if offen: + slots = [{ + "key": f"bausteine-{topic}-klaerung-j{j}", + "prompt": _prompt( + "Bausteine-Klaerung", topic=topic, + konsens="\n".join(f"- {t}" for t in konsens) or "(noch leer)", + rest="\n".join(f"- {b['titel']}" for b in rest_rows), + final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.", + out_path=p, + ), + "role": "judge", "capabilities": "files", + "payload": (lambda result, p=p: _runde_schema(_json_datei(p), final=True)), + } for j, p in offen] + vorhanden = KONSOLIDIERUNG_PANEL - len(offen) + 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 - 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" return False - aufnehmen, _ = ergebnis - auf_norm = {_norm_titel(_titel(t)) for t in aufnehmen} + # Mehrheit je Rest-Eintrag (per Norm-Titel). Tie → behalten (votes*2 >= n). + 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: - 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") return True @@ -1808,14 +2100,99 @@ def _gliederung_schema(data, valid: set[int]): 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: """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". → {"kapitel":[{titel,nummern}]} (auch in files["gliederung"]).""" topic, is_cancelled = ctx.topic, ctx.is_cancelled valid = set(entries) - liste = "\n".join(f"{n}. {t}" for n, t in entries.items()) 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) async def _vorschlag(i, path): @@ -1894,6 +2271,26 @@ def _beispiel_schema(data): 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_PROMPT = {"karteikarte": "Artefakt-Karteikarte", "beispiel": "Artefakt-Beispiel"} _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: """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).""" - topic, is_cancelled = ctx.topic, ctx.is_cancelled + je Typ über Chunks. Worked Examples werden gegen die Fakten verifiziert (falsche verworfen); + 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"] + caps = "files" # Bausteine mit Subs + Fakten-Zeilen als Input-Block (extract-once aus den Fakten). bausteine = [] 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): 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] = {} for typ in ARTEFAKT_TYPEN: schema = _ARTEFAKT_SCHEMA[typ] @@ -1957,7 +2386,10 @@ async def _artefakte_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i return None eintraege: list = [] 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 atomic_write_json(files["artefakte"], ergebnis, indent=1) return ergebnis @@ -2069,7 +2501,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = _reset_ab_phase(topic, label) await _reset_db_ab_phase(topic, label) # 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(): abgebrochen() return diff --git a/backend/config.py b/backend/config.py index 11d9d66..3af6585 100644 --- a/backend/config.py +++ b/backend/config.py @@ -52,6 +52,11 @@ CRAWL_NOISE_PATTERNS = [ # eindeutig the ] 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). # Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt. TIMEOUTS = { diff --git a/backend/crawl.py b/backend/crawl.py index cc9be61..03737b6 100644 --- a/backend/crawl.py +++ b/backend/crawl.py @@ -20,6 +20,7 @@ log = logging.getLogger("creator.crawl") MAX_TIEFE = 3 MAX_SEITEN = 500 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 _UA = "Mozilla/5.0 (creator-lernbot)" @@ -46,6 +47,37 @@ def _is_pdf(url: str) -> bool: 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: """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) domain = urlparse(start_url).netloc + prefix = _scope_prefix(start_url) # nur Links unter diesem Pfad-Segment folgen gesehen: set[str] = set() queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)] gespeichert = 0 @@ -85,13 +118,14 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: continue 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: log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen try: - text = page.inner_text("body").strip() + page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS) 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: atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}") gespeichert += 1 @@ -103,7 +137,8 @@ def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: for href in hrefs: nxt = urldefrag(href)[0] 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)) finally: browser.close() diff --git a/backend/lernen.py b/backend/lernen.py index 509e444..af94ced 100644 --- a/backend/lernen.py +++ b/backend/lernen.py @@ -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 # 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 = { - "anfaenger": "Der Lerner ist ANFÄNGER. Frage nach dem Grundverständnis — das Kernkonzept, einfach und direkt.", - "fortgeschritten": "Der Lerner ist FORTGESCHRITTEN. Frage etwas tiefer — Zusammenhänge und die typische Anwendung.", - "experte": "Der Lerner ist EXPERTE. Frage anspruchsvoll — Sonderfälle, Abgrenzungen, typische Tücken.", - "meister": "Der Lerner ist auf MEISTER-Niveau. Frage sehr anspruchsvoll — Edge-Cases, präzise Begründung, Transfer auf Neues.", + "anfaenger": "Der Lerner ist ANFÄNGER. Kognitiv: ERINNERN/VERSTEHEN. Frage nach dem Grundverständnis — das Kernkonzept, einfach und direkt.", + "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. 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. Kognitiv: BEWERTEN/TRANSFER. Lass das Konzept auf ein NEUES Problem übertragen, eine Entscheidung begründen oder einen Trade-off abwägen.", } diff --git a/backend/pipeline.py b/backend/pipeline.py index f2d1366..ea22957 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -181,6 +181,32 @@ def _relevanz_schema(data, ids: set[int] | None = None): 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 diff --git a/backend/requirements.txt b/backend/requirements.txt index c8b2753..52e427f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ fastapi uvicorn[standard] aiosqlite playwright +trafilatura transformers # torch NICHT hier listen — sonst zieht pip die CUDA-Variante (~2,5 GB). # Es wird separat als CPU-Build installiert (Dockerfile + Makefile-Target `install`). diff --git a/templates/Prompt/Artefakt-Beispiel-Check.md b/templates/Prompt/Artefakt-Beispiel-Check.md new file mode 100644 index 0000000..f2e3b3e --- /dev/null +++ b/templates/Prompt/Artefakt-Beispiel-Check.md @@ -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} diff --git a/templates/Prompt/Bausteine-Klaerung.md b/templates/Prompt/Bausteine-Klaerung.md index 3e2a900..1868a2f 100644 --- a/templates/Prompt/Bausteine-Klaerung.md +++ b/templates/Prompt/Bausteine-Klaerung.md @@ -9,6 +9,7 @@ EINZELN GENANNTE EINTRÄGE (jeweils entscheiden): Regeln: - 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). +- 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} Schreibe NUR die JSON-Datei nach: {out_path} diff --git a/templates/Prompt/Fakten-Ergaenzung.md b/templates/Prompt/Fakten-Ergaenzung.md new file mode 100644 index 0000000..382597d --- /dev/null +++ b/templates/Prompt/Fakten-Ergaenzung.md @@ -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": "", "subbaustein": "", + "kernpunkte": ["…"], "voraussetzungen": "…", "huerden": "…", + "belegte_fakten": [{{"text": "…", "quelle": "…"}}], "beispiel_idee": "…"}} +]}} + +Gib sonst keinen Text aus. +{extra} diff --git a/templates/Prompt/Frage-Muster-Recherche.md b/templates/Prompt/Frage-Muster-Recherche.md index cc2e681..25633d9 100644 --- a/templates/Prompt/Frage-Muster-Recherche.md +++ b/templates/Prompt/Frage-Muster-Recherche.md @@ -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 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} AUFGABE (für **jeden** der Bausteine oben): - Erzeuge für **jeden** seiner Subbausteine **genau ein** Muster — eine konkrete, knappe Frage zum Kernkonzept dieses Subbausteins. - Jedes Muster ist EINE konkrete Frage (1–2 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung. - 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. - `baustein` ist exakt einer der Baustein-Titel oben. `subbaustein` ist exakt einer der Subbaustein-Titel des jeweiligen Bausteins. diff --git a/templates/Prompt/Gliederung-Voraussetzungen.md b/templates/Prompt/Gliederung-Voraussetzungen.md new file mode 100644 index 0000000..16c8c0f --- /dev/null +++ b/templates/Prompt/Gliederung-Voraussetzungen.md @@ -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 0–2 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} diff --git a/templates/Prompt/Guide-Gliederung-Judge.md b/templates/Prompt/Guide-Gliederung-Judge.md index 319a1c0..f28dfba 100644 --- a/templates/Prompt/Guide-Gliederung-Judge.md +++ b/templates/Prompt/Guide-Gliederung-Judge.md @@ -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. -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} DIE VORSCHLÄGE (je Kapitel die Baustein-Nummern): diff --git a/templates/Prompt/Guide-Gliederung.md b/templates/Prompt/Guide-Gliederung.md index aa3b7d2..9fcf794 100644 --- a/templates/Prompt/Guide-Gliederung.md +++ b/templates/Prompt/Guide-Gliederung.md @@ -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} +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: - 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. diff --git a/templates/Prompt/Quelle-Relevanz.md b/templates/Prompt/Quelle-Relevanz.md new file mode 100644 index 0000000..f87fdfd --- /dev/null +++ b/templates/Prompt/Quelle-Relevanz.md @@ -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"}}}} diff --git a/templates/Prompt/Relevanz-Mapping.md b/templates/Prompt/Relevanz-Mapping.md index b4c53ca..c48dab2 100644 --- a/templates/Prompt/Relevanz-Mapping.md +++ b/templates/Prompt/Relevanz-Mapping.md @@ -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. 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. Schreibe NUR die JSON-Datei nach: {out_path} diff --git a/templates/Prompt/Relevanz-Recherche.md b/templates/Prompt/Relevanz-Recherche.md index 1ceb566..c8be425 100644 --- a/templates/Prompt/Relevanz-Recherche.md +++ b/templates/Prompt/Relevanz-Recherche.md @@ -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. -SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein): +SUBBAUSTEINE (Nummer in Klammern der zugehörige Baustein; viele nennen ihre Kernpunkte mit „Kern:"): {subbausteine} +Nutze die Kernpunkte zur fundierten Bewertung — urteile auf dem Inhalt, nicht nur dem Titel. + 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. - **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: -- Bewerte jeden Punkt einzeln, gemessen an SEINEM Baustein und am Thema. -- Im Zweifel **relevant** — lieber zu viel als versehentlich Wichtiges weglassen. +- Diese Subbausteine gehören **alle schon** zum Thema — die Frage ist nicht *ob*, sondern *wie zentral*. Bewerte **Kern vs Rand SEINES Bausteins**, einzeln. +- `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. Schreibe NUR die JSON-Datei nach: {out_path} diff --git a/templates/Prompt/Stufen-Mapping.md b/templates/Prompt/Stufen-Mapping.md index e0c0d1a..db9b9ee 100644 --- a/templates/Prompt/Stufen-Mapping.md +++ b/templates/Prompt/Stufen-Mapping.md @@ -11,6 +11,7 @@ Stufe = Lernpfad-Position (WANN man den Punkt braucht), NICHT Schwierigkeit: Regeln: - Ein einfaches Nischen-Detail ist `experte`; ein fundamentales komplexes Konzept ist `anfaenger`. - 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. Schreibe NUR die JSON-Datei nach: {out_path} diff --git a/templates/Prompt/Stufen-Recherche.md b/templates/Prompt/Stufen-Recherche.md index bd6dd13..5d2a322 100644 --- a/templates/Prompt/Stufen-Recherche.md +++ b/templates/Prompt/Stufen-Recherche.md @@ -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). -SUBBAUSTEINE (nach Baustein gruppiert): +SUBBAUSTEINE (nach Baustein gruppiert; viele nennen ihre Kernpunkte mit „Kern:"): {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: - **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. @@ -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. - 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} Format (für JEDE Nummer genau eine Stufe; kein weiterer Text): diff --git a/templates/Prompt/Subbaustein-Mapping.md b/templates/Prompt/Subbaustein-Mapping.md index 8a6f6ab..ba73fc8 100644 --- a/templates/Prompt/Subbaustein-Mapping.md +++ b/templates/Prompt/Subbaustein-Mapping.md @@ -2,9 +2,13 @@ Unten je Baustein des Themas "{topic}" die gefundenen Subbausteine. Gib je Baust {source} -GEFUNDENE SUBBAUSTEINE: +GEFUNDENE SUBBAUSTEINE (je Baustein zwei Gruppen): {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: - **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.)