This commit is contained in:
team3
2026-07-06 17:32:52 +02:00
parent cc27e53b9e
commit 73ac3e532f
19 changed files with 565 additions and 523 deletions

39
backend/auto_loop.py Normal file
View 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 (010), `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 (010).
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"}

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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]:

View File

@@ -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

View 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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View 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"]