This commit is contained in:
team3
2026-06-21 13:27:19 +02:00
parent ba529e61a8
commit 20831e1399
14 changed files with 486 additions and 55 deletions

View File

@@ -21,7 +21,8 @@ from agents import kill_process
from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER
from fsutil import atomic_write_text, atomic_write_json from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_datei from jsonio import read_json_file as _json_datei
from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner from lernen import FRAGETYPEN
from paths import arbeit_dir, bausteine_path, frage_muster_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner
from crawl import crawl from crawl import crawl
from pipeline import ( from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _relevanz_schema, _rest_schema, CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _relevanz_schema, _rest_schema,
@@ -51,6 +52,7 @@ BAUSTEINE_STEPS = (
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
"Stufen finden", "Stufen wählen", "Stufen klären", "Stufen finden", "Stufen wählen", "Stufen klären",
"Relevanz finden", "Relevanz wählen", "Relevanz klären", "Relevanz finden", "Relevanz wählen", "Relevanz klären",
"Fragen finden", "Fragen wählen", "Fragen klären",
) )
@@ -93,6 +95,20 @@ def subbausteine_titel(topic: str, baustein: str) -> list[str]:
] ]
def lade_frage_muster(topic: str, baustein: str) -> list[dict]:
"""Vordefinierte Frage-Muster eines Bausteins aus dem Sidecar (leer = Fallback auf Live)."""
fm = _json_datei(frage_muster_path(topic))
if not isinstance(fm, dict):
return []
return [
{"subbaustein": str(e.get("subbaustein", "")).strip(),
"typ": str(e.get("typ", "")).strip(),
"frage": frage}
for e in (fm.get(baustein) or [])
if isinstance(e, dict) and (frage := str(e.get("frage", "")).strip())
]
def lade_uebersicht(topic: str) -> list[dict]: def lade_uebersicht(topic: str) -> list[dict]:
"""Strukturierte Baustein-Liste für die Übersicht: Titel + Beschreibung + Subbausteine/Stufen. """Strukturierte Baustein-Liste für die Übersicht: Titel + Beschreibung + Subbausteine/Stufen.
@@ -132,6 +148,7 @@ def _bausteine_steps(topic: str) -> tuple:
"Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären",
"Stufen finden", "Stufen wählen", "Stufen klären", "Stufen finden", "Stufen wählen", "Stufen klären",
"Relevanz finden", "Relevanz wählen", "Relevanz klären", "Relevanz finden", "Relevanz wählen", "Relevanz klären",
"Fragen finden", "Fragen wählen", "Fragen klären",
) )
mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest
return (("Quelle laden",) if q["type"] == "link" else ()) + mitte return (("Quelle laden",) if q["type"] == "link" else ()) + mitte
@@ -141,6 +158,40 @@ def _step_idx(topic: str, name: str) -> int:
return _bausteine_steps(topic).index(name) return _bausteine_steps(topic).index(name)
# Grobe Anzeige-Phasen: bündeln die Feinschritte (intern bleibt alles feingranular).
# Sonderschritte (Quelle laden, Ergänzung) gehören zur Phase „Inventar".
PHASEN = (
("Inventar", ("Quelle laden", "Recherche", "Konsolidierung", "Klärung", "Ergänzung")),
("Subbausteine", ("Subbausteine finden", "Subbausteine wählen", "Subbausteine klären")),
("Stufen", ("Stufen finden", "Stufen wählen", "Stufen klären")),
("Relevanz", ("Relevanz finden", "Relevanz wählen", "Relevanz klären")),
("Fragen", ("Fragen finden", "Fragen wählen", "Fragen klären")),
)
def _phasen(topic: str) -> list[tuple[str, int]]:
"""[(grob_label, Anzahl vorhandener Feinschritte)] für die aktuelle Quelle."""
feine = _bausteine_steps(topic)
return [(label, n) for label, members in PHASEN if (n := sum(f in members for f in feine))]
def _phasen_status(topic: str, current: int | None) -> list[dict]:
"""Grobe Phasen-Zustände aus dem feinen Fortschritt `current` (None = alles pending,
len(feine) = alles done). → [{label, state}] mit state done/active/pending."""
out, start = [], 0
for label, n in _phasen(topic):
end = start + n
if current is None or current < start:
state = "pending"
elif current >= end:
state = "done"
else:
state = "active"
out.append({"label": label, "state": state})
start = end
return out
def _bausteine_files(topic: str) -> dict: def _bausteine_files(topic: str) -> dict:
arbeit = arbeit_dir(topic) arbeit = arbeit_dir(topic)
runden = range(1, KONSENS_MAX_RUNDEN + 1) runden = range(1, KONSENS_MAX_RUNDEN + 1)
@@ -154,18 +205,19 @@ def _bausteine_files(topic: str) -> dict:
"ergaenzung": arbeit / "ergaenzung.json", "ergaenzung": arbeit / "ergaenzung.json",
"sub_roh": arbeit / "subbausteine-roh.json", "sub_roh": arbeit / "subbausteine-roh.json",
"sidecar": subbausteine_path(topic), "sidecar": subbausteine_path(topic),
"frage_muster": frage_muster_path(topic),
} }
def _alle_slot_dateien(files: dict) -> list[Path]: def _alle_slot_dateien(files: dict) -> list[Path]:
arbeit = files["arbeit"] arbeit = files["arbeit"]
# Subbaustein-/Stufen-Slots sind pro Chunk dynamisch — per Glob einsammeln. # Subbaustein-/Stufen-Slots sind pro Chunk dynamisch — per Glob einsammeln.
dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*"))) if arbeit.is_dir() else [] dyn = (list(arbeit.glob("subbaustein-*")) + list(arbeit.glob("stufe-*")) + list(arbeit.glob("relevanz-*")) + list(arbeit.glob("frage-muster-*"))) if arbeit.is_dir() else []
return [ return [
*files["recherche"], files["recherche_mapping"], *files["recherche"], files["recherche_mapping"],
*(p for slots in files["auswahl"].values() for p in slots), *(p for slots in files["auswahl"].values() for p in slots),
*files["mapping"].values(), files["ergaenzung"], *files["mapping"].values(), files["ergaenzung"],
files["sub_roh"], files["sidecar"], *dyn, files["sub_roh"], files["sidecar"], files["frage_muster"], *dyn,
] ]
@@ -199,38 +251,37 @@ def _resume_step(topic: str) -> int:
sidecar = _json_datei(files["sidecar"]) sidecar = _json_datei(files["sidecar"])
if _sidecar_schema(sidecar) is not None: if _sidecar_schema(sidecar) is not None:
# Stufen fertig; nur noch Relevanz offen? # Stufen fertig; nur noch Relevanz offen?
if _relevanz_komplett(sidecar): if not _relevanz_komplett(sidecar):
return len(_bausteine_steps(topic)) return _step_idx(topic, "Relevanz finden")
return _step_idx(topic, "Relevanz finden") # Relevanz fertig; nur noch Frage-Muster offen?
if not _frage_muster_komplett(topic):
return _step_idx(topic, "Fragen finden")
return len(_bausteine_steps(topic))
if _sub_roh_schema(_json_datei(files["sub_roh"])) is None: if _sub_roh_schema(_json_datei(files["sub_roh"])) is None:
return _step_idx(topic, "Subbausteine finden") return _step_idx(topic, "Subbausteine finden")
return _step_idx(topic, "Stufen finden") return _step_idx(topic, "Stufen finden")
def bausteine_status(topic: str) -> dict: def bausteine_status(topic: str) -> dict:
steps = _bausteine_steps(topic) # Intern feingranular (Resume/Progress); für die Anzeige zu 5 groben Phasen gebündelt.
feine = _bausteine_steps(topic)
ready = bausteine_path(topic).exists() ready = bausteine_path(topic).exists()
generating = topic in _bausteine_progress generating = topic in _bausteine_progress
partial = False partial = False
if generating: if generating:
current = _bausteine_step.get(topic) current = _bausteine_step.get(topic)
states = [
"pending" if current is None else "done" if i < current else "active" if i == current else "pending"
for i in range(len(steps))
]
elif ready: elif ready:
states = ["done"] * len(steps) current = len(feine) # alles fertig → alle Phasen done
else: else:
nxt = _resume_step(topic) current = _resume_step(topic)
partial = nxt > 0 partial = current > 0
states = ["done" if i < nxt else "pending" for i in range(len(steps))]
return { return {
"ready": ready, "ready": ready,
"generating": generating, "generating": generating,
"progress": _bausteine_progress.get(topic), "progress": _bausteine_progress.get(topic),
"error": _bausteine_errors.get(topic), "error": _bausteine_errors.get(topic),
"partial": partial, "partial": partial,
"steps": [{"label": label, "state": s} for label, s in zip(steps, states)], "steps": _phasen_status(topic, current),
} }
@@ -242,12 +293,49 @@ def reset_bausteine(topic: str) -> None:
files = _bausteine_files(topic) files = _bausteine_files(topic)
files["final"].unlink(missing_ok=True) files["final"].unlink(missing_ok=True)
files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/ files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/
files["frage_muster"].unlink(missing_ok=True) # ebenfalls im Themen-Root
quelle_path(topic).unlink(missing_ok=True) quelle_path(topic).unlink(missing_ok=True)
shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # gecrawlte Link-Quelle shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # gecrawlte Link-Quelle
shutil.rmtree(files["arbeit"], ignore_errors=True) shutil.rmtree(files["arbeit"], ignore_errors=True)
_bausteine_errors.pop(topic, None) _bausteine_errors.pop(topic, None)
def _reset_ab_phase(topic: str, phase: int) -> None:
"""Artefakte AB der groben Phase löschen (1 Inventar … 5 Fragen), frühere behalten.
Danach baut der normale Resume-Flow die fehlenden Phasen neu. Kumulativ: Re-Run ab P
löscht P…5. quelle.json + Crawl bleiben immer (Quellen-Wahl erhalten)."""
files = _bausteine_files(topic)
arbeit = files["arbeit"]
def glob_del(pat: str) -> None:
if arbeit.is_dir():
for p in arbeit.glob(pat):
p.unlink(missing_ok=True)
if phase <= 5: # Fragen
files["frage_muster"].unlink(missing_ok=True)
glob_del("frage-muster-*")
if phase <= 4: # Relevanz
glob_del("relevanz-*")
if phase <= 3: # Stufen + Relevanz teilen die Sidecar → ab Stufen ganz neu
files["sidecar"].unlink(missing_ok=True)
glob_del("stufe-*")
else: # ab Relevanz: Stufen behalten, nur Relevanz-Felder strippen
sc = _json_datei(files["sidecar"])
if isinstance(sc, dict):
for subs in sc.values():
for s in (subs if isinstance(subs, list) else []):
if isinstance(s, dict):
s.pop("relevanz", None)
atomic_write_json(files["sidecar"], sc, indent=1)
if phase <= 2: # Subbausteine
files["sub_roh"].unlink(missing_ok=True)
glob_del("subbaustein-*")
if phase <= 1: # Inventar = kompletter Frischstart (alle Zwischendateien)
for p_alt in _alle_slot_dateien(files):
p_alt.unlink(missing_ok=True)
def _ergaenzung_schema(data): def _ergaenzung_schema(data):
"""{"bausteine": [{"titel", "beschreibung"}]} → Liste (leer erlaubt) · sonst None.""" """{"bausteine": [{"titel", "beschreibung"}]} → Liste (leer erlaubt) · sonst None."""
if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list): if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list):
@@ -357,6 +445,32 @@ def _relevanz_komplett(data) -> bool:
) )
def _frage_muster_schema(data) -> list[dict] | None:
"""{"muster": [{subbaustein, typ, frage}, …]} → Liste valider Einträge · sonst None.
typ muss ein bekannter Fragetyp sein; subbaustein + frage nicht leer. Leere Liste → None.
"""
if not isinstance(data, dict) or not isinstance(data.get("muster"), list):
return None
out = []
for e in data["muster"]:
if not isinstance(e, dict):
return None
sub = str(e.get("subbaustein", "")).strip()
typ = str(e.get("typ", "")).strip().casefold()
frage = str(e.get("frage", "")).strip()
if not sub or typ not in FRAGETYPEN or not frage:
return None
out.append({"subbaustein": sub, "typ": typ, "frage": frage})
return out or None
def _frage_muster_komplett(topic: str) -> bool:
"""Frage-Muster-Sidecar existiert (Build gelaufen)? Einzelne leere Bausteine
fallen zur Prüfungszeit auf Live-Generierung zurück — daher genügt die Datei."""
return isinstance(_json_datei(frage_muster_path(topic)), dict)
def _read(p: Path) -> str: def _read(p: Path) -> str:
return p.read_text(encoding="utf-8") if p.exists() else "" return p.read_text(encoding="utf-8") if p.exists() else ""
@@ -741,7 +855,106 @@ async def _relevanz_block(ctx: GenContext, set_p, files: dict, sidecar: dict, in
return relevanz_by_id return relevanz_by_id
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: def _norm_frage(t: str) -> str:
return " ".join(str(t or "").lower().split())
async def _frage_muster_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str) -> dict | None:
"""Block E: drei Phasen — Finden (1 Generator je Baustein, parallel), Wählen (Code:
relevante Subs + Dedup), Klären (Kritiker je Baustein bereinigt die Tabelle).
Generativ statt Vote: Muster sind Text, kein 3-Rater-Konsens sinnvoll.
{Baustein-Titel: [{subbaustein, typ, frage}, …]} oder None bei Abbruch."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
arbeit = files["arbeit"]
# Je Baustein nur RELEVANTE Subbausteine (relevanz != 'rand'; None zählt als relevant).
bausteine = []
for titel, subs in sidecar.items():
rel = [s["titel"] for s in subs if isinstance(s, dict) and s.get("relevanz") != "rand" and str(s.get("titel", "")).strip()]
if rel:
bausteine.append((titel, rel))
if not bausteine:
return {}
typen_block = "\n".join(f"- {k}: {v}" for k, v in FRAGETYPEN.items())
def roh_path(c):
return arbeit / f"frage-muster-c{c}.json"
def final_path(c):
return arbeit / f"frage-muster-final-c{c}.json"
# Phase „Fragen finden": pro Baustein 1 Generator, alle parallel.
async def _finde(c, titel, rel):
fp = roh_path(c)
if _frage_muster_schema(_json_datei(fp)):
return # Resume
sub_block = "\n".join(f"- {s}" for s in rel)
status, _ = await run_single_slot(
ctx, f"Frage-Muster {c}",
key=f"bausteine-{topic}-frage-muster-c{c}",
prompt=_prompt("Frage-Muster-Recherche", topic=topic, baustein=titel,
subbausteine=sub_block, typen=typen_block, out_path=fp, extra=_extra(instructions)),
role="fast", capabilities="files",
payload=lambda result, p=fp: _frage_muster_schema(_json_datei(p)),
timeout=_timeout("frage_muster", len(rel)),
)
if status == FAILED:
_log(topic, f"Frage-Muster Baustein {c} fehlgeschlagen — kein Muster (Fallback Live)")
set_p(f"Fragen finden ({len(bausteine)} Bausteine)…", step=_step_idx(topic, "Fragen finden"))
await asyncio.gather(*[_finde(c, t, r) for c, (t, r) in enumerate(bausteine, 1)], return_exceptions=True)
if is_cancelled():
return None
# Phase „Fragen wählen": Code — nur relevante Subbausteine, Dubletten je Baustein raus.
set_p("Fragen wählen…", step=_step_idx(topic, "Fragen wählen"))
roh_by_c = {}
for c, (titel, rel) in enumerate(bausteine, 1):
eintraege = _frage_muster_schema(_json_datei(roh_path(c))) or []
rel_set = set(rel)
gesehen, sauber = set(), []
for e in eintraege:
norm = _norm_frage(e["frage"])
if e["subbaustein"] not in rel_set or norm in gesehen:
continue
gesehen.add(norm)
sauber.append(e)
roh_by_c[c] = sauber
# Phase „Fragen klären": Kritiker je Baustein bereinigt die Tabelle (eindeutig, distinkt).
async def _klaere(c, titel):
roh = roh_by_c[c]
fp = final_path(c)
if _frage_muster_schema(_json_datei(fp)) or not roh:
return
tabelle = "\n".join(f"{i}. [{e['typ']}] ({e['subbaustein']}) {e['frage']}" for i, e in enumerate(roh, 1))
status, _ = await run_single_slot(
ctx, f"Frage-Muster-Klärung {c}",
key=f"bausteine-{topic}-frage-muster-final-c{c}",
prompt=_prompt("Frage-Muster-Kritik", topic=topic, baustein=titel, tabelle=tabelle, out_path=fp, extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result, p=fp: _frage_muster_schema(_json_datei(p)),
timeout=_timeout("frage_muster_check", len(roh)),
)
if status == FAILED:
_log(topic, f"Frage-Muster-Klärung Baustein {c} fehlgeschlagen — Roh-Muster übernommen")
set_p(f"Fragen klären ({len(bausteine)} Bausteine)…", step=_step_idx(topic, "Fragen klären"))
await asyncio.gather(*[_klaere(c, t) for c, (t, _) in enumerate(bausteine, 1)], return_exceptions=True)
if is_cancelled():
return None
# Final: geklärte Tabelle je Baustein, Fallback auf Roh-Muster.
ergebnis = {}
for c, (titel, rel) in enumerate(bausteine, 1):
rel_set = set(rel)
final = _frage_muster_schema(_json_datei(final_path(c)))
if not final:
final = roh_by_c[c]
ergebnis[titel] = [e for e in final if e["subbaustein"] in rel_set]
return ergebnis
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_phase: int | None = None) -> None:
if topic in _bausteine_progress: if topic in _bausteine_progress:
return return
_bausteine_progress[topic] = "Wartend…" _bausteine_progress[topic] = "Wartend…"
@@ -769,6 +982,10 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
try: try:
async with _semaphore: async with _semaphore:
files["arbeit"].mkdir(parents=True, exist_ok=True) files["arbeit"].mkdir(parents=True, exist_ok=True)
# Re-Run ab gewählter Phase: Artefakte ab dort löschen; der Frischstart-Block
# unten wird übersprungen (er würde bei erhaltener Sidecar sonst alles wischen).
if ab_phase is not None:
_reset_ab_phase(topic, ab_phase)
# Link-Quelle: erst crawlen (gleiche Domain, begrenzt) → wird zur Ordner-Quelle. # Link-Quelle: erst crawlen (gleiche Domain, begrenzt) → wird zur Ordner-Quelle.
if q["type"] == "link" and not _crawl_fertig(topic): if q["type"] == "link" and not _crawl_fertig(topic):
set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle laden")) set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle laden"))
@@ -784,7 +1001,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
# „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND # „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND
# Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor, # Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor,
# ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen. # ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen.
fertig = final_path.exists() and _sidecar_schema(_json_datei(files["sidecar"])) is not None # Bei explizitem Re-Run (ab_phase) hat _reset_ab_phase das schon erledigt.
fertig = ab_phase is None and final_path.exists() and _sidecar_schema(_json_datei(files["sidecar"])) is not None
if fertig: if fertig:
for p_alt in _alle_slot_dateien(files): for p_alt in _alle_slot_dateien(files):
p_alt.unlink(missing_ok=True) p_alt.unlink(missing_ok=True)
@@ -1019,6 +1237,19 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
gid += 1 gid += 1
sub["relevanz"] = relevanz_by_id.get(gid, "relevant") sub["relevanz"] = relevanz_by_id.get(gid, "relevant")
atomic_write_json(files["sidecar"], sidecar, indent=1) atomic_write_json(files["sidecar"], sidecar, indent=1)
# Block E: Frage-Muster je relevantem Subbaustein × Typ → eigenes Sidecar.
# Zur Prüfungszeit zieht jeder Agent ein Muster ohne Zurücklegen und formuliert
# daraus eine Frage — distinkte Saat verhindert die Doppelfragen der Live-Generierung.
sidecar = _json_datei(files["sidecar"])
if _sidecar_schema(sidecar) is not None and _relevanz_komplett(sidecar) and not _frage_muster_komplett(topic):
muster = await _frage_muster_block(ctx, set_p, files, sidecar, instructions)
if is_cancelled():
abgebrochen()
return
if muster is None:
return # Abbruch
atomic_write_json(files["frage_muster"], muster, indent=1)
except Exception as e: except Exception as e:
log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic) log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic)
_bausteine_errors[topic] = str(e)[:2000] _bausteine_errors[topic] = str(e)[:2000]

View File

@@ -56,6 +56,8 @@ TIMEOUTS = {
"stufe_check": (300, 10), # Judge entscheidet strittige Stufen im Chunk "stufe_check": (300, 10), # Judge entscheidet strittige Stufen im Chunk
"relevanz": (300, 10), # Subbausteine relevant/rand je Chunk "relevanz": (300, 10), # Subbausteine relevant/rand je Chunk
"relevanz_check": (300, 10), # Judge entscheidet strittige Relevanz im Chunk "relevanz_check": (300, 10), # Judge entscheidet strittige Relevanz im Chunk
"frage_muster": (300, 15), # Frage-Muster je Baustein (Subbausteine × Typen)
"frage_muster_check": (300, 10), # Kritiker bereinigt die Muster-Tabelle je Baustein
"writer": (600, 120), # pro Section im Chunk "writer": (600, 120), # pro Section im Chunk
"lese_check": (300, 10), # pro Section im Paket "lese_check": (300, 10), # pro Section im Paket
"onepager_recherche": (900, 0), "onepager_recherche": (900, 0),

View File

@@ -273,6 +273,28 @@ async def pruefung_frage(
return None return None
async def pruefung_frage_variante(
topic: str, baustein: str, section: str, kompakt: str | None,
muster: str, provider: str = DEFAULT_PROVIDER,
) -> str | None:
"""Aktion 'frage' mit Muster: aus einem vordefinierten Muster eine konkrete, im
Wortlaut leicht andere Frage formulieren. Kein Kritiker (Muster ist build-geprüft).
Stil-Guard bleibt als billiger Schutz gegen Doppelfragen · None bei Fehler."""
try:
section_block, kompakt_block = _bloecke(section, kompakt)
data = await _gen_call(
"Baustein-Frage-Variante", "fast", _frage_schema, provider,
topic=topic, baustein=baustein, section_block=section_block,
kompakt_block=kompakt_block, muster=muster,
)
if data is None:
return None
return data["frage"]
except Exception:
log.warning("[%s] Frage-Variante fehlgeschlagen (%s)", topic, baustein, exc_info=True)
return None
async def pruefung_bewertung_schnell( async def pruefung_bewertung_schnell(
topic: str, baustein: str, section: str, kompakt: str | None, topic: str, baustein: str, section: str, kompakt: str | None,
frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER, frage: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,

View File

@@ -31,6 +31,7 @@ class BausteineCreateRequest(BaseModel):
provider: ProviderType = "claude" provider: ProviderType = "claude"
source_type: SourceType = "thema" source_type: SourceType = "thema"
source_ort: str = Field(default="", max_length=2000) source_ort: str = Field(default="", max_length=2000)
ab_phase: int | None = Field(default=None, ge=1, le=5) # Re-Run ab grober Phase (1 Inventar … 5 Fragen); None = Resume/Fortsetzen ohne Löschen
class BausteineStep(BaseModel): class BausteineStep(BaseModel):
@@ -220,6 +221,7 @@ class BausteinPruefungRequest(BaseModel):
vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen vermeide: list[str] = [] # schon gestellte + vorgemerkte Fragen — sinngemäß nicht wiederholen
nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt nachgefragt: bool = False # für diese Frage wurde nachgefragt → Gewinn auf +1 gedeckelt
begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden begruendung: str = Field(default="", max_length=2000) # „Gründlich prüfen": warum mit der Bewertung unzufrieden
muster: str = Field(default="", max_length=2000) # gezogenes Frage-Muster (Saat); leer → Live-Generierung (Fallback)
# Basis wird serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client. # Basis wird serverseitig je Frage geführt (offene_frage-Anker) — nicht mehr vom Client.
tier2: bool = False # ganzer Guide absolviert (alle ≥3) → 1 bei falsch, Deckel 10 tier2: bool = False # ganzer Guide absolviert (alle ≥3) → 1 bei falsch, Deckel 10
tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, 2 bei falsch, Deckel 25 tier3: bool = False # ganzer Guide verstanden (alle ≥10) → Meisterpfad, 2 bei falsch, Deckel 25

View File

@@ -26,6 +26,11 @@ def subbausteine_path(topic: str) -> Path:
return topic_dir(topic) / "subbausteine.json" return topic_dir(topic) / "subbausteine.json"
def frage_muster_path(topic: str) -> Path:
"""Sidecar: pro Baustein vordefinierte Frage-Muster (Subbaustein × Typ → Beispielfrage)."""
return topic_dir(topic) / "frage_muster.json"
def quelle_path(topic: str) -> Path: def quelle_path(topic: str) -> Path:
"""Persistierte Quellen-Wahl pro Thema: {type, ort, spec}.""" """Persistierte Quellen-Wahl pro Thema: {type, ort, spec}."""
return topic_dir(topic) / "quelle.json" return topic_dir(topic) / "quelle.json"

View File

@@ -16,9 +16,9 @@ from database import (
list_baustein_progress, get_baustein_progress, set_offene_frage, list_baustein_progress, get_baustein_progress, set_offene_frage,
set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten, set_baustein_score, set_baustein_absolviert, set_baustein_verstanden, set_baustein_gemeistert, delete_baustein_daten,
) )
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, lade_frage_muster
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, score_berechnen from lernen import NOETIG, MASTERY, MEISTERN, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, score_berechnen
from guide import generate_guide, guide_slot_dateien from guide import generate_guide, guide_slot_dateien
from pipeline import cancel_guide from pipeline import cancel_guide
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen
@@ -156,7 +156,7 @@ async def create_bausteine(req: BausteineCreateRequest):
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.") raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
qp.parent.mkdir(parents=True, exist_ok=True) qp.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()}) atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()})
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider)) asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase))
return {"ok": True} return {"ok": True}
@@ -206,6 +206,12 @@ async def get_bausteine_uebersicht(topic: str):
return lade_uebersicht(topic) return lade_uebersicht(topic)
@router.get("/bausteine/frage-muster")
async def get_frage_muster(topic: str, baustein: str):
"""Vordefinierte Frage-Muster eines Bausteins (leer = Fallback auf Live-Generierung)."""
return {"muster": lade_frage_muster(topic, baustein)}
# --- Baustein-Lernen: Chat, Prüfung --- # --- Baustein-Lernen: Chat, Prüfung ---
@router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse) @router.get("/bausteine/lernstand", response_model=BausteinLernstandResponse)
@@ -270,8 +276,13 @@ async def baustein_pruefung_route(req: BausteinPruefungRequest):
msgs = [m.model_dump() for m in req.messages] msgs = [m.model_dump() for m in req.messages]
if req.aktion == "frage": if req.aktion == "frage":
subs = subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt) if req.muster.strip():
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider) # Aus gezogenem Muster eine konkrete, leicht andere Frage formulieren (kein Dedup nötig).
frage = await pruefung_frage_variante(req.topic, req.baustein, req.section, kompakt, req.muster, provider=req.provider)
else:
# Fallback (kein Muster-Sidecar): Live-Generierung mit zufälligem Typ/Subbaustein.
subs = subbausteine_titel(req.topic, req.baustein) # Fokus-Kandidaten (zufällig gewählt)
frage = await pruefung_frage(req.topic, req.baustein, req.section, kompakt, msgs, subbausteine=subs, vermeide=req.vermeide, provider=req.provider)
if frage is None: if frage is None:
raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen") raise HTTPException(502, "Frage fehlgeschlagen — bitte erneut versuchen")
return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert} return {"frage": frage, "gute_antworten": gute, "absolviert": absolviert, "verstanden": verstanden, "gemeistert": gemeistert}

View File

@@ -233,11 +233,12 @@ async function handleResetBausteine() {
await loadBausteine() await loadBausteine()
} }
async function handleBausteineClick({ instructions }) { async function handleBausteineClick({ instructions, abPhase = null }) {
if (!selectedTopic.value) return if (!selectedTopic.value) return
uiError.value = null uiError.value = null
try { try {
await apiCreateBausteine(selectedTopic.value, instructions, provider.value) // Quelle ist hier schon festgeschrieben; nur abPhase steuert den Re-Run-Umfang.
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase)
} catch (e) { } catch (e) {
uiError.value = e.message uiError.value = e.message
return return

View File

@@ -42,11 +42,11 @@ export async function fetchBausteineStatus(topic) {
return res.json() return res.json()
} }
export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '') { export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null) {
const res = await fetch(`${BASE}/bausteine`, { const res = await fetch(`${BASE}/bausteine`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt }), body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt, ab_phase: abPhase }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
@@ -78,16 +78,21 @@ export async function chatBaustein({ topic, baustein, section, section_kompakt =
export async function pruefeBaustein({ export async function pruefeBaustein({
topic, baustein, section, section_kompakt = '', provider, topic, baustein, section, section_kompakt = '', provider,
aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [], aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [],
nachgefragt = false, begruendung = '', tier2 = false, tier3 = false, messages = [], gruendlich = false, nachgefragt = false, begruendung = '', muster = '', tier2 = false, tier3 = false, messages = [], gruendlich = false,
}) { }) {
const res = await fetch(`${BASE}/bausteine/pruefung`, { const res = await fetch(`${BASE}/bausteine/pruefung`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, tier2, tier3, messages, provider, gruendlich }), body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, muster, tier2, tier3, messages, provider, gruendlich }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function fetchFrageMuster(topic, baustein) {
const res = await fetch(`${BASE}/bausteine/frage-muster?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`)
return jsonOrThrow(res)
}
export async function fetchTopicFortschritt(topic) { export async function fetchTopicFortschritt(topic) {
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`) const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
return res.json() return res.json()

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { chatBaustein, pruefeBaustein } from '../api.js' import { chatBaustein, pruefeBaustein, fetchFrageMuster } from '../api.js'
import { usePruefSlot } from '../pruefungCache.js' import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown } from '../markdown.js' import { renderMarkdown } from '../markdown.js'
import { useChat, istUnten } from '../composables/useChat.js' import { useChat, istUnten } from '../composables/useChat.js'
@@ -53,6 +53,9 @@ const pruefMessages = slot.messages // {role, kind: 'frage'|'nachfrage'|'antwort
const pruefPhase = slot.phase const pruefPhase = slot.phase
const aktuelleFrage = slot.aktuelleFrage // ankert Bewertung/Diskussion const aktuelleFrage = slot.aktuelleFrage // ankert Bewertung/Diskussion
const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion über eine Bewertung const letztesFeedback = slot.letztesFeedback // Kontext für die Diskussion über eine Bewertung
const musterQuelle = slot.musterQuelle // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
const musterPool = slot.musterPool // Arbeitskopie, ohne Zurücklegen; leer → Reset
const musterGeladen = slot.musterGeladen
// Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner. // Transient (pro Instanz): Eingabe-Entwurf, Lade-Spinner.
const pruefInput = ref('') const pruefInput = ref('')
const pruefLoading = ref(false) const pruefLoading = ref(false)
@@ -124,36 +127,76 @@ function gestellteFragen() {
return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content))) return new Set(pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => normFrage(m.content)))
} }
// --- Fragen-Pool: ZIEL vorgenerierte, geprüfte, unbeantwortete Fragen je Baustein (im Slot) --- // --- Fragen-Pool: ZIEL vorformulierte, unbeantwortete Fragen je Baustein (im Slot) ---
const POOL_ZIEL = 5 const POOL_ZIEL = 5
const pool = slot.pool const pool = slot.pool
const inflight = slot.inflight const inflight = slot.inflight
let poolMiss = 0 // aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife (kleine Bausteine) let poolMiss = 0 // nur Fallback: aufeinanderfolgende Dubletten/Fehler — Cap gegen Endlosschleife
// Muster-Modus aktiv, sobald geladen und Muster vorhanden; sonst Fallback auf Live-Generierung.
const musterModus = computed(() => musterGeladen.value && musterQuelle.value.length > 0)
function istBekannt(text) { function istBekannt(text) {
const n = normFrage(text) const n = normFrage(text)
return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(f) === n) return !n || gestellteFragen().has(n) || pool.value.some((f) => normFrage(f) === n)
} }
// Schon gestellte + bereits vorgemerkte Fragen — der Generator/Kritiker soll sie // Schon gestellte + bereits vorgemerkte Fragen — der Generator soll sie (auch
// (auch sinngemäß) meiden. Original-Texte, nicht normalisiert. // sinngemäß) meiden. Nur Fallback-Modus. Original-Texte, nicht normalisiert.
function vermeideListe() { function vermeideListe() {
const gestellt = pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => m.content) const gestellt = pruefMessages.value.filter((m) => m.kind === 'frage').map((m) => m.content)
return [...gestellt, ...pool.value] return [...gestellt, ...pool.value]
} }
// Muster-Sidecar einmal je Baustein laden (Slot-cached). Leer → Fallback.
async function musterLaden() {
if (musterGeladen.value) return
try {
const res = await fetchFrageMuster(props.topic, props.baustein)
musterQuelle.value = (res.muster || []).map((m) => (m.frage || '').trim()).filter(Boolean)
} catch {
musterQuelle.value = []
}
musterGeladen.value = true
}
function mischen(arr) { // Fisher-Yates
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
// Ein Muster ohne Zurücklegen ziehen; Pool leer → Reset (neu mischen). null = Fallback.
function musterNehmen() {
if (!musterQuelle.value.length) return null
if (!musterPool.value.length) musterPool.value = mischen(musterQuelle.value)
return musterPool.value.shift()
}
function frageEinzeln() { function frageEinzeln() {
// Ein Agent → eine Frage (Backend wählt zufälligen Typ + Subbaustein, dann Kritiker). const muster = musterNehmen()
if (muster) {
// Muster-Modus: aus gezogener Saat eine konkrete, leicht andere Frage formulieren.
return pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'frage', muster,
}).then((res) => res.frage)
}
// Fallback (kein Muster-Sidecar): Live-Generierung mit Vermeide-Liste.
return pruefeBaustein({ return pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt, topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe(), provider: props.provider, aktion: 'frage', messages: pruefDialog(), vermeide: vermeideListe(),
}).then((res) => res.frage) }).then((res) => res.frage)
} }
// Pool PARALLEL auffüllen: pro fehlender Frage ein eigener Agent gleichzeitig. // Pool PARALLEL auffüllen: pro fehlender Frage ein eigener Agent. Im Muster-Modus
// Initial 5 parallel; nach Verbrauch genau die fehlende(n) nach. // zieht jeder ein DISTINKTES Muster (ohne Zurücklegen) → keine Kollision, kein Miss-Cap.
function poolFuellen() { function poolFuellen() {
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < 3) { const cap = musterModus.value ? Infinity : 3
while (pool.value.length + inflight.value < POOL_ZIEL && poolMiss < cap) {
inflight.value++ inflight.value++
frageEinzeln() frageEinzeln()
.then((text) => { .then((text) => {
@@ -168,9 +211,10 @@ function poolFuellen() {
// Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten. // Eine Frage aus dem Pool nehmen; ist er leer, auf die erste eintreffende warten.
async function poolNehmen() { async function poolNehmen() {
if (!pool.value.length) { if (!pool.value.length) {
const cap = musterModus.value ? Infinity : 3
pruefLoading.value = true pruefLoading.value = true
try { try {
while (!pool.value.length && (inflight.value > 0 || poolMiss < 3)) { while (!pool.value.length && (inflight.value > 0 || poolMiss < cap)) {
if (!inflight.value) poolFuellen() if (!inflight.value) poolFuellen()
await new Promise((r) => setTimeout(r, 120)) await new Promise((r) => setTimeout(r, 120))
} }
@@ -179,10 +223,11 @@ async function poolNehmen() {
return pool.value.length ? pool.value.shift() : null return pool.value.length ? pool.value.shift() : null
} }
// Erste Frage (Phase idle): ZIEL parallel anstoßen, schnellste zuerst zeigen. // Erste Frage (Phase idle): Muster laden, ZIEL parallel anstoßen, schnellste zuerst zeigen.
async function frageAnfordern() { async function frageAnfordern() {
if (pruefLoading.value) return if (pruefLoading.value) return
poolMiss = 0 poolMiss = 0
await musterLaden()
poolFuellen() poolFuellen()
const text = await poolNehmen() const text = await poolNehmen()
if (text) { if (text) {
@@ -197,6 +242,7 @@ async function frageAnfordern() {
// „Nächste Frage": sofort aus dem Pool, dann nachfüllen. // „Nächste Frage": sofort aus dem Pool, dann nachfüllen.
async function naechsteFrageZeigen() { async function naechsteFrageZeigen() {
if (pruefLoading.value) return if (pruefLoading.value) return
await musterLaden()
const text = await poolNehmen() const text = await poolNehmen()
if (text) { if (text) {
frageZeigen(text) frageZeigen(text)

View File

@@ -67,6 +67,17 @@ const bausteineState = computed(() => {
return props.bausteine.ready ? 'done' : 'none' return props.bausteine.ready ? 'done' : 'none'
}) })
// Re-Run ab gewählter Phase (1-basiert). null = ab Anfang / Fortsetzen.
const gewaehltePhase = ref(null)
// Phasen anklickbar, sobald etwas (teil-)gebaut ist und gerade nicht generiert wird.
const phasenWaehlbar = computed(() => (props.bausteine.ready || props.bausteine.partial) && !props.bausteine.generating)
const gewaehltesLabel = computed(() => (props.bausteine.steps || [])[(gewaehltePhase.value || 0) - 1]?.label || '')
function phaseKlick(n) {
if (!phasenWaehlbar.value) return
gewaehltePhase.value = gewaehltePhase.value === n ? null : n
}
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile // Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile
const activeGenerations = computed(() => { const activeGenerations = computed(() => {
const bausteinLines = props.activeBausteine const bausteinLines = props.activeBausteine
@@ -90,7 +101,10 @@ function confirmResetBausteine() {
function handleBausteinePlay() { function handleBausteinePlay() {
if (bausteineState.value === 'generating') return if (bausteineState.value === 'generating') return
emit('bausteineClick', { instructions: '' }) // Nur „Neu generieren" (ready) nutzt eine Phase; Fortsetzen/Erstbau resumen ohne Löschen.
const abPhase = props.bausteine.ready ? (gewaehltePhase.value ?? 1) : null
emit('bausteineClick', { instructions: '', abPhase })
gewaehltePhase.value = null
} }
// Name-Klick = Primäraktion: fertige Bausteine → Übersicht, sonst Panel auf/zu. // Name-Klick = Primäraktion: fertige Bausteine → Übersicht, sonst Panel auf/zu.
@@ -361,12 +375,13 @@ function saveQuelle() {
>Pausiert</span> >Pausiert</span>
<span class="step-dots"> <span class="step-dots">
<span <span
v-for="s in (bausteine.steps || [])" v-for="(s, i) in (bausteine.steps || [])"
:key="s.label" :key="s.label"
class="step-dot" class="step-pill"
:class="s.state" :class="[s.state, { sel: gewaehltePhase === i + 1, klickbar: phasenWaehlbar }]"
:title="s.state === 'active' ? (bausteine.progress || s.label) : s.label" :title="(s.state === 'active' ? (bausteine.progress || s.label) : s.label) + (phasenWaehlbar ? ' — Klick: ab hier neu' : '')"
></span> @click.stop="phaseKlick(i + 1)"
>{{ i + 1 }}</span>
</span> </span>
</button> </button>
<button class="panel-toggle" :class="{ open: isOpen('bausteine') }" title="Aktionen" @click.stop="togglePanel('bausteine')"></button> <button class="panel-toggle" :class="{ open: isOpen('bausteine') }" title="Aktionen" @click.stop="togglePanel('bausteine')"></button>
@@ -376,7 +391,7 @@ function saveQuelle() {
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'bausteine' }" @click="confirmCancelBausteine">{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Abbrechen' }}</button> <button class="panel-btn danger" :class="{ armed: pendingConfirm === 'bausteine' }" @click="confirmCancelBausteine">{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Abbrechen' }}</button>
</template> </template>
<template v-else> <template v-else>
<button class="panel-btn play" @click="handleBausteinePlay">{{ bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Neu generieren' : 'Generieren' }}</button> <button class="panel-btn play" @click="handleBausteinePlay">{{ bausteine.partial ? 'Fortsetzen' : bausteine.ready ? (gewaehltePhase ? `Ab «${gewaehltesLabel}» neu` : 'Neu generieren') : 'Generieren' }}</button>
<button <button
v-if="bausteine.ready || bausteine.partial" v-if="bausteine.ready || bausteine.partial"
class="panel-btn danger" class="panel-btn danger"
@@ -717,27 +732,45 @@ function saveQuelle() {
.step-dots { .step-dots {
display: inline-flex; display: inline-flex;
gap: 4px; gap: 5px;
flex: 1; flex: 1;
} }
.step-dot { /* Grobe Phasen als nummerierte Pillen (15) — Anzeige + anklickbar für Re-Run ab hier. */
width: 7px; .step-pill {
height: 7px; display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
border-radius: 50%; border-radius: 50%;
background: var(--border-strong); background: var(--border-strong);
color: var(--bg);
font-size: 0.62rem;
font-weight: 700;
line-height: 1;
flex-shrink: 0; flex-shrink: 0;
border: 1.5px solid transparent;
} }
.step-dot.done { .step-pill.done {
background: var(--success-border); background: var(--success-border);
} }
.step-dot.active { .step-pill.active {
background: var(--warning-border); background: var(--warning-border);
animation: dot-pulse 1.2s ease-in-out infinite; animation: dot-pulse 1.2s ease-in-out infinite;
} }
.step-pill.klickbar {
cursor: pointer;
}
.step-pill.sel {
border-color: var(--text);
box-shadow: 0 0 0 1px var(--text);
}
@keyframes dot-pulse { @keyframes dot-pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.35; } 50% { opacity: 0.35; }

View File

@@ -15,8 +15,11 @@ export function usePruefSlot(key) {
phase: ref('idle'), phase: ref('idle'),
aktuelleFrage: ref(''), aktuelleFrage: ref(''),
letztesFeedback: ref(''), letztesFeedback: ref(''),
pool: ref([]), // vorgenerierte, geprüfte, unbeantwortete Fragen pool: ref([]), // vorformulierte, unbeantwortete Fragen
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert) inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert)
musterQuelle: ref([]), // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
musterPool: ref([]), // Arbeitskopie: gezogene Saat ohne Zurücklegen; leer → Reset
musterGeladen: ref(false), // Muster-Sidecar schon abgerufen?
}) })
} }
return store.get(key) return store.get(key)

View File

@@ -0,0 +1,21 @@
Du bist Prüfer in einem Lern-Guide zum Thema "{topic}", Baustein "{baustein}". Formuliere dem Lerner EINE Verständnisfrage. Du bekommst dafür ein **Muster** — eine Beispielfrage. Stelle dieselbe Sache, aber im Wortlaut **leicht anders** (andere Formulierung, gleicher Kern und gleiche Schwierigkeit).
MUSTER (Vorlage — gleiche Sache, neuer Wortlaut):
{muster}
BAUSTEIN AUS DEM GUIDE (fachliche Referenz — die Frage darf ihr nie widersprechen):
{section_block}
KOMPAKTE FASSUNG (Merksätze, falls vorhanden):
{kompakt_block}
HARTE REGELN:
- GENAU EINE Frage. Ein einziges Fragezeichen. Eine einzige Sache.
- Maximal 12 Sätze, keine Vorrede, kein Szenario-Aufbau.
- Verboten: zwei Fragen mit "und"/"sowie" verketten, "nenne drei …", Aufzähl-Forderungen.
- Gleiche Kernfrage wie das Muster, nur anders formuliert — KEINE neue Sache, KEINE wörtliche Kopie.
- Frag nur, was aus dem Material beantwortbar ist. Keine erfundenen Zusatzannahmen.
- Sprich den Lerner direkt an, klares Deutsch, keine Floskeln.
Gib NUR dieses JSON aus (kein weiterer Text):
{{"frage": "genau eine kurze Frage"}}

View File

@@ -0,0 +1,21 @@
Du bist Qualitäts-Prüfer für den Fragen-Vorrat einer Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Ein anderer Agent hat Frage-Muster erstellt. Prüfe sie und gib eine **bereinigte** Endfassung zurück.
ZU PRÜFENDE MUSTER (Nummer. [typ] (subbaustein) Frage):
{tabelle}
PRÜFE UND BEREINIGE GEGEN DIESE KRITERIEN:
- **Stil:** jede Frage GENAU EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", keine Aufzählung. Maximal 12 Sätze. Verstöße → umformulieren.
- **Eindeutig beantwortbar:** aus dem Baustein-Wissen klar beantwortbar, keine erfundenen Annahmen. Unbeantwortbares → korrigieren oder entfernen.
- **Distinkt:** keine zwei Muster, die sinngemäß dieselbe Frage stellen (auch nicht über Typen hinweg). Dubletten → eines behalten, das andere entfernen.
- **Typ-Treue:** die Frage passt zum angegebenen Typ; sonst Typ anpassen oder Muster entfernen.
- `subbaustein` und `typ` der Einträge unverändert übernehmen (nur `frage` darf sich ändern); nur entfernen, nicht neu erfinden.
Behalte gute Muster unverändert. Ändere nur, was wirklich gegen die Kriterien verstößt.
Schreibe die bereinigte Endfassung als JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{"muster": [
{{"subbaustein": "<exakter Titel>", "typ": "<Schlüssel>", "frage": "<eine konkrete Frage>"}}
]}}
Gib sonst keinen Text aus.
{extra}

View File

@@ -0,0 +1,28 @@
Du baust den Fragen-Vorrat für eine Lern-Prüfung zum Thema "{topic}", Baustein "{baustein}". Erzeuge **Frage-Muster** — je eine konkrete Beispielfrage pro Subbaustein und Fragetyp. Aus diesen Mustern werden später bei der Prüfung leicht abgewandelte Fragen formuliert.
RELEVANTE SUBBAUSTEINE (nur zu diesen Fragen bauen):
{subbausteine}
FRAGETYPEN (Schlüssel : Bedeutung):
{typen}
AUFGABE:
- Erzeuge für **jeden** Subbaustein zu **jedem** passenden Fragetyp **ein** Muster (volles Kreuzprodukt).
- Lasse eine Kombination **weg**, wenn der Typ klar nicht passt (z.B. Code-Vorhersage bei reiner Theorie ohne Code). Nichts erzwingen.
- Jedes Muster ist EINE konkrete, knappe Frage (12 Sätze, genau ein Fragezeichen, eine Sache) — kein Mehrteiler, keine Aufzählung.
- Die Frage muss aus dem Baustein-Wissen beantwortbar sein. Erfinde keine Zusatzannahmen.
- Innerhalb eines Subbausteins sollen die Muster verschiedener Typen **wirklich verschiedene** Aspekte treffen — keine Umformulierungen derselben Frage.
- Der `typ` ist exakt einer der Schlüssel oben. `subbaustein` ist exakt einer der Titel oben.
HARTE STIL-REGELN JE FRAGE:
- Genau EINE Frage, ein Fragezeichen, eine Sache. Kein "und"/"sowie", kein "nenne drei …".
- Maximal 12 Sätze, keine Vorrede, kein Szenario-Aufbau.
- Direkte Ansprache, klares Deutsch.
{extra}
Schreibe das Ergebnis als JSON in die Datei {out_path} (nutze dein Schreib-Werkzeug), GENAU in diesem Format:
{{"muster": [
{{"subbaustein": "<exakter Titel>", "typ": "<Schlüssel>", "frage": "<eine konkrete Frage>"}}
]}}
Gib sonst keinen Text aus.