This commit is contained in:
team3
2026-06-29 17:11:23 +02:00
parent 87fc9b209a
commit 12f97031e8
7 changed files with 330 additions and 38 deletions

View File

@@ -356,6 +356,17 @@ def _resume_step(topic: str) -> int:
return _step_idx(topic, "Stufen finden")
def _feine_status(topic: str, current: int | None) -> list[dict]:
"""Feiner Teilschritt-Status: je Schritt {label, phase, state}. state aus `current`
(done = idx<current, active = ==, pending = >). Phasen-Label aus PHASEN."""
schritt_phase = {s: label for label, schritte in PHASEN for s in schritte}
out = []
for i, s in enumerate(_bausteine_steps(topic)):
state = "pending" if current is None or current < i else "done" if current > i else "active"
out.append({"label": s, "phase": schritt_phase.get(s, ""), "state": state})
return out
def bausteine_status(topic: str) -> dict:
# Intern feingranular (Resume/Progress); für die Anzeige zu 5 groben Phasen gebündelt.
feine = _bausteine_steps(topic)
@@ -376,6 +387,7 @@ def bausteine_status(topic: str) -> dict:
"error": _bausteine_errors.get(topic),
"partial": partial,
"steps": _phasen_status(topic, current),
"feine_steps": _feine_status(topic, current),
}
@@ -450,6 +462,91 @@ def _reset_ab_phase(topic: str, label: str) -> None:
files["final"].unlink(missing_ok=True)
async def _reset_ab_step(topic: str, step_idx: int) -> None:
"""Feiner Reset AB einem Teilschritt (0-basierter Index in _bausteine_steps). Setzt
pipeline_state + Artefakte + DB ab hier zurück; frühere Schritte bleiben. Inventar-Teilschritte
rekonstruieren den DB-Status aus den Artefakten (Dedup/Filter sicher; Klärung fällt robust auf
Konsolidierung zurück, weil die Klärung umbenennt → Titel-Mismatch wäre fragil)."""
feine = list(_bausteine_steps(topic))
if not (0 <= step_idx < len(feine)):
return
ab = set(feine[step_idx:])
files = _bausteine_files(topic)
arbeit = files["arbeit"]
def gd(pat: str) -> None:
if arbeit.is_dir():
for p in arbeit.glob(pat):
p.unlink(missing_ok=True)
await db.delete_pipeline_state(topic, list(feine[step_idx:]))
# Spätere Artefakte/DB kumulativ ab dem betroffenen Schritt (von hinten nach vorn).
if {"Beispiele", "Karteikarten"} & ab:
files["artefakte"].unlink(missing_ok=True); gd("artefakt-*"); await db.delete_sub_artefakte(topic)
if any(s.startswith("Fragen") for s in ab):
files["frage_muster"].unlink(missing_ok=True); gd("frage-muster-*"); await db.delete_frage_muster(topic)
if "Gliederung" in ab:
files["gliederung"].unlink(missing_ok=True); gd("gliederung-*"); await db.delete_gliederung(topic)
if any(s.startswith("Relevanz") for s in ab):
gd("relevanz-*")
if any(s.startswith("Stufen") for s in ab):
gd("stufe-*")
if any(s.startswith("Fakten") for s in ab):
files["fakten"].unlink(missing_ok=True); gd("fakten-*")
# Sidecar trägt Subbausteine + ihre Felder stufe/relevanz/fakten. Ab Subbausteinen ganz neu;
# sonst nur die Felder der neu-zu-bauenden Phasen strippen — Subbausteine bleiben erhalten.
if any(s.startswith("Subbaustein") for s in ab):
files["sidecar"].unlink(missing_ok=True)
else:
strip = {f for s, f in (("Fakten", "fakten"), ("Stufen", "stufe"), ("Relevanz", "relevanz"))
if any(x.startswith(s) for x in ab)}
if strip:
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):
for f in strip:
s.pop(f, None)
atomic_write_json(files["sidecar"], sc, indent=1)
if any(s.startswith("Subbaustein") for s in ab):
files["sub_roh"].unlink(missing_ok=True); gd("subbaustein-*"); await db.delete_subbausteine(topic)
# --- Inventar (DB-Status kaskadiert) ---
if "Bausteine-Filter" in ab and not ({"Klärung", "Konsolidierung", "Recherche", "Dedup"} & ab):
# Nur Filter neu: degradierte Bausteine zurück auf konsens.
d = _json_datei(arbeit / "inventar-filter.json")
for f in (d.get("fragmente", []) if isinstance(d, dict) else []):
await db.set_baustein_status(topic, _norm_titel(f.get("fragment", "")), "konsens")
gd("inventar-filter*")
if "Dedup" in ab and not ({"Klärung", "Konsolidierung", "Recherche"} & ab):
# Dedup (+Filter) neu: alle im Dedup/Filter verworfenen Bausteine zurück auf konsens.
for art in ("dedup-runde-1.json", "inventar-filter.json"):
d = _json_datei(arbeit / art)
titel = ([t for g in d.get("gruppen", []) for t in g] if isinstance(d, dict) and "gruppen" in d
else [f.get("fragment", "") for f in d.get("fragmente", [])] if isinstance(d, dict) else [])
for t in titel:
await db.set_baustein_status(topic, _norm_titel(t), "konsens")
gd("dedup-*"); gd("inventar-filter*")
if {"Klärung", "Konsolidierung"} & ab and not ({"Recherche"} & ab):
# Klärung/Konsolidierung neu: Inventar-DB leeren (Recherche-Reader bleiben). Klärungs-Rückbau
# wäre wegen Umbenennung fragil → ab Konsolidierung sauber neu aufbauen.
await db.delete_bausteine(topic)
gd("klaerung*"); gd("konsolidierung-*"); gd("dedup-*"); gd("inventar-filter*")
if "Recherche" in ab: # ganzes Inventar wie Phase-Reset
for p_alt in _alle_slot_dateien(files):
p_alt.unlink(missing_ok=True)
files["final"].unlink(missing_ok=True)
await db.delete_bausteine(topic)
async def reset_bausteine_ab_step(topic: str, step_idx: int) -> None:
"""Öffentlich: NUR ab einem Teilschritt zurücksetzen — kein Neu-Generieren. Lässt einen
Teil-Stand zurück (die Schritte ab hier gelten als offen). Läuft eine Generierung → ignorieren."""
if topic in _bausteine_progress:
return
await _reset_ab_step(topic, step_idx)
def _ergaenzung_schema(data):
"""{"bausteine": [{"titel", "beschreibung"}]} → Liste (leer erlaubt) · sonst None."""
if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list):
@@ -2999,7 +3096,7 @@ async def _reset_db_ab_phase(topic: str, label: str) -> None:
await db.delete_pipeline_state(topic, ["Quelle aufbereiten"])
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_phase: int | None = None) -> None:
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_phase: int | None = None, ab_step: int | None = None) -> None:
if topic in _bausteine_progress:
return
_bausteine_progress[topic] = "Wartend…"
@@ -3029,7 +3126,9 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
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:
if ab_step is not None: # feiner Teilschritt-Re-Run (Vorrang vor ab_phase)
await _reset_ab_step(topic, ab_step)
elif ab_phase is not None:
phasen = _phasen(topic)
label = phasen[ab_phase - 1][0] if 1 <= ab_phase <= len(phasen) else "Inventar"
_reset_ab_phase(topic, label)
@@ -3043,7 +3142,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str =
# Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor,
# ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen.
# 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
fertig = ab_phase is None and ab_step is None and final_path.exists() and _sidecar_schema(_json_datei(files["sidecar"])) is not None
if fertig:
for p_alt in _alle_slot_dateien(files):
p_alt.unlink(missing_ok=True)

