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, 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 """Kanban entry point: source prep, then both boards (inventory + artefacts) until
quiescence. research=False = Continue (drain the existing queue, no new search). 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: if topic in _blocks_progress:
return return
_blocks_progress[topic] = "Warten…" _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" _blocks_errors[topic] = "Cancelled — progress is preserved"
return return
import board_inventory # lazy: the boards import blocks import board_inventory # lazy: the boards import blocks
status: dict = {}
ok = await board_inventory.run_boards(ctx, set_p, files, q, folder, instructions, 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(): if not ok and is_cancelled():
_blocks_errors[topic] = "Cancelled — progress is preserved" _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: except Exception as e:
log.exception("[%s] Blocks generation failed", topic) log.exception("[%s] Blocks generation failed", topic)
_blocks_errors[topic] = str(e)[:2000] _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, 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. """Run the inventory board (plus board 2 „Artefakte") until quiescence.
research=False = Continue: drain the existing queue, search nothing new. 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 topic = ctx.topic
flow = Flow(topic, files["arbeit"]) flow = Flow(topic, files["arbeit"])
flow.state["instructions"] = instructions flow.state["instructions"] = instructions
flow.state["qa_force"] = qa_force 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]}" 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_id"] = run_id
flow.state["run_started"] = datetime.now(timezone.utc).isoformat() 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 from blocks import _blocks_errors # lazy: Import-Zyklus vermeiden
_blocks_errors[topic] = flow.state.get("infra_error") \ _blocks_errors[topic] = flow.state.get("infra_error") \
or "Pausiert: API-Ratelimit (429) — «Fortsetzen», sobald Kontingent zurück" 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 return True # kein Fehler: Karten warten resümierbar in ihrer Spalte
if flow.state.get("qa_paused"): 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 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_final(topic, files)
await _write_run_summary(topic, flow) 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 return True
finally: finally:
# erst NACH der Abschluss-QA leeren: deren Judge-Events gehören zum Lauf — # 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) report = await qa.qa_report(topic, llm=QA_GATE_LLM)
note = float(report["note"]) if report else 10.0 note = float(report["note"]) if report else 10.0
flow.state["qa_note"] = note flow.state["qa_note"] = note
if report: if not report:
try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen flow.state["qa_ok"] = True # nichts messbar → fail-open (QA ist Helfer, nicht Jailer)
await qa.write_report(report) flow.wake.set()
except Exception: return
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic) try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen
if note >= QA_GATE_NOTE: await qa.write_report(report)
_log(topic, f"QA-Gate: Note {note}{QA_GATE_NOTE} — Board 2 startet") 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 flow.state["qa_ok"] = True
else: else:
_log(topic, f"QA-Gate: Note {note} < {QA_GATE_NOTE} — pausiert (Continue erzwingt)") # Stillstand/Limit ohne 100 % → Abbruch: Repair hat keinen Pfad für die Restbefunde.
set_p(f"QA-Note {note} < {QA_GATE_NOTE} — pausiert") 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.state["qa_paused"] = True
flow.stop = True flow.stop = True
flow.wake.set() flow.wake.set()
@@ -1803,6 +1853,55 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
flow.wake.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): async def _write_run_summary(topic: str, flow: Flow):
"""lauf-summary.json: the per-run numbers block QA diffs against. Never fatal.""" """lauf-summary.json: the per-run numbers block QA diffs against. Never fatal."""
try: try:
@@ -2034,23 +2133,6 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
return moved 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: async def requeue_dead(topic: str) -> int:
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count.""" """Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
n = 0 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) 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: async with _semaphore:
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Starting…", updated_at=now) 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) await asyncio.to_thread(_convert_pdfs, project)
import guide_board # lazy — guide_board imports helpers from this module 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 → complete fresh start (board + slots wiped). Otherwise the cards
# A FINISHED guide without ab_step → complete fresh start (board + slots wiped). # are leftovers of an abort/error/repair → resume at their stored stage.
# Otherwise cards are leftovers of an abort/error → resume at their stored stage. if content_path.exists():
if ab_step is not None:
await guide_board.reset_from_stage(topic, format_name, ab_step)
elif content_path.exists():
counts = await guide_stage_counts(topic, format_name) counts = await guide_stage_counts(topic, format_name)
if not counts or set(counts) == {"done"}: if not counts or set(counts) == {"done"}:
await delete_guide_board(topic, format_name) 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 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 format: FormatType
instructions: str = Field(default="", max_length=2000) instructions: str = Field(default="", max_length=2000)
provider: ProviderType = DEFAULT_PROVIDER 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): class TopicCreateRequest(BaseModel):
@@ -39,6 +32,7 @@ class QaRunRequest(BaseModel):
class RepairRequest(BaseModel): class RepairRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100) 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): class BlocksCreateRequest(BaseModel):
@@ -49,11 +43,11 @@ class BlocksCreateRequest(BaseModel):
source_location: str = Field(default="", max_length=20000) # link mode: one URL per line 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 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") qa_force: bool = False # True = übersteuert ein pausierendes QA-Gate („Trotzdem fortsetzen")
# „Auto" pro Ebene: nach der QA im Loop „Befunde beheben", bis 100 % / Stillstand / 10×.
# Aus = nach der QA stoppen (Kontrollpunkt). Default an.
class BlocksCardRestartRequest(BaseModel): auto_inventory: bool = True
topic: str = Field(min_length=1) auto_artefacts: bool = True
card_id: str = Field(min_length=1, max_length=200) auto_guide: bool = True
class GuideFormatRequest(BaseModel): class GuideFormatRequest(BaseModel):
@@ -68,17 +62,6 @@ class PracticeAnswerRequest(BaseModel):
correct: bool 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): class BlocksStatusResponse(BaseModel):

View File

