update
This commit is contained in:
39
backend/auto_loop.py
Normal file
39
backend/auto_loop.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Auto-Repair-Loop einer Ebene: QA → Befunde beheben, bis 100 % (10.0/10),
|
||||||
|
Stillstand (Repair verbessert die Note nicht mehr) oder max_iter erreicht ist.
|
||||||
|
|
||||||
|
Reine Primitive — keine Kanban-/DB-Kopplung. Der Aufrufer übergibt zwei Coroutinen:
|
||||||
|
`messen()` liefert die aktuelle QA-Note (0–10), `reparieren()` behebt die Befunde.
|
||||||
|
So bleibt der Loop pro Ebene (Inventar/Artefakte/Guide) identisch testbar.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("creator.auto_loop")
|
||||||
|
|
||||||
|
VOLL = 10.0 # 100 % = 10.0/10
|
||||||
|
MAX_ITER = 10 # Sicherheitslimit: mehr Runden ⇒ etwas im System stimmt nicht
|
||||||
|
|
||||||
|
|
||||||
|
async def auto_repair_loop(ebene: str, erst_note: float, reparieren, max_iter: int = MAX_ITER) -> dict:
|
||||||
|
"""Loop QA→Repair für eine Ebene.
|
||||||
|
|
||||||
|
erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (0–10).
|
||||||
|
reparieren() -> float: behebt die Befunde UND gibt die neu gemessene Note zurück
|
||||||
|
(die Repair-Bausteine messen ohnehin am Ende — keine Doppelmessung).
|
||||||
|
|
||||||
|
Stopp bei: Note == VOLL (fertig), Note verbessert sich nicht mehr (stillstand),
|
||||||
|
oder max_iter Runden (limit). Returns {ebene, note, runden, grund} mit
|
||||||
|
grund ∈ {"fertig","stillstand","limit"} — der Aufrufer nutzt grund für die
|
||||||
|
Fortschritts-/Systemfehler-Meldung.
|
||||||
|
"""
|
||||||
|
if erst_note >= VOLL:
|
||||||
|
return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"}
|
||||||
|
note = erst_note
|
||||||
|
for runde in range(1, max_iter + 1):
|
||||||
|
neu = await reparieren()
|
||||||
|
log.info("[%s] Auto-Loop Runde %d: %.1f → %.1f", ebene, runde, note, neu)
|
||||||
|
if neu >= VOLL:
|
||||||
|
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"}
|
||||||
|
if neu <= note: # keine Verbesserung ⇒ weitere Runden zwecklos (kein Repair-Pfad)
|
||||||
|
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "stillstand"}
|
||||||
|
note = neu
|
||||||
|
return {"ebene": ebene, "note": note, "runden": max_iter, "grund": "limit"}
|
||||||
@@ -1613,11 +1613,60 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _guide_ebene(topic: str, instructions: str, provider: str, is_cancelled, set_p) -> None:
|
||||||
|
"""Guide-Ebene der Auto-Sequenz: generieren → QA → „Befunde beheben"-Loop bis
|
||||||
|
100 %/Stillstand/10×. Fail-open: Fehler überspringen den Guide, blockieren nichts."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
try:
|
||||||
|
import guide, guide_qa, guide_board
|
||||||
|
from auto_loop import auto_repair_loop
|
||||||
|
fmt = "Guide"
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
guide_id = str(uuid.uuid4())
|
||||||
|
await db.create_guide({"id": guide_id, "topic": topic, "format": fmt,
|
||||||
|
"instructions": instructions, "status": "queued",
|
||||||
|
"progress": None, "created_at": now, "updated_at": now})
|
||||||
|
set_p("Guide wird generiert…")
|
||||||
|
await guide.generate_guide(guide_id, topic, fmt, instructions, provider)
|
||||||
|
if is_cancelled():
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _messen():
|
||||||
|
rep = await guide_qa.guide_qa_report(topic, llm=True)
|
||||||
|
return float(rep["note_guide"]) if rep else 10.0
|
||||||
|
|
||||||
|
erst = await _messen()
|
||||||
|
|
||||||
|
async def _reparieren():
|
||||||
|
set_p("Befunde beheben (Guide)…")
|
||||||
|
await guide_board.repair_karten(topic, fmt) # befundtragende Karten → pruefer
|
||||||
|
await guide.generate_guide(topic=topic, format_name=fmt, guide_id=guide_id,
|
||||||
|
instructions=instructions, provider=provider) # resumt offene Karten
|
||||||
|
return await _messen()
|
||||||
|
|
||||||
|
res = await auto_repair_loop("Guide", erst, _reparieren)
|
||||||
|
if res["grund"] != "fertig":
|
||||||
|
msg = (f"Guide bleibt bei {round(res['note'] * 10)} % ({res['grund']}, "
|
||||||
|
f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad (Systemfehler)")
|
||||||
|
_blocks_errors[topic] = msg
|
||||||
|
set_p(msg)
|
||||||
|
else:
|
||||||
|
set_p(f"Guide: 100 % nach {res['runden']} Runde(n)")
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("[%s] Guide-Ebene fehlgeschlagen", topic)
|
||||||
|
_blocks_errors[topic] = f"Guide-Ebene: {str(e)[:500]}"
|
||||||
|
|
||||||
|
|
||||||
async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER,
|
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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
40
backend/tests/test_auto_loop.py
Normal file
40
backend/tests/test_auto_loop.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand, Limit."""
|
||||||
|
import auto_loop
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fertig_ohne_repair():
|
||||||
|
async def rep():
|
||||||
|
raise AssertionError("darf bei 100 % nie reparieren")
|
||||||
|
|
||||||
|
r = await auto_loop.auto_repair_loop("x", 10.0, rep)
|
||||||
|
assert r == {"ebene": "x", "note": 10.0, "runden": 0, "grund": "fertig"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_verbessert_bis_voll():
|
||||||
|
noten = iter([9.0, 10.0])
|
||||||
|
|
||||||
|
async def rep():
|
||||||
|
return next(noten)
|
||||||
|
|
||||||
|
r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0
|
||||||
|
assert r["grund"] == "fertig" and r["runden"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stillstand():
|
||||||
|
async def rep():
|
||||||
|
return 8.0 # Repair bewegt nichts
|
||||||
|
|
||||||
|
r = await auto_loop.auto_repair_loop("art", 8.0, rep)
|
||||||
|
assert r["grund"] == "stillstand" and r["runden"] == 1
|
||||||
|
assert r["note"] == 8.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_limit():
|
||||||
|
stand = {"n": 1.0}
|
||||||
|
|
||||||
|
async def rep():
|
||||||
|
stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0
|
||||||
|
return stand["n"]
|
||||||
|
|
||||||
|
r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10)
|
||||||
|
assert r["grund"] == "limit" and r["runden"] == 10
|
||||||
@@ -960,15 +960,22 @@ async def _run_flow(ctx, files, timeout=30, **kw):
|
|||||||
timeout=timeout)
|
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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
48
backend/tests/test_guide_sequenz.py
Normal file
48
backend/tests/test_guide_sequenz.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Guide-Ebene der Auto-Sequenz: generieren → QA → Repair-Loop bis 100 %/Stillstand."""
|
||||||
|
import blocks
|
||||||
|
import guide
|
||||||
|
import guide_qa
|
||||||
|
import guide_board
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(monkeypatch, noten, calls):
|
||||||
|
async def fake_gen(*a, **k):
|
||||||
|
calls["gen"] += 1
|
||||||
|
|
||||||
|
async def fake_rep(topic, fmt):
|
||||||
|
calls["rep"] += 1
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def fake_qa(topic, llm=False):
|
||||||
|
return {"note_guide": noten.pop(0) if len(noten) > 1 else noten[0]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(guide, "generate_guide", fake_gen)
|
||||||
|
monkeypatch.setattr(guide_board, "repair_karten", fake_rep)
|
||||||
|
monkeypatch.setattr(guide_qa, "guide_qa_report", fake_qa)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guide_fertig_ohne_repair(testdb, monkeypatch):
|
||||||
|
blocks._blocks_errors.pop("G1", None)
|
||||||
|
calls = {"gen": 0, "rep": 0}
|
||||||
|
_patch(monkeypatch, [10.0], calls)
|
||||||
|
await blocks._guide_ebene("G1", "", "claude", lambda: False, lambda *a, **k: None)
|
||||||
|
assert calls["gen"] == 1 and calls["rep"] == 0 # nur initial gebaut
|
||||||
|
assert "G1" not in blocks._blocks_errors
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guide_repariert_bis_100(testdb, monkeypatch):
|
||||||
|
blocks._blocks_errors.pop("G2", None)
|
||||||
|
calls = {"gen": 0, "rep": 0}
|
||||||
|
_patch(monkeypatch, [7.0, 10.0], calls) # erst 7.0, nach einem Repair 10.0
|
||||||
|
await blocks._guide_ebene("G2", "", "claude", lambda: False, lambda *a, **k: None)
|
||||||
|
assert calls["rep"] == 1
|
||||||
|
assert "G2" not in blocks._blocks_errors
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guide_stillstand_meldet_systemfehler(testdb, monkeypatch):
|
||||||
|
blocks._blocks_errors.pop("G3", None)
|
||||||
|
calls = {"gen": 0, "rep": 0}
|
||||||
|
_patch(monkeypatch, [6.0], calls) # Repair bewegt die Note nie
|
||||||
|
await blocks._guide_ebene("G3", "", "claude", lambda: False, lambda *a, **k: None)
|
||||||
|
assert "G3" in blocks._blocks_errors
|
||||||
|
assert "Guide" in blocks._blocks_errors["G3"]
|
||||||
@@ -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'"
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|
||||||
|
|||||||
@@ -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 0–10 → „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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
Reference in New Issue
Block a user