diff --git a/backend/auto_loop.py b/backend/auto_loop.py new file mode 100644 index 0000000..bd05f13 --- /dev/null +++ b/backend/auto_loop.py @@ -0,0 +1,39 @@ +"""Auto-Repair-Loop einer Ebene: QA → Befunde beheben, bis 100 % (10.0/10), +Stillstand (Repair verbessert die Note nicht mehr) oder max_iter erreicht ist. + +Reine Primitive — keine Kanban-/DB-Kopplung. Der Aufrufer übergibt zwei Coroutinen: +`messen()` liefert die aktuelle QA-Note (0–10), `reparieren()` behebt die Befunde. +So bleibt der Loop pro Ebene (Inventar/Artefakte/Guide) identisch testbar. +""" +import logging + +log = logging.getLogger("creator.auto_loop") + +VOLL = 10.0 # 100 % = 10.0/10 +MAX_ITER = 10 # Sicherheitslimit: mehr Runden ⇒ etwas im System stimmt nicht + + +async def auto_repair_loop(ebene: str, erst_note: float, reparieren, max_iter: int = MAX_ITER) -> dict: + """Loop QA→Repair für eine Ebene. + + erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (0–10). + reparieren() -> float: behebt die Befunde UND gibt die neu gemessene Note zurück + (die Repair-Bausteine messen ohnehin am Ende — keine Doppelmessung). + + Stopp bei: Note == VOLL (fertig), Note verbessert sich nicht mehr (stillstand), + oder max_iter Runden (limit). Returns {ebene, note, runden, grund} mit + grund ∈ {"fertig","stillstand","limit"} — der Aufrufer nutzt grund für die + Fortschritts-/Systemfehler-Meldung. + """ + if erst_note >= VOLL: + return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"} + note = erst_note + for runde in range(1, max_iter + 1): + neu = await reparieren() + log.info("[%s] Auto-Loop Runde %d: %.1f → %.1f", ebene, runde, note, neu) + if neu >= VOLL: + return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"} + if neu <= note: # keine Verbesserung ⇒ weitere Runden zwecklos (kein Repair-Pfad) + return {"ebene": ebene, "note": neu, "runden": runde, "grund": "stillstand"} + note = neu + return {"ebene": ebene, "note": note, "runden": max_iter, "grund": "limit"} diff --git a/backend/blocks.py b/backend/blocks.py index b35963c..07abcd0 100644 --- a/backend/blocks.py +++ b/backend/blocks.py @@ -1613,11 +1613,60 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None: +async def _guide_ebene(topic: str, instructions: str, provider: str, is_cancelled, set_p) -> None: + """Guide-Ebene der Auto-Sequenz: generieren → QA → „Befunde beheben"-Loop bis + 100 %/Stillstand/10×. Fail-open: Fehler überspringen den Guide, blockieren nichts.""" + import uuid + from datetime import datetime, timezone + try: + import guide, guide_qa, guide_board + from auto_loop import auto_repair_loop + fmt = "Guide" + now = datetime.now(timezone.utc).isoformat() + guide_id = str(uuid.uuid4()) + await db.create_guide({"id": guide_id, "topic": topic, "format": fmt, + "instructions": instructions, "status": "queued", + "progress": None, "created_at": now, "updated_at": now}) + set_p("Guide wird generiert…") + await guide.generate_guide(guide_id, topic, fmt, instructions, provider) + if is_cancelled(): + return + + async def _messen(): + rep = await guide_qa.guide_qa_report(topic, llm=True) + return float(rep["note_guide"]) if rep else 10.0 + + erst = await _messen() + + async def _reparieren(): + set_p("Befunde beheben (Guide)…") + await guide_board.repair_karten(topic, fmt) # befundtragende Karten → pruefer + await guide.generate_guide(topic=topic, format_name=fmt, guide_id=guide_id, + instructions=instructions, provider=provider) # resumt offene Karten + return await _messen() + + res = await auto_repair_loop("Guide", erst, _reparieren) + if res["grund"] != "fertig": + msg = (f"Guide bleibt bei {round(res['note'] * 10)} % ({res['grund']}, " + f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad (Systemfehler)") + _blocks_errors[topic] = msg + set_p(msg) + else: + set_p(f"Guide: 100 % nach {res['runden']} Runde(n)") + except Exception as e: + log.exception("[%s] Guide-Ebene fehlgeschlagen", topic) + _blocks_errors[topic] = f"Guide-Ebene: {str(e)[:500]}" + + async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, - research: bool = True, qa_force: bool = False) -> None: + research: bool = True, qa_force: bool = False, + auto_inventory: bool = True, auto_artefacts: bool = True, + auto_guide: bool = True) -> None: """Kanban entry point: source prep, then both boards (inventory + artefacts) until quiescence. research=False = Continue (drain the existing queue, no new search). - A run on a finished topic ADDS research (live extension) — full rebuild = DELETE /blocks.""" + A run on a finished topic ADDS research (live extension) — full rebuild = DELETE /blocks. + auto_* per level: after each level's QA, loop „Befunde beheben" until 100 % / stall / 10×; + if off, stop after that level's QA (control point).""" if topic in _blocks_progress: return _blocks_progress[topic] = "Warten…" @@ -1646,10 +1695,16 @@ async def generate_blocks(topic: str, instructions: str = "", provider: str = DE _blocks_errors[topic] = "Cancelled — progress is preserved" return import board_inventory # lazy: the boards import blocks + status: dict = {} ok = await board_inventory.run_boards(ctx, set_p, files, q, folder, instructions, - research=research, qa_force=qa_force) + research=research, qa_force=qa_force, + auto_inventory=auto_inventory, auto_artefacts=auto_artefacts, + status_out=status) if not ok and is_cancelled(): _blocks_errors[topic] = "Cancelled — progress is preserved" + elif ok and auto_guide and status.get("weiter") and not is_cancelled(): + # Auto-Sequenz: Inventar + Artefakte liefen sauber durch → Guide-Ebene. + await _guide_ebene(topic, instructions, provider, is_cancelled, set_p) except Exception as e: log.exception("[%s] Blocks generation failed", topic) _blocks_errors[topic] = str(e)[:2000] diff --git a/backend/board_inventory.py b/backend/board_inventory.py index 8824bac..d69f5f0 100644 --- a/backend/board_inventory.py +++ b/backend/board_inventory.py @@ -1673,14 +1673,22 @@ async def _preload_state(flow: Flow): async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str, - research: bool = True, artefacts: bool = True, qa_force: bool = False) -> bool: + research: bool = True, artefacts: bool = True, qa_force: bool = False, + auto_inventory: bool = True, auto_artefacts: bool = True, + status_out: dict | None = None) -> bool: """Run the inventory board (plus board 2 „Artefakte") until quiescence. research=False = Continue: drain the existing queue, search nothing new. - qa_force=True overrides a failed QA gate (user clicked „Trotzdem fortsetzen").""" + qa_force=True overrides a failed QA gate (user clicked „Trotzdem fortsetzen"). + auto_inventory: after the inventory QA, loop repair until 100 %/stall/10×, then open the + board-2 gate; off = pause after the QA. auto_artefacts: same loop after board 2. + status_out (optional): filled with {"weiter": bool, "note_artefakte": float|None} — the + orchestrator uses „weiter" to decide whether the guide level may start next.""" topic = ctx.topic flow = Flow(topic, files["arbeit"]) flow.state["instructions"] = instructions flow.state["qa_force"] = qa_force + flow.state["auto_inventory"] = auto_inventory + flow.state["auto_artefacts"] = auto_artefacts run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-{uuid.uuid4().hex[:4]}" flow.state["run_id"] = run_id flow.state["run_started"] = datetime.now(timezone.utc).isoformat() @@ -1744,11 +1752,21 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr from blocks import _blocks_errors # lazy: Import-Zyklus vermeiden _blocks_errors[topic] = flow.state.get("infra_error") \ or "Pausiert: API-Ratelimit (429) — «Fortsetzen», sobald Kontingent zurück" + if status_out is not None: + status_out["weiter"] = False return True # kein Fehler: Karten warten resümierbar in ihrer Spalte if flow.state.get("qa_paused"): + if status_out is not None: + status_out["weiter"] = False # Inventar pausiert → Board 2 lief nicht return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in generate + # Board 2 lief durch → Artefakt-Ebene: QA + (Auto) „Befunde beheben"-Loop. + if artefacts and not ctx.is_cancelled(): + await _artefakt_auto_loop(ctx, flow, set_p) await _write_final(topic, files) await _write_run_summary(topic, flow) + if status_out is not None: + status_out["weiter"] = not (ctx.is_cancelled() or flow.state.get("artefakt_stopp")) + status_out["note_artefakte"] = flow.state.get("note_artefakte") return True finally: # erst NACH der Abschluss-QA leeren: deren Judge-Events gehören zum Lauf — @@ -1781,17 +1799,49 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_ report = await qa.qa_report(topic, llm=QA_GATE_LLM) note = float(report["note"]) if report else 10.0 flow.state["qa_note"] = note - if report: - try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen - await qa.write_report(report) - except Exception: - log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic) - if note >= QA_GATE_NOTE: - _log(topic, f"QA-Gate: Note {note} ≥ {QA_GATE_NOTE} — Board 2 startet") + if not report: + flow.state["qa_ok"] = True # nichts messbar → fail-open (QA ist Helfer, nicht Jailer) + flow.wake.set() + return + try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen + await qa.write_report(report) + except Exception: + log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic) + + if not flow.state.get("auto_inventory", True): + # Auto aus: nach der QA anhalten (Kontrollpunkt), egal welche Note. + _log(topic, f"Inventar-QA {round(note * 10)} % — Auto aus, pausiert") + set_p(f"Inventar-QA {round(note * 10)} % — Auto aus, «Fortsetzen» startet Board 2") + flow.state["qa_paused"] = True + flow.stop = True + flow.wake.set() + return + + # Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×. + from auto_loop import auto_repair_loop + import repair as _repair + + async def _reparieren(): + set_p("Befunde beheben (Inventar)…") + r = await _repair.repair_befunde(topic, "inventory") # behebt + misst neu + n = float(r.get("note", 10.0)) + flow.state["qa_note"] = n + return n + + res = await auto_repair_loop("Inventar", note, _reparieren) + end_note = res["note"] + if res["grund"] == "fertig": + _log(topic, f"Inventar: 100 % nach {res['runden']} Runde(n) — Board 2 startet") flow.state["qa_ok"] = True else: - _log(topic, f"QA-Gate: Note {note} < {QA_GATE_NOTE} — pausiert (Continue erzwingt)") - set_p(f"QA-Note {note} < {QA_GATE_NOTE} — pausiert") + # Stillstand/Limit ohne 100 % → Abbruch: Repair hat keinen Pfad für die Restbefunde. + msg = (f"Inventar bleibt bei {round(end_note * 10)} % ({res['grund']}, " + f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad für die " + f"Restbefunde (Systemfehler)") + _log(topic, msg) + set_p(msg) + from blocks import _blocks_errors + _blocks_errors[topic] = msg flow.state["qa_paused"] = True flow.stop = True flow.wake.set() @@ -1803,6 +1853,55 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_ flow.wake.set() +async def _artefakt_auto_loop(ctx: GenContext, flow: Flow, set_p): + """Nach Board 2: Artefakt-QA → (Auto) „Befunde beheben"-Loop bis 100 %/Stillstand/10×. + Auto aus = nur messen (Kontrollpunkt). Setzt flow.state[„note_artefakte"] und bei + Stopp flow.state[„artefakt_stopp"] (der Guide startet dann nicht automatisch). + Fail-open: ein QA-Fehler überspringt die Ebene (QA ist Helfer, nicht Jailer).""" + topic = ctx.topic + try: + import qa + rep = await qa.qa_report(topic, llm=QA_GATE_LLM) + na = rep.get("note_artefakte") if rep else None + if na is None: # Board 2 hat (noch) nichts geliefert → nichts zu messen + return + na = float(na) + flow.state["note_artefakte"] = na + try: + await qa.write_report(rep) + except Exception: + log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic) + if na >= 10.0: + return + if not flow.state.get("auto_artefacts", True): + _log(topic, f"Artefakt-QA {round(na * 10)} % — Auto aus, Guide startet nicht automatisch") + set_p(f"Artefakt-QA {round(na * 10)} % — Auto aus") + flow.state["artefakt_stopp"] = True + return + from auto_loop import auto_repair_loop + import repair as _repair + + async def _reparieren(): + set_p("Befunde beheben (Artefakte)…") + r = await _repair.repair_befunde(topic, "artefacts") + return float(r.get("note_artefakte") or 10.0) + + res = await auto_repair_loop("Artefakte", na, _reparieren) + flow.state["note_artefakte"] = res["note"] + if res["grund"] != "fertig": + msg = (f"Artefakte bleiben bei {round(res['note'] * 10)} % ({res['grund']}, " + f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad (Systemfehler)") + _log(topic, msg) + set_p(msg) + from blocks import _blocks_errors + _blocks_errors[topic] = msg + flow.state["artefakt_stopp"] = True + else: + _log(topic, f"Artefakte: 100 % nach {res['runden']} Runde(n)") + except Exception: + log.exception("[%s] Artefakt-Auto-Loop fehlgeschlagen — übersprungen (fail-open)", topic) + + async def _write_run_summary(topic: str, flow: Flow): """lauf-summary.json: the per-run numbers block QA diffs against. Never fatal.""" try: @@ -2034,23 +2133,6 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict return moved -async def restart_artefact_card(topic: str, card_id: str) -> bool: - """Restart ONE artefacts card from `generate` — wipes only ITS derived DB rows - (per-block work-dir slots overwrite themselves; finalize re-upserts later). - Only call while nothing is generating (the route guards). → False if unknown.""" - card = await db.kanban_get_card(topic, "artefacts", card_id) - if card is None or card["kind"] != "ablock": - return False - await db.delete_subblocks(topic, card_id) - await db.delete_question_pattern(topic, card_id) - await db.delete_sub_artefakte(topic, card_id) - p = {k: v for k, v in card["payload"].items() if k in ("title", "description")} - await db.kanban_set_payload(topic, "artefacts", card_id, p) - await db.kanban_advance(topic, "artefacts", card_id, "generate") - await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart") - return True - - async def requeue_dead(topic: str) -> int: """Dead-letter → restart stage by card kind (fresh retries). → requeued count.""" n = 0 diff --git a/backend/guide.py b/backend/guide.py index b5ede96..e9d6627 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -230,7 +230,7 @@ async def reconcile_guides() -> None: await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now) -async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None: +async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None: async with _semaphore: now = datetime.now(timezone.utc).isoformat() await update_guide(guide_id, status="generating", progress="Starting…", updated_at=now) @@ -247,12 +247,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio await asyncio.to_thread(_convert_pdfs, project) import guide_board # lazy — guide_board imports helpers from this module - # Re-run from stage: cards from `ab_step` onward back to that column. - # A FINISHED guide without ab_step → complete fresh start (board + slots wiped). - # Otherwise cards are leftovers of an abort/error → resume at their stored stage. - if ab_step is not None: - await guide_board.reset_from_stage(topic, format_name, ab_step) - elif content_path.exists(): + # A FINISHED guide → complete fresh start (board + slots wiped). Otherwise the cards + # are leftovers of an abort/error/repair → resume at their stored stage. + if content_path.exists(): counts = await guide_stage_counts(topic, format_name) if not counts or set(counts) == {"done"}: await delete_guide_board(topic, format_name) diff --git a/backend/guide_board.py b/backend/guide_board.py index dcc749e..0c34f67 100644 --- a/backend/guide_board.py +++ b/backend/guide_board.py @@ -782,29 +782,3 @@ async def repair_karten(topic: str, format_name: str) -> list[str]: return betroffen -async def reset_card(topic: str, format_name: str, block_norm: str, ab_stage: int) -> bool: - """Reset ONE guide card to a stage (single-card variant of reset_from_stage): - fields re-zeroed, md only wiped for writer(2) and earlier, lernziele only for 0.""" - ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1)) - cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)} - if block_norm not in cards: - return False - fields = dict(stage=GUIDE_STAGES[ab_stage], status="open", writer_rounds=0, gate_info="") - if ab_stage <= 2: - fields["md"] = "" - if ab_stage == 0: - await db.delete_lernziele(topic, block_norm) - await db.set_guide_card(topic, format_name, block_norm, **fields) - return True - - -async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int: - """Cards in stages ≥ ab_stage (incl. done) back to GUIDE_STAGES[ab_stage].""" - ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1)) - target = GUIDE_STAGES[ab_stage] - stages = list(GUIDE_STAGES[ab_stage:]) + ["done"] - if ab_stage == 0: - await db.delete_lernziele_all(topic) - moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target, - clear_md=ab_stage <= 2) - return moved diff --git a/backend/models.py b/backend/models.py index 26f5cca..8c386d6 100644 --- a/backend/models.py +++ b/backend/models.py @@ -19,13 +19,6 @@ class GuideCreateRequest(BaseModel): format: FormatType instructions: str = Field(default="", max_length=2000) provider: ProviderType = DEFAULT_PROVIDER - ab_step: int | None = Field(default=None, ge=0, le=5) # re-run from board stage (0 lernziele … 5 lesbarkeit); None = full/resume - - -class GuideBoardResetRequest(BaseModel): - topic: str = Field(min_length=1, max_length=100) - format: FormatType = "Guide" - ab_stage: int = Field(ge=0, le=5) # reset cards back to this board stage (no generation) class TopicCreateRequest(BaseModel): @@ -39,6 +32,7 @@ class QaRunRequest(BaseModel): class RepairRequest(BaseModel): topic: str = Field(min_length=1, max_length=100) + ebene: str | None = None # None = alle Fixes, "inventory" oder "artefacts" filtert je Ebene class BlocksCreateRequest(BaseModel): @@ -49,11 +43,11 @@ class BlocksCreateRequest(BaseModel): source_location: str = Field(default="", max_length=20000) # link mode: one URL per line research: bool = True # False = Continue: drain the existing kanban queue, no new search qa_force: bool = False # True = übersteuert ein pausierendes QA-Gate („Trotzdem fortsetzen") - - -class BlocksCardRestartRequest(BaseModel): - topic: str = Field(min_length=1) - card_id: str = Field(min_length=1, max_length=200) + # „Auto" pro Ebene: nach der QA im Loop „Befunde beheben", bis 100 % / Stillstand / 10×. + # Aus = nach der QA stoppen (Kontrollpunkt). Default an. + auto_inventory: bool = True + auto_artefacts: bool = True + auto_guide: bool = True class GuideFormatRequest(BaseModel): @@ -68,17 +62,6 @@ class PracticeAnswerRequest(BaseModel): correct: bool -class GuideCardResetRequest(BaseModel): - topic: str = Field(min_length=1) - format: str = Field(min_length=1) - block_norm: str = Field(min_length=1, max_length=200) - ab_stage: int = Field(ge=0, le=5) - - -class BlocksResetStageRequest(BaseModel): - topic: str = Field(min_length=1, max_length=100) - board: Literal["inventory", "artefacts"] - stage: str = Field(min_length=1, max_length=40) # kanban column to reset back to class BlocksStatusResponse(BaseModel): diff --git a/backend/repair.py b/backend/repair.py index 21d2dd3..0facc5e 100644 --- a/backend/repair.py +++ b/backend/repair.py @@ -20,7 +20,9 @@ from textkit import _norm_title, _title, clean_title, parse_facts log = logging.getLogger("creator.repair") -async def repair_befunde(topic: str) -> dict: +async def repair_befunde(topic: str, ebene: str | None = None) -> dict: + """ebene=None → alle Fixes; „inventory" → nur Inventar (Hygiene/Merges/Fremd); + „artefacts" → nur Artefakte (Sub-Merges/Waisen). Misst am Ende IMMER beide Noten neu.""" reports = qa.report_paths(topic) report = _json_file(reports[-1]) if reports else None if not report: @@ -28,21 +30,26 @@ async def repair_befunde(topic: str) -> dict: files = _blocks_files(topic) cards = await db.kanban_cards(topic, board="inventory", stage="done_block") by_norm = {_norm_title(c["payload"].get("title", "")): c for c in cards} + inv = ebene in (None, "inventory") + art = ebene in (None, "artefacts") - hygiene = await _fix_hygiene(topic, report, by_norm, files) - merges = await _merge_dubletten(topic, report, by_norm, files) - sub_merges, frei_subs = await _merge_sub_dubletten(topic, report, files) - entfernt, frei_bloecke = await _entferne_fremd_unecht(topic, report, by_norm, files) - aufgeraeumt = await _raeume_waisen(topic) + hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else [] + merges = await _merge_dubletten(topic, report, by_norm, files) if inv else [] + sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], []) + entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], []) + aufgeraeumt = (await _raeume_waisen(topic)) if art else 0 # llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report # blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln neu = await qa.qa_report(topic, llm=True) if neu: await qa.write_report(neu) + na = neu.get("note_artefakte") if neu else None return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt, "aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke, - "braucht_research": len(report.get("luecken", []))} + "braucht_research": len(report.get("luecken", [])), + "note": float(neu["note"]) if neu else 10.0, # neu gemessene Inventar-Note + "note_artefakte": float(na) if na is not None else None} # None solange Board 2 leer async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]: diff --git a/backend/routes.py b/backend/routes.py index 830ed2c..60ea38c 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -24,7 +24,7 @@ from database import ( ) from textkit import _norm_title from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files -from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card +from board_inventory import add_research_agent, board_snapshot, requeue_dead from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup, leitner_step, PRACTICE_NEW_PER_SESSION from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level from pipeline import cancel_guide @@ -32,9 +32,9 @@ from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learns from models import ( GuideCreateRequest, GuideResponse, TopicCreateRequest, - BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse, QaRunRequest, RepairRequest, - GuideCardResetRequest, GuideFormatRequest, - GuideBoardResetRequest, GuideChatRequest, GuideChatResponse, + BlocksCreateRequest, BlocksStatusResponse, QaRunRequest, RepairRequest, + GuideFormatRequest, + GuideChatRequest, GuideChatResponse, ProviderInfo, FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview, BlockChatRequest, BlockChatResponse, @@ -166,7 +166,9 @@ async def create_blocks(req: BlocksCreateRequest): qp.parent.mkdir(parents=True, exist_ok=True) atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()}) asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider, - research=req.research, qa_force=req.qa_force)) + research=req.research, qa_force=req.qa_force, + auto_inventory=req.auto_inventory, auto_artefacts=req.auto_artefacts, + auto_guide=req.auto_guide)) return {"ok": True} @@ -230,7 +232,7 @@ async def run_repair_route(req: RepairRequest): _repair_laeuft.add(topic) try: import repair - res = await repair.repair_befunde(topic) + res = await repair.repair_befunde(topic, req.ebene) if "fehler" in res: raise HTTPException(status_code=404, detail=res["fehler"]) return res @@ -249,43 +251,11 @@ async def add_blocks_research(topic: str, provider: str = DEFAULT_PROVIDER): return {"ok": True, "attached": False} -@router.post("/blocks/reset-stage") -async def reset_blocks_stage(req: BlocksResetStageRequest): - """Reset cards from a column onward back to that column (no regeneration).""" - topic = req.topic.strip() - if (await blocks_status(topic))["generating"]: - return {"ok": True, "status": "generating"} # don't interfere with a running generation - moved = await reset_board_from_stage(topic, req.board, req.stage, _blocks_files(topic)) - return {"ok": True, "moved": moved} - - @router.post("/blocks/requeue-dead") async def requeue_blocks_dead(topic: str): return {"ok": True, "requeued": await requeue_dead(topic)} -@router.post("/blocks/card-restart") -async def blocks_card_restart(req: BlocksCardRestartRequest): - if (await blocks_status(req.topic))["generating"]: - return {"ok": True, "status": "generating"} - if not await restart_artefact_card(req.topic, req.card_id): - raise HTTPException(404, "Card not found") - return {"ok": True} - - -@router.post("/guides/board/card-reset") -async def guide_card_reset(req: GuideCardResetRequest): - running = [g for g in await list_guides() - if g["topic"] == req.topic and g["format"] == req.format - and g["status"] in ("queued", "generating")] - if running: - return {"ok": True, "status": "generating"} - from guide_board import reset_card - if not await reset_card(req.topic, req.format, req.block_norm, req.ab_stage): - raise HTTPException(404, "Card not found") - return {"ok": True} - - @router.post("/blocks/cancel") async def cancel_blocks_route(topic: str): if not cancel_blocks(topic): @@ -301,6 +271,18 @@ async def remove_blocks(topic: str): return {"ok": True} +@router.delete("/blocks/artefacts") +async def remove_artefacts(topic: str): + """Artefakte-Ebene leeren: Board-2-Daten (Subs/Facts/Flashcards) weg, Inventar bleibt. + Karten gehen zurück auf `generate` — via „Artefakte generieren" (Fortsetzen) neu baubar.""" + topic = topic.strip() + if (await blocks_status(topic))["generating"]: + return {"ok": True, "status": "generating"} + from board_inventory import reset_board_from_stage + moved = await reset_board_from_stage(topic, "artefacts", "generate", _blocks_files(topic)) + return {"ok": True, "moved": moved} + + @router.delete("/blocks/progress") async def reset_block_progress(topic: str, block: str): """Reset learning progress of ONE block to zero (score/streak/flags/open question).""" @@ -692,7 +674,7 @@ async def create(req: GuideCreateRequest): "updated_at": now, } await create_guide(guide) - asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider, ab_step=req.ab_step)) + asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider)) return guide @@ -719,7 +701,7 @@ async def get_guide_board(topic: str, format: str = "Guide"): @router.post("/guides/board/repair") -async def repair_guide_board(req: GuideBoardResetRequest): +async def repair_guide_board(req: GuideFormatRequest): """Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`, dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu.""" import guide_board @@ -739,19 +721,6 @@ async def repair_guide_board(req: GuideBoardResetRequest): return {"ok": True, "betroffen": betroffen} -@router.post("/guides/board/reset") -async def reset_guide_board(req: GuideBoardResetRequest): - """Reset cards from a stage onward — without generation (pendant to blocks reset-stage).""" - import guide_board - topic = req.topic.strip() - guide = next((g for g in await list_guides() - if g["topic"] == topic and g["format"] == req.format), None) - if guide and guide["status"] in ("queued", "generating"): - return {"ok": True, "status": "generating"} - moved = await guide_board.reset_from_stage(topic, req.format, req.ab_stage) - return {"ok": True, "moved": moved} - - @router.get("/guides/{guide_id}/content") async def guide_content(guide_id: str, level: int = 4): """Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this diff --git a/backend/tests/test_auto_loop.py b/backend/tests/test_auto_loop.py new file mode 100644 index 0000000..a5c6206 --- /dev/null +++ b/backend/tests/test_auto_loop.py @@ -0,0 +1,40 @@ +"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand, Limit.""" +import auto_loop + + +async def test_fertig_ohne_repair(): + async def rep(): + raise AssertionError("darf bei 100 % nie reparieren") + + r = await auto_loop.auto_repair_loop("x", 10.0, rep) + assert r == {"ebene": "x", "note": 10.0, "runden": 0, "grund": "fertig"} + + +async def test_verbessert_bis_voll(): + noten = iter([9.0, 10.0]) + + async def rep(): + return next(noten) + + r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0 + assert r["grund"] == "fertig" and r["runden"] == 2 + + +async def test_stillstand(): + async def rep(): + return 8.0 # Repair bewegt nichts + + r = await auto_loop.auto_repair_loop("art", 8.0, rep) + assert r["grund"] == "stillstand" and r["runden"] == 1 + assert r["note"] == 8.0 + + +async def test_limit(): + stand = {"n": 1.0} + + async def rep(): + stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0 + return stand["n"] + + r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10) + assert r["grund"] == "limit" and r["runden"] == 10 diff --git a/backend/tests/test_board_inventory.py b/backend/tests/test_board_inventory.py index 3a2ed49..17e7c3e 100644 --- a/backend/tests/test_board_inventory.py +++ b/backend/tests/test_board_inventory.py @@ -960,15 +960,22 @@ async def _run_flow(ctx, files, timeout=30, **kw): timeout=timeout) -async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch): - """Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate.""" +async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch): + """Auto an, aber Repair schließt die Befunde nicht → Stillstand → Flow pausiert, + Board-2-Karten warten in generate.""" import qa as qa_mod + import repair as repair_mod db, ctx, files = board_env async def bad_qa(topic, llm=False): return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} + + async def stuck_repair(topic, ebene=None): + return {"note": 5.0} # bewegt nichts + monkeypatch.setattr(qa_mod, "qa_report", bad_qa) monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) + monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair) monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05) await _seed(db) ok = await _run_flow(ctx, files) @@ -978,6 +985,51 @@ async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch): assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 +async def test_qa_gate_auto_reaches_100(board_env, monkeypatch): + """Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch.""" + import qa as qa_mod + import repair as repair_mod + db, ctx, files = board_env + + async def bad_qa(topic, llm=False): + return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} + + async def good_repair(topic, ebene=None): + return {"note": 10.0} + + monkeypatch.setattr(qa_mod, "qa_report", bad_qa) + monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) + monkeypatch.setattr(repair_mod, "repair_befunde", good_repair) + monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05) + await _seed(db) + ok = await _run_flow(ctx, files) + assert ok + assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5 + + +async def test_qa_gate_auto_off_pauses(board_env, monkeypatch): + """Auto aus → nach der QA anhalten (Kontrollpunkt), egal welche Note; kein Repair.""" + import qa as qa_mod + import repair as repair_mod + db, ctx, files = board_env + + async def good_qa(topic, llm=False): + return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} + + async def no_repair(topic, ebene=None): + raise AssertionError("Auto aus darf nicht reparieren") + + monkeypatch.setattr(qa_mod, "qa_report", good_qa) + monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) + monkeypatch.setattr(repair_mod, "repair_befunde", no_repair) + monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05) + await _seed(db) + ok = await _run_flow(ctx, files, auto_inventory=False) + assert ok + warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate") + assert len(warten) == 4 # Board 2 wartet trotz Note 10.0 (Auto aus = Kontrollpunkt) + + async def test_qa_gate_force_overrides(board_env, monkeypatch): """qa_force=True („Trotzdem fortsetzen") übersteuert die schlechte Note.""" import qa as qa_mod @@ -994,7 +1046,7 @@ async def test_qa_gate_force_overrides(board_env, monkeypatch): ok = await _run_flow(ctx, files, qa_force=True) assert ok assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5 - assert called["n"] <= 1 # Gate-Lauf übersprungen; höchstens Abschluss-QA + assert called["n"] <= 2 # Gate-QA übersprungen (force); nur Artefakt-Loop + Abschluss-QA async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch): @@ -1011,7 +1063,7 @@ async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch): await _seed(db) ok = await _run_flow(ctx, files) assert ok - assert called["n"] == 1 # kein Gate-Lauf; nur die Abschluss-QA der Lauf-Summary + assert called["n"] == 2 # kein Gate-Lauf; Artefakt-Loop-QA + Abschluss-QA der Lauf-Summary async def test_qa_gate_fail_open(board_env, monkeypatch): diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index b41032d..411097f 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -146,41 +146,6 @@ def test_env_file_wins(tmp_path, monkeypatch): assert os.environ["X_CREATOR_TESTKEY"] == "neu" -async def test_restart_artefact_card_wipes_only_that_block(testdb): - import board_inventory as bi - db = testdb - for norm in ("alpha", "beta"): - await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact", - {"title": norm.title(), "raw": {norm: ["S"]}, "facts": {}}) - await db.upsert_subblock(TOPIC, norm, "s1", norm.title(), "Sub Eins") - await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?") - await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}") - assert await bi.restart_artefact_card(TOPIC, "alpha") is True - assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate" - assert await db.list_subblocks(TOPIC, "alpha") == [] - assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched - assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False - - -async def test_guide_reset_card_single(testdb): - import guide_board as gb - db = testdb - for n in ("alpha", "beta"): - await db.upsert_guide_card(TOPIC, "Guide", n, n.title()) - await db.set_guide_card(TOPIC, "Guide", n, stage="done", status="ok", - writer_rounds=2, md="# SECTION Text", gate_info="x") - await db.put_lernziel(TOPIC, n, "z1", "Ziel eins") - assert await gb.reset_card(TOPIC, "Guide", "alpha", 0) is True - cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")} - assert cards["alpha"]["stage"] == "lernziele" and cards["alpha"]["md"] == "" and cards["alpha"]["writer_rounds"] == 0 - assert cards["beta"]["stage"] == "done" and cards["beta"]["md"] # untouched - assert await db.list_lernziele(TOPIC) and all(z["block_norm"] != "alpha" for z in await db.list_lernziele(TOPIC)) - # ab_stage 3 (pruefer) behält md - assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True - cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")} - assert cards["beta"]["stage"] == "pruefer" and cards["beta"]["md"] - - async def test_completeness_route(testdb, tmp_path, monkeypatch): import routes, paths db = testdb diff --git a/backend/tests/test_guide_board.py b/backend/tests/test_guide_board.py index 673273f..c772e59 100644 --- a/backend/tests/test_guide_board.py +++ b/backend/tests/test_guide_board.py @@ -58,25 +58,6 @@ def test_auftraege_schwelle_und_kritisch(): assert len(z) == 2 and k is True -async def test_reset_from_stage(testdb): - db = testdb - await db.upsert_guide_card(TOPIC, FMT, "a", "A") - await db.upsert_guide_card(TOPIC, FMT, "b", "B") - await db.set_guide_card(TOPIC, FMT, "a", stage="done", md="text", writer_rounds=2) - await db.set_guide_card(TOPIC, FMT, "b", stage="pruefer", md="text") - await db.put_lernziel(TOPIC, "a", "z1", "Ziel") - # reset ab writer (idx 2): beide Karten zurück, md geleert, Ziele bleiben - moved = await gb.reset_from_stage(TOPIC, FMT, 2) - assert moved == 2 - cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)} - assert cards["a"]["stage"] == "writer" and cards["a"]["md"] == "" and cards["a"]["writer_rounds"] == 0 - assert cards["b"]["stage"] == "writer" - assert await db.list_lernziele(TOPIC, "a") - # reset ab lernziele (idx 0): Ziele weg - await gb.reset_from_stage(TOPIC, FMT, 0) - assert not await db.list_lernziele(TOPIC, "a") - assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "lernziele" - async def test_done_step(testdb): db = testdb diff --git a/backend/tests/test_guide_sequenz.py b/backend/tests/test_guide_sequenz.py new file mode 100644 index 0000000..7000a9a --- /dev/null +++ b/backend/tests/test_guide_sequenz.py @@ -0,0 +1,48 @@ +"""Guide-Ebene der Auto-Sequenz: generieren → QA → Repair-Loop bis 100 %/Stillstand.""" +import blocks +import guide +import guide_qa +import guide_board + + +def _patch(monkeypatch, noten, calls): + async def fake_gen(*a, **k): + calls["gen"] += 1 + + async def fake_rep(topic, fmt): + calls["rep"] += 1 + return [] + + async def fake_qa(topic, llm=False): + return {"note_guide": noten.pop(0) if len(noten) > 1 else noten[0]} + + monkeypatch.setattr(guide, "generate_guide", fake_gen) + monkeypatch.setattr(guide_board, "repair_karten", fake_rep) + monkeypatch.setattr(guide_qa, "guide_qa_report", fake_qa) + + +async def test_guide_fertig_ohne_repair(testdb, monkeypatch): + blocks._blocks_errors.pop("G1", None) + calls = {"gen": 0, "rep": 0} + _patch(monkeypatch, [10.0], calls) + await blocks._guide_ebene("G1", "", "claude", lambda: False, lambda *a, **k: None) + assert calls["gen"] == 1 and calls["rep"] == 0 # nur initial gebaut + assert "G1" not in blocks._blocks_errors + + +async def test_guide_repariert_bis_100(testdb, monkeypatch): + blocks._blocks_errors.pop("G2", None) + calls = {"gen": 0, "rep": 0} + _patch(monkeypatch, [7.0, 10.0], calls) # erst 7.0, nach einem Repair 10.0 + await blocks._guide_ebene("G2", "", "claude", lambda: False, lambda *a, **k: None) + assert calls["rep"] == 1 + assert "G2" not in blocks._blocks_errors + + +async def test_guide_stillstand_meldet_systemfehler(testdb, monkeypatch): + blocks._blocks_errors.pop("G3", None) + calls = {"gen": 0, "rep": 0} + _patch(monkeypatch, [6.0], calls) # Repair bewegt die Note nie + await blocks._guide_ebene("G3", "", "claude", lambda: False, lambda *a, **k: None) + assert "G3" in blocks._blocks_errors + assert "Guide" in blocks._blocks_errors["G3"] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9cdd144..7041448 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,6 @@ @@ -183,92 +146,69 @@ async function repairClick() {