@@ -20,7 +20,9 @@ from textkit import _norm_title, _title, clean_title, parse_facts
log = logging.getLogger("creator.repair") 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) reports = qa.report_paths(topic)
report = _json_file(reports[-1]) if reports else None report = _json_file(reports[-1]) if reports else None
if not report: if not report:
@@ -28,21 +30,26 @@ async def repair_befunde(topic: str) -> dict:
files = _blocks_files(topic) files = _blocks_files(topic)
cards = await db.kanban_cards(topic, board="inventory", stage="done_block") cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
by_norm = {_norm_title(c["payload"].get("title", "")): c for c in cards} 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) hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else []
merges = await _merge_dubletten(topic, report, by_norm, files) merges = await _merge_dubletten(topic, report, by_norm, files) if inv else []
sub_merges, frei_subs = await _merge_sub_dubletten(topic, report, files) 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) entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], [])
aufgeraeumt = await _raeume_waisen(topic) aufgeraeumt = (await _raeume_waisen(topic)) if art else 0
# llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report # 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 # blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
neu = await qa.qa_report(topic, llm=True) neu = await qa.qa_report(topic, llm=True)
if neu: if neu:
await qa.write_report(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, return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
"aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke, "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]: 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 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 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 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 guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from pipeline import cancel_guide from pipeline import cancel_guide
@@ -32,9 +32,9 @@ from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learns
from models import ( from models import (
GuideCreateRequest, GuideResponse, GuideCreateRequest, GuideResponse,
TopicCreateRequest, TopicCreateRequest,
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse, QaRunRequest, RepairRequest, BlocksCreateRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
GuideCardResetRequest, GuideFormatRequest, GuideFormatRequest,
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse, GuideChatRequest, GuideChatResponse,
ProviderInfo, ProviderInfo,
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview, FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BlockChatRequest, BlockChatResponse, BlockChatRequest, BlockChatResponse,
@@ -166,7 +166,9 @@ async def create_blocks(req: BlocksCreateRequest):
qp.parent.mkdir(parents=True, exist_ok=True) qp.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()}) atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider, 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} return {"ok": True}
@@ -230,7 +232,7 @@ async def run_repair_route(req: RepairRequest):
_repair_laeuft.add(topic) _repair_laeuft.add(topic)
try: try:
import repair import repair
res = await repair.repair_befunde(topic) res = await repair.repair_befunde(topic, req.ebene)
if "fehler" in res: if "fehler" in res:
raise HTTPException(status_code=404, detail=res["fehler"]) raise HTTPException(status_code=404, detail=res["fehler"])
return res return res
@@ -249,43 +251,11 @@ async def add_blocks_research(topic: str, provider: str = DEFAULT_PROVIDER):
return {"ok": True, "attached": False} 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") @router.post("/blocks/requeue-dead")
async def requeue_blocks_dead(topic: str): async def requeue_blocks_dead(topic: str):
return {"ok": True, "requeued": await requeue_dead(topic)} 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") @router.post("/blocks/cancel")
async def cancel_blocks_route(topic: str): async def cancel_blocks_route(topic: str):
if not cancel_blocks(topic): if not cancel_blocks(topic):
@@ -301,6 +271,18 @@ async def remove_blocks(topic: str):
return {"ok": True} 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") @router.delete("/blocks/progress")
async def reset_block_progress(topic: str, block: str): async def reset_block_progress(topic: str, block: str):
"""Reset learning progress of ONE block to zero (score/streak/flags/open question).""" """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, "updated_at": now,
} }
await create_guide(guide) 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 return guide
@@ -719,7 +701,7 @@ async def get_guide_board(topic: str, format: str = "Guide"):
@router.post("/guides/board/repair") @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`, """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.""" dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
import guide_board import guide_board
@@ -739,19 +721,6 @@ async def repair_guide_board(req: GuideBoardResetRequest):
return {"ok": True, "betroffen": betroffen} 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") @router.get("/guides/{guide_id}/content")
async def guide_content(guide_id: str, level: int = 4): 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 """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) timeout=timeout)
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch): async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate.""" """Auto an, aber Repair schließt die Befunde nicht → Stillstand → Flow pausiert,
Board-2-Karten warten in generate."""
import qa as qa_mod import qa as qa_mod
import repair as repair_mod
db, ctx, files = board_env db, ctx, files = board_env
async def bad_qa(topic, llm=False): async def bad_qa(topic, llm=False):
return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} 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, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) 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) monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
await _seed(db) await _seed(db)
ok = await _run_flow(ctx, files) 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 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): async def test_qa_gate_force_overrides(board_env, monkeypatch):
"""qa_force=True („Trotzdem fortsetzen") übersteuert die schlechte Note.""" """qa_force=True („Trotzdem fortsetzen") übersteuert die schlechte Note."""
import qa as qa_mod 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) ok = await _run_flow(ctx, files, qa_force=True)
assert ok assert ok
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5 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): 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) await _seed(db)
ok = await _run_flow(ctx, files) ok = await _run_flow(ctx, files)
assert ok 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): 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" 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): async def test_completeness_route(testdb, tmp_path, monkeypatch):
import routes, paths import routes, paths
db = testdb db = testdb

View File

