update
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"""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
|
||||
lernziele judge-Rolle Backward Design — objectives BEFORE writing
|
||||
zuweisung code chapter/order from the outline artefact + facts grounding
|
||||
writer guide-Rolle ONE coherent per-block text, only from VERIFIED FACTS
|
||||
fakten_gate judge-Rolle CoVe: atomic claims, each binary against the facts → minimal fix
|
||||
coverage judge-Rolle objective↔section mapping; gap → back to writer (max 2 rounds)
|
||||
lesbarkeit judge-Rolle 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
|
||||
@@ -20,7 +20,7 @@ import re
|
||||
|
||||
import database as db
|
||||
import readability
|
||||
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR
|
||||
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||
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,
|
||||
@@ -34,7 +34,10 @@ STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "W
|
||||
"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)
|
||||
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
|
||||
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
|
||||
# from the old 10-slot era while the .env already allowed 24).
|
||||
CARD_CONCURRENCY = MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||
|
||||
|
||||
def _safe(norm: str) -> str:
|
||||
@@ -142,6 +145,15 @@ def _card_assignment(env: _Env, card: dict) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Live info per active card (in-memory): what the card is doing RIGHT NOW —
|
||||
# board_snapshot shows it as the info line while status == active.
|
||||
_live_info: dict[tuple[str, str, str], str] = {}
|
||||
|
||||
|
||||
def _live(env: _Env, card: dict, msg: str) -> None:
|
||||
_live_info[(env.topic, env.format, card["block_norm"])] = msg
|
||||
|
||||
|
||||
async def _set(env: _Env, card: dict, **fields):
|
||||
card.update(fields)
|
||||
await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)
|
||||
@@ -179,6 +191,82 @@ async def _stage_zuweisung(env: _Env, card: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# A single section over ~45 subs measurably breaks the writer/coverage (Front Matter:
|
||||
# 4/6 objectives open after 2 rounds). First drafts of oversized cards are written in two
|
||||
# halves and merged back into ONE canonical section (all gates/assembly read one section).
|
||||
WRITER_SPLIT_SUBS = 30
|
||||
|
||||
|
||||
def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
|
||||
"""Rebuild ONE canonical fragment from two half-sections: header + anchor from part A,
|
||||
sub blocks of both parts in order, both layers. Part B's framing is dropped — its
|
||||
prompt forbids an intro; keeping it would inject a second lead-in mid-section."""
|
||||
lines = []
|
||||
if sec_a.get("chapters"):
|
||||
lines.append(f"<!-- kapitel: {sec_a['chapters']} -->")
|
||||
lines.append(f"<!-- section: {sec_a['title']} -->")
|
||||
lines.append("<!-- compact -->")
|
||||
if sec_a.get("anker_compact"):
|
||||
lines.append(sec_a["anker_compact"])
|
||||
for sub in [*sec_a["subs"], *sec_b["subs"]]:
|
||||
if sub.get("compact"):
|
||||
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
|
||||
lines.append(sub["compact"])
|
||||
lines.append("<!-- ausführlich -->")
|
||||
if sec_a.get("anchor"):
|
||||
lines.append(sec_a["anchor"])
|
||||
for sub in [*sec_a["subs"], *sec_b["subs"]]:
|
||||
if sub.get("md"):
|
||||
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
|
||||
lines.append(sub["md"])
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
async def _write_split(env: _Env, card: dict, ziele_text: str):
|
||||
"""First draft in two halves (parallel), merged into one section.
|
||||
→ merged text | None (failed) | False (cancelled)."""
|
||||
from guide import _level_label
|
||||
norm = card["block_norm"]
|
||||
subs = env.subs_by_title.get(card["block"], [])
|
||||
half = (len(subs) + 1) // 2
|
||||
parts = (subs[:half], subs[half:])
|
||||
hints = (
|
||||
"TEIL 1/2: Schreibe den Abschnitts-EINSTIEG und die folgenden Unterpunkte. "
|
||||
"Weitere Unterpunkte folgen in Teil 2 — KEIN Fazit, KEIN Ausblick am Ende.",
|
||||
"TEIL 2/2: FORTSETZUNG desselben Abschnitts. KEIN neuer Einstieg, KEINE "
|
||||
"Wiederholung von Teil 1 — direkt mit den Unterpunkten weitermachen.",
|
||||
)
|
||||
|
||||
async def _one(i):
|
||||
assignment = "\n".join([f"- {card['block']}"]
|
||||
+ [f" [{_level_label(s)}] {s['title']}" for s in parts[i]])
|
||||
path = env.slot(f"card-{_safe(norm)}-r0-{'ab'[i]}.md")
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _payload(result, p=path):
|
||||
t = p.read_text(encoding="utf-8") if p.exists() else ""
|
||||
sec = _first_section(t)
|
||||
return t if sec and sec.get("md", "").strip() else None
|
||||
|
||||
return await run_single_slot(
|
||||
env.ctx, f"Writer {card['block']} ({i + 1}/2)",
|
||||
key=f"{env.guide_id}-w-{_safe(norm)}-r0-{'ab'[i]}",
|
||||
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
|
||||
chapter=card.get("chapter") or "Inhalte",
|
||||
assignment=assignment, ziele=ziele_text,
|
||||
facts=_card_facts(env, card["block"]), gaps="\n" + hints[i] + "\n",
|
||||
spec=env.spec, out_path=path, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_payload,
|
||||
timeout=_timeout("writer", 1))
|
||||
|
||||
results = await asyncio.gather(_one(0), _one(1))
|
||||
if any(s == CANCELLED for s, _ in results):
|
||||
return False
|
||||
if any(s == FAILED for s, _ in results):
|
||||
return None
|
||||
return _merge_split_sections(_first_section(results[0][1]), _first_section(results[1][1]))
|
||||
|
||||
|
||||
async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
ziele = await db.list_lernziele(env.topic, norm)
|
||||
@@ -188,6 +276,17 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
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")
|
||||
# oversized first drafts: two halves, merged into one canonical section
|
||||
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
|
||||
text = await _write_split(env, card, ziele_text)
|
||||
if text is False:
|
||||
return False
|
||||
if text is None:
|
||||
await _set(env, card, status="error", gate_info="Writer (Split) ohne Ergebnis")
|
||||
return False
|
||||
await _set(env, card, md=text, stage="fakten_gate", status="open")
|
||||
return True
|
||||
|
||||
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
@@ -251,7 +350,7 @@ async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
|
||||
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)
|
||||
role="guide", capabilities="files", payload=_fixload, # Fix auf der Schreib-Rolle, Gate auf der Judge-Rolle
|
||||
timeout=_timeout("fakten_gate", 1))
|
||||
if fstatus == CANCELLED:
|
||||
return False
|
||||
@@ -372,23 +471,31 @@ _STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
|
||||
|
||||
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])
|
||||
try:
|
||||
await _run_card_inner(env, card)
|
||||
finally:
|
||||
_live_info.pop((env.topic, env.format, card["block_norm"]), None)
|
||||
|
||||
|
||||
async def _run_card_inner(env: _Env, card: dict) -> None:
|
||||
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
|
||||
_live(env, card, STAGE_LABELS.get(card["stage"], card["stage"]) + "…")
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -498,16 +605,35 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
|
||||
views = []
|
||||
for c in in_stage[:limit]:
|
||||
zc = ziele.get(c["block_norm"])
|
||||
views.append({"title": c["block"],
|
||||
info = c["gate_info"][:200] if c["status"] == "error" else ""
|
||||
if c["status"] == "active":
|
||||
info = _live_info.get((topic, format_name, c["block_norm"]), "") or info
|
||||
views.append({"title": c["block"], "card_id": c["block_norm"],
|
||||
"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 "",
|
||||
"info": info,
|
||||
"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_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))
|
||||
|
||||
Reference in New Issue
Block a user