View File

@@ -31,6 +31,12 @@ class BausteineCreateRequest(BaseModel):
source_type: SourceType = "thema"
source_ort: str = Field(default="", max_length=2000)
ab_phase: int | None = Field(default=None, ge=1, le=9) # Re-Run ab grober Phase (Position in _phasen(topic), 1-based; bis 9: …Gliederung/Fragen/Artefakte); None = Resume/Fortsetzen ohne Löschen
ab_step: int | None = Field(default=None, ge=0) # Re-Run ab feinem Teilschritt (0-basierter Index in _bausteine_steps); hat Vorrang vor ab_phase
class BausteineResetStepRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
ab_step: int = Field(ge=0) # NUR zurücksetzen ab diesem Teilschritt (kein Neu-Generieren)
class BausteineStep(BaseModel):
@@ -38,6 +44,12 @@ class BausteineStep(BaseModel):
state: Literal["done", "active", "pending"]
class BausteineFeinStep(BaseModel):
label: str
phase: str = ""
state: Literal["done", "active", "pending"]
class BausteineStatusResponse(BaseModel):
ready: bool
generating: bool
@@ -45,6 +57,7 @@ class BausteineStatusResponse(BaseModel):
error: str | None = None
partial: bool = False
steps: list[BausteineStep] = []
feine_steps: list[BausteineFeinStep] = []
class ProjectResponse(BaseModel):

View File

@@ -20,7 +20,7 @@ from database import (
delete_topic_pipeline, delete_quelle, get_guide_content, delete_guide_content,
get_sub_artefakte,
)
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, reset_bausteine_ab_step, lade_quelle, lade_uebersicht, subbausteine_titel, subbausteine_frei, lade_frage_muster, lade_frage_muster_frei
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from lernen import 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, huerden_distraktor_block, score_berechnen, floor_aus_score, stufe_aus_score, cap_final, cap_aktuell, freie_ebene, schwellen, punkte_delta, deckel_nachfrage
from guide import generate_guide, guide_slot_dateien, guide_fertig_step, block_pruefen, block_uebernehmen, content_fuer_ebene
@@ -29,7 +29,7 @@ from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lern
from models import (
GuideCreateRequest, GuideResponse,
TopicCreateRequest,
BausteineCreateRequest, BausteineStatusResponse,
BausteineCreateRequest, BausteineResetStepRequest, BausteineStatusResponse,
GuideChatRequest, GuideChatResponse,
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
@@ -164,7 +164,7 @@ async def create_bausteine(req: BausteineCreateRequest):
raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.")
qp.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()})
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase))
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step))
return {"ok": True}
@@ -182,6 +182,15 @@ async def remove_bausteine(topic: str):
return {"ok": True}
@router.post("/bausteine/reset-step")
async def reset_bausteine_step(req: BausteineResetStepRequest):
topic = req.topic.strip()
if bausteine_status(topic)["generating"]:
return {"ok": True, "status": "generating"} # nicht in laufende Generierung eingreifen
await reset_bausteine_ab_step(topic, req.ab_step)
return {"ok": True}
@router.delete("/bausteine/fortschritt")
async def reset_baustein_fortschritt(topic: str, baustein: str):
"""Lern-Fortschritt EINES Bausteins auf null (Score/Streak/Flags/offene Frage)."""