@@ -58,25 +58,6 @@ def test_auftraege_schwelle_und_kritisch():
assert len(z) == 2 and k is True 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): async def test_done_step(testdb):
db = 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"]

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js' import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js' import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
@@ -182,12 +182,31 @@ async function loadBlocks() {
} }
} }
// „Auto"-Ebenen-Flags pro Topic (localStorage): nach der QA „Befunde beheben" bis 100 %.
const autoInventory = ref(true)
const autoArtefacts = ref(true)
const autoGuide = ref(true)
function ladeAuto(topic) {
const g = (e) => { const v = localStorage.getItem(`auto_${topic}_${e}`); return v === null ? true : v === 'true' }
autoInventory.value = g('inventory')
autoArtefacts.value = g('artefacts')
autoGuide.value = g('guide')
}
function handleSetAuto({ ebene, value }) {
const r = { inventory: autoInventory, artefacts: autoArtefacts, guide: autoGuide }[ebene]
if (!r) return
r.value = value
if (selectedTopic.value) localStorage.setItem(`auto_${selectedTopic.value}_${ebene}`, value ? 'true' : 'false')
}
const autoObj = () => ({ inventory: autoInventory.value, artefacts: autoArtefacts.value, guide: autoGuide.value })
function selectTopic(topic) { function selectTopic(topic) {
selectedTopic.value = topic selectedTopic.value = topic
previewGuide.value = null previewGuide.value = null
sidebarSticky.value = false sidebarSticky.value = false
mainView.value = 'generation' // topic click → generation board (guide only on pill click) mainView.value = 'generation' // topic click → generation board (guide only on pill click)
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact' viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
ladeAuto(topic)
localStorage.setItem('lastTopic', topic) localStorage.setItem('lastTopic', topic)
loadBlocks() loadBlocks()
} }
@@ -207,20 +226,6 @@ async function handleResetBlocks() {
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks() if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
} }
async function handleResetStage({ board, stage, restart = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
await apiResetBlocksStage(selectedTopic.value, board, stage)
if (restart) await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) // Continue: Queue abarbeiten
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks()
if (restart) startPolling()
}
async function handleAddResearch() { async function handleAddResearch() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
uiError.value = null uiError.value = null
@@ -238,7 +243,7 @@ async function handleRequeueDead() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
const ok = await withUiError(async () => { const ok = await withUiError(async () => {
await apiRequeueDead(selectedTopic.value) await apiRequeueDead(selectedTopic.value)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false, false, autoObj())
}) })
if (!ok) return if (!ok) return
await loadBlocks() await loadBlocks()
@@ -250,7 +255,7 @@ async function handleBlocksClick({ instructions = '', research = true, qaForce =
uiError.value = null uiError.value = null
try { try {
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten). // research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce) await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj())
} catch (e) { } catch (e) {
uiError.value = e.message uiError.value = e.message
return return
@@ -300,7 +305,7 @@ function handleOpenBlocksView() {
previewGuide.value = null previewGuide.value = null
} }
async function handleFormatClick({ format, instructions = '', abStep = null }) { async function handleFormatClick({ format, instructions = '' }) {
if (!selectedTopic.value) return if (!selectedTopic.value) return
// No duplicate start: if a generation is already running for topic+format, ignore // No duplicate start: if a generation is already running for topic+format, ignore
const running = guides.value.some( const running = guides.value.some(
@@ -310,7 +315,7 @@ async function handleFormatClick({ format, instructions = '', abStep = null }) {
if (running) { handleOpenGuideBoard(format); return } if (running) { handleOpenGuideBoard(format); return }
uiError.value = null uiError.value = null
try { try {
await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep) await apiCreate(selectedTopic.value, format, instructions, provider.value)
} catch (e) { } catch (e) {
uiError.value = e.message uiError.value = e.message
return return
@@ -344,34 +349,6 @@ async function handleRemoveGuideFormat(format) {
await loadGuides() await loadGuides()
} }
async function handleRestartCard(cardId) {
uiError.value = null
try {
await apiRestartBlocksCard(selectedTopic.value, cardId)
await handleBlocksClick({ research: false }) // Continue: der Flow zieht die Karte
} catch (e) {
uiError.value = e.message
}
}
async function handleResetGuideCard({ format, blockNorm, abStage }) {
uiError.value = null
try {
await apiResetGuideCard(selectedTopic.value, format, blockNorm, abStage)
} catch (e) {
uiError.value = e.message
}
}
async function handleGuideBoardReset({ format, abStage }) {
uiError.value = null
try {
await apiResetGuideBoard(selectedTopic.value, format, abStage)
} catch (e) {
uiError.value = e.message
}
}
function handleGuideBoardPreview() { function handleGuideBoardPreview() {
const g = doneByFormat.value[guideBoardFormat.value] const g = doneByFormat.value[guideBoardFormat.value]
if (g) handlePreview(g) if (g) handlePreview(g)
@@ -502,8 +479,10 @@ onMounted(async () => {
:partial="blocks.partial" :partial="blocks.partial"
:error="blocks.error" :error="blocks.error"
:guideFormat="guideBoardFormat" :guideFormat="guideBoardFormat"
:autoInventory="autoInventory"
:autoArtefacts="autoArtefacts"
:autoGuide="autoGuide"
@close="mainView = 'blocks'" @close="mainView = 'blocks'"
@resetStage="handleResetStage"
@restartAll="() => handleBlocksClick({ research: true })" @restartAll="() => handleBlocksClick({ research: true })"
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })" @continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })"
@addResearch="handleAddResearch" @addResearch="handleAddResearch"
@@ -512,11 +491,9 @@ onMounted(async () => {
@cancel="handleCancelBlocks" @cancel="handleCancelBlocks"
@cancelGuide="handleCancel" @cancelGuide="handleCancel"
@startGuide="handleFormatClick" @startGuide="handleFormatClick"
@resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview" @preview="handleGuideBoardPreview"
@removeFormat="handleRemoveGuideFormat" @removeFormat="handleRemoveGuideFormat"
@restartCard="handleRestartCard" @setAuto="handleSetAuto"
@resetGuideCard="handleResetGuideCard"
/> />
<GeneralExamPanel <GeneralExamPanel
v-else-if="selectedTopic && mainView === 'general'" v-else-if="selectedTopic && mainView === 'general'"

View File

@@ -35,15 +35,18 @@ async function req(path, { method = 'GET', body, query } = {}) {
export const fetchGuides = () => req('/guides') export const fetchGuides = () => req('/guides')
export const createGuide = (topic, format, instructions = '', provider = 'claude', abStep = null) => export const createGuide = (topic, format, instructions = '', provider = 'claude') =>
req('/guides', { method: 'POST', body: { topic, format, instructions, provider, ab_step: abStep } }) req('/guides', { method: 'POST', body: { topic, format, instructions, provider } })
export const fetchActiveBlocks = () => req('/blocks/active') export const fetchActiveBlocks = () => req('/blocks/active')
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } }) export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) => export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false, auto = {}) =>
req('/blocks', { method: 'POST', body: { topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce } }) req('/blocks', { method: 'POST', body: {
topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce,
auto_inventory: auto.inventory ?? true, auto_artefacts: auto.artefacts ?? true, auto_guide: auto.guide ?? true,
} })
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter). // Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } }) export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
@@ -51,26 +54,19 @@ export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic
// Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report. // Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report.
export const runQa = (topic, llm = true) => req('/blocks/qa', { method: 'POST', body: { topic, llm } }) export const runQa = (topic, llm = true) => req('/blocks/qa', { method: 'POST', body: { topic, llm } })
// QA-Befunde gezielt beheben (Hygiene, bestätigte Dubletten, Fremd/Unecht nach Gegen-Judge). // QA-Befunde gezielt beheben; ebene = 'inventory' | 'artefacts' | undefined (alle).
export const runRepair = (topic) => req('/blocks/repair', { method: 'POST', body: { topic } }) export const runRepair = (topic, ebene = null) => req('/blocks/repair', { method: 'POST', body: { topic, ebene } })
// Karten ab Spalte zurücksetzen (keine Generierung). // Artefakte-Ebene leeren (Board-2-Daten weg, Inventar bleibt).
export const resetBlocksStage = (topic, board, stage) => export const removeArtefacts = (topic) => req('/blocks/artefacts', { method: 'DELETE', query: { topic } })
req('/blocks/reset-stage', { method: 'POST', body: { topic, board, stage } })
// Einen weiteren Research-Agenten anhängen (Attach-or-Start). // Einen weiteren Research-Agenten anhängen (Attach-or-Start).
export const addBlocksResearch = (topic, provider = 'claude') => export const addBlocksResearch = (topic, provider = 'claude') =>
req('/blocks/research', { method: 'POST', query: { topic, provider } }) req('/blocks/research', { method: 'POST', query: { topic, provider } })
export const restartBlocksCard = (topic, cardId) =>
req('/blocks/card-restart', { method: 'POST', body: { topic, card_id: cardId } })
export const removeGuideFormat = (topic, format) => export const removeGuideFormat = (topic, format) =>
req('/guides/board/remove', { method: 'POST', body: { topic, format } }) req('/guides/board/remove', { method: 'POST', body: { topic, format } })
export const resetGuideCard = (topic, format, blockNorm, abStage) =>
req('/guides/board/card-reset', { method: 'POST', body: { topic, format, block_norm: blockNorm, ab_stage: abStage } })
export const requeueBlocksDead = (topic) => export const requeueBlocksDead = (topic) =>
req('/blocks/requeue-dead', { method: 'POST', query: { topic } }) req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
@@ -78,12 +74,9 @@ export const requeueBlocksDead = (topic) =>
export const fetchGuideBoard = (topic, format = 'Guide') => export const fetchGuideBoard = (topic, format = 'Guide') =>
req('/guides/board', { query: { topic, format } }) req('/guides/board', { query: { topic, format } })
export const resetGuideBoard = (topic, format, abStage) =>
req('/guides/board/reset', { method: 'POST', body: { topic, format, ab_stage: abStage } })
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf. // Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
export const repairGuideBoard = (topic, format) => export const repairGuideBoard = (topic, format) =>
req('/guides/board/repair', { method: 'POST', body: { topic, format, ab_stage: 0 } }) req('/guides/board/repair', { method: 'POST', body: { topic, format } })
export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } }) export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard, fetchRuns, runQa, runRepair } from '../api.js' import { fetchBlocksBoard, fetchRuns, runQa, runRepair, removeArtefacts } from '../api.js'
import { usePolling } from '../composables/usePolling.js' import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js' import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js' import { fmtRuntime, fmtTokens } from '../format.js'
@@ -16,9 +16,12 @@ const props = defineProps({
partial: { type: Boolean, default: false }, partial: { type: Boolean, default: false },
error: { type: String, default: null }, error: { type: String, default: null },
guideFormat: { type: String, default: 'Guide' }, guideFormat: { type: String, default: 'Guide' },
autoInventory: { type: Boolean, default: true },
autoArtefacts: { type: Boolean, default: true },
autoGuide: { type: Boolean, default: true },
}) })
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch', const emit = defineEmits(['close', 'restartAll', 'continueAll', 'addResearch', 'requeueDead',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard']) 'removeAll', 'removeArtefacts', 'cancel', 'cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ── // ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
const board = ref(null) const board = ref(null)
@@ -29,8 +32,6 @@ async function pollBoard() {
board.value = await fetchBlocksBoard(props.topic) board.value = await fetchBlocksBoard(props.topic)
pollError.value = null pollError.value = null
} catch (e) { } catch (e) {
// 404 = Board noch nicht gebaut (kein Fehler); alles andere (500/Netz) sichtbar machen,
// statt es als „leeres Board" zu tarnen
if (e.status === 404) board.value = null if (e.status === 404) board.value = null
else pollError.value = e.message else pollError.value = e.message
} }
@@ -49,8 +50,6 @@ const dead = computed(() => board.value?.dead || [])
const qa = computed(() => board.value?.qa || null) const qa = computed(() => board.value?.qa || null)
// Ehrlicher Fortschritt aus den Spaltenzahlen: gewichteter Spaltenindex (Terminal = 1,0). // Ehrlicher Fortschritt aus den Spaltenzahlen: gewichteter Spaltenindex (Terminal = 1,0).
// Kein einzelner „100%"-Wert wird geraten — wächst der Umfang (Research legt Karten nach),
// SINKT der Wert; das ist die Wahrheit und wird mit „Umfang wächst noch" gekennzeichnet.
const TERMINAL = new Set(['done_block', 'done_artefact', 'rejected', 'grouped']) const TERMINAL = new Set(['done_block', 'done_artefact', 'rejected', 'grouped'])
function boardProgress(cols) { function boardProgress(cols) {
const total = cols.reduce((n, c) => n + c.total, 0) const total = cols.reduce((n, c) => n + c.total, 0)
@@ -65,6 +64,9 @@ const inventoryProgress = computed(() => boardProgress(inventoryCols.value))
const artefactProgress = computed(() => boardProgress(artefactCols.value)) const artefactProgress = computed(() => boardProgress(artefactCols.value))
const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0) const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0)
// Score 010 → „100 %"
const pct = (note) => Math.round(note * 10) + ' %'
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ────── // ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
const run = ref(null) const run = ref(null)
const now = ref(Date.now()) const now = ref(Date.now())
@@ -97,79 +99,40 @@ watch(() => props.generating, (g) => {
watch(() => props.topic, () => { run.value = null; loadRun() }) watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) }) onUnmounted(() => { if (clock) clearInterval(clock) })
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel). const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming', function later(fn) { fn(); setTimeout(pollBoard, 600) }
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
'subblocks', 'facts', 'konsolidierung', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label}
const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts)
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung (mit 3s-Auto-Reset)
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
resetConfirm()
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
}
function cardClick(k) {
if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten
resetConfirm()
sel.value = null
selCard.value = selCard.value?.card_id === k.card_id ? null : k
}
function restartCard() {
const k = selCard.value
selCard.value = null
resetConfirm()
later(() => emit('restartCard', k.card_id))
}
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
fn()
setTimeout(pollBoard, 600)
}
function resetHere(restart) {
const s = sel.value
sel.value = null
resetConfirm()
later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
}
// ── QA + Befunde beheben (je Ebene) ──────────────────────────────────────────────
const qaBusy = ref(false) const qaBusy = ref(false)
const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden
async function runQaClick() { async function runQaClick() {
if (qaBusy.value) return if (qaBusy.value) return
qaBusy.value = true qaBusy.value = true
try { await runQa(props.topic) }
finally { qaBusy.value = false; pollBoard(); guideRefresh.value++ }
}
const repairBusy = ref('') // '' | 'inventory' | 'artefacts'
const repairInfo = ref({}) // { inventory, artefacts }
async function repairClick(ebene) {
if (repairBusy.value) return
repairBusy.value = ebene
repairInfo.value = { ...repairInfo.value, [ebene]: '' }
try { try {
await runQa(props.topic) const r = await runRepair(props.topic, ebene)
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
repairInfo.value = { ...repairInfo.value, [ebene]: n === 0 ? 'keine behebbaren Befunde'
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt` }
} catch (e) {
repairInfo.value = { ...repairInfo.value, [ebene]: String(e.message || e) }
} finally { } finally {
qaBusy.value = false repairBusy.value = ''; pollBoard()
pollBoard()
guideRefresh.value++
} }
} }
const repairBusy = ref(false) function removeArtefactsClick() {
const repairInfo = ref('') later(() => removeArtefacts(props.topic).then(pollBoard))
async function repairClick() {
if (repairBusy.value) return
repairBusy.value = true
repairInfo.value = ''
try {
const r = await runRepair(props.topic)
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
repairInfo.value = n === 0
? 'keine behebbaren Befunde'
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt · ${(r.freigesprochen || []).length} freigesprochen`
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {
repairBusy.value = false
pollBoard()
}
} }
</script> </script>
@@ -183,92 +146,69 @@ async function repairClick() {
</header> </header>
<div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</div> <div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</div>
<div v-if="qa && qa.pausiert && qa.note != null" class="qa-pause">
<strong>QA-Gate: Note {{ qa.note.toFixed(1) }} unter Schwelle {{ qa.schwelle }} pausiert.</strong>
<span v-if="qa.befunde?.length"> Befunde: {{ qa.befunde.join(' · ') }}</span>
<button class="gen-act" @click="emit('continueAll', { qaForce: true })">Trotzdem fortsetzen</button>
</div>
<section class="gen-section"> <section class="gen-section">
<div class="gen-steps-top"> <div class="gen-steps-top">
<span class="gen-title">Bausteine</span> <span class="gen-title">Bausteine</span>
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div> <div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
<span v-if="runLaufzeit" class="gen-run" :title="runTokenTitel">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span> <span v-if="runLaufzeit" class="gen-run" :title="runTokenTitel">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<span v-if="run?.fails?.length" class="gen-run-fail" :title="run.fails.map((f) => f.key + ': ' + f.error).join('\n')"> {{ run.fails.length }} Fehler</span> <span v-if="run?.fails?.length" class="gen-run-fail" :title="run.fails.map((f) => f.key + ': ' + f.error).join('\n')"> {{ run.fails.length }} Fehler</span>
<div v-if="!generating" class="gen-actions"> <div v-if="generating" class="gen-actions">
<button class="gen-act" :disabled="qaBusy" title="QA-Lauf wie am Gate (inkl. LLM-Stichprobe)"
@click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="repairBusy"
title="QA-Befunde gezielt beheben: Hygiene, bestätigte Dubletten mergen, Fremd/Unecht nach Gegen-Judge entfernen"
@click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Recherche' : 'Generieren' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
<button
v-if="ready || partial || error"
class="gen-act danger"
:class="{ armed: isArmed('remove') }"
:title="error ? 'Räumt Crawl/Reste auf — danach neu generierbar' : ''"
@click="armOrRun('remove', () => later(() => emit('removeAll')))"
>{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<div v-else class="gen-actions">
<button class="gen-act" @click="emit('addResearch')">+ Recherche</button> <button class="gen-act" @click="emit('addResearch')">+ Recherche</button>
<button class="gen-act danger" @click="emit('cancel')">Abbrechen</button> <button class="gen-act danger" @click="emit('cancel')">Abbrechen</button>
</div> </div>
<button <button
v-if="dead.length" v-if="dead.length && generating"
class="gen-act" class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')" :title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="later(() => emit('requeueDead'))" @click="later(() => emit('requeueDead'))"
> {{ dead.length }} dead</button> > {{ dead.length }} dead</button>
</div> </div>
<!-- Ebene Inventar -->
<div class="gen-board-label"> <div class="gen-board-label">
Inventar Inventar
<span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'" <span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note) }}</span>
:title="'QA-Schwelle ' + qa.schwelle">QA {{ qa.note.toFixed(1) }}/10</span> <label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="autoInventory" @change="emit('setAuto', { ebene: 'inventory', value: $event.target.checked })"> Auto
</label>
</div>
<div v-if="!generating" class="gen-bar">
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('inventory')">{{ repairBusy === 'inventory' ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.inventory" class="repair-info">{{ repairInfo.inventory }}</span>
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Recherche' : 'Generieren' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
<button v-if="ready || partial || error" class="gen-act danger" :class="{ armed: isArmed('remove') }"
title="Löscht das gesamte Topic-Inventar (Artefakte + Guide hängen daran)"
@click="armOrRun('remove', () => later(() => emit('removeAll')))">{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
</div> </div>
<ProgressBar v-if="inventoryProgress.total" :value="inventoryProgress.value" <ProgressBar v-if="inventoryProgress.total" :value="inventoryProgress.value"
:label="`${inventoryProgress.done}/${inventoryProgress.total} Karten fertig · ${Math.round(inventoryProgress.value * 100)} %`" :label="`${inventoryProgress.done}/${inventoryProgress.total} Karten fertig · ${Math.round(inventoryProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" /> :hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard <KanbanBoard :columns="inventoryCols" :agents="board?.agents || []" :generating="generating" />
:columns="inventoryCols"
:agents="board?.agents || []" <!-- Ebene Artefakte -->
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<div class="gen-board-label"> <div class="gen-board-label">
Artefakte Artefakte
<span v-if="qa && qa.note_artefakte != null" class="qa-note" <span v-if="qa && qa.note_artefakte != null" class="qa-note" :class="qa.note_artefakte >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note_artefakte) }}</span>
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'" <label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span> <input type="checkbox" :checked="autoArtefacts" @change="emit('setAuto', { ebene: 'artefacts', value: $event.target.checked })"> Auto
</label>
</div>
<div v-if="!generating" class="gen-bar">
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('artefacts')">{{ repairBusy === 'artefacts' ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.artefacts" class="repair-info">{{ repairInfo.artefacts }}</span>
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen (Fortsetzen)" @click="emit('continueAll')">Generieren</button>
<button v-if="ready || partial" class="gen-act danger" :class="{ armed: isArmed('remove-art') }"
title="Nur die Artefakte leeren — Inventar bleibt"
@click="armOrRun('remove-art', removeArtefactsClick)">{{ isArmed('remove-art') ? 'Sicher?' : 'Entfernen' }}</button>
</div> </div>
<ProgressBar v-if="artefactProgress.total" :value="artefactProgress.value" <ProgressBar v-if="artefactProgress.total" :value="artefactProgress.value"
:label="`${artefactProgress.done}/${artefactProgress.total} Karten fertig · ${Math.round(artefactProgress.value * 100)} %`" :label="`${artefactProgress.done}/${artefactProgress.total} Karten fertig · ${Math.round(artefactProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" /> :hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard <KanbanBoard :columns="artefactCols" :agents="board?.agents || []" :generating="generating" />
:columns="artefactCols"
:generating="generating"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
<div v-if="selCard && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Karte «{{ selCard.title }}»:</span>
<button class="gen-act play" :class="{ armed: isArmed('card') }" @click="armOrRun('card', restartCard)">{{ isArmed('card') ? 'Sicher?' : ' Karte neu generieren' }}</button>
<button class="gen-act ghost" @click="selCard = null; resetConfirm()">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="gen-act play" @click="resetHere(true)"> neu generieren</button>
<button class="gen-act danger" :class="{ armed: isArmed('reset') }" @click="armOrRun('reset', () => resetHere(false))">{{ isArmed('reset') ? 'Sicher?' : ' nur zurücksetzen' }}</button>
<button class="gen-act ghost" @click="sel = null; resetConfirm()">Abbrechen</button>
</div>
</section> </section>
<section class="gen-section"> <section class="gen-section">
@@ -276,12 +216,12 @@ async function repairClick() {
:topic="topic" :topic="topic"
:format="guideFormat" :format="guideFormat"
:refresh="guideRefresh" :refresh="guideRefresh"
:auto="autoGuide"
@cancelGuide="(id) => emit('cancelGuide', id)" @cancelGuide="(id) => emit('cancelGuide', id)"
@startGuide="(p) => emit('startGuide', p)" @startGuide="(p) => emit('startGuide', p)"
@resetStage="(p) => emit('resetGuideStage', p)"
@preview="emit('preview')" @preview="emit('preview')"
@removeFormat="(f) => emit('removeFormat', f)" @removeFormat="(f) => emit('removeFormat', f)"
@resetCard="(p) => emit('resetGuideCard', p)" @setAuto="(v) => emit('setAuto', { ebene: 'guide', value: v })"
/> />
</section> </section>
</div> </div>
@@ -330,12 +270,15 @@ async function repairClick() {
} }
.gen-title { font-size: 0.9rem; font-weight: 700; } .gen-title { font-size: 0.9rem; font-weight: 700; }
.gen-board-label { .gen-board-label {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.64rem; font-size: 0.64rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--text-faint); color: var(--text-faint);
margin: 0.5rem 0 0.3rem; margin: 0.7rem 0 0.3rem;
} }
.gen-progress { .gen-progress {
display: flex; display: flex;
@@ -355,16 +298,20 @@ async function repairClick() {
.gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; } .gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.gen-actions { margin-left: auto; display: flex; gap: 0.4rem; } .gen-actions { margin-left: auto; display: flex; gap: 0.4rem; }
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
.gen-step-actions { .auto-box {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.25rem;
margin-top: 0.75rem; font-size: 0.68rem;
padding-top: 0.7rem; font-weight: 600;
border-top: 1px dashed var(--border-strong); color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
cursor: pointer;
} }
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); } .auto-box input { cursor: pointer; margin: 0; }
.gen-act { .gen-act {
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
@@ -377,16 +324,18 @@ async function repairClick() {
font-weight: 600; font-weight: 600;
} }
.gen-act:hover { border-color: var(--accent); } .gen-act:hover { border-color: var(--accent); }
.gen-act:disabled { opacity: 0.55; cursor: default; }
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } .gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gen-act.play:hover { background: var(--accent-hover); } .gen-act.play:hover { background: var(--accent-hover); }
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; } .gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gen-act.danger.armed { background: var(--danger); color: #fff; } .gen-act.danger.armed { background: var(--danger); color: #fff; }
.gen-act.ghost { color: var(--text-muted); }
.qa-note { .qa-note {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
padding: 0.1rem 0.5rem; padding: 0.1rem 0.5rem;
border-radius: 999px; border-radius: 999px;
text-transform: none;
letter-spacing: 0;
} }
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; } .qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; } .qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
@@ -394,16 +343,4 @@ async function repairClick() {
.gen-poll-error { color: var(--danger); font-size: 0.82rem; padding: 0.3rem 0; } .gen-poll-error { color: var(--danger); font-size: 0.82rem; padding: 0.3rem 0; }
.gen-run { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; } .gen-run { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.gen-run-fail { font-size: 0.78rem; color: var(--danger); white-space: nowrap; } .gen-run-fail { font-size: 0.78rem; color: var(--danger); white-space: nowrap; }
.qa-pause {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.5rem 0.8rem;
margin-bottom: 0.8rem;
border: 1px solid color-mix(in srgb, #ef4444 40%, transparent);
border-radius: 8px;
background: color-mix(in srgb, #ef4444 8%, transparent);
font-size: 0.85rem;
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard, fetchRuns, repairGuideBoard } from '../api.js' import { fetchGuideBoard, fetchRuns, repairGuideBoard, runQa } from '../api.js'
import { usePolling } from '../composables/usePolling.js' import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js' import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js' import { fmtRuntime, fmtTokens } from '../format.js'
@@ -11,8 +11,9 @@ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
format: { type: String, default: 'Guide' }, format: { type: String, default: 'Guide' },
refresh: { type: Number, default: 0 }, // Eltern-Signal: QA/Repair schrieben einen Guide-Report refresh: { type: Number, default: 0 }, // Eltern-Signal: QA/Repair schrieben einen Guide-Report
auto: { type: Boolean, default: true },
}) })
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard']) const emit = defineEmits(['cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
const board = ref(null) const board = ref(null)
const pollError = ref(null) const pollError = ref(null)
@@ -22,13 +23,10 @@ async function poll() {
board.value = await fetchGuideBoard(props.topic, props.format) board.value = await fetchGuideBoard(props.topic, props.format)
pollError.value = null pollError.value = null
} catch (e) { } catch (e) {
// 404 = Board noch nicht gebaut; echte Fehler (500/Netz) sichtbar machen
if (e.status === 404) board.value = null if (e.status === 404) board.value = null
else pollError.value = e.message else pollError.value = e.message
} }
} }
// startPoll bleibt für die manuellen Starts nach startGuide/repairClick exponiert;
// usePolling pausiert im Hintergrund-Tab und stoppt selbst, sobald generating false wird.
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200) const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true }) watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
@@ -69,34 +67,16 @@ watch(() => generating.value, (g) => {
watch(() => props.topic, () => { run.value = null; loadRun() }) watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) }) onUnmounted(() => { if (clock) clearInterval(clock) })
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done"). const { isArmed, armOrRun } = useConfirm() // 2-Klick-Bestätigung
const STAGES = ['lernziele', 'zuweisung', 'writer', 'pruefer', 'fix']
const sel = ref(null)
const selCard = ref(null)
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung (mit 3s-Auto-Reset)
function stageClick(c) { const qaBusy = ref(false)
if (generating.value || !STAGES.includes(c.key)) return async function runQaClick() {
resetConfirm() if (qaBusy.value) return
selCard.value = null qaBusy.value = true
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) } try { await runQa(props.topic) }
finally { qaBusy.value = false; poll() }
} }
function cardClick(k) {
if (generating.value || !k.card_id) return
resetConfirm()
sel.value = null
const idx = Math.max(0, STAGES.indexOf(k.column))
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
}
function resetCardHere() {
const k = selCard.value
selCard.value = null
resetConfirm()
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
setTimeout(poll, 400)
}
const repairBusy = ref(false) const repairBusy = ref(false)
const repairInfo = ref('') const repairInfo = ref('')
async function repairClick() { async function repairClick() {
@@ -114,26 +94,17 @@ async function repairClick() {
repairBusy.value = false repairBusy.value = false
} }
} }
function restartHere() {
const s = sel.value
sel.value = null
emit('startGuide', { format: props.format, abStep: s.idx })
startPoll()
}
function resetHere() {
const s = sel.value
sel.value = null
emit('resetStage', { format: props.format, abStage: s.idx })
setTimeout(poll, 400)
}
</script> </script>
<template> <template>
<section class="gb-board"> <section class="gb-board">
<div class="gb-top"> <div class="gb-top">
<span class="gb-title">Guide · {{ format }}</span> <span class="gb-title">Guide · {{ format }}</span>
<span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 9 ? 'ok' : 'bad'" <span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 10 ? 'ok' : 'bad'"
title="Guide-QA (make qa-guide)">QA {{ board.qa_guide.toFixed(1) }}/10</span> title="Guide-QA">QA {{ Math.round(board.qa_guide * 10) }} %</span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="auto" @change="emit('setAuto', $event.target.checked)"> Auto
</label>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span> <span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
<span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span> <span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div> <div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
@@ -144,11 +115,12 @@ function resetHere() {
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button> <button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
</template> </template>
<template v-else> <template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button> <button class="gb-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total && board?.qa_guide != null && board.qa_guide < 10" class="gb-act" <button v-if="total && board?.qa_guide != null && board.qa_guide < 10" class="gb-act"
:disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button> :disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span> <span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span>
<button class="gb-act play" @click="emit('startGuide', { format }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gb-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button> <button v-if="total" class="gb-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button>
</template> </template>
</div> </div>
@@ -157,28 +129,7 @@ function resetHere() {
<ProgressBar v-if="total" :value="progressValue" <ProgressBar v-if="total" :value="progressValue"
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" /> :label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
<KanbanBoard <KanbanBoard :columns="columns" :agents="board?.agents || []" :generating="generating" />
:columns="columns"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.key || null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
<div v-if="selCard && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
<button class="gb-act play" :class="{ armed: isArmed('card') }" @click="armOrRun('card', resetCardHere)">{{ isArmed('card') ? 'Sicher?' : ' Karte neu (ab Lernziele)' }}</button>
<button class="gb-act ghost" @click="selCard = null; resetConfirm()">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
<button class="gb-act play" @click="restartHere"> neu generieren</button>
<button class="gb-act danger" :class="{ armed: isArmed('reset') }" @click="armOrRun('reset', resetHere)">{{ isArmed('reset') ? 'Sicher?' : ' nur zurücksetzen' }}</button>
<button class="gb-act ghost" @click="sel = null; resetConfirm()">Abbrechen</button>
</div>
<div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div> <div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
</section> </section>
@@ -186,7 +137,7 @@ function resetHere() {
<style scoped> <style scoped>
.gb-board { padding: 0.85rem 0 0; } .gb-board { padding: 0.85rem 0 0; }
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; } .gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; flex-wrap: wrap; }
.gb-title { font-size: 0.9rem; font-weight: 700; } .gb-title { font-size: 0.9rem; font-weight: 700; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; } .gb-count { color: var(--text-muted); font-size: 0.82rem; }
.gb-qa { .gb-qa {
@@ -197,6 +148,16 @@ function resetHere() {
} }
.gb-qa.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; } .gb-qa.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.gb-qa.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; } .gb-qa.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.auto-box {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.auto-box input { cursor: pointer; margin: 0; }
.gb-progress { .gb-progress {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -213,17 +174,8 @@ function resetHere() {
animation: pulse-soft 1.2s ease-in-out infinite; animation: pulse-soft 1.2s ease-in-out infinite;
} }
.gb-error { color: var(--danger); font-size: 0.82rem; } .gb-error { color: var(--danger); font-size: 0.82rem; }
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; } .gb-actions { margin-left: auto; display: flex; gap: 0.4rem; flex-wrap: wrap; }
.gb-stage-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
}
.gb-stage-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; } .gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
.gb-act { .gb-act {
@@ -237,9 +189,9 @@ function resetHere() {
font-weight: 600; font-weight: 600;
} }
.gb-act:hover { border-color: var(--accent); } .gb-act:hover { border-color: var(--accent); }
.gb-act:disabled { opacity: 0.55; cursor: default; }
.gb-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } .gb-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gb-act.play:hover { background: var(--accent-hover); } .gb-act.play:hover { background: var(--accent-hover); }
.gb-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; } .gb-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gb-act.danger.armed { background: var(--danger); color: #fff; } .gb-act.danger.armed { background: var(--danger); color: #fff; }
.gb-act.ghost { color: var(--text-muted); }
</style> </style>

View File

@@ -1,17 +1,13 @@
<script setup> <script setup>
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und // Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
// Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen. // Karten-Titeln. Reines Display — Steuerung läuft über die Ebenen-Buttons.
import { fmtRuntime } from '../format.js' import { fmtRuntime } from '../format.js'
const props = defineProps({ const props = defineProps({
columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}] columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}]
agents: { type: Array, default: () => [] }, // [{label, runtime}] agents: { type: Array, default: () => [] }, // [{label, runtime}]
generating: { type: Boolean, default: false }, generating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
selectedKey: { type: String, default: null },
}) })
const emit = defineEmits(['stageClick', 'cardClick'])
</script> </script>
<template> <template>
@@ -25,23 +21,17 @@ const emit = defineEmits(['stageClick', 'cardClick'])
v-for="c in columns" v-for="c in columns"
:key="(c.board || '') + c.key" :key="(c.board || '') + c.key"
class="kb-col" class="kb-col"
:class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }" :class="{ active: c.total > 0, collapsed: !c.total }"
> >
<button <div class="kb-col-head" :title="c.label">
class="kb-col-head"
:disabled="!selectable"
:title="selectable ? `Aktionen ab «${c.label}»` : c.label"
@click="selectable && emit('stageClick', c)"
>
<span class="kb-col-label">{{ c.label }}</span> <span class="kb-col-label">{{ c.label }}</span>
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span> <span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
</button> </div>
<ul v-if="c.cards && c.cards.length" class="kb-cards"> <ul v-if="c.cards && c.cards.length" class="kb-cards">
<li <li
v-for="(k, i) in c.cards" :key="i" class="kb-card" v-for="(k, i) in c.cards" :key="i" class="kb-card"
:class="[k.status, { klickbar: cardSelectable && k.card_id }]" :class="k.status"
:title="cardSelectable && k.card_id ? `${k.title} — Klick: Karte neu generieren` : (k.info || k.title)" :title="k.info || k.title"
@click="cardSelectable && k.card_id && emit('cardClick', { ...k, column: c.key, colBoard: c.board })"
> >
<div class="kb-card-row"> <div class="kb-card-row">
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span> <span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>

