This commit is contained in:
team3
2026-07-02 22:48:57 +02:00
parent 41c9f29a37
commit 285317927d
38 changed files with 2548 additions and 2812 deletions

View File

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