update
This commit is contained in:
521
backend/guide_board.py
Normal file
521
backend/guide_board.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""Board 3 „Guide": one card per block, linear stages with gates between them.
|
||||
|
||||
lernziele judge Backward Design — objectives BEFORE writing
|
||||
zuweisung code chapter/order from the outline artefact + facts grounding
|
||||
writer guide ONE coherent per-block text, only from VERIFIED FACTS
|
||||
fakten_gate judge CoVe: atomic claims, each binary against the facts → minimal fix
|
||||
coverage judge objective↔section mapping; gap → back to writer (max 2 rounds)
|
||||
lesbarkeit judge Lese-Check + deterministic readability gate → fix → done
|
||||
|
||||
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
|
||||
needed); stage transitions are persisted in guide_cards, so the board is live and
|
||||
cancel/resume just picks cards up at their stored stage. Assembly keeps the exact
|
||||
legacy content format → content_fuer_level / TopicDetail stay untouched.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import database as db
|
||||
import readability
|
||||
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||
_timeout, is_guide_cancelled, run_single_slot)
|
||||
from textkit import _norm_title, _parse_fragment, _title
|
||||
|
||||
log = logging.getLogger("creator.guide_board")
|
||||
|
||||
GUIDE_STAGES = ("lernziele", "zuweisung", "writer", "fakten_gate", "coverage", "lesbarkeit")
|
||||
STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "Writer",
|
||||
"fakten_gate": "Fakten-Gate", "coverage": "Coverage",
|
||||
"lesbarkeit": "Lesbarkeit", "done": "Fertig"}
|
||||
MAX_WRITER_ROUNDS = 2 # coverage → writer feedback loop cap (gains die after round 1–2)
|
||||
CARD_CONCURRENCY = 10 # simultaneous cards (the per-topic agent semaphore is the hard cap)
|
||||
|
||||
|
||||
def _safe(norm: str) -> str:
|
||||
return re.sub(r"\W+", "_", norm)[:50] or "block"
|
||||
|
||||
|
||||
def _ziele_schema(data):
|
||||
"""{"ziele":[{id,text,sub}]} → list of dicts · None on invalid structure."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("ziele"), list) or not data["ziele"]:
|
||||
return None
|
||||
out, seen = [], set()
|
||||
for z in data["ziele"]:
|
||||
if not isinstance(z, dict):
|
||||
return None
|
||||
zid = str(z.get("id", "")).strip()
|
||||
text = str(z.get("text", "")).strip()
|
||||
if not zid or not text or zid in seen or len(out) >= 12:
|
||||
continue
|
||||
seen.add(zid)
|
||||
out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()})
|
||||
return out or None
|
||||
|
||||
|
||||
def _gate_schema(data):
|
||||
"""{"ok":true} → [] · {"claims":[{text,grund}]} → list · None invalid."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
claims = data.get("claims")
|
||||
if not isinstance(claims, list) or not claims:
|
||||
return None
|
||||
out = []
|
||||
for c in claims:
|
||||
if isinstance(c, dict) and str(c.get("text", "")).strip():
|
||||
out.append({"text": str(c["text"]).strip(), "grund": str(c.get("grund", "")).strip()})
|
||||
return out
|
||||
|
||||
|
||||
def _coverage_schema(data, ziel_ids: set[str]):
|
||||
"""{"ziele":{id:bool}, "luecken":[{ziel,fehlt}], "ballast":[str]} — ziele must cover all ids."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("ziele"), dict):
|
||||
return None
|
||||
ziele = {}
|
||||
for k, v in data["ziele"].items():
|
||||
ziele[str(k)] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
|
||||
if not ziel_ids <= set(ziele):
|
||||
return None
|
||||
luecken = [{"ziel": str(l.get("ziel", "")), "fehlt": str(l.get("fehlt", ""))}
|
||||
for l in data.get("luecken", []) if isinstance(l, dict) and str(l.get("fehlt", "")).strip()]
|
||||
ballast = [str(b).strip() for b in data.get("ballast", []) if str(b).strip()]
|
||||
return {"ziele": ziele, "luecken": luecken, "ballast": ballast}
|
||||
|
||||
|
||||
def _problems_schema(data):
|
||||
"""Lese-Check: {"ok":true} → [] · {"problems":[{section,problem}]} → [problem…]."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
probs = data.get("problems")
|
||||
if not isinstance(probs, list) or not probs:
|
||||
return None
|
||||
out = [str(p.get("problem", "")).strip() for p in probs
|
||||
if isinstance(p, dict) and str(p.get("problem", "")).strip()]
|
||||
return out or None
|
||||
|
||||
|
||||
def _first_section(md: str) -> dict | None:
|
||||
secs = _parse_fragment(md) if md else []
|
||||
return secs[0] if secs else None
|
||||
|
||||
|
||||
class _Env:
|
||||
"""Shared per-run context for the card tasks."""
|
||||
|
||||
def __init__(self, ctx, guide_id, topic, format_name, instructions, content_path,
|
||||
subs_by_title, chapter_map, fallback_facts, spec):
|
||||
self.ctx = ctx
|
||||
self.guide_id = guide_id
|
||||
self.topic = topic
|
||||
self.format = format_name
|
||||
self.instructions = instructions
|
||||
self.content_path = content_path
|
||||
self.subs_by_title = subs_by_title # block title → [sub dicts]
|
||||
self.chapter_map = chapter_map # block_norm → (chapter title, ord)
|
||||
self.fallback_facts = fallback_facts # generic source hint (legacy topics without facts)
|
||||
self.spec = spec
|
||||
|
||||
def slot(self, name: str):
|
||||
return self.content_path.parent / f"{self.content_path.stem}.{name}"
|
||||
|
||||
|
||||
def _card_facts(env: _Env, block_title: str) -> str:
|
||||
from guide import _facts_grounding # lazy: guide imports this module
|
||||
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
|
||||
return grounding or env.fallback_facts
|
||||
|
||||
|
||||
def _card_assignment(env: _Env, card: dict) -> str:
|
||||
from guide import _level_label
|
||||
lines = [f"- {card['block']}"]
|
||||
for s in env.subs_by_title.get(card["block"], []):
|
||||
lines.append(f" [{_level_label(s)}] {s['title']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _set(env: _Env, card: dict, **fields):
|
||||
card.update(fields)
|
||||
await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)
|
||||
|
||||
|
||||
# ── Stages ─────────────────────────────────────────────────────────────────────────
|
||||
async def _stage_lernziele(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
if not await db.list_lernziele(env.topic, norm):
|
||||
path = env.slot(f"ziele-{_safe(norm)}.json")
|
||||
subs = "\n".join(f"- [{s.get('level', 'beginner')}] {s['title']}"
|
||||
for s in env.subs_by_title.get(card["block"], [])) or "(keine)"
|
||||
status, ziele = await run_single_slot(
|
||||
env.ctx, f"Lernziele {card['block']}", key=f"{env.guide_id}-ziele-{_safe(norm)}",
|
||||
prompt=_prompt("Guide-Lernziele", topic=env.topic, block=card["block"],
|
||||
subs=subs, facts=_card_facts(env, card["block"]),
|
||||
out_path=path, extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _ziele_schema(_json_file(path)),
|
||||
timeout=_timeout("lernziele", len(env.subs_by_title.get(card["block"], []))))
|
||||
if status == CANCELLED:
|
||||
return False
|
||||
if status == FAILED:
|
||||
await _set(env, card, status="error", gate_info="Lernziele ohne Ergebnis")
|
||||
return False
|
||||
for z in ziele:
|
||||
await db.put_lernziel(env.topic, norm, z["id"], z["text"], _norm_title(z["sub"]))
|
||||
await _set(env, card, stage="zuweisung", status="open")
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_zuweisung(env: _Env, card: dict) -> bool:
|
||||
chapter, ord_ = env.chapter_map.get(card["block_norm"], ("Weitere Inhalte", 10_000))
|
||||
await _set(env, card, chapter=chapter, ord=ord_, stage="writer")
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
ziele = await db.list_lernziele(env.topic, norm)
|
||||
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
|
||||
gaps = ""
|
||||
if card["writer_rounds"] > 0 and card.get("gate_info"):
|
||||
gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
|
||||
"below, cut the listed ballast, keep everything else as-is.\n"
|
||||
f"PREVIOUS VERSION:\n{card.get('md', '')}\n\nGAPS/BALLAST:\n{card['gate_info']}\n")
|
||||
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _payload(result):
|
||||
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
sec = _first_section(text)
|
||||
return text if sec and sec.get("md", "").strip() else None
|
||||
|
||||
status, text = await run_single_slot(
|
||||
env.ctx, f"Writer {card['block']}", key=f"{env.guide_id}-w-{_safe(norm)}-r{card['writer_rounds']}",
|
||||
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
|
||||
chapter=card.get("chapter") or "Inhalte",
|
||||
assignment=_card_assignment(env, card), ziele=ziele_text,
|
||||
facts=_card_facts(env, card["block"]), gaps=gaps, spec=env.spec,
|
||||
out_path=path, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_payload,
|
||||
timeout=_timeout("writer", 1))
|
||||
if status == CANCELLED:
|
||||
return False
|
||||
if status == FAILED:
|
||||
await _set(env, card, status="error", gate_info="Writer ohne Ergebnis")
|
||||
return False
|
||||
await _set(env, card, md=text, stage="fakten_gate", status="open")
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
sec = _first_section(card["md"])
|
||||
if sec is None:
|
||||
await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar")
|
||||
return False
|
||||
facts = _card_facts(env, card["block"])
|
||||
path = env.slot(f"gate-{_safe(norm)}-r{card['writer_rounds']}.json")
|
||||
status, claims = await run_single_slot(
|
||||
env.ctx, f"Fakten-Gate {card['block']}", key=f"{env.guide_id}-gate-{_safe(norm)}-r{card['writer_rounds']}",
|
||||
prompt=_prompt("Guide-Fakten-Gate", topic=env.topic, block=card["block"],
|
||||
section=sec["md"], facts=facts, out_path=path,
|
||||
extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _gate_schema(_json_file(path)),
|
||||
timeout=_timeout("fakten_gate", 1))
|
||||
if status == CANCELLED:
|
||||
return False
|
||||
if status == FAILED:
|
||||
claims = [] # gate failure must not block the card — logged, text stands
|
||||
_log(env.topic, f"Fakten-Gate {card['block']}: kein Ergebnis — Text bleibt ungeprüft")
|
||||
if claims:
|
||||
_log(env.topic, f"Fakten-Gate {card['block']}: {len(claims)} unbelegte Claims → Fix")
|
||||
fixp = env.slot(f"gatefix-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||
fixp.unlink(missing_ok=True)
|
||||
claims_text = "\n".join(f"- {c['text']}" + (f" ({c['grund']})" if c["grund"] else "")
|
||||
for c in claims)
|
||||
|
||||
def _fixload(result):
|
||||
text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
|
||||
return text if _first_section(text) else None
|
||||
|
||||
fstatus, fixed = await run_single_slot(
|
||||
env.ctx, f"Fakten-Fix {card['block']}", key=f"{env.guide_id}-gatefix-{_safe(norm)}-r{card['writer_rounds']}",
|
||||
prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
|
||||
section=card["md"], claims=claims_text, facts=facts,
|
||||
out_path=fixp, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_fixload, # Fix ≠ Gate-Modell (kein Selbst-Check)
|
||||
timeout=_timeout("fakten_gate", 1))
|
||||
if fstatus == CANCELLED:
|
||||
return False
|
||||
if fstatus == OK and fixed:
|
||||
new_sec = _first_section(fixed)
|
||||
# marker invariant: a fix that loses the sub markers kills the level filter → discard
|
||||
if sec.get("subs") and not (new_sec and new_sec.get("subs")):
|
||||
_log(env.topic, f"Fakten-Fix {card['block']} ohne Sub-Marker — verworfen")
|
||||
else:
|
||||
card["md"] = fixed
|
||||
await _set(env, card, md=card["md"], stage="coverage", status="open")
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_coverage(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
ziele = await db.list_lernziele(env.topic, norm)
|
||||
if not ziele:
|
||||
await _set(env, card, stage="lesbarkeit")
|
||||
return True
|
||||
sec = _first_section(card["md"])
|
||||
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele)
|
||||
path = env.slot(f"coverage-{_safe(norm)}-r{card['writer_rounds']}.json")
|
||||
ids = {z["ziel_id"] for z in ziele}
|
||||
status, res = await run_single_slot(
|
||||
env.ctx, f"Coverage {card['block']}", key=f"{env.guide_id}-cov-{_safe(norm)}-r{card['writer_rounds']}",
|
||||
prompt=_prompt("Guide-Coverage", topic=env.topic, block=card["block"],
|
||||
ziele=ziele_text, section=sec["md"] if sec else card["md"],
|
||||
out_path=path, extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _coverage_schema(_json_file(path), ids),
|
||||
timeout=_timeout("coverage", len(ziele)))
|
||||
if status == CANCELLED:
|
||||
return False
|
||||
if status == FAILED:
|
||||
_log(env.topic, f"Coverage {card['block']}: kein Ergebnis — weiter ohne Gate")
|
||||
await _set(env, card, stage="lesbarkeit")
|
||||
return True
|
||||
for zid, ok in res["ziele"].items():
|
||||
if zid in ids:
|
||||
await db.set_ziel_covered(env.topic, norm, zid, ok)
|
||||
if res["luecken"] and card["writer_rounds"] < MAX_WRITER_ROUNDS:
|
||||
info = "\n".join(f"- Lücke ({l['ziel']}): {l['fehlt']}" for l in res["luecken"])
|
||||
if res["ballast"]:
|
||||
info += "\n" + "\n".join(f"- Ballast (kürzen): {b}" for b in res["ballast"])
|
||||
_log(env.topic, f"Coverage {card['block']}: {len(res['luecken'])} Lücke(n) → Writer-Runde "
|
||||
f"{card['writer_rounds'] + 1}")
|
||||
await _set(env, card, writer_rounds=card["writer_rounds"] + 1, gate_info=info,
|
||||
stage="writer", status="open")
|
||||
return True
|
||||
if res["luecken"]:
|
||||
_log(env.topic, f"Coverage {card['block']}: Lücken bleiben nach {MAX_WRITER_ROUNDS} Runden")
|
||||
await _set(env, card, gate_info="", stage="lesbarkeit")
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_lesbarkeit(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
sec = _first_section(card["md"])
|
||||
if sec is None:
|
||||
await _set(env, card, status="error", gate_info="Fragment unlesbar")
|
||||
return False
|
||||
problems: list[str] = []
|
||||
path = env.slot(f"lese-{_safe(norm)}-r{card['writer_rounds']}.json")
|
||||
status, res = await run_single_slot(
|
||||
env.ctx, f"Lese-Check {card['block']}", key=f"{env.guide_id}-lese-{_safe(norm)}",
|
||||
prompt=_prompt("Guide-Lese-Check", topic=env.topic, format_name=env.format,
|
||||
spec=env.spec, sections=f"SECTION: {card['block']}\n{sec['md']}",
|
||||
out_path=path, extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _problems_schema(_json_file(path)),
|
||||
timeout=_timeout("lese_check", 1))
|
||||
if status == CANCELLED:
|
||||
return False
|
||||
if status == OK and res:
|
||||
problems += res
|
||||
if READABILITY_ACTIVE: # deterministic gate, external grounding
|
||||
hints = await asyncio.to_thread(readability.rate_sections, {1: sec["md"]})
|
||||
if hints.get(1):
|
||||
problems.append(hints[1])
|
||||
if problems:
|
||||
from guide import _level_label
|
||||
subs = env.subs_by_title.get(card["block"], [])
|
||||
sub_list = "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
|
||||
tasks = (f"SECTION: {card['block']}\n"
|
||||
f"SUBBLOCKS (set one `<!-- sub: LABEL | title -->` marker each, label/order as here):\n{sub_list}\n"
|
||||
f"PROBLEM: {' · '.join(problems)}\nCURRENT CONTENT:\n{sec['md']}")
|
||||
fixp = env.slot(f"lesefix-{_safe(norm)}.md")
|
||||
fixp.unlink(missing_ok=True)
|
||||
|
||||
def _fixload(result):
|
||||
text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
|
||||
return text if _first_section(text) else None
|
||||
|
||||
fstatus, fixed = await run_single_slot(
|
||||
env.ctx, f"Lese-Fix {card['block']}", key=f"{env.guide_id}-lesefix-{_safe(norm)}",
|
||||
prompt=_prompt("Guide-Sections-Fix", topic=env.topic, format_name=env.format,
|
||||
facts=_card_facts(env, card["block"]), spec=env.spec, tasks=tasks,
|
||||
out_path=fixp, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_fixload,
|
||||
timeout=_timeout("writer", 1))
|
||||
if fstatus == CANCELLED:
|
||||
return False
|
||||
if fstatus == OK and fixed:
|
||||
new_sec = _first_section(fixed)
|
||||
if sec.get("subs") and not (new_sec and new_sec.get("subs")):
|
||||
_log(env.topic, f"Lese-Fix {card['block']} ohne Sub-Marker — verworfen")
|
||||
else:
|
||||
card["md"] = fixed
|
||||
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
|
||||
return True
|
||||
|
||||
|
||||
_STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
|
||||
"writer": _stage_writer, "fakten_gate": _stage_fakten_gate,
|
||||
"coverage": _stage_coverage, "lesbarkeit": _stage_lesbarkeit}
|
||||
|
||||
|
||||
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
|
||||
async with sem:
|
||||
while card["stage"] != "done":
|
||||
if is_guide_cancelled(env.guide_id):
|
||||
await _set(env, card, status="open") # no longer being worked
|
||||
return
|
||||
fn = _STAGE_FN.get(card["stage"])
|
||||
if fn is None: # unknown stage → park as error
|
||||
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
|
||||
return
|
||||
if card["status"] != "active":
|
||||
await _set(env, card, status="active") # live board: this card is being worked
|
||||
try:
|
||||
if not await fn(env, card):
|
||||
return
|
||||
except Exception as e:
|
||||
log.exception("[%s] guide card %s failed", env.topic, card["block"])
|
||||
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
|
||||
return
|
||||
|
||||
|
||||
# ── Orchestration ──────────────────────────────────────────────────────────────────
|
||||
async def _chapter_map(topic: str, entries: dict[int, str]) -> dict[str, tuple[str, int]]:
|
||||
"""block_norm → (chapter title, global order) from the outline artefact."""
|
||||
from guide import _outline_from_db, _fallback_outline, _with_remainder
|
||||
plan = await _outline_from_db(topic, entries) or _fallback_outline(entries)
|
||||
plan = _with_remainder(plan, entries)
|
||||
out: dict[str, tuple[str, int]] = {}
|
||||
i = 0
|
||||
for ch in plan:
|
||||
for num in ch.get("nums", []):
|
||||
if num in entries:
|
||||
out[_norm_title(_title(entries[num]))] = (ch.get("title") or "Kapitel", i)
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
async def run_guide_board(guide_id: str, topic: str, format_name: str, entries: dict[int, str],
|
||||
instructions: str, provider: str, content_path) -> list[dict] | None:
|
||||
"""Seed one card per block (existing cards keep their stage — resume), run all cards,
|
||||
assemble the chapters in the legacy content format. → chapters | None (cancel/empty)."""
|
||||
from blocks import source_folder
|
||||
from guide import _load_subblocks
|
||||
ctx = GenContext(topic=topic, provider=provider,
|
||||
is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
|
||||
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||||
subs_raw = await _load_subblocks(topic)
|
||||
project = source_folder(topic)
|
||||
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
|
||||
else _prompt("Guide-Facts-Thema"))
|
||||
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
|
||||
subs_raw, await _chapter_map(topic, entries), fallback, spec)
|
||||
for num, line in entries.items():
|
||||
title = _title(line)
|
||||
await db.upsert_guide_card(topic, format_name, _norm_title(title), title)
|
||||
cards = await db.list_guide_cards(topic, format_name)
|
||||
open_cards = [c for c in cards if c["stage"] != "done"]
|
||||
if open_cards:
|
||||
sem = asyncio.Semaphore(CARD_CONCURRENCY)
|
||||
|
||||
async def _progress():
|
||||
while True:
|
||||
counts = await db.guide_stage_counts(topic, format_name)
|
||||
done = counts.get("done", 0)
|
||||
total = sum(counts.values())
|
||||
await db.update_guide(guide_id, progress=f"Board: {done}/{total} Karten fertig")
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
reporter = asyncio.create_task(_progress())
|
||||
try:
|
||||
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
|
||||
finally:
|
||||
reporter.cancel()
|
||||
if is_guide_cancelled(guide_id):
|
||||
return None
|
||||
# assembly — identical shape to the legacy pipeline
|
||||
cards = await db.list_guide_cards(topic, format_name)
|
||||
chapters: list[dict] = []
|
||||
by_chapter: dict[str, list[dict]] = {}
|
||||
order: list[str] = []
|
||||
for c in sorted(cards, key=lambda c: (c["ord"], c["block_norm"])):
|
||||
if c["stage"] != "done":
|
||||
_log(topic, f"Guide: Karte '{c['block']}' nicht fertig ({c['stage']}) — Abschnitt fehlt")
|
||||
continue
|
||||
sec = _first_section(c["md"])
|
||||
if sec is None:
|
||||
continue
|
||||
ch = c["chapter"] or "Inhalte"
|
||||
if ch not in by_chapter:
|
||||
by_chapter[ch] = []
|
||||
order.append(ch)
|
||||
by_chapter[ch].append({
|
||||
"num": c["ord"], "title": c["block"], "md": sec["md"],
|
||||
"compact": sec.get("compact", ""), "anchor": sec.get("anchor", ""),
|
||||
"anker_compact": sec.get("anker_compact", ""), "subs": sec.get("subs", []),
|
||||
"checkable": format_name == "Guide" or bool(
|
||||
any(s.get("relevance") == "relevant" for s in subs_raw.get(c["block"], []))),
|
||||
})
|
||||
for ch in order:
|
||||
chapters.append({"title": ch, "sections": by_chapter[ch]})
|
||||
return chapters or None
|
||||
|
||||
|
||||
async def done_step(topic: str, format_name: str) -> int:
|
||||
"""Sidebar dots: highest fully completed stage index. -1 = nothing, len(stages) at done."""
|
||||
counts = await db.guide_stage_counts(topic, format_name)
|
||||
if not counts:
|
||||
return -1
|
||||
if set(counts) == {"done"}:
|
||||
return len(GUIDE_STAGES)
|
||||
lowest = min(GUIDE_STAGES.index(s) for s in counts if s in GUIDE_STAGES)
|
||||
return lowest - 1 if lowest > 0 else -1
|
||||
|
||||
|
||||
async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
|
||||
"""Live guide board: columns with counts + cards (title, rounds, covered objectives)."""
|
||||
cards = await db.list_guide_cards(topic, format_name)
|
||||
ziele = {}
|
||||
for z in await db.list_lernziele(topic):
|
||||
d = ziele.setdefault(z["block_norm"], [0, 0])
|
||||
d[1] += 1
|
||||
d[0] += 1 if z["covered"] else 0
|
||||
columns = []
|
||||
for stage in (*GUIDE_STAGES, "done"):
|
||||
in_stage = [c for c in cards if c["stage"] == stage]
|
||||
views = []
|
||||
for c in in_stage[:limit]:
|
||||
zc = ziele.get(c["block_norm"])
|
||||
views.append({"title": c["block"],
|
||||
"status": c["status"] if c["status"] in ("error", "active") else "open",
|
||||
"rounds": c["writer_rounds"],
|
||||
"info": c["gate_info"][:200] if c["status"] == "error" else "",
|
||||
"ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
|
||||
columns.append({"key": stage, "label": STAGE_LABELS[stage],
|
||||
"total": len(in_stage), "cards": views})
|
||||
return {"columns": columns}
|
||||
|
||||
|
||||
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:
|
||||
for c in await db.list_guide_cards(topic, format_name):
|
||||
await db.delete_lernziele(topic, c["block_norm"])
|
||||
moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target,
|
||||
clear_md=ab_stage <= 2)
|
||||
return moved
|
||||
Reference in New Issue
Block a user