update
This commit is contained in:
@@ -46,6 +46,16 @@ def active_agents(scope_prefix: str | None = None) -> list[dict]:
|
||||
and (not scope_prefix or k.startswith(scope_prefix))]
|
||||
return sorted(out, key=lambda a: -a["runtime"])
|
||||
|
||||
|
||||
# Board-2-Calls tragen diese Marker im agent_key; alles andere unter blocks-{topic}- ist Board 1.
|
||||
_ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix",
|
||||
"-sub-crossblock", "-outline")
|
||||
|
||||
|
||||
def agent_ebene(key: str) -> str:
|
||||
"""Generierungs-Ebene eines Blocks-Agenten (für Board-Anzeige/Accounting): inventory|artefacts."""
|
||||
return "artefacts" if any(m in key for m in _ARTEFAKT_MARKER) else "inventory"
|
||||
|
||||
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
||||
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
|
||||
# in the semaphore queue are also stopped immediately on abort instead of still starting.
|
||||
@@ -366,6 +376,8 @@ async def run_agent(
|
||||
if on_event is not None and scope is not None: # batch pipeline only, never fatal
|
||||
try:
|
||||
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
|
||||
if agent_key.startswith("blocks-"): # Ebene fürs Per-Board-Accounting
|
||||
meta["board"] = agent_ebene(agent_key)
|
||||
if err_tail:
|
||||
meta["stderr"] = err_tail
|
||||
if api_tokens: # direct-API path: usage from the response, even on rc!=0
|
||||
|
||||
@@ -1661,7 +1661,7 @@ async def _guide_ebene(topic: str, instructions: str, provider: str, is_cancelle
|
||||
async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER,
|
||||
research: bool = True, qa_force: bool = False,
|
||||
auto_inventory: bool = True, auto_artefacts: bool = True,
|
||||
auto_guide: bool = True) -> None:
|
||||
auto_guide: bool = True, only_artefacts: bool = False) -> None:
|
||||
"""Kanban entry point: source prep, then both boards (inventory + artefacts) until
|
||||
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.
|
||||
@@ -1699,7 +1699,7 @@ async def generate_blocks(topic: str, instructions: str = "", provider: str = DE
|
||||
ok = await board_inventory.run_boards(ctx, set_p, files, q, folder, instructions,
|
||||
research=research, qa_force=qa_force,
|
||||
auto_inventory=auto_inventory, auto_artefacts=auto_artefacts,
|
||||
status_out=status)
|
||||
inventory=not only_artefacts, status_out=status)
|
||||
if not ok and is_cancelled():
|
||||
_blocks_errors[topic] = "Cancelled — progress is preserved"
|
||||
elif ok and auto_guide and status.get("weiter") and not is_cancelled():
|
||||
|
||||
@@ -103,6 +103,21 @@ def make_spawner(topic: str, files: dict):
|
||||
return spawn
|
||||
|
||||
|
||||
async def seed_artefact_cards(topic: str, files: dict) -> int:
|
||||
"""Board-2-only (Inventar läuft NICHT mit): für jeden fertigen Block eine ablock-Karte
|
||||
erzeugen, falls noch keine existiert — sonst liefe ohne den Board-1-Spawn-Hook nichts
|
||||
durch Board 2. Vorhandene Karten bleiben (sie resümieren in ihrer Spalte). → neue Karten."""
|
||||
spawn = make_spawner(topic, files)
|
||||
existing = {c["card_id"] for c in await db.kanban_cards(topic, board=BOARD, kind="ablock")}
|
||||
n = 0
|
||||
for c in await db.kanban_cards(topic, board="inventory", kind="block", stage="done_block"):
|
||||
norm = c["payload"].get("mirrored_norm")
|
||||
if norm and norm not in existing:
|
||||
await spawn(c["card_id"], c["payload"])
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
async def _gather_cards(ctx: GenContext, flow: Flow, cards, one):
|
||||
results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
|
||||
errs = [r for r in results if isinstance(r, Exception)]
|
||||
|
||||
@@ -1682,8 +1682,10 @@ async def _preload_state(flow: Flow):
|
||||
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,
|
||||
auto_inventory: bool = True, auto_artefacts: bool = True,
|
||||
status_out: dict | None = None) -> bool:
|
||||
inventory: bool = True, status_out: dict | None = None) -> bool:
|
||||
"""Run the inventory board (plus board 2 „Artefakte") until quiescence.
|
||||
inventory=False = artefacts-only: board 1 is NOT run; board-2 cards are seeded from the
|
||||
finished blocks and the QA gate stays open (the inventory is already done).
|
||||
research=False = Continue: drain the existing queue, search nothing new.
|
||||
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
|
||||
@@ -1704,22 +1706,28 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
pages = await db.list_content(topic)
|
||||
flow.state["pages"] = pages or sorted(set(_crawl_index(folder).values()))
|
||||
await _preload_state(flow)
|
||||
stages = inventory_stages(ctx, flow)
|
||||
stages = inventory_stages(ctx, flow) if inventory else []
|
||||
inv_names = [st.stage for st in stages]
|
||||
if artefacts:
|
||||
import board_artefacts # lazy — board_artefacts imports blocks too
|
||||
flow.state["spawn_artefact"] = board_artefacts.make_spawner(topic, files)
|
||||
await board_artefacts.ensure_outline_card(topic)
|
||||
if not inventory: # board 1 does not run → seed the board-2 cards from the finished blocks
|
||||
geseedet = await board_artefacts.seed_artefact_cards(topic, files)
|
||||
if geseedet:
|
||||
_log(topic, f"Board 2 (nur Artefakte): {geseedet} Karte(n) aus fertigen Blocks erzeugt")
|
||||
migriert = await board_artefacts.migriere_alt_karten(topic)
|
||||
if migriert:
|
||||
_log(topic, f"Board 2: {migriert} Karte(n) der alten Stage-Struktur → generate")
|
||||
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
|
||||
if QA_GATE_NOTE > 0:
|
||||
if QA_GATE_NOTE > 0 and inventory:
|
||||
# QA gate: board 2 waits until the inventory QA passed (or the user forces).
|
||||
# Costs pipelining (board 2 no longer starts per finished block) but saves
|
||||
# tokens on a bad foundation — the watcher below runs the QA and decides.
|
||||
sub = next(st for st in stages if st.stage == "generate")
|
||||
sub.gate = lambda: bool(flow.state.get("qa_ok") or flow.state.get("qa_force"))
|
||||
elif not inventory:
|
||||
flow.state["qa_ok"] = True # artefacts-only: no inventory gate, board 2 runs immediately
|
||||
stages = chain_stages(stages)
|
||||
if artefacts:
|
||||
# Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the
|
||||
@@ -1728,7 +1736,7 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
outline = next(s for s in stages if s.stage == "outline")
|
||||
outline.upstream = [u for u in outline.upstream if u not in
|
||||
("verify", "artefakte", "finalize", "konsolidierung")]
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if (research and inventory) else []
|
||||
|
||||
async def _as_producer(coro):
|
||||
try:
|
||||
@@ -1745,7 +1753,7 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
# cancel hook: blocks.cancel_blocks flips is_cancelled; stop the flow with it
|
||||
stopper = asyncio.create_task(_stop_on_cancel(ctx, flow))
|
||||
watcher = (asyncio.create_task(_qa_gate_watch(ctx, flow, inv_names, set_p))
|
||||
if artefacts and QA_GATE_NOTE > 0 else None)
|
||||
if artefacts and QA_GATE_NOTE > 0 and inventory else None)
|
||||
try:
|
||||
try:
|
||||
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
|
||||
|
||||
@@ -882,18 +882,27 @@ async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
|
||||
return dead
|
||||
|
||||
|
||||
async def events_run_summary(topic: str, run_id: str) -> dict:
|
||||
"""Agent/token aggregate of ONE run — the numbers block of lauf-summary.json."""
|
||||
async def events_run_summary(topic: str, run_id: str, board: str | None = None) -> dict:
|
||||
"""Agent/token aggregate of ONE run — the numbers block of lauf-summary.json.
|
||||
board="inventory"|"artefacts" scopes to that level (via meta.board); None = whole run.
|
||||
Returns start/ende (MIN/MAX ts of the scoped events) so the caller can show a per-board span."""
|
||||
db = await get_db()
|
||||
where = "topic = ? AND run_id = ? AND kind = 'agent'"
|
||||
params = [topic, run_id]
|
||||
if board:
|
||||
where += " AND json_extract(meta,'$.board') = ?"
|
||||
params.append(board)
|
||||
cursor = await db.execute(
|
||||
"""SELECT status, COUNT(*), SUM(dur_ms),
|
||||
SUM(json_extract(meta,'$.tokens.input')), SUM(json_extract(meta,'$.tokens.output')),
|
||||
SUM(json_extract(meta,'$.tokens.cache_read')), SUM(json_extract(meta,'$.tokens.cache_write'))
|
||||
FROM events WHERE topic = ? AND run_id = ? AND kind = 'agent' GROUP BY status""",
|
||||
(topic, run_id))
|
||||
f"""SELECT status, COUNT(*), SUM(dur_ms),
|
||||
SUM(json_extract(meta,'$.tokens.input')), SUM(json_extract(meta,'$.tokens.output')),
|
||||
SUM(json_extract(meta,'$.tokens.cache_read')), SUM(json_extract(meta,'$.tokens.cache_write')),
|
||||
MIN(ts), MAX(ts)
|
||||
FROM events WHERE {where} GROUP BY status""",
|
||||
params)
|
||||
agents = {"gesamt": 0, "ok": 0, "timeout": 0, "cancelled": 0, "sonstige": 0, "verlorene_min": 0}
|
||||
tokens = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0}
|
||||
for status, n, dur, ti, to, cr, cw in await cursor.fetchall():
|
||||
start = ende = None
|
||||
for status, n, dur, ti, to, cr, cw, mn, mx in await cursor.fetchall():
|
||||
agents["gesamt"] += n
|
||||
if status in ("ok", "timeout", "cancelled"):
|
||||
agents[status] += n
|
||||
@@ -905,7 +914,11 @@ async def events_run_summary(topic: str, run_id: str) -> dict:
|
||||
tokens["output"] += to or 0
|
||||
tokens["cache_read"] += cr or 0
|
||||
tokens["cache_write"] += cw or 0
|
||||
return {"agents": agents, "tokens": tokens}
|
||||
if mn and (start is None or mn < start):
|
||||
start = mn
|
||||
if mx and (ende is None or mx > ende):
|
||||
ende = mx
|
||||
return {"agents": agents, "tokens": tokens, "start": start, "ende": ende}
|
||||
|
||||
|
||||
async def list_runs(topic: str, limit: int = 10) -> list[dict]:
|
||||
@@ -919,13 +932,16 @@ async def list_runs(topic: str, limit: int = 10) -> list[dict]:
|
||||
out = []
|
||||
for run_id, start, ende in rows:
|
||||
summary = await events_run_summary(topic, run_id)
|
||||
# Per-Board-Aufschlüsselung (Zeit/Tokens getrennt für Inventar vs. Artefakte)
|
||||
boards = {b: await events_run_summary(topic, run_id, board=b)
|
||||
for b in ("inventory", "artefacts")}
|
||||
cur = await db.execute(
|
||||
"SELECT key, status, meta, ts FROM events WHERE topic = ? AND run_id = ? AND kind = 'fail' "
|
||||
"ORDER BY ts DESC LIMIT 10", (topic, run_id))
|
||||
fails = [{"key": k, "status": s, "error": json.loads(m or "{}").get("error", ""), "ts": ts}
|
||||
for k, s, m, ts in await cur.fetchall()]
|
||||
out.append({"run_id": run_id, "aktiv": _current_run.get(topic) == run_id,
|
||||
"start": start, "ende": ende, **summary, "fails": fails})
|
||||
"start": start, "ende": ende, **summary, "boards": boards, "fails": fails})
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class TopicCreateRequest(BaseModel):
|
||||
class QaRunRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
llm: bool = True # wie das Gate: Echtheits-/Dubletten-Stichprobe inklusive
|
||||
ebene: str | None = None # None = alle Ebenen; "inventory"/"artefacts" = ohne Guide-QA-Overhead
|
||||
|
||||
|
||||
class RepairRequest(BaseModel):
|
||||
@@ -48,6 +49,7 @@ class BlocksCreateRequest(BaseModel):
|
||||
auto_inventory: bool = True
|
||||
auto_artefacts: bool = True
|
||||
auto_guide: bool = True
|
||||
only_artefacts: bool = False # True = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate)
|
||||
|
||||
|
||||
class GuideFormatRequest(BaseModel):
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from agents import active_agents, provider_available
|
||||
from agents import active_agents, agent_ebene, provider_available
|
||||
from config import DEFAULT_PROVIDER, PROJECTS_DIR, UNI_DIR, PROVIDERS
|
||||
from database import (
|
||||
create_guide, delete_guide, get_guide, list_guides, update_guide,
|
||||
@@ -168,7 +168,7 @@ async def create_blocks(req: BlocksCreateRequest):
|
||||
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider,
|
||||
research=req.research, qa_force=req.qa_force,
|
||||
auto_inventory=req.auto_inventory, auto_artefacts=req.auto_artefacts,
|
||||
auto_guide=req.auto_guide))
|
||||
auto_guide=req.auto_guide, only_artefacts=req.only_artefacts))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -180,7 +180,8 @@ async def get_blocks_board(topic: str):
|
||||
snap["generating"] = status["generating"]
|
||||
snap["progress"] = status["progress"]
|
||||
snap["error"] = status["error"]
|
||||
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
||||
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"),
|
||||
"runtime": a["runtime"], "ebene": agent_ebene(a["key"])}
|
||||
for a in active_agents(f"blocks-{topic}-")]
|
||||
return snap
|
||||
|
||||
@@ -201,11 +202,13 @@ async def run_qa_route(req: QaRunRequest):
|
||||
raise HTTPException(status_code=404, detail="keine fertigen Bausteine")
|
||||
await qa.write_report(report)
|
||||
note_guide = None
|
||||
# ebene-scoped: ein Inventar-/Artefakt-QA-Klick misst NICHT den (teuren) Guide mit.
|
||||
guide_scope = req.ebene in (None, "guide")
|
||||
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
|
||||
import guide_qa
|
||||
from database import list_guide_cards
|
||||
if any(c["stage"] == "done" and (c.get("md") or "").strip()
|
||||
for c in await list_guide_cards(req.topic)):
|
||||
if guide_scope and any(c["stage"] == "done" and (c.get("md") or "").strip()
|
||||
for c in await list_guide_cards(req.topic)):
|
||||
grep = await guide_qa.guide_qa_report(req.topic, llm=req.llm)
|
||||
if grep:
|
||||
await asyncio.to_thread(guide_qa._write_report, grep)
|
||||
@@ -658,7 +661,13 @@ async def block_exam_route(req: BlockExamRequest):
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
guides, levels = await load_learnstate()
|
||||
reason = guide_lock(req.topic.strip(), req.format, guides, levels)
|
||||
from database import list_blocks
|
||||
from paths import blocks_path
|
||||
topic = req.topic.strip()
|
||||
# DB is authoritative (generate_guide builds from it); blocks.md is only a legacy fallback
|
||||
# and is absent when the inventory paused at the QA gate — don't lock the guide on the file.
|
||||
has_blocks = bool(await list_blocks(topic, status="consensus")) or blocks_path(topic).exists()
|
||||
reason = guide_lock(topic, req.format, guides, levels, has_blocks)
|
||||
if reason:
|
||||
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
|
||||
await create_topic(req.topic.strip())
|
||||
|
||||
@@ -14,7 +14,7 @@ import json
|
||||
from database import list_block_scores_all, subs_per_level_all, list_guides
|
||||
from guide import guide_slot_files
|
||||
from learning import cap_final, LEVELS, _threshold
|
||||
from paths import blocks_path, guide_content_path
|
||||
from paths import guide_content_path
|
||||
from textkit import _norm_title
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
@@ -128,13 +128,16 @@ def formats_stats(guides: list[dict], levels: dict[str, dict[str, set[str]]]) ->
|
||||
return formats
|
||||
|
||||
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]],
|
||||
has_blocks: bool) -> str | None:
|
||||
"""Reason why a fresh start for topic+format is locked — None = allowed.
|
||||
|
||||
Exactly the rules from POST /guides: blocks required, no duplicate start,
|
||||
learning debt only for genuine new creations (resume/regenerate are free).
|
||||
`has_blocks` = DB consensus blocks OR the legacy blocks.md exist — checked by the
|
||||
caller, because generate_guide builds from the DB (a paused inventory writes no file).
|
||||
"""
|
||||
if not blocks_path(topic).exists():
|
||||
if not has_blocks:
|
||||
return "Create blocks first"
|
||||
for g in guides:
|
||||
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
|
||||
|
||||
@@ -270,3 +270,15 @@ async def test_ram_gate_cancelled_scope(ram_gate):
|
||||
def test_meminfo_reads_proc():
|
||||
mem = agents._meminfo()
|
||||
assert mem is not None and 0 < mem[0] <= mem[1] # Linux-Testumgebung
|
||||
|
||||
|
||||
def test_agent_ebene_klassifiziert_board():
|
||||
"""Board-2-Marker (art-/sb-/crossblock/outline) → artefacts; alles andere → inventory."""
|
||||
T = "shopware"
|
||||
for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y",
|
||||
f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"):
|
||||
assert agents.agent_ebene(key) == "inventory", key
|
||||
for key in (f"blocks-{T}-alpha-sb-enrich-h", f"blocks-{T}-alpha-sb-verify-h-j1",
|
||||
f"blocks-{T}-alpha-art-gen-h-t1", f"blocks-{T}-alpha-art-check-h",
|
||||
f"blocks-{T}-sub-crossblock-h-j1", f"blocks-{T}-outline-judge"):
|
||||
assert agents.agent_ebene(key) == "artefacts", key
|
||||
|
||||
@@ -1237,7 +1237,7 @@ async def test_reset_generate_behaelt_subs_loescht_artefakte(testdb, tmp_path):
|
||||
(arbeit / "ab-alpha").mkdir(parents=True)
|
||||
(arbeit / "ab-alpha" / "facts.json").write_text("{}", encoding="utf-8")
|
||||
files = {"arbeit": arbeit}
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"):
|
||||
files[k] = tmp_path / f"{k}.json"
|
||||
files[k].write_text("{}", encoding="utf-8")
|
||||
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
|
||||
@@ -1245,7 +1245,7 @@ async def test_reset_generate_behaelt_subs_loescht_artefakte(testdb, tmp_path):
|
||||
assert len(await db.list_subblocks(TOPIC)) == 1 # Sub bleibt (Board-1-Eigentum)
|
||||
assert not await db.list_question_pattern(TOPIC, "alpha") # abgeleitete Frage weg
|
||||
assert not (arbeit / "ab-alpha").exists()
|
||||
assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"))
|
||||
assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"))
|
||||
|
||||
|
||||
# ── Naming-Abstraktion: freier Name nur mit Anker ───────────────────────────────────
|
||||
|
||||
@@ -72,6 +72,27 @@ async def test_e2e_guide(fake_welt, testdb, tmp_path):
|
||||
assert await pruefe_guide_invarianten(TOPIC) == []
|
||||
|
||||
|
||||
async def test_e2e_only_artefacts(fake_welt, testdb, tmp_path):
|
||||
"""Artefakt-only (inventory=False): nach removeArtefacts läuft NUR Board 2 auf den
|
||||
fertigen Blocks — kein Board 1, keine Research-Agenten. Karten aus done_block geseedet."""
|
||||
ok, files = await _lauf(tmp_path)
|
||||
assert ok
|
||||
db = testdb
|
||||
subs_vorher = {r["sub_norm"] for r in await db.list_subblocks(TOPIC)}
|
||||
assert subs_vorher
|
||||
# Artefakte entfernen (Karten zurück auf generate, Board-1-Subs bleiben)
|
||||
await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
|
||||
fake_welt.calls.clear()
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
ok2 = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
|
||||
research=False, inventory=False), timeout=60)
|
||||
assert ok2
|
||||
assert not any("-research-" in k for k in fake_welt.calls) # Board 1 lief NICHT
|
||||
assert any("-sb-enrich-" in k or "-sb-verify-" in k for k in fake_welt.calls) # Board 2 lief
|
||||
assert {r["sub_norm"] for r in await db.list_subblocks(TOPIC)} == subs_vorher # vollständig
|
||||
|
||||
|
||||
async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
|
||||
"""Zweiter Lauf (Continue, research=False) hinterlässt keine Waisen/Reste."""
|
||||
ok, files = await _lauf(tmp_path)
|
||||
|
||||
@@ -284,6 +284,23 @@ async def test_events_run_summary_aggregates(testdb):
|
||||
assert s["tokens"] == {"input": 15, "output": 2, "cache_read": 80, "cache_write": 1}
|
||||
|
||||
|
||||
async def test_events_run_summary_per_board(testdb):
|
||||
"""meta.board scoped die Aggregation: board="artefacts" summiert nur Artefakt-Agenten."""
|
||||
db = testdb
|
||||
db.set_current_run(TOPIC, "r2")
|
||||
await db.add_event(TOPIC, "agent", key="inv", status="ok", dur_ms=1000,
|
||||
meta={"board": "inventory", "tokens": {"input": 10, "output": 2}})
|
||||
await db.add_event(TOPIC, "agent", key="art", status="ok", dur_ms=2000,
|
||||
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
|
||||
db.set_current_run(TOPIC, None)
|
||||
art = await db.events_run_summary(TOPIC, "r2", board="artefacts")
|
||||
assert art["agents"]["gesamt"] == 1
|
||||
assert art["tokens"] == {"input": 5, "output": 7, "cache_read": 0, "cache_write": 0}
|
||||
inv = await db.events_run_summary(TOPIC, "r2", board="inventory")
|
||||
assert inv["agents"]["gesamt"] == 1 and inv["tokens"]["input"] == 10
|
||||
assert (await db.events_run_summary(TOPIC, "r2"))["agents"]["gesamt"] == 2 # ohne board = alle
|
||||
|
||||
|
||||
async def test_topic_delete_entfernt_guides_und_kanban(testdb, tmp_path, monkeypatch):
|
||||
"""DELETE /topics: guides/guide_cards/kanban_cards mitlöschen — GET /topics leitet
|
||||
Topics aus guides ab, sonst taucht das gelöschte Topic sofort wieder auf."""
|
||||
|
||||
@@ -250,12 +250,13 @@ async function handleRequeueDead() {
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleBlocksClick({ instructions = '', research = true, qaForce = false }) {
|
||||
async function handleBlocksClick({ instructions = '', research = true, qaForce = false, onlyArtefacts = false }) {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj())
|
||||
// onlyArtefacts=true = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate).
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj(), onlyArtefacts)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
@@ -484,7 +485,7 @@ onMounted(async () => {
|
||||
:autoGuide="autoGuide"
|
||||
@close="mainView = 'blocks'"
|
||||
@restartAll="() => handleBlocksClick({ research: true })"
|
||||
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })"
|
||||
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce), onlyArtefacts: !!(opts && opts.onlyArtefacts) })"
|
||||
@addResearch="handleAddResearch"
|
||||
@requeueDead="handleRequeueDead"
|
||||
@removeAll="handleResetBlocks"
|
||||
|
||||
@@ -42,17 +42,18 @@ export const fetchActiveBlocks = () => req('/blocks/active')
|
||||
|
||||
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
|
||||
|
||||
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false, auto = {}) =>
|
||||
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false, auto = {}, onlyArtefacts = false) =>
|
||||
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,
|
||||
only_artefacts: onlyArtefacts,
|
||||
} })
|
||||
|
||||
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
|
||||
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
|
||||
|
||||
// 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, ebene = null) => req('/blocks/qa', { method: 'POST', body: { topic, llm, ebene } })
|
||||
|
||||
// QA-Befunde gezielt beheben; ebene = 'inventory' | 'artefacts' | undefined (alle).
|
||||
export const runRepair = (topic, ebene = null) => req('/blocks/repair', { method: 'POST', body: { topic, ebene } })
|
||||
|
||||
@@ -78,20 +78,19 @@ async function loadRun() {
|
||||
} catch { run.value = null }
|
||||
}
|
||||
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
|
||||
const runLaufzeit = computed(() => {
|
||||
if (!run.value?.start) return null
|
||||
const start = Date.parse(run.value.start)
|
||||
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
|
||||
// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte (aus run.boards).
|
||||
function boardZeit(b) {
|
||||
if (!b?.start) return null
|
||||
const start = Date.parse(b.start)
|
||||
const ende = run.value?.aktiv ? now.value : Date.parse(b.ende || b.start)
|
||||
return fmtRuntime((ende - start) / 1000)
|
||||
})
|
||||
const runTokens = computed(() => {
|
||||
const t = run.value?.tokens
|
||||
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
|
||||
})
|
||||
const runTokenTitel = computed(() => {
|
||||
const t = run.value?.tokens || {}
|
||||
return `Input ${t.input || 0} · Output ${t.output || 0} · Cache ${(t.cache_read || 0) + (t.cache_write || 0)}`
|
||||
})
|
||||
}
|
||||
function boardTokens(b) {
|
||||
const t = b?.tokens
|
||||
return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null
|
||||
}
|
||||
const invStat = computed(() => run.value?.boards?.inventory)
|
||||
const artStat = computed(() => run.value?.boards?.artefacts)
|
||||
watch(() => props.generating, (g) => {
|
||||
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
|
||||
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
|
||||
@@ -105,10 +104,10 @@ function later(fn) { fn(); setTimeout(pollBoard, 600) }
|
||||
// ── QA + Befunde beheben (je Ebene) ──────────────────────────────────────────────
|
||||
const qaBusy = ref(false)
|
||||
const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden
|
||||
async function runQaClick() {
|
||||
async function runQaClick(ebene = null) {
|
||||
if (qaBusy.value) return
|
||||
qaBusy.value = true
|
||||
try { await runQa(props.topic) }
|
||||
qaBusy.value = ebene || true // markiert die geklickte Ebene (Button-Label)
|
||||
try { await runQa(props.topic, true, ebene) }
|
||||
finally { qaBusy.value = false; pollBoard(); guideRefresh.value++ }
|
||||
}
|
||||
|
||||
@@ -165,7 +164,6 @@ function removeArtefactsClick() {
|
||||
<div class="gen-steps-top">
|
||||
<span class="gen-title">Bausteine</span>
|
||||
<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="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">
|
||||
<button class="gen-act" @click="emit('addResearch')">+ Recherche</button>
|
||||
@@ -183,12 +181,13 @@ function removeArtefactsClick() {
|
||||
<div class="gen-board-label">
|
||||
Inventar
|
||||
<span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note) }}</span>
|
||||
<span v-if="boardZeit(invStat)" class="gen-run">⏱ {{ boardZeit(invStat) }}<template v-if="boardTokens(invStat)"> · {{ boardTokens(invStat) }} Tokens</template></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 class="gen-act" :disabled="!!qaBusy" @click="runQaClick('inventory')">{{ qaBusy === 'inventory' ? '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>
|
||||
@@ -200,21 +199,22 @@ function removeArtefactsClick() {
|
||||
<ProgressBar v-if="inventoryProgress.total" :value="inventoryProgress.value"
|
||||
:label="`${inventoryProgress.done}/${inventoryProgress.total} Karten fertig · ${Math.round(inventoryProgress.value * 100)} %`"
|
||||
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
|
||||
<KanbanBoard :columns="inventoryCols" :agents="board?.agents || []" :generating="generating" />
|
||||
<KanbanBoard :columns="inventoryCols" :agents="(board?.agents || []).filter(a => a.ebene !== 'artefacts')" :generating="generating" />
|
||||
|
||||
<!-- Ebene Artefakte -->
|
||||
<div class="gen-board-label">
|
||||
Artefakte
|
||||
<span v-if="qa && qa.note_artefakte != null" class="qa-note" :class="qa.note_artefakte >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note_artefakte) }}</span>
|
||||
<span v-if="boardZeit(artStat)" class="gen-run">⏱ {{ boardZeit(artStat) }}<template v-if="boardTokens(artStat)"> · {{ boardTokens(artStat) }} Tokens</template></span>
|
||||
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
|
||||
<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 class="gen-act" :disabled="!!qaBusy" @click="runQaClick('artefacts')">{{ qaBusy === 'artefacts' ? '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', { qaForce: true })">Generieren</button>
|
||||
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen — nur Artefakte, ohne Board 1" @click="emit('continueAll', { qaForce: true, onlyArtefacts: true })">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>
|
||||
@@ -222,7 +222,7 @@ function removeArtefactsClick() {
|
||||
<ProgressBar v-if="artefactProgress.total" :value="artefactProgress.value"
|
||||
:label="`${artefactProgress.done}/${artefactProgress.total} Karten fertig · ${Math.round(artefactProgress.value * 100)} %`"
|
||||
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
|
||||
<KanbanBoard :columns="artefactCols" :generating="generating" />
|
||||
<KanbanBoard :columns="artefactCols" :agents="(board?.agents || []).filter(a => a.ebene === 'artefacts')" :generating="generating" />
|
||||
</section>
|
||||
|
||||
<section class="gen-section">
|
||||
|
||||
Reference in New Issue
Block a user