diff --git a/backend/guide.py b/backend/guide.py index 3ab0e0f..2359b5d 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -22,7 +22,7 @@ from config import ( ) import lesbarkeit from database import list_guides, update_guide -from fsutil import atomic_write_json +from fsutil import atomic_write_json, atomic_write_text from jsonio import read_json_file as _json_datei from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path from pipeline import ( @@ -101,6 +101,35 @@ def guide_slot_dateien(content_path: Path) -> list[Path]: return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path] +def _fertig_path(content_path: Path) -> Path: + return content_path.parent / f"{content_path.stem}.fertig" + + +def guide_fertig_step(content_path: Path) -> int: + """Höchster VOLL abgeschlossener Schritt-Index (Marker je Thema+Format). -1 = keiner. + Existiert die Content-Datei, sind alle Schritte fertig.""" + if content_path.exists(): + return len(GUIDE_STEPS) - 1 + try: + return int(_fertig_path(content_path).read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return -1 + + +def _set_fertig(content_path: Path, step: int) -> None: + """Marker auf `step` setzen — monoton (nur erhöhen), außer beim Re-Run-Reset (force).""" + if step > guide_fertig_step(content_path): + atomic_write_text(_fertig_path(content_path), str(step)) + + +def _reset_fertig(content_path: Path, step: int) -> None: + """Marker hart auf `step` setzen (für Re-Run ab Schritt; step kann sinken).""" + if step < 0: + _fertig_path(content_path).unlink(missing_ok=True) + else: + atomic_write_text(_fertig_path(content_path), str(step)) + + # Slot-Datei-Globs je Schritt (Index = GUIDE_STEPS). Stem-verankert, kollisionsfrei. _STEP_GLOBS = ( ("auswahl-*", "auswahl-mapping-*"), # 0 Auswahl (deterministisch → meist leer) @@ -121,6 +150,7 @@ def _reset_guide_ab_step(content_path: Path, step: int) -> None: for pat in globs: for p in d.glob(f"{stem}.{pat}"): p.unlink(missing_ok=True) + _reset_fertig(content_path, step - 1) # Schritte < step gelten als fertig def _resolve_auswahl(data, entries: dict[int, str], k_min: int, k_max: int) -> list[int] | None: @@ -552,6 +582,7 @@ async def _generate_sections( # Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges). plan = _mit_resten(plan, sel_entries) + _set_fertig(content_path, 1) # Gliederung steht # Chunks festlegen: 1 Chunk = 1 Baustein. So läuft JEDER Section-Schritt (Inhalte, # Inhalts-Prüfung, Writer, Lese-Check) mit genau einem Agent je Baustein. Der Writer @@ -605,6 +636,8 @@ async def _generate_sections( if not inhalt_by_num: await _fail(guide_id, "Keine Inhalte identifiziert") return None + if all(p.exists() for p in inhalt_paths): + _set_fertig(content_path, 2) # Inhalte vollständig inhalt_chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in inhalt_by_num] for chunk in chunks] @@ -670,6 +703,8 @@ async def _generate_sections( if num in probleme_by_num and sec["md"].strip(): inhalt_by_num[num] = sec["md"] + _set_fertig(content_path, 3) # Inhalts-Check durch + # Schritt 4: Schreiben — Writer formuliert die geprüften Inhalte aus (Resume). def inhalte_text(chunk) -> str: nums = [num for ch in chunk for num in ch["nums"] if num in inhalt_by_num] @@ -718,6 +753,8 @@ async def _generate_sections( if not by_num: await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden") return None + if all(p.exists() for p in paths): + _set_fertig(content_path, 4) # Schreiben vollständig # Schritt 3: Lese-Prüfungs-Loop — Check pro Writer-Paket, Fix nur für # beanstandete Sections; Folgerunden prüfen NUR die ersetzten Sections. @@ -821,6 +858,7 @@ async def _generate_sections( _log(topic, f"Lese-Prüfung: 1 Runde — Überarbeitung bleibt ungeprüft") break scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums] + _set_fertig(content_path, 5) # Lese-Prüfung durch # Prüfbar = Format hat Prüfung UND Baustein hat ≥1 relevanten Subbaustein. # Guide ist immer prüfbar (auch ohne Relevanz-Daten, Fallback = alles). diff --git a/backend/routes.py b/backend/routes.py index 3d7c594..2daabec 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -19,7 +19,7 @@ from database import ( 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 lernen import NOETIG, MASTERY, MEISTERN, NIVEAUS, baustein_chat, baustein_diskussion, baustein_element_anlegen, pruefung_bewertung, pruefung_bewertung_schnell, pruefung_frage, pruefung_frage_variante, quiz_generieren, lueckwahl_generieren, lueckentext_generieren, lueckentext_pruefen, score_berechnen, floor_aus_meilensteinen, _zufall_malus -from guide import generate_guide, guide_slot_dateien +from guide import generate_guide, guide_slot_dateien, guide_fertig_step from pipeline import cancel_guide from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand, thema_abgeschlossen from models import ( @@ -474,6 +474,13 @@ async def guide_locks(topic: str): return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *FORMATE)} +@router.get("/guides/steps") +async def guide_steps(topic: str): + """Höchster voll abgeschlossener Schritt-Index je Format (artefakt-basiert, -1 = keiner). + Treibt die klickbaren Schritt-Kugeln (wie die Bausteine-Phasen).""" + return {fmt: guide_fertig_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")} + + @router.get("/guides/{guide_id}", response_model=GuideResponse) async def get_one(guide_id: str): guide = await get_guide(guide_id) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4354d6f..868f389 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,6 @@