update
This commit is contained in:
39
backend/auto_loop.py
Normal file
39
backend/auto_loop.py
Normal file
@@ -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"}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
40
backend/tests/test_auto_loop.py
Normal file
40
backend/tests/test_auto_loop.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
48
backend/tests/test_guide_sequenz.py
Normal file
48
backend/tests/test_guide_sequenz.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user