update
This commit is contained in:
@@ -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"<!-- baustein: {titel_by_num[num]} -->\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"<!-- 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"))
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user