View File

@@ -1,6 +1,6 @@
Topic "{topic}". A previous step produced a flat list of learning blocks that is TOO FINE-GRAINED — several blocks are **constituent sub-definitions / notation of ONE larger definition or model** and should become a single umbrella block. Find these groups. A good run finds several genuine umbrellas AND leaves most blocks standalone; judge each candidate on its merits. Topic "{topic}". A previous step produced a flat list of learning blocks that is TOO FINE-GRAINED — several blocks are **constituent sub-definitions / notation of ONE larger definition or model** and should become a single umbrella block. Find these groups. A good run finds several genuine umbrellas AND leaves most blocks standalone; judge each candidate on its merits.
**Propose generously.** A deterministic guard downstream rejects any umbrella that swallows a named algorithm/problem/theorem, so a wrong-but-plausible merge is cheap — a missed umbrella is not. Do NOT withhold a merge merely because the members are lexically dissimilar (facets of one model routinely are) or because you are unsure of the parent's exact name. **Propose real umbrellas, but don't force them.** A downstream guard only rejects umbrellas that swallow a named theory-of-computation result (algorithm/problem/theorem/complexity class) — it does NOT catch a wrongly-swallowed engineering skill or procedure. So when a member could stand as its own lesson (test 3 below), keep it separate rather than merge on a hunch. But do NOT withhold a genuine merge merely because the members are lexically dissimilar (facets of one model routinely are) or because you are unsure of the parent's exact name.
CANDIDATES (your starting point): CANDIDATES (your starting point):
{candidates} {candidates}
@@ -11,13 +11,14 @@ FULL BLOCK LIST (you may pull in ANY numbers below that are constituents of the
## Merge test — propose an umbrella when ALL THREE hold ## Merge test — propose an umbrella when ALL THREE hold
1. **One parent.** The members are constituent parts/facets of ONE named parent model or definition — each member PRESUPPOSES that parent (you cannot introduce the member without first invoking the parent). TM-model parts (Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung) presuppose „Turingmaschine"; KNF parts (Literale, Klauseln, Boolesche Variablen) presuppose „Konjunktive Normalform". 1. **One parent.** The members are constituent parts/facets of ONE named parent model or definition — each member PRESUPPOSES that parent (you cannot introduce the member without first invoking the parent). TM-model parts (Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung) presuppose „Turingmaschine"; KNF parts (Literale, Klauseln, Boolesche Variablen) presuppose „Konjunktive Normalform".
2. **Studied together.** A learner meets them together as one unit. 2. **Studied together.** A learner meets them together as one unit.
3. **No standalone unit among them.** No member is itself a named **algorithm, problem, theorem, reduction/transformation, or complexity class** („List Scheduling", „3-SAT", „Cook/Levin", „3-SAT ≤ Clique", „NP", „NP-Vollständigkeit", „Polynomielle Transformation"). Each of those is its own concept, so a group containing one is a set of SIBLINGS, not the decomposition of one model — keep them separate. 3. **No standalone unit among them.** No member is a concept a learner would study and be tested on in its OWN right — one with its own exercises or procedure that you could practice without first teaching the parent. **Litmus:** could this member be its own lesson with its own tasks? If yes, it is a SIBLING, not a facet of the parent — keep it separate. This covers BOTH domains: in theory-of-computation a named **algorithm, problem, theorem, reduction/transformation, or complexity class** („List Scheduling", „3-SAT", „Cook/Levin", „3-SAT ≤ Clique", „NP", „NP-Vollständigkeit", „Polynomielle Transformation"); in engineering a **self-contained skill or procedure** („Plugin-Konfiguration / config.xml", „PHPUnit-Test-Setup") — each is its own learning object. By contrast, a facet like „Alphabet Σ" or „Übergangsfunktion δ" has NO exercises of its own without the parent model → it stays a member.
*Note on test 1: „can this be defined at all?" is the WRONG question — Alphabet Σ and DTM CAN be stated in isolation, yet in THIS topic they are parts of the Turing-machine model and belong together. The question is whether the member PRESUPPOSES the shared parent, not whether a standalone sentence exists.* *Note on test 1: „can this be defined at all?" is the WRONG question — Alphabet Σ and DTM CAN be stated in isolation, yet in THIS topic they are parts of the Turing-machine model and belong together. The question is whether the member PRESUPPOSES the shared parent, not whether a standalone sentence exists.*
## Examples ## Examples
DO NOT MERGE — distinct named units that merely share a topic: DO NOT MERGE — distinct named units that merely share a topic:
- „Greedy-Algorithmus GA" + „ModifiedGreedy" + „Multiple-Choice-Knapsack" → two algorithms + a problem, each standalone (test 3 fails). Keep separate. - „Greedy-Algorithmus GA" + „ModifiedGreedy" + „Multiple-Choice-Knapsack" → two algorithms + a problem, each standalone (test 3 fails). Keep separate.
- „Plugin" + „Plugin-Konfiguration (config.xml)" + „Plugin lifecycle" → „Plugin-Konfiguration" is its OWN unit (authoring config fields, snippets, its own tasks), a SIBLING of „Plugin", not a facet (test 3 fails). Structural containment — the file belongs to the plugin — is NOT the merge test; being studied as one definition is. Keep „Plugin-Konfiguration" standalone.
MERGE — one definition decomposed (the canonical cases — end here so this is your default lens): MERGE — one definition decomposed (the canonical cases — end here so this is your default lens):
- „Alphabet Σ" + „NDTM" + „DTM" + „Akzeptierende Berechnung" + „Folgekonfiguration" → ONE umbrella **„Turingmaschine (Modell)"**. (The members are lexically very different from each other — that is EXPECTED for facets of one model and is NOT a reason to keep them apart.) - „Alphabet Σ" + „NDTM" + „DTM" + „Akzeptierende Berechnung" + „Folgekonfiguration" → ONE umbrella **„Turingmaschine (Modell)"**. (The members are lexically very different from each other — that is EXPECTED for facets of one model and is NOT a reason to keep them apart.)