diff --git a/backend/agents.py b/backend/agents.py index 8dc9b94..796f615 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -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 diff --git a/backend/blocks.py b/backend/blocks.py index 07abcd0..7980501 100644 --- a/backend/blocks.py +++ b/backend/blocks.py @@ -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(): diff --git a/backend/board_artefacts.py b/backend/board_artefacts.py index ab50d86..7363b5d 100644 --- a/backend/board_artefacts.py +++ b/backend/board_artefacts.py @@ -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)] diff --git a/backend/board_inventory.py b/backend/board_inventory.py index f21145b..379f6b4 100644 --- a/backend/board_inventory.py +++ b/backend/board_inventory.py @@ -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) diff --git a/backend/database.py b/backend/database.py index 38b647f..2d39ec9 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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 diff --git a/backend/models.py b/backend/models.py index 8c386d6..1653d22 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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): diff --git a/backend/routes.py b/backend/routes.py index 9316842..ffd5990 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -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()) diff --git a/backend/rules.py b/backend/rules.py index e0a0cd9..2af976d 100644 --- a/backend/rules.py +++ b/backend/rules.py @@ -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"): diff --git a/backend/tests/test_agents_api.py b/backend/tests/test_agents_api.py index be1c6c4..eeee8b9 100644 --- a/backend/tests/test_agents_api.py +++ b/backend/tests/test_agents_api.py @@ -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 diff --git a/backend/tests/test_board_inventory.py b/backend/tests/test_board_inventory.py index 6967349..397ea0b 100644 --- a/backend/tests/test_board_inventory.py +++ b/backend/tests/test_board_inventory.py @@ -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 ─────────────────────────────────── diff --git a/backend/tests/test_e2e_fake.py b/backend/tests/test_e2e_fake.py index 4548bc6..d42fe8c 100644 --- a/backend/tests/test_e2e_fake.py +++ b/backend/tests/test_e2e_fake.py @@ -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) diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index ecdca0a..ba6a7a7 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -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.""" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7041448..8e48d6c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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" diff --git a/frontend/src/api.js b/frontend/src/api.js index b20e3a1..eebc52e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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 } }) diff --git a/frontend/src/components/GenerationView.vue b/frontend/src/components/GenerationView.vue index 6568dbb..258248e 100644 --- a/frontend/src/components/GenerationView.vue +++ b/frontend/src/components/GenerationView.vue @@ -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() {
Bausteine
{{ progress }}
- ⏱ {{ runLaufzeit }} ⚠ {{ run.fails.length }} Fehler
@@ -183,12 +181,13 @@ function removeArtefactsClick() {
Inventar QA {{ pct(qa.note) }} + ⏱ {{ boardZeit(invStat) }}
- + {{ repairInfo.inventory }} @@ -200,21 +199,22 @@ function removeArtefactsClick() { - +
Artefakte QA {{ pct(qa.note_artefakte) }} + ⏱ {{ boardZeit(artStat) }}
- + {{ repairInfo.artefacts }} - + @@ -222,7 +222,7 @@ function removeArtefactsClick() { - +