From 250ea0b7640e01c850c81471f76a857e6df6bf0a Mon Sep 17 00:00:00 2001 From: Team3 Date: Sun, 5 Jul 2026 15:26:22 +0200 Subject: [PATCH] refactor --- Makefile | 5 +- backend/block_calls.py | 1 - backend/blocks.py | 24 +- backend/board_artefacts.py | 9 +- backend/board_inventory.py | 21 +- backend/config.py | 38 +- backend/database.py | 833 +++++++++--------- backend/fake_agents.py | 3 +- backend/guide.py | 44 +- backend/guide_board.py | 115 ++- backend/guide_qa.py | 31 +- backend/{tests => }/invarianten.py | 0 backend/models.py | 13 - backend/pipeline.py | 73 +- backend/qa.py | 108 ++- backend/repair.py | 37 +- backend/routes.py | 62 +- backend/rules.py | 20 +- backend/tests/test_e2e_fake.py | 2 +- backend/tests/test_events.py | 72 +- backend/tests/test_guide_board.py | 75 ++ backend/tests/test_qa.py | 4 +- backend/tests/test_race.py | 53 -- backend/tests/test_repair.py | 20 +- backend/tests/test_subblocks.py | 4 +- backend/tests/test_train.py | 4 +- backend/textkit.py | 10 + backend/train_f0.py | 2 +- docker-compose.yml | 12 +- frontend/src/App.vue | 33 +- frontend/src/api.js | 338 ++----- frontend/src/assets/shared.css | 7 + frontend/src/components/BlockFocus.vue | 13 +- frontend/src/components/BlockPanel.vue | 57 +- frontend/src/components/BlocksOverview.vue | 27 +- frontend/src/components/ChatTranscript.vue | 88 ++ frontend/src/components/GenerationView.vue | 132 ++- frontend/src/components/GuideBoardSection.vue | 82 +- frontend/src/components/KanbanBoard.vue | 13 +- frontend/src/components/ProgressBar.vue | 26 + frontend/src/components/SourceForm.vue | 72 ++ frontend/src/components/TopicDetail.vue | 150 +--- frontend/src/components/TopicSidebar.vue | 167 +--- frontend/src/format.js | 19 + frontend/src/main.js | 1 + 45 files changed, 1468 insertions(+), 1452 deletions(-) rename backend/{tests => }/invarianten.py (100%) create mode 100644 frontend/src/assets/shared.css create mode 100644 frontend/src/components/ChatTranscript.vue create mode 100644 frontend/src/components/ProgressBar.vue create mode 100644 frontend/src/components/SourceForm.vue create mode 100644 frontend/src/format.js diff --git a/Makefile b/Makefile index 24fb4d5..1e608dc 100644 --- a/Makefile +++ b/Makefile @@ -47,10 +47,11 @@ stop: logs: $(COMPOSE) logs -f -remove: stop +remove: + @read -p "storage/ (DB + alle Nutzdaten) wirklich löschen? [y/N] " a && [ "$$a" = "y" ] @echo "Lösche Datenbank und generierte Dateien..." rm -rf storage/* - @echo "Fertig." + @echo "Fertig. (Server ggf. separat stoppen: make stop)" searxng: docker run -d --name searxng --restart unless-stopped -p 8888:8080 searxng/searxng diff --git a/backend/block_calls.py b/backend/block_calls.py index 5c3abb4..cc39d2e 100644 --- a/backend/block_calls.py +++ b/backend/block_calls.py @@ -13,7 +13,6 @@ relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashca import asyncio import hashlib -import json import logging import database as db diff --git a/backend/blocks.py b/backend/blocks.py index 1ec885d..7343d73 100644 --- a/backend/blocks.py +++ b/backend/blocks.py @@ -18,33 +18,25 @@ import math import re import shutil import subprocess -import time import unicodedata from pathlib import Path import database as db import embedding -from agents import kill_process, cancel_scope, clear_scope, run_agent -from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR, SUB_VARIANT_COS, SEED_COVER_COS, SUBBLOCK_MAX, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES -from fsutil import atomic_write_text, atomic_write_json +from agents import kill_process, cancel_scope, clear_scope +from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, SUB_VARIANT_COS, SUBBLOCK_MAX, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES +from fsutil import atomic_write_json from jsonio import parse_json_text, read_json_file as _json_file from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder from crawl import crawl from pipeline import ( - CANCELLED, FAILED, OK, GenContext, _detached, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race, - _relevance_schema, _runde_schema, _semaphore, _str_list, _levels_schema, _timeout, run_single_slot, -) -from textkit import ( - _unique_title, _load_blocks, _norm_title, _parse_selection, _parse_subblocks, _title, - _resolve_title, _title_index, clean_title, + GenContext, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race, + _semaphore, _timeout, run_single_slot, ) +from textkit import _load_blocks, _norm_title, _parse_selection, _title # Pipeline-Tuning-Konstanten liegen zentral in config.py (tunebar via CREATOR_PARAMS). -from config import ( # noqa: E402 - CONSOLIDATION_CHUNK, CONSOLIDATION_PANEL, DEDUP_GLOBAL_FLOOR, - DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, - FILTER_CHUNK, FILTER_RECHECK_PANEL, - RESEARCH_BATCH, RESEARCH_READERS, RESEARCH_SECTION_CHARS, RESEARCH_THEMA_AGENTS) +from config import RESEARCH_SECTION_CHARS # noqa: E402 log = logging.getLogger("creator.blocks") @@ -269,8 +261,6 @@ async def blocks_status(topic: str) -> dict: "progress": _blocks_progress.get(topic), "error": _blocks_errors.get(topic), "partial": not generating and open_cards > 0, - "steps": [], # legacy phase pills — replaced by the live board - "feine_steps": [], } diff --git a/backend/board_artefacts.py b/backend/board_artefacts.py index 1c3d053..a9ad0fe 100644 --- a/backend/board_artefacts.py +++ b/backend/board_artefacts.py @@ -24,7 +24,7 @@ from fsutil import atomic_write_json from jsonio import read_json_file as _json_file from kanban import Flow, Stage from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot -from textkit import _norm_title, _title +from textkit import _norm_title, _title, parse_facts log = logging.getLogger("creator.board_artefacts") @@ -107,6 +107,8 @@ 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)] if errs: + for e in errs[1:]: # nur errs[0] wird re-raised — der Rest darf nicht stumm verschwinden + log.error("weitere Karten-Exception im Batch: %r", e) raise errs[0] flow.wake.set() @@ -316,10 +318,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc return def _kp(r: dict) -> list: - try: - return (json.loads(r.get("facts") or "{}")).get("key_points") or [] - except ValueError: - return [] + return parse_facts(r.get("facts")).get("key_points") or [] def _side(tag: str, r: dict) -> str: return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r)) diff --git a/backend/board_inventory.py b/backend/board_inventory.py index a5e41eb..1303a1d 100644 --- a/backend/board_inventory.py +++ b/backend/board_inventory.py @@ -39,9 +39,7 @@ import kanban from kanban import Flow, Stage, chain_stages import blocks from blocks import ( - DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK, - FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS, - RESEARCH_THEMA_AGENTS, _FILTER_NOTATION, _GROUP_STANDALONE, + _FILTER_NOTATION, _GROUP_STANDALONE, _build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques, _completion_schema, _containment_parent, _crawl_index, _file_payload, _filter_schema, _filter_suspect, _is_artifact, _is_named_statement, @@ -50,7 +48,9 @@ from blocks import ( _aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder, ) from config import (QA_GATE_NOTE, QA_GATE_LLM, - + DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK, + FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS, + RESEARCH_THEMA_AGENTS, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP, EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS, GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR, @@ -1915,14 +1915,9 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None: flow and flow.state.get("qa_note") is not None): return None import qa - tdir = qa.QA_DIR / topic - # by mtime: a re-run overwrites the run-id-named file, which sorts before timestamp names. - # guide-* reports are the guide_qa series — they must not shadow the inventory badge. - reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")), - key=lambda p: p.stat().st_mtime) if tdir.is_dir() else [] - if not reports: + r = qa.latest_report(topic) + if r is None: return None - r = _json_file(reports[-1]) or {} note = r.get("note") if note is None: return None @@ -1965,9 +1960,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict await _requeue(r, stage) await db.kanban_delete_cards(topic, "inventory", "cluster") await db.kanban_delete_cards(topic, "inventory", "block") - dbc = await db.get_db() - await dbc.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,)) - await dbc.commit() + await db.kanban_delete_members(topic) await db.delete_blocks(topic) await _clean_artefact_state(topic, files) elif board == "inventory" and stage in _CLUSTER_STAGES: diff --git a/backend/config.py b/backend/config.py index 7acee73..b627c06 100644 --- a/backend/config.py +++ b/backend/config.py @@ -164,27 +164,18 @@ EVIDENCE_CTX_LINES = 15 # context lines around a cited source position # ── Pipeline tuning (zentral, tunebar via CREATOR_PARAMS — siehe Override-Hook am Datei-Ende; # Registry mit Suchraum: backend/train_params.py). QA-/Detektor-Konstanten bleiben bewusst in # qa.py/guide_qa.py — die Messlatte darf nie Teil des Suchraums sein. ───────────────────────── -SUBBLOCK_CHUNK = 10 # subblock finder: 1 agent per ~10 blocks, capped SUBBLOCK_MAX = 40 # chunk cap -LEVEL_CHUNK = 100 # classifying is cheap → large packages RESEARCH_BATCH = 20 # crawl pages per batch RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2) RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema") RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard) RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live) -CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (fallback path) DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair DEDUP_PAIRS_CHUNK = 40 # pairs per judge package DEDUP_TITLE_AUTO = 0.95 # near-identical TITLE cosine → merge without judge DEDUP_GLOBAL_FLOOR = 0.65 # global post-naming dedup candidate floor FILTER_CHUNK = 35 # blocks per judge in the degrade pass -QUESTION_CHUNK_SUBS = 25 # target relevant subs per question chunk (LPT) -QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern -FACTS_CHUNK_SUBS = 10 # facts extraction chunk (chunk count = parallelism) -ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples bulk chunk -FACTS_CHECK_PANEL = 3 # judges per facts-check chunk (majority) CONSOLIDATION_PANEL = 3 # mapping judges per chunk -SUBBLOCK_PANEL = 3 # judges in the subblock clarification # Board 2, verschmolzene Calls (block_calls.py): Panel-Größen der neuen Struktur. # Konsens braucht ≥2 unabhängige Nennungen bzw. Einstimmigkeit — 2 ist das Minimum, # 3 kauft Robustheit für +50 % Tokens auf dem jeweiligen Segment. @@ -193,6 +184,10 @@ VERIFY_PANEL = 2 # unabhängige Prüfer-Calls pro Block (+ Ersatz bei ART_SPLIT_SUBS = 20 # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below min(this, relevante Subs) → log only (falsch fixt immer) +ZIELE_MAX = 12 # Lernziele-Cap pro Block (mehr verwässert Coverage-Prüfung und Writer-Fokus) +# Prüfer-Längenband um guide_qa.block_budget: enger als das QA-Band (0.35–1.5), damit der +# Fix VOR der QA-Grenze greift. Die QA-Messlatte selbst bleibt bewusst in guide_qa.py. +FIX_LAENGE_BAND = (0.5, 1.2) WRITER_SPLIT_SUBS = 30 # guide writer splits sections above this sub count KANBAN_BATCH = 5 # cards a worker pulls per micro-batch MAX_CARD_RETRIES = 3 # failures per card → dead-letter @@ -205,31 +200,22 @@ MAX_RESTARTS = 2 # agent restart cap per race slot # Fix-/Gate-Call (die laufen normal 110–135 s). 0 = aus. HEDGE_NACH_S = 90 JUDGE_CHUNK = 40 # repair: findings per judge call +EVENTS_RETENTION_TAGE = 60 # events älter als das werden beim Start gelöscht (Tabelle wuchs unbegrenzt) EVIDENCE_PER_BLOCK = 6000 # repair: excerpt chars per fremd candidate ABSCHLUSS_QA_LLM = 1 # 0 = Abschluss-QA ohne LLM-Judges (Training misst selbst; spart Minuten) # Timeouts per agent step: (base seconds, seconds per block/section). # Applies equally to all providers — whoever is too slow gets restarted or overtaken. TIMEOUTS = { - "research": (900, 0), # p95 measured 125 s (web mode); uni/link sections need headroom "research_mapping": (600, 3), # n = pre-merged entries "selection_mapping": (600, 2), # n = remaining entries (block inventory) "ergaenzung": (600, 0), # subject-field extension for projects (web research) "plan": (300, 5), "plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections - "content": (450, 30), # facts find/erg/fix — p95 measured 241 s (was 600+90n) # Judge caps tightened 2026-07-04: judge p50 is 6–72 s; a stalled call burns the whole # cap and its retry heals in seconds — the old 300 s base tripled the stall cost. - "content_check": (150, 8), # content exam per block in the package - "subblock": (400, 15), # finder round — p95 measured 124 s (was 900+45n) "subblock_check": (150, 10), # judge decides contested subblocks in the chunk - "konsolidierung": (300, 20), # consolidation judge sees ALL subs with key points - "level": (300, 10), # classify subblocks per chunk - "level_check": (150, 8), # judge decides contested levels in the chunk "relevance": (300, 10), # subblocks relevant/peripheral per chunk - "relevance_check": (150, 8), # judge decides contested relevance in the chunk - "question_pattern": (300, 15), # question patterns per block (subblocks × types) - "question_pattern_check": (150, 8), # critic cleans up the pattern table per block # Board 2, verschmolzene Calls: größere Outputs pro Call, dafür wenige Segmente "generate": (450, 0), # Subs+Facts+Level in einem (Sub-Zahl vorab unbekannt) "verify": (300, 10), # Audit über alle Subs (n = Subs), key points gekappt @@ -237,19 +223,11 @@ TIMEOUTS = { "artefakt": (450, 15), # Fragen+Karten+Beispiele (n = Subs) "artefakt_check": (200, 8), # Beispiel-Verifikation + Fragen-Kritik (n = Subs) "writer": (450, 60), # per section — split keeps sections ≤30 subs - "lese_check": (300, 10), # per section in the package # guide board (per card = one block) "lernziele": (300, 5), # backward-design objectives per block - "fakten_gate": (600, 5), # CoVe claim check per block - "coverage": (300, 5), # objective↔section mapping per block -} - -# Purpose per format — flows into the outline judge (what the guide should achieve). -# German strings: these are inserted verbatim into the judge prompt → kept German on purpose. -FORMAT_PURPOSE = { - "Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen", - "FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen", - "Rest": "einen Ergänzungs-Guide — nur die Randthemen", + "pruefer": (600, 5), # verschmolzener Qualitäts-Pass (Gate+Coverage+Lese) per block + # QA/Repair-Judge-Wellen (qa.judge_wave) — außerhalb der Boards, keine n-Skalierung + "qa_judge": (600, 0), } # Provider stacks: completely independent, any one can be removed at any time. diff --git a/backend/database.py b/backend/database.py index 8096310..38b647f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,4 +1,6 @@ +import asyncio import json +from contextlib import asynccontextmanager import aiosqlite from config import DB_PATH @@ -222,6 +224,11 @@ CREATE_EVENTS_INDEX = """ CREATE INDEX IF NOT EXISTS idx_events ON events(topic, ts) """ +# run-Summaries filtern topic + run_id — ohne den Index wird jeder Aufruf ein Topic-Scan +CREATE_EVENTS_RUN_INDEX = """ +CREATE INDEX IF NOT EXISTS idx_events_run ON events(topic, run_id) +""" + CREATE_KANBAN_PULL_INDEX = """ CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at) """ @@ -280,6 +287,39 @@ async def get_db() -> aiosqlite.Connection: return _db +_write_lock: asyncio.Lock | None = None +_write_lock_loop: asyncio.AbstractEventLoop | None = None + + +def _get_write_lock() -> asyncio.Lock: + """Lock lazily bound to the CURRENT loop: an asyncio.Lock is loop-bound, and a + module-global one breaks when tests run each case in a fresh loop.""" + global _write_lock, _write_lock_loop + loop = asyncio.get_running_loop() + if _write_lock is None or _write_lock_loop is not loop: + _write_lock = asyncio.Lock() + _write_lock_loop = loop + return _write_lock + + +@asynccontextmanager +async def _tx(): + """Atomarer Schreibblock: Lock + commit. Die eine geteilte Connection interleavt sonst + fremde Commits zwischen execute und commit eines Batches. Lock NUR in Blatt-Funktionen, + die selbst committen — nie in Funktionen, die andere schreibende DB-Funktionen rufen.""" + async with _get_write_lock(): + db = await get_db() + try: + yield db + await db.commit() + except BaseException: + try: + await db.rollback() + except Exception: + pass + raise + + async def init_db(): db = await get_db() # WAL survives crashes much better; busy_timeout absorbs short locks. @@ -359,6 +399,10 @@ async def init_db(): await db.execute("ALTER TABLE events ADD COLUMN run_id TEXT NOT NULL DEFAULT ''") except aiosqlite.OperationalError: pass + await db.execute(CREATE_EVENTS_RUN_INDEX) # nach der run_id-Migration — Spalte muss existieren + # Retention: events wachsen sonst unbegrenzt (gelöscht wurde nur per Topic-Delete) + from config import EVENTS_RETENTION_TAGE + await db.execute("DELETE FROM events WHERE ts < datetime('now', ?)", (f"-{EVENTS_RETENTION_TAGE} days",)) await db.execute( "UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' " "WHERE status IN ('queued', 'generating')" @@ -379,14 +423,13 @@ def _row_to_dict(row, cursor): async def create_guide(guide: dict) -> dict: - db = await get_db() - await db.execute( - """INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at) - VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""", - guide, - ) - await db.commit() - return guide + async with _tx() as db: + await db.execute( + """INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at) + VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""", + guide, + ) + return guide async def get_guide(guide_id: str) -> dict | None: @@ -411,9 +454,8 @@ async def _update(table: str, fields: dict, where: dict) -> None: rename (SET new norm WHERE old norm).""" sets = ", ".join(f"{k} = :{k}" for k in fields) cond = " AND ".join(f"{k} = :w_{k}" for k in where) - db = await get_db() - await db.execute(f"UPDATE {table} SET {sets} WHERE {cond}", {**fields, **{f"w_{k}": v for k, v in where.items()}}) - await db.commit() + async with _tx() as db: + await db.execute(f"UPDATE {table} SET {sets} WHERE {cond}", {**fields, **{f"w_{k}": v for k, v in where.items()}}) async def update_guide(guide_id: str, **fields) -> None: @@ -421,22 +463,20 @@ async def update_guide(guide_id: str, **fields) -> None: async def delete_guide(guide_id: str) -> bool: - db = await get_db() - cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,)) - await db.commit() - return cursor.rowcount > 0 + async with _tx() as db: + cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,)) + return cursor.rowcount > 0 # --- Topics --- async def create_topic(name: str) -> None: from datetime import datetime, timezone - db = await get_db() - await db.execute( - "INSERT OR IGNORE INTO topics (name, created_at) VALUES (?, ?)", - (name, datetime.now(timezone.utc).isoformat()), - ) - await db.commit() + async with _tx() as db: + await db.execute( + "INSERT OR IGNORE INTO topics (name, created_at) VALUES (?, ?)", + (name, datetime.now(timezone.utc).isoformat()), + ) async def list_topics() -> list[str]: @@ -447,9 +487,8 @@ async def list_topics() -> list[str]: async def delete_topic(name: str) -> None: - db = await get_db() - await db.execute("DELETE FROM topics WHERE name = ?", (name,)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM topics WHERE name = ?", (name,)) # --- Block learning: deep-dives + exam progress --- @@ -487,40 +526,37 @@ async def get_block_progress(topic: str, block: str) -> dict: async def set_open_question(topic: str, block: str, question: str, basis: int, streak: int) -> None: """Freeze base + streak BEFORE the now-open question (anchor for idempotent re-rating).""" - db = await get_db() - now = _now() - await db.execute( - """INSERT INTO block_progress (topic, block, offene_question, offene_basis, offene_streak, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(topic, block) DO UPDATE SET - offene_question = excluded.offene_question, offene_basis = excluded.offene_basis, - offene_streak = excluded.offene_streak, updated_at = excluded.updated_at""", - (topic, block, question, basis, streak, now), - ) - await db.commit() + async with _tx() as db: + now = _now() + await db.execute( + """INSERT INTO block_progress (topic, block, offene_question, offene_basis, offene_streak, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(topic, block) DO UPDATE SET + offene_question = excluded.offene_question, offene_basis = excluded.offene_basis, + offene_streak = excluded.offene_streak, updated_at = excluded.updated_at""", + (topic, block, question, basis, streak, now), + ) async def set_block_score_and_streak(topic: str, block: str, score: int, streak: int) -> tuple[int, int]: """Set score + streak atomically (clamped by the caller). Returns (score, streak).""" - db = await get_db() - await db.execute( - """INSERT INTO block_progress (topic, block, good_answers, streak, updated_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(topic, block) DO UPDATE SET - good_answers = excluded.good_answers, streak = excluded.streak, - updated_at = excluded.updated_at""", - (topic, block, score, streak, _now()), - ) - await db.commit() - return score, streak + async with _tx() as db: + await db.execute( + """INSERT INTO block_progress (topic, block, good_answers, streak, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(topic, block) DO UPDATE SET + good_answers = excluded.good_answers, streak = excluded.streak, + updated_at = excluded.updated_at""", + (topic, block, score, streak, _now()), + ) + return score, streak async def delete_block_progress(topic: str, block: str) -> None: """Reset the progress of ONE block: delete the row (score/streak/flags/open question). If the row is missing, get_block_progress returns defaults (0) — i.e. a full reset.""" - db = await get_db() - await db.execute("DELETE FROM block_progress WHERE topic = ? AND block = ?", (topic, block)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM block_progress WHERE topic = ? AND block = ?", (topic, block)) # Sub-level from the two orthogonal columns: peripheral → 4 (V), otherwise level (learning-path position): @@ -602,9 +638,8 @@ async def list_block_scores_all() -> list[tuple[str, str, int]]: async def delete_block_data(topic: str) -> None: - db = await get_db() - await db.execute("DELETE FROM block_progress WHERE topic = ?", (topic,)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM block_progress WHERE topic = ?", (topic,)) # --- Blocks pipeline content: inventory / subblocks / question pattern / coverage / state / source --- @@ -617,25 +652,24 @@ async def upsert_block(topic: str, title_norm: str, title: str, description: str ONE statement (json1), because several reader coroutines upsert concurrently — a read-modify-write across `await` would lose members. `mentions` stays in sync with `len(reader)`. `reader=None` (e.g. from `_set_inventar`) leaves the set unchanged.""" - db = await get_db() - rid = reader if isinstance(reader, str) and reader else None - await db.execute( - """INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at) - VALUES (?, ?, ?, ?, 1, 'candidate', ?, ?, ?) - ON CONFLICT(topic, title_norm) DO UPDATE SET - reader = (SELECT json_group_array(v) FROM ( - SELECT value AS v FROM json_each(blocks.reader) - UNION SELECT ? WHERE ? IS NOT NULL)), - mentions = (SELECT count(*) FROM ( - SELECT value AS v FROM json_each(blocks.reader) - UNION SELECT ? WHERE ? IS NOT NULL)), - sources = excluded.sources, updated_at = excluded.updated_at""", - (topic, title_norm, title, description, - json.dumps(sources or [], ensure_ascii=False), - json.dumps([rid] if rid else [], ensure_ascii=False), _now(), - rid, rid, rid, rid), - ) - await db.commit() + async with _tx() as db: + rid = reader if isinstance(reader, str) and reader else None + await db.execute( + """INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at) + VALUES (?, ?, ?, ?, 1, 'candidate', ?, ?, ?) + ON CONFLICT(topic, title_norm) DO UPDATE SET + reader = (SELECT json_group_array(v) FROM ( + SELECT value AS v FROM json_each(blocks.reader) + UNION SELECT ? WHERE ? IS NOT NULL)), + mentions = (SELECT count(*) FROM ( + SELECT value AS v FROM json_each(blocks.reader) + UNION SELECT ? WHERE ? IS NOT NULL)), + sources = excluded.sources, updated_at = excluded.updated_at""", + (topic, title_norm, title, description, + json.dumps(sources or [], ensure_ascii=False), + json.dumps([rid] if rid else [], ensure_ascii=False), _now(), + rid, rid, rid, rid), + ) async def list_blocks(topic: str, status: str | None = None) -> list[dict]: @@ -669,9 +703,8 @@ async def set_block_status(topic: str, title_norm: str, status: str, title: str async def delete_blocks(topic: str) -> None: - db = await get_db() - await db.execute("DELETE FROM blocks WHERE topic = ?", (topic,)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM blocks WHERE topic = ?", (topic,)) # ── Kanban dataflow (generic card layer, boards 'inventory' + 'artefacts') ──────── @@ -743,12 +776,11 @@ def set_current_run(topic: str, run_id: str | None) -> None: async def add_event(topic: str, kind: str, key: str = "", label: str = "", status: str = "", dur_ms: int | None = None, wait_ms: int | None = None, meta: dict | None = None) -> None: """One pipeline-history row, own commit. Callers treat this as fire-and-forget.""" - db = await get_db() - await db.execute( - "INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)", - (topic, _now(), kind, key, label, status, dur_ms, wait_ms, - json.dumps(meta or {}, ensure_ascii=False), _current_run.get(topic, ""))) - await db.commit() + async with _tx() as db: + await db.execute( + "INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)", + (topic, _now(), kind, key, label, status, dur_ms, wait_ms, + json.dumps(meta or {}, ensure_ascii=False), _current_run.get(topic, ""))) async def _add_events_many(db, topic: str, rows: list[tuple]) -> None: @@ -766,40 +798,37 @@ async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str """Batch stage moves in ONE commit (the flow advances whole packages).""" if not moves: return - db = await get_db() - now = _now() - await db.executemany( - """UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ? - WHERE topic = ? AND board = ? AND card_id = ?""", - [(stage, now, topic, board, cid) for cid, stage in moves]) - await _add_events_many(db, topic, [("stage", f"{board}:{cid}", "", stage) for cid, stage in moves]) - await db.commit() + async with _tx() as db: + now = _now() + await db.executemany( + """UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ? + WHERE topic = ? AND board = ? AND card_id = ?""", + [(stage, now, topic, board, cid) for cid, stage in moves]) + await _add_events_many(db, topic, [("stage", f"{board}:{cid}", "", stage) for cid, stage in moves]) async def kanban_upsert_card(topic: str, board: str, card_id: str, kind: str, stage: str, payload: dict | None = None) -> None: """Insert or overwrite a card (stable ids → growing clusters upsert, never duplicate). payload=None keeps the existing payload on conflict.""" - db = await get_db() - await db.execute( - """INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at) - VALUES (?, ?, ?, ?, ?, COALESCE(?, '{}'), ?) - ON CONFLICT(topic, board, card_id) DO UPDATE SET - kind = excluded.kind, stage = excluded.stage, - payload = COALESCE(?, kanban_cards.payload), - retries = 0, not_before = '', last_error = NULL, updated_at = excluded.updated_at""", - (topic, board, card_id, kind, stage, - json.dumps(payload, ensure_ascii=False) if payload is not None else None, _now(), - json.dumps(payload, ensure_ascii=False) if payload is not None else None)) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at) + VALUES (?, ?, ?, ?, ?, COALESCE(?, '{}'), ?) + ON CONFLICT(topic, board, card_id) DO UPDATE SET + kind = excluded.kind, stage = excluded.stage, + payload = COALESCE(?, kanban_cards.payload), + retries = 0, not_before = '', last_error = NULL, updated_at = excluded.updated_at""", + (topic, board, card_id, kind, stage, + json.dumps(payload, ensure_ascii=False) if payload is not None else None, _now(), + json.dumps(payload, ensure_ascii=False) if payload is not None else None)) async def kanban_set_payload(topic: str, board: str, card_id: str, payload: dict) -> None: - db = await get_db() - await db.execute( - "UPDATE kanban_cards SET payload = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?", - (json.dumps(payload, ensure_ascii=False), _now(), topic, board, card_id)) - await db.commit() + async with _tx() as db: + await db.execute( + "UPDATE kanban_cards SET payload = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?", + (json.dumps(payload, ensure_ascii=False), _now(), topic, board, card_id)) async def kanban_get_card(topic: str, board: str, card_id: str) -> dict | None: @@ -826,32 +855,31 @@ async def kanban_fail_card(topic: str, board: str, card_id: str, error: str, max_retries: int, backoff_base: float = 30.0) -> bool: """Register a processing failure: retries++, exponential backoff (not_before), and after `max_retries` → stage 'dead' (dead-letter, requeue-able). → True if the card went dead.""" - db = await get_db() - cursor = await db.execute( - "SELECT retries FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?", - (topic, board, card_id)) - row = await cursor.fetchone() - if row is None: - return False - retries = (row[0] or 0) + 1 - dead = retries >= max_retries - if dead: + async with _tx() as db: + cursor = await db.execute( + "SELECT retries FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?", + (topic, board, card_id)) + row = await cursor.fetchone() + if row is None: + return False + retries = (row[0] or 0) + 1 + dead = retries >= max_retries + if dead: + await db.execute( + """UPDATE kanban_cards SET stage = 'dead', retries = ?, not_before = '', + last_error = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?""", + (retries, error[:500], _now(), topic, board, card_id)) + else: + await db.execute( + """UPDATE kanban_cards SET retries = ?, not_before = ?, last_error = ?, updated_at = ? + WHERE topic = ? AND board = ? AND card_id = ?""", + (retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(), + topic, board, card_id)) await db.execute( - """UPDATE kanban_cards SET stage = 'dead', retries = ?, not_before = '', - last_error = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?""", - (retries, error[:500], _now(), topic, board, card_id)) - else: - await db.execute( - """UPDATE kanban_cards SET retries = ?, not_before = ?, last_error = ?, updated_at = ? - WHERE topic = ? AND board = ? AND card_id = ?""", - (retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(), - topic, board, card_id)) - await db.execute( - "INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)", - (topic, _now(), "fail", f"{board}:{card_id}", "", "dead" if dead else f"retry{retries}", - None, None, json.dumps({"error": error[:200]}, ensure_ascii=False), _current_run.get(topic, ""))) - await db.commit() - return dead + "INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)", + (topic, _now(), "fail", f"{board}:{card_id}", "", "dead" if dead else f"retry{retries}", + None, None, json.dumps({"error": error[:200]}, ensure_ascii=False), _current_run.get(topic, ""))) + return dead async def events_run_summary(topic: str, run_id: str) -> dict: @@ -880,6 +908,27 @@ async def events_run_summary(topic: str, run_id: str) -> dict: return {"agents": agents, "tokens": tokens} +async def list_runs(topic: str, limit: int = 10) -> list[dict]: + """Läufe eines Topics (Blocks UND Guide — beide setzen run_id), jüngster zuerst: + Zeitspanne, Agent-/Token-Bilanz, letzte Fehler. Datenquelle für GET /api/runs.""" + db = await get_db() + cursor = await db.execute( + "SELECT run_id, MIN(ts), MAX(ts) FROM events WHERE topic = ? AND run_id != '' " + "GROUP BY run_id ORDER BY 3 DESC LIMIT ?", (topic, limit)) + rows = await cursor.fetchall() + out = [] + for run_id, start, ende in rows: + summary = await events_run_summary(topic, run_id) + 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}) + return out + + async def kanban_dead(topic: str) -> list[dict]: """Dead-letter cards across boards (for the board UI + requeue).""" return await kanban_cards(topic, stage="dead") @@ -887,14 +936,13 @@ async def kanban_dead(topic: str) -> list[dict]: async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int: """dead → `stage` (fresh retries). → number of requeued cards.""" - db = await get_db() - cursor = await db.execute( - """UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ? - WHERE topic = ? AND board = ? AND stage = 'dead'""", - (stage, _now(), topic, board)) - await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)]) - await db.commit() - return cursor.rowcount + async with _tx() as db: + cursor = await db.execute( + """UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ? + WHERE topic = ? AND board = ? AND stage = 'dead'""", + (stage, _now(), topic, board)) + await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)]) + return cursor.rowcount async def kanban_stage_counts(topic: str) -> dict[str, dict[str, int]]: @@ -920,44 +968,45 @@ async def kanban_stage_cards(topic: str, board: str, stage: str, limit: int = 20 async def kanban_delete_card(topic: str, board: str, card_id: str) -> None: """Delete ONE card (repair: the merged-away/removed block's board-2 card).""" - db = await get_db() - await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?", - (topic, board, card_id)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?", + (topic, board, card_id)) async def kanban_delete_cards(topic: str, board: str, kind: str | None = None) -> None: """Delete derived cards (board reset) — kind=None wipes the whole board.""" - db = await get_db() - if kind: - await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND kind = ?", - (topic, board, kind)) - else: - await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board)) - await db.commit() + async with _tx() as db: + if kind: + await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND kind = ?", + (topic, board, kind)) + else: + await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board)) async def kanban_reset(topic: str, board: str | None = None) -> None: - db = await get_db() - if board: - await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board)) - if board == "inventory": + async with _tx() as db: + if board: + await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board)) + if board == "inventory": + await db.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,)) + else: + await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (topic,)) await db.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,)) - else: - await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (topic,)) - await db.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,)) - await db.commit() async def kanban_set_members(topic: str, group_id: str, members: list[str]) -> None: """Replace the member set of a cluster (one member belongs to exactly one cluster).""" - db = await get_db() - await db.execute("DELETE FROM kanban_members WHERE topic = ? AND group_id = ?", (topic, group_id)) - await db.executemany( - """INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?) - ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""", - [(topic, m, group_id) for m in members]) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM kanban_members WHERE topic = ? AND group_id = ?", (topic, group_id)) + await db.executemany( + """INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?) + ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""", + [(topic, m, group_id) for m in members]) + + +async def kanban_delete_members(topic: str) -> None: + async with _tx() as db: + await db.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,)) async def kanban_members_of(topic: str, group_id: str) -> list[str]: @@ -971,14 +1020,32 @@ async def kanban_members_of(topic: str, group_id: str) -> list[str]: async def upsert_guide_card(topic: str, format: str, block_norm: str, block: str, stage: str = "lernziele") -> None: """Insert a card; an existing one keeps its stage/progress (resume).""" - db = await get_db() - await db.execute( - """INSERT INTO guide_cards (topic, format, block_norm, block, stage, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(topic, format, block_norm) DO UPDATE SET - block = excluded.block, updated_at = excluded.updated_at""", - (topic, format, block_norm, block, stage, _now())) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO guide_cards (topic, format, block_norm, block, stage, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(topic, format, block_norm) DO UPDATE SET + block = excluded.block, updated_at = excluded.updated_at""", + (topic, format, block_norm, block, stage, _now())) + + +async def upsert_guide_cards_many(topic: str, format: str, cards: list[tuple[str, str]]) -> None: + """Batch-Seed (block_norm, block); ein Commit statt einem pro Karte.""" + if not cards: + return + async with _tx() as db: + await db.executemany( + """INSERT INTO guide_cards (topic, format, block_norm, block, stage, updated_at) + VALUES (?, ?, ?, ?, 'lernziele', ?) + ON CONFLICT(topic, format, block_norm) DO UPDATE SET + block = excluded.block, updated_at = excluded.updated_at""", + [(topic, format, bn, b, _now()) for bn, b in cards]) + + +async def delete_lernziele_all(topic: str) -> None: + """Alle Lernziele eines Topics in einem Statement (statt Karte für Karte).""" + async with _tx() as db: + await db.execute("DELETE FROM guide_lernziele WHERE topic = ?", (topic,)) async def list_guide_cards(topic: str, format: str | None = None) -> list[dict]: @@ -997,17 +1064,16 @@ async def list_guide_cards(topic: str, format: str | None = None) -> list[dict]: async def set_guide_card(topic: str, format: str, block_norm: str, **fields) -> None: if not fields: return - db = await get_db() - cols = ", ".join(f"{k} = ?" for k in fields) - await db.execute( - f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?", - (*fields.values(), _now(), topic, format, block_norm)) - if "stage" in fields: # the guide board moves stages here, not via kanban_advance_many - await _add_events_many(db, topic, [("stage", f"guide:{format}:{block_norm}", "", fields["stage"])]) - elif fields.get("status") == "error": # guide cards fail here, not via kanban_fail_card - await _add_events_many(db, topic, [("fail", f"guide:{format}:{block_norm}", "", - str(fields.get("gate_info", ""))[:200])]) - await db.commit() + async with _tx() as db: + cols = ", ".join(f"{k} = ?" for k in fields) + await db.execute( + f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?", + (*fields.values(), _now(), topic, format, block_norm)) + if "stage" in fields: # the guide board moves stages here, not via kanban_advance_many + await _add_events_many(db, topic, [("stage", f"guide:{format}:{block_norm}", "", fields["stage"])]) + elif fields.get("status") == "error": # guide cards fail here, not via kanban_fail_card + await _add_events_many(db, topic, [("fail", f"guide:{format}:{block_norm}", "", + str(fields.get("gate_info", ""))[:200])]) async def guide_stage_counts(topic: str, format: str) -> dict[str, int]: @@ -1023,40 +1089,37 @@ async def reset_guide_cards_from_stage(topic: str, format: str, stages: list[str """Cards sitting in any of `stages` → back to `to_stage` (fresh rounds/gate info).""" if not stages: return 0 - db = await get_db() - ph = ",".join("?" * len(stages)) - md = ", md = ''" if clear_md else "" - cursor = await db.execute( - f"""UPDATE guide_cards SET stage = ?, status = 'open', writer_rounds = 0, - gate_info = ''{md}, updated_at = ? - WHERE topic = ? AND format = ? AND stage IN ({ph})""", - (to_stage, _now(), topic, format, *stages)) - await db.commit() - return cursor.rowcount + async with _tx() as db: + ph = ",".join("?" * len(stages)) + md = ", md = ''" if clear_md else "" + cursor = await db.execute( + f"""UPDATE guide_cards SET stage = ?, status = 'open', writer_rounds = 0, + gate_info = ''{md}, updated_at = ? + WHERE topic = ? AND format = ? AND stage IN ({ph})""", + (to_stage, _now(), topic, format, *stages)) + return cursor.rowcount async def delete_guide_board(topic: str, format: str | None = None) -> None: - db = await get_db() - if format: - await db.execute("DELETE FROM guide_cards WHERE topic = ? AND format = ?", (topic, format)) - cursor = await db.execute("SELECT count(*) FROM guide_cards WHERE topic = ?", (topic,)) - if (await cursor.fetchone())[0] == 0: + async with _tx() as db: + if format: + await db.execute("DELETE FROM guide_cards WHERE topic = ? AND format = ?", (topic, format)) + cursor = await db.execute("SELECT count(*) FROM guide_cards WHERE topic = ?", (topic,)) + if (await cursor.fetchone())[0] == 0: + await db.execute("DELETE FROM guide_lernziele WHERE topic = ?", (topic,)) + else: + await db.execute("DELETE FROM guide_cards WHERE topic = ?", (topic,)) await db.execute("DELETE FROM guide_lernziele WHERE topic = ?", (topic,)) - else: - await db.execute("DELETE FROM guide_cards WHERE topic = ?", (topic,)) - await db.execute("DELETE FROM guide_lernziele WHERE topic = ?", (topic,)) - await db.commit() async def put_lernziel(topic: str, block_norm: str, ziel_id: str, text: str, sub_norm: str = "") -> None: - db = await get_db() - await db.execute( - """INSERT INTO guide_lernziele (topic, block_norm, ziel_id, text, sub_norm, covered, updated_at) - VALUES (?, ?, ?, ?, ?, 0, ?) - ON CONFLICT(topic, block_norm, ziel_id) DO UPDATE SET - text = excluded.text, sub_norm = excluded.sub_norm, updated_at = excluded.updated_at""", - (topic, block_norm, ziel_id, text, sub_norm, _now())) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO guide_lernziele (topic, block_norm, ziel_id, text, sub_norm, covered, updated_at) + VALUES (?, ?, ?, ?, ?, 0, ?) + ON CONFLICT(topic, block_norm, ziel_id) DO UPDATE SET + text = excluded.text, sub_norm = excluded.sub_norm, updated_at = excluded.updated_at""", + (topic, block_norm, ziel_id, text, sub_norm, _now())) async def list_lernziele(topic: str, block_norm: str | None = None) -> list[dict]: @@ -1072,17 +1135,15 @@ async def list_lernziele(topic: str, block_norm: str | None = None) -> list[dict async def set_ziel_covered(topic: str, block_norm: str, ziel_id: str, covered: bool) -> None: - db = await get_db() - await db.execute( - "UPDATE guide_lernziele SET covered = ?, updated_at = ? WHERE topic = ? AND block_norm = ? AND ziel_id = ?", - (1 if covered else 0, _now(), topic, block_norm, ziel_id)) - await db.commit() + async with _tx() as db: + await db.execute( + "UPDATE guide_lernziele SET covered = ?, updated_at = ? WHERE topic = ? AND block_norm = ? AND ziel_id = ?", + (1 if covered else 0, _now(), topic, block_norm, ziel_id)) async def delete_lernziele(topic: str, block_norm: str) -> None: - db = await get_db() - await db.execute("DELETE FROM guide_lernziele WHERE topic = ? AND block_norm = ?", (topic, block_norm)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM guide_lernziele WHERE topic = ? AND block_norm = ?", (topic, block_norm)) async def kanban_membership(topic: str) -> dict[str, str]: @@ -1093,12 +1154,11 @@ async def kanban_membership(topic: str) -> dict[str, str]: async def kanban_set_member(topic: str, member_id: str, group_id: str) -> None: - db = await get_db() - await db.execute( - """INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?) - ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""", - (topic, member_id, group_id)) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?) + ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""", + (topic, member_id, group_id)) async def kanban_add_title(topic: str, board: str, card_id: str, title: str, @@ -1111,12 +1171,11 @@ async def kanban_add_title(topic: str, board: str, card_id: str, title: str, if row is None: payload = {"title": title, "description": description, "sources": [source] if source else [], "readers": [reader]} - db = await get_db() - await db.execute( - """INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at) - VALUES (?, ?, ?, 'title', 'ingest', ?, ?)""", - (topic, board, card_id, json.dumps(payload, ensure_ascii=False), _now())) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at) + VALUES (?, ?, ?, 'title', 'ingest', ?, ?)""", + (topic, board, card_id, json.dumps(payload, ensure_ascii=False), _now())) return True p = row["payload"] p["readers"] = list(dict.fromkeys((p.get("readers") or []) + [reader])) @@ -1128,15 +1187,14 @@ async def kanban_add_title(topic: str, board: str, card_id: str, title: str, async def upsert_subblock(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str) -> None: - db = await get_db() - await db.execute( - """INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, status, updated_at) - VALUES (?, ?, ?, ?, ?, 1, 'candidate', ?) - ON CONFLICT(topic, block_norm, sub_norm) DO UPDATE SET - mentions = mentions + 1, updated_at = excluded.updated_at""", - (topic, block_norm, sub_norm, block, sub_title, _now()), - ) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, status, updated_at) + VALUES (?, ?, ?, ?, ?, 1, 'candidate', ?) + ON CONFLICT(topic, block_norm, sub_norm) DO UPDATE SET + mentions = mentions + 1, updated_at = excluded.updated_at""", + (topic, block_norm, sub_norm, block, sub_title, _now()), + ) async def put_subblock(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str, @@ -1144,19 +1202,18 @@ async def put_subblock(topic: str, block_norm: str, sub_norm: str, block: str, s facts: str | None = None, status: str = "consensus") -> None: """Insert/update WITHOUT a mention counter (mirror from the sidecar). Overwrite level/relevance/facts only when a new value is passed (COALESCE protects existing data).""" - db = await get_db() - await db.execute( - """INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, level, relevance, facts, status, updated_at) - VALUES (?, ?, ?, ?, ?, 1, ?, ?, COALESCE(?, ''), ?, ?) - ON CONFLICT(topic, block_norm, sub_norm) DO UPDATE SET - block = excluded.block, sub_title = excluded.sub_title, - level = COALESCE(excluded.level, subblocks.level), - relevance = COALESCE(excluded.relevance, subblocks.relevance), - facts = COALESCE(NULLIF(excluded.facts, ''), subblocks.facts), - status = excluded.status, updated_at = excluded.updated_at""", - (topic, block_norm, sub_norm, block, sub_title, level, relevance, facts, status, _now()), - ) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, level, relevance, facts, status, updated_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, COALESCE(?, ''), ?, ?) + ON CONFLICT(topic, block_norm, sub_norm) DO UPDATE SET + block = excluded.block, sub_title = excluded.sub_title, + level = COALESCE(excluded.level, subblocks.level), + relevance = COALESCE(excluded.relevance, subblocks.relevance), + facts = COALESCE(NULLIF(excluded.facts, ''), subblocks.facts), + status = excluded.status, updated_at = excluded.updated_at""", + (topic, block_norm, sub_norm, block, sub_title, level, relevance, facts, status, _now()), + ) async def list_subblocks(topic: str, block_norm: str | None = None) -> list[dict]: @@ -1174,51 +1231,47 @@ async def list_subblocks(topic: str, block_norm: str | None = None) -> list[dict async def default_subblock_levels(topic: str, block_norm: str) -> None: """Classify stragglers after finalize: consensus rows without a valid level fall out of the guide/practice/level queries (re-run resume left 25 such rows — invisible content).""" - db = await get_db() - await db.execute( - """UPDATE subblocks SET level = 'advanced' WHERE topic = ? AND block_norm = ? - AND status = 'consensus' AND (level IS NULL OR level NOT IN ('beginner', 'advanced', 'expert'))""", - (topic, block_norm)) - await db.execute( - """UPDATE subblocks SET relevance = 'relevant' WHERE topic = ? AND block_norm = ? - AND status = 'consensus' AND relevance IS NULL""", - (topic, block_norm)) - await db.commit() + async with _tx() as db: + await db.execute( + """UPDATE subblocks SET level = 'advanced' WHERE topic = ? AND block_norm = ? + AND status = 'consensus' AND (level IS NULL OR level NOT IN ('beginner', 'advanced', 'expert'))""", + (topic, block_norm)) + await db.execute( + """UPDATE subblocks SET relevance = 'relevant' WHERE topic = ? AND block_norm = ? + AND status = 'consensus' AND relevance IS NULL""", + (topic, block_norm)) async def copy_topic(quelle: str, ziel: str) -> None: """Trainings-Helfer: Kanban-Karten + Block-Rows der Quelle unter neuem Topic duplizieren (Frozen-Inventar-Trials — Board 2 läuft auf identischem Board-1-Stand neu). Nur DB; Dateien (source.json/blocks.md) kopiert der Runner.""" - db = await get_db() - await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (ziel,)) - await db.execute("DELETE FROM blocks WHERE topic = ?", (ziel,)) - await db.execute( - """INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, retries, not_before, last_error, updated_at) - SELECT ?, board, card_id, kind, stage, payload, 0, 0, '', updated_at - FROM kanban_cards WHERE topic = ?""", (ziel, quelle)) - await db.execute( - """INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at) - SELECT ?, title_norm, title, description, mentions, status, sources, reader, updated_at - FROM blocks WHERE topic = ?""", (ziel, quelle)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (ziel,)) + await db.execute("DELETE FROM blocks WHERE topic = ?", (ziel,)) + await db.execute( + """INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, retries, not_before, last_error, updated_at) + SELECT ?, board, card_id, kind, stage, payload, 0, 0, '', updated_at + FROM kanban_cards WHERE topic = ?""", (ziel, quelle)) + await db.execute( + """INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at) + SELECT ?, title_norm, title, description, mentions, status, sources, reader, updated_at + FROM blocks WHERE topic = ?""", (ziel, quelle)) async def delete_stale_consensus(topic: str, block_norm: str, keep: set[str]) -> None: """Drop consensus rows of a block that are NOT in this run's sidecar (`keep`): finalize only upserts, so re-runs piled up orphan rows (measured: 25 subs without any board-2 output). variant/discarded rows stay — QA reads those statuses.""" - db = await get_db() - cursor = await db.execute( - "SELECT sub_norm FROM subblocks WHERE topic = ? AND block_norm = ? AND status = 'consensus'", - (topic, block_norm)) - rows = await cursor.fetchall() - stale = [r[0] for r in rows if r[0] not in keep] - for sn in stale: - await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ? AND sub_norm = ?", - (topic, block_norm, sn)) - if stale: - await db.commit() + async with _tx() as db: + cursor = await db.execute( + "SELECT sub_norm FROM subblocks WHERE topic = ? AND block_norm = ? AND status = 'consensus'", + (topic, block_norm)) + rows = await cursor.fetchall() + stale = [r[0] for r in rows if r[0] not in keep] + for sn in stale: + await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ? AND sub_norm = ?", + (topic, block_norm, sn)) async def set_subblock_fields(topic: str, block_norm: str, sub_norm: str, **fields) -> None: @@ -1228,24 +1281,22 @@ async def set_subblock_fields(topic: str, block_norm: str, sub_norm: str, **fiel async def delete_subblocks(topic: str, block_norm: str | None = None) -> None: - db = await get_db() - if block_norm is None: - await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,)) - else: - await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ?", (topic, block_norm)) - await db.commit() + async with _tx() as db: + if block_norm is None: + await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,)) + else: + await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ?", (topic, block_norm)) async def upsert_question_pattern(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str, question: str) -> None: - db = await get_db() - await db.execute( - """INSERT INTO question_pattern (topic, block_norm, sub_norm, block, sub_title, question, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(topic, block_norm, sub_norm) DO UPDATE SET - sub_title = excluded.sub_title, question = excluded.question, updated_at = excluded.updated_at""", - (topic, block_norm, sub_norm, block, sub_title, question, _now()), - ) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO question_pattern (topic, block_norm, sub_norm, block, sub_title, question, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(topic, block_norm, sub_norm) DO UPDATE SET + sub_title = excluded.sub_title, question = excluded.question, updated_at = excluded.updated_at""", + (topic, block_norm, sub_norm, block, sub_title, question, _now()), + ) async def list_question_pattern(topic: str, block_norm: str | None = None) -> list[dict]: @@ -1285,41 +1336,38 @@ async def event_span(topic: str) -> int: async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None: - db = await get_db() - if block_norm is None: - await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,)) - else: - await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ?", (topic, block_norm)) - await db.commit() + async with _tx() as db: + if block_norm is None: + await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,)) + else: + await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ?", (topic, block_norm)) async def mark_sources_read_done(topic: str, sources: list[str]) -> None: """Mark the cited crawl pages as read_done (research-loop coverage).""" if not sources: return - db = await get_db() - now = _now() - await db.executemany( - """INSERT INTO research_coverage (topic, source, read_done, updated_at) VALUES (?, ?, 1, ?) - ON CONFLICT(topic, source) DO UPDATE SET read_done = 1, updated_at = excluded.updated_at""", - [(topic, q, now) for q in sources], - ) - await db.commit() + async with _tx() as db: + now = _now() + await db.executemany( + """INSERT INTO research_coverage (topic, source, read_done, updated_at) VALUES (?, ?, 1, ?) + ON CONFLICT(topic, source) DO UPDATE SET read_done = 1, updated_at = excluded.updated_at""", + [(topic, q, now) for q in sources], + ) async def mark_content(topic: str, content: list[str], noise: list[str]) -> None: """Store the triage result per crawl page: content=1 (content) or 0 (noise).""" - db = await get_db() - now = _now() - rows = [(topic, q, 1, now) for q in content] + [(topic, q, 0, now) for q in noise] - if not rows: - return - await db.executemany( - """INSERT INTO research_coverage (topic, source, content, updated_at) VALUES (?, ?, ?, ?) - ON CONFLICT(topic, source) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at""", - rows, - ) - await db.commit() + async with _tx() as db: + now = _now() + rows = [(topic, q, 1, now) for q in content] + [(topic, q, 0, now) for q in noise] + if not rows: + return + await db.executemany( + """INSERT INTO research_coverage (topic, source, content, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(topic, source) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at""", + rows, + ) async def list_content(topic: str) -> list[str]: @@ -1332,19 +1380,17 @@ async def list_content(topic: str) -> list[str]: async def delete_coverage(topic: str) -> None: - db = await get_db() - await db.execute("DELETE FROM research_coverage WHERE topic = ?", (topic,)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM research_coverage WHERE topic = ?", (topic,)) async def set_step_status(topic: str, step: str, status: str) -> None: - db = await get_db() - await db.execute( - """INSERT INTO pipeline_state (topic, step, status, updated_at) VALUES (?, ?, ?, ?) - ON CONFLICT(topic, step) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at""", - (topic, step, status, _now()), - ) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO pipeline_state (topic, step, status, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(topic, step) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at""", + (topic, step, status, _now()), + ) async def get_step_status(topic: str, step: str) -> str: @@ -1363,20 +1409,18 @@ async def get_step_status(topic: str, step: str) -> str: async def delete_source(topic: str) -> None: - db = await get_db() - await db.execute("DELETE FROM source WHERE topic = ?", (topic,)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM source WHERE topic = ?", (topic,)) async def set_guide_content(topic: str, format: str, content_json: str) -> None: """Store finished guide content (JSON blob) per topic+format.""" - db = await get_db() - await db.execute( - """INSERT INTO guide_content (topic, format, json, updated_at) VALUES (?, ?, ?, ?) - ON CONFLICT(topic, format) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""", - (topic, format, content_json, _now()), - ) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO guide_content (topic, format, json, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(topic, format) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""", + (topic, format, content_json, _now()), + ) async def get_guide_content(topic: str, format: str) -> str | None: @@ -1387,23 +1431,21 @@ async def get_guide_content(topic: str, format: str) -> str | None: async def delete_guide_content(topic: str, format: str | None = None) -> None: - db = await get_db() - if format is None: - await db.execute("DELETE FROM guide_content WHERE topic = ?", (topic,)) - else: - await db.execute("DELETE FROM guide_content WHERE topic = ? AND format = ?", (topic, format)) - await db.commit() + async with _tx() as db: + if format is None: + await db.execute("DELETE FROM guide_content WHERE topic = ?", (topic,)) + else: + await db.execute("DELETE FROM guide_content WHERE topic = ? AND format = ?", (topic, format)) async def set_outline(topic: str, outline_json: str) -> None: """Store the outline (chapter→numbers, JSON) per topic — blocks artifact for the guide.""" - db = await get_db() - await db.execute( - """INSERT INTO guide_outline (topic, json, updated_at) VALUES (?, ?, ?) - ON CONFLICT(topic) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""", - (topic, outline_json, _now()), - ) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO guide_outline (topic, json, updated_at) VALUES (?, ?, ?) + ON CONFLICT(topic) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""", + (topic, outline_json, _now()), + ) async def get_outline(topic: str) -> str | None: @@ -1414,24 +1456,22 @@ async def get_outline(topic: str) -> str | None: async def delete_outline(topic: str) -> None: - db = await get_db() - await db.execute("DELETE FROM guide_outline WHERE topic = ?", (topic,)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM guide_outline WHERE topic = ?", (topic,)) async def put_sub_artifact(topic: str, block_norm: str, sub_norm: str, type: str, data: str, block: str = "", sub_title: str = "") -> None: """Store one learning artifact (flashcard/example) as JSON in `data`.""" - db = await get_db() - await db.execute( - """INSERT INTO sub_artefakte (topic, block_norm, sub_norm, type, block, sub_title, data, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(topic, block_norm, sub_norm, type) DO UPDATE SET - block = excluded.block, sub_title = excluded.sub_title, - data = excluded.data, updated_at = excluded.updated_at""", - (topic, block_norm, sub_norm, type, block, sub_title, data, _now()), - ) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO sub_artefakte (topic, block_norm, sub_norm, type, block, sub_title, data, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(topic, block_norm, sub_norm, type) DO UPDATE SET + block = excluded.block, sub_title = excluded.sub_title, + data = excluded.data, updated_at = excluded.updated_at""", + (topic, block_norm, sub_norm, type, block, sub_title, data, _now()), + ) async def get_sub_artefakte(topic: str, type: str | None = None, @@ -1460,15 +1500,14 @@ async def get_practice_progress(topic: str) -> list[dict]: async def upsert_practice_progress(topic: str, block_norm: str, sub_norm: str, box: int, due_at: str) -> None: - db = await get_db() - await db.execute( - """INSERT INTO practice_progress (topic, block_norm, sub_norm, box, due_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(topic, block_norm, sub_norm) - DO UPDATE SET box = excluded.box, due_at = excluded.due_at, - updated_at = excluded.updated_at""", - (topic, block_norm, sub_norm, box, due_at, _now())) - await db.commit() + async with _tx() as db: + await db.execute( + """INSERT INTO practice_progress (topic, block_norm, sub_norm, box, due_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(topic, block_norm, sub_norm) + DO UPDATE SET box = excluded.box, due_at = excluded.due_at, + updated_at = excluded.updated_at""", + (topic, block_norm, sub_norm, box, due_at, _now())) async def sub_levels_norm(topic: str) -> dict[tuple[str, str], int]: @@ -1494,27 +1533,24 @@ async def subs_per_level_norm(topic: str) -> dict[str, dict[int, int]]: async def delete_artefakt_row(topic: str, block_norm: str, sub_norm: str, type: str) -> None: """Remove ONE artefact row (repair: dead target — sub discarded or gone).""" - db = await get_db() - await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ? AND sub_norm = ? AND type = ?", - (topic, block_norm, sub_norm, type)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ? AND sub_norm = ? AND type = ?", + (topic, block_norm, sub_norm, type)) async def delete_frage_row(topic: str, block_norm: str, sub_norm: str) -> None: """Remove ONE question_pattern row (repair: dead target).""" - db = await get_db() - await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ? AND sub_norm = ?", - (topic, block_norm, sub_norm)) - await db.commit() + async with _tx() as db: + await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ? AND sub_norm = ?", + (topic, block_norm, sub_norm)) async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None: - db = await get_db() - if block_norm is None: - await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,)) - else: - await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ?", (topic, block_norm)) - await db.commit() + async with _tx() as db: + if block_norm is None: + await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,)) + else: + await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ?", (topic, block_norm)) async def get_block_hurdles(topic: str, block_norm: str) -> list[str]: @@ -1542,8 +1578,7 @@ async def get_block_hurdles(topic: str, block_norm: str) -> list[str]: async def delete_topic_pipeline(topic: str) -> None: """Discard the blocks area of a topic (inventory/subs/pattern/coverage/state/artifacts). NOT the topic config `source` — that is managed separately (delete_source).""" - db = await get_db() - for tab in ("blocks", "subblocks", "question_pattern", "research_coverage", - "pipeline_state", "guide_outline", "sub_artefakte", "practice_progress", "events"): - await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,)) - await db.commit() + async with _tx() as db: + for tab in ("blocks", "subblocks", "question_pattern", "research_coverage", + "pipeline_state", "guide_outline", "sub_artefakte", "practice_progress", "events"): + await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,)) diff --git a/backend/fake_agents.py b/backend/fake_agents.py index 97a5954..1c6a47e 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -288,13 +288,12 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None: import kanban import pipeline import qa - import repair async def fake_run_agent(agent_key, prompt, timeout, provider="", role="fast", capabilities="none", lane="batch", scope=None, on_line=None, label=""): return welt.respond(agent_key, prompt, capabilities) - for mod in (agents, pipeline, blocks, guide, repair): + for mod in (agents, pipeline, guide): setattr_fn(mod, "run_agent", fake_run_agent) setattr_fn(blocks, "CONSENSUS_GRACE", 0) setattr_fn(bi, "_QA_GATE_POLL", 0.05) diff --git a/backend/guide.py b/backend/guide.py index 6304bc3..b5ede96 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -11,7 +11,6 @@ Step files are kept → an abort preserves progress, ▶ resumes at the open ste import asyncio import json import logging -import math from datetime import datetime, timezone from pathlib import Path @@ -19,26 +18,14 @@ import uuid from agents import run_agent from blocks import _convert_pdfs, source_folder -from config import ( - DEFAULT_PROVIDER, FORMAT_PURPOSE, CONSENSUS_GRACE, - READABILITY_ACTIVE, TEMPLATES_DIR, -) -import readability +from config import DEFAULT_PROVIDER, TEMPLATES_DIR from database import (list_guides, update_guide, list_blocks, list_subblocks, set_guide_content, get_guide_content, get_outline, guide_stage_counts, delete_guide_board) -from fsutil import atomic_write_json, atomic_write_text -from jsonio import read_json_file as _json_file, parse_json_text as _parse_json_text -from paths import blocks_path, guide_content_path, project_dir, subblocks_path -from pipeline import ( - CANCELLED, FAILED, GenContext, _claude_error, _extra, - _fail, _gather_error, _gather_progress, _log, _prompt, _race, - _semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled, - is_guide_cancelled, run_single_slot, -) -from textkit import ( - _unique_title, _load_blocks, _norm_title, _parse_fragment, _split_chunks, - _title, _resolve_title, _title_index, -) +from fsutil import atomic_write_json +from jsonio import read_json_file as _json_file +from paths import blocks_path, guide_content_path, subblocks_path +from pipeline import _fail, _prompt, _semaphore, clear_guide_cancelled, is_guide_cancelled +from textkit import _unique_title, _load_blocks, _norm_title, _title, parse_facts log = logging.getLogger("creator.guide") @@ -66,10 +53,7 @@ async def _load_subblocks(topic: str) -> dict[str, list[dict]]: out: dict[str, list[dict]] = {} for r in await list_subblocks(topic): if r["status"] == "consensus" and r["sub_title"]: - try: - facts = json.loads(r["facts"]) if r.get("facts") else {} - except (ValueError, TypeError): - facts = {} + facts = parse_facts(r.get("facts")) level = r["level"] if r["level"] in _LEVELS_OK else "advanced" out.setdefault(r["block"], []).append( {"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts}) @@ -234,10 +218,16 @@ async def reconcile_guides() -> None: file write and status update. """ for g in await list_guides(): - if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists(): - log.warning("[%s] Guide %s: done without content file — set to error", g["topic"], g["id"]) - now = datetime.now(timezone.utc).isoformat() - await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now) + if g["status"] != "done": + continue + # DB-first wie die Content-Route — die Datei ist nur Legacy-Fallback + if await get_guide_content(g["topic"], g["format"]) is not None: + continue + if guide_content_path(g["topic"], g["format"]).exists(): + continue + log.warning("[%s] Guide %s: done without content — set to error", g["topic"], g["id"]) + now = datetime.now(timezone.utc).isoformat() + await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now) async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None: diff --git a/backend/guide_board.py b/backend/guide_board.py index 611a9b3..dcc749e 100644 --- a/backend/guide_board.py +++ b/backend/guide_board.py @@ -22,10 +22,9 @@ import re import database as db import readability from blocks import _sink_json -from config import (FORMAT_PURPOSE, READABILITY_ACTIVE, - TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC) +from config import (FIX_LAENGE_BAND, READABILITY_ACTIVE, TEMPLATES_DIR, + MAX_CONCURRENT_AGENTS_PER_TOPIC, ZIELE_MAX) from guide_qa import block_budget -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) @@ -57,7 +56,7 @@ def _ziele_schema(data): 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: + if not zid or not text or zid in seen or len(out) >= ZIELE_MAX: continue seen.add(zid) out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()}) @@ -140,10 +139,35 @@ class _Env: return self.content_path.parent / f"{self.content_path.stem}.{name}" +def _memo(env, attr: str) -> dict: + """Lazy per-Karte-Cache auf dem env-Objekt (funktioniert auch für Test-Mocks). Lernziele, + Beispiel-Rows und Facts-Grounding sind während EINES Laufs immutabel, wurden aber je Karte + 2–3× neu geholt (writer, pruefer, re-pruefer). Karten haben disjunkte block_norm-Keys und + laufen ihre Stages seriell → kein Race.""" + d = env.__dict__.get(attr) + if d is None: + d = env.__dict__[attr] = {} + return d + + +async def _ziele(env: _Env, block_norm: str) -> list[dict]: + cache = _memo(env, "_ziele_cache") + if block_norm not in cache: + cache[block_norm] = await db.list_lernziele(env.topic, block_norm) + return cache[block_norm] + + +def _ziele_text(ziele: list[dict]) -> str: + return "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)" + + 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 + cache = _memo(env, "_facts_cache") + if block_title not in cache: + from guide import _facts_grounding # lazy: guide imports this module + grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])}) + cache[block_title] = grounding or env.fallback_facts + return cache[block_title] async def _card_examples(env: _Env, block_norm: str, subs: list[dict], @@ -151,7 +175,10 @@ async def _card_examples(env: _Env, block_norm: str, subs: list[dict], """Verified worked examples of the block as writer input, matched to `subs` via sub_norm (a split half gets only its own). Rows whose sub does not match (generation mismatch) go to the full writer / split part 1 so they never vanish silently.""" - rows = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm) + cache = _memo(env, "_example_rows") + if block_norm not in cache: + cache[block_norm] = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm) + rows = cache[block_norm] if not rows: return "" wanted = {_norm_title(s["title"]) for s in subs} @@ -326,8 +353,7 @@ async def _write_split(env: _Env, card: dict, ziele_text: str): 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)" + ziele_text = _ziele_text(await _ziele(env, norm)) # 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) @@ -385,9 +411,10 @@ def _det_hinweise(env: _Env, card: dict, sec: dict) -> list[str]: budget = block_budget(subs_all) aus = re.split(r"", sec["md"], maxsplit=1) zeichen = len(aus[1] if len(aus) == 2 else sec["md"]) - if not (0.5 * budget <= zeichen <= 1.2 * budget): + lo, hi = FIX_LAENGE_BAND + if not (lo * budget <= zeichen <= hi * budget): out.append( - f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(0.5 * budget)}–{round(1.2 * budget)}): " + f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(lo * budget)}–{round(hi * budget)}): " f"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und " f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten") return out @@ -424,8 +451,8 @@ async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[st Section-Text in drei seriellen Calls). Text-Antwort + Engine-Sink (Datei-schreibende Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED).""" 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)" + ziele = await _ziele(env, norm) + ziele_text = _ziele_text(ziele) ids = {z["ziel_id"] for z in ziele} facts = _card_facts(env, card["block"]) ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], [])) @@ -441,7 +468,7 @@ async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[st hinweise=hinweise, extra=_extra(env.instructions)), role="judge", capabilities="none", payload=lambda result: _sink_json(result, path, lambda d: _pruefer_schema(d, ids)), - timeout=_timeout("fakten_gate", 1)) + timeout=_timeout("pruefer", 1)) if status != OK or verdict is None: return None for zid, ok in verdict["ziele"].items(): @@ -520,12 +547,19 @@ async def _stage_fix(env: _Env, card: dict) -> bool: card["md"] = fixed angewandt = True rest = "" + if not angewandt: + # Fix ohne Ergebnis: Befunde nicht stumm löschen — sie bleiben im gate_info sichtbar + rest = "Fix ohne Ergebnis — offene Befunde:\n" + auftraege + _log(env.topic, f"Fix {card['block']}: nicht angewandt — Befunde bleiben sichtbar") if kritisch and angewandt: sec2 = _first_section(card["md"]) verdict = await _pruefer_call(env, card, sec2, "re", []) if verdict is None and is_guide_cancelled(env.guide_id): return False - if verdict: + if verdict is None: + rest = "Re-Prüfer ohne Ergebnis — Fix ungeprüft übernommen" + _log(env.topic, f"Re-Prüfer {card['block']}: kein Ergebnis — Fix ungeprüft übernommen") + else: zeilen, _k = _auftraege(verdict, [], _n_rel(env, card)) if zeilen: rest = "Rest-Befunde nach Fix:\n" + "\n".join(zeilen) @@ -568,6 +602,22 @@ async def _run_card_inner(env: _Env, card: dict) -> None: # ── Orchestration ────────────────────────────────────────────────────────────────── +async def _progress_reporter(guide_id: str, topic: str, format_name: str, takt: float = 2.0) -> None: + """Live-Fortschritt fürs Frontend; ein DB-Fehler darf den Reporter nie beenden + (der Fortschritt fror sonst still ein), unveränderter Stand wird nicht geschrieben.""" + zuletzt = None + while True: + try: + counts = await db.guide_stage_counts(topic, format_name) + stand = (counts.get("done", 0), sum(counts.values())) + if stand != zuletzt: + zuletzt = stand + await db.update_guide(guide_id, progress=f"Board: {stand[0]}/{stand[1]} Karten fertig") + except Exception: + log.exception("[%s] guide progress reporter", topic) + await asyncio.sleep(takt) + + 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 @@ -602,25 +652,20 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries: 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) + await db.upsert_guide_cards_many( + topic, format_name, + [(_norm_title(_title(line)), _title(line)) for line in entries.values()]) 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()) + reporter = asyncio.create_task(_progress_reporter(guide_id, topic, format_name)) try: - await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards]) + ergebnisse = await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards], + return_exceptions=True) + for c, r in zip(open_cards, ergebnisse): + if isinstance(r, BaseException): + log.error("[%s] guide card task %s: %r", topic, c["block"], r) finally: reporter.cancel() if is_guide_cancelled(guide_id): @@ -701,9 +746,7 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict: columns.append({"key": stage, "label": STAGE_LABELS[stage], "total": len(in_stage), "cards": views}) import qa as qa_mod # lazy wie in board_inventory - tdir = qa_mod.QA_DIR / topic - greports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else [] - note_guide = (_json_file(greports[-1]) or {}).get("note_guide") if greports else None + note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide") return {"columns": columns, "qa_guide": note_guide} @@ -713,8 +756,7 @@ async def repair_karten(topic: str, format_name: str) -> list[str]: generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel.""" import qa as qa_mod - tdir = qa_mod.QA_DIR / topic - reports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else [] + reports = qa_mod.report_paths(topic, guide=True) rep = _json_file(reports[-1]) if reports else None if not rep: return [] @@ -762,8 +804,7 @@ async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int: 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"]) + await db.delete_lernziele_all(topic) moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target, clear_md=ab_stage <= 2) return moved diff --git a/backend/guide_qa.py b/backend/guide_qa.py index 3671f3e..0485046 100644 --- a/backend/guide_qa.py +++ b/backend/guide_qa.py @@ -10,7 +10,7 @@ Report: storage/qa//guide-.json + Konsolen-Digest. """ import asyncio -import json +import logging import re import sys from datetime import datetime, timezone @@ -19,7 +19,9 @@ import database as db import qa import readability from fsutil import atomic_write_json -from textkit import _norm_title +from textkit import _norm_title, parse_facts + +log = logging.getLogger("creator.guide_qa") JACCARD_ABSATZ = 0.6 # Wort-Jaccard, ab dem zwei Absätze als Doppel gelten ABSATZ_MIN_CHARS = 200 # kürzere Absätze sind Übergänge — kein Dubletten-Signal @@ -137,22 +139,11 @@ async def _fachlich_falsch(topic: str, cards: list[dict]) -> list[str]: """LLM-Stichprobe: Section enthält eine fachlich falsche Aussage? Zwei unabhängige Durchgänge, nur DOPPELT bestätigte zählen — ein Einzel-Judge schwankte zwischen 0 und 5 Befunden am selben Guide und kippte die Note (Gewicht 3.0) auf 0.""" - from agents import run_agent - from jsonio import parse_json_text - from pipeline import _yesno_schema - async def _pass(kandidaten: list[dict], tag: str) -> list[str]: - out = [] - for lo in range(0, len(kandidaten), 5): - chunk = kandidaten[lo:lo + 5] - listing = "\n\n".join(f"{k}. SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}" - for k, c in enumerate(chunk, 1)) - rc, txt, _err = await run_agent( - f"qa-guide-{topic}-fakten{tag}-{lo}", qa._qa_prompt("QA-Guide-Fakten", topic=topic, extra="", sections=listing), - 600, role="judge", capabilities="none", scope=topic, label=f"Guide-QA Fakten{tag} {lo}") - v = (_yesno_schema(parse_json_text(txt)) or {}) if rc == 0 else {} - out += [c["block"] for k, c in enumerate(chunk, 1) if v.get(k) == "ja"] - return out + items = [f"SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}" for c in kandidaten] + v = await qa.judge_wave("QA-Guide-Fakten", topic, f"fakten{tag}", "sections", items, + chunk=5, prefix="qa-guide", label="Guide-QA") + return [c["block"] for k, c in enumerate(kandidaten, 1) if v.get(k) == "ja"] verdacht = await _pass(cards, "") if not verdacht: @@ -178,12 +169,8 @@ async def guide_qa_report(topic: str, llm: bool = False) -> dict | None: for r in await db.list_subblocks(topic): if r["status"] != "consensus": continue - try: - facts = json.loads(r["facts"]) if r["facts"] else {} - except (ValueError, TypeError): - facts = {} subs_by_norm.setdefault(r["block_norm"], []).append( - {"relevance": r["relevance"], "facts": facts if isinstance(facts, dict) else {}}) + {"relevance": r["relevance"], "facts": parse_facts(r["facts"])}) if r["relevance"] != "peripheral": subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"]) ziele = [dict(r) for r in await db.list_lernziele(topic)] diff --git a/backend/tests/invarianten.py b/backend/invarianten.py similarity index 100% rename from backend/tests/invarianten.py rename to backend/invarianten.py diff --git a/backend/models.py b/backend/models.py index 0324a00..5f8e9ce 100644 --- a/backend/models.py +++ b/backend/models.py @@ -81,25 +81,12 @@ class BlocksResetStageRequest(BaseModel): stage: str = Field(min_length=1, max_length=40) # kanban column to reset back to -class BlocksStep(BaseModel): - label: str - state: Literal["done", "active", "pending"] - - -class BlocksFineStep(BaseModel): - label: str - phase: str = "" - state: Literal["done", "active", "pending"] - - class BlocksStatusResponse(BaseModel): ready: bool generating: bool progress: str | None = None error: str | None = None partial: bool = False - steps: list[BlocksStep] = [] - feine_steps: list[BlocksFineStep] = [] class FolderResponse(BaseModel): diff --git a/backend/pipeline.py b/backend/pipeline.py index 984fdb5..e99856e 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -9,14 +9,11 @@ import asyncio import logging from dataclasses import dataclass from datetime import datetime, timezone -from pathlib import Path from typing import Callable from agents import run_agent, kill_process, cancel_scope, clear_scope from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS from database import update_guide -from jsonio import read_json_file as _json_file -from textkit import _STUFEN log = logging.getLogger("creator.pipeline") @@ -132,7 +129,6 @@ def _runde_schema(data, final: bool = False): return include, rest -_RELEVANCE = ("relevant", "peripheral") _YESNO = ("ja", "nein") @@ -159,23 +155,13 @@ def _enum_map_schema(key: str, allowed): return parse -_levels_schema = _enum_map_schema("levels", _STUFEN) # level ∈ beginner/advanced/expert -_relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈ relevant/peripheral _yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein from config import MAX_RESTARTS as _MAX_RESTARTS, HEDGE_NACH_S as _HEDGE_NACH_S # noqa: E402 — zentral tunebar -# Detached Nachzügler-Tasks (late-Fold): Referenz gegen GC, Aufräumen via done-callback. -_NACHZUEGLER: set[asyncio.Task] = set() - -def _detached(task: asyncio.Task) -> None: - _NACHZUEGLER.add(task) - task.add_done_callback(_NACHZUEGLER.discard) - - -async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None, late=None) -> list | None: +async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, cancelled=None, *, grace: int | None = None) -> list | None: """Starts all slots in parallel and collects `quorum` valid results. Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)` @@ -188,16 +174,6 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: a timer of `grace` seconds. After it expires, running agents are only killed if the minimum stands — otherwise the race, including restarts, keeps running until it stands. Returns: `quorum` to `len(slots)` results. - - `min_runtime` (wall-clock from start): the race does not return before it - elapses while agents are still running — gives them time to search thoroughly. - `max_runtime` (wall-clock from start): hard cap — returns whatever is collected - (or None if nothing), killing the rest. Both default off; only Research sets them. - - `late(value)` (async): Nachzügler werden beim Quorum-Return NICHT gekillt, sondern - laufen detached weiter; jedes noch eintreffende valide Ergebnis geht an `late`. - Ersetzt den grace-Timer der Finder-Runden — der hielt die Runde bis 300 s offen, - nur damit die dritte Stimme zählt (gemessen: 73 s Warten pro Runde). """ attempts = {i: 0 for i in range(len(slots))} tasks: dict[asyncio.Task, int] = {} @@ -209,9 +185,6 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: # hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110–135 s laufen. hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0 loop = asyncio.get_running_loop() - start = loop.time() - min_deadline = start + min_runtime if min_runtime else None - max_deadline = start + max_runtime if max_runtime else None deadline: float | None = None def spawn(i: int, suffix: str = "") -> None: @@ -227,29 +200,6 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: keys[task] = key born[task] = loop.time() - spaet: set[int] = set() # je Slot zählt nur EIN spätes Ergebnis (Hedge-Zwilling = Echo) - - def _detach_rest() -> None: - """Quorum steht: Nachzügler an `late` übergeben statt killen (nur Erfolgs-Return).""" - if late is None: - return - for t, i in list(tasks.items()): - tasks.pop(t) - keys.pop(t, None) - born.pop(t, None) - - async def _warte(t=t, i=i): - try: - r = await t - if i in spaet: - return - if r and r[0] == 0 and (val := slots[i]["payload"](r)) is not None: - spaet.add(i) - await late(val) - except (asyncio.CancelledError, Exception): # noqa: BLE001 — Nachzügler sind best-effort - pass - _detached(asyncio.create_task(_warte())) - for i in range(len(slots)): spawn(i) @@ -258,13 +208,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: while tasks: if cancelled and cancelled(): return None - # Hard wall-clock cap: return whatever we have (None if empty), kill the rest. - if max_deadline is not None and loop.time() >= max_deadline: - _log(topic, f"{label}: max runtime {max_runtime}s reached ({len(results)} valid)") - return results or None - min_ok = min_deadline is None or loop.time() >= min_deadline - if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok: - _detach_rest() + if deadline is not None and len(results) >= quorum and loop.time() >= deadline: return results # Hedge: a slot running HEDGE_NACH_S without result gets ONE parallel twin # (key -h) — first valid result wins. Stalled provider calls burned the full @@ -277,14 +221,10 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: hedged.add(i) spawn(i, suffix="-h") _log(topic, f"{label} {i + 1}: {round(hedge_s)}s ohne Ergebnis — Hedge-Zwilling gestartet") - # Wake up for the earliest relevant deadline (grace, min, max, or next hedge). + # Wake up for the earliest relevant deadline (grace or next hedge). waits = [] if deadline is not None and len(results) >= quorum: waits.append(deadline - loop.time()) - if min_deadline is not None: - waits.append(min_deadline - loop.time()) - if max_deadline is not None: - waits.append(max_deadline - loop.time()) if hedge_s: naechste = [born[t] + hedge_s - loop.time() for t in tasks if tasks[t] not in hedged | fertig] @@ -323,11 +263,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: if grace is not None and deadline is None: deadline = loop.time() + grace _log(topic, f"{label}: first result — grace {grace}s running") - if on_update: - on_update(len(results)) - if (len(results) >= quorum and (grace is None or loop.time() >= deadline) - and (min_deadline is None or loop.time() >= min_deadline)): - _detach_rest() + if len(results) >= quorum and (grace is None or loop.time() >= deadline): return results continue @@ -340,7 +276,6 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: if attempts[i] <= _MAX_RESTARTS and not enough and not zwilling and not (cancelled and cancelled()): spawn(i) if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace) - _detach_rest() return results _log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)") return None diff --git a/backend/qa.py b/backend/qa.py index 482c1a5..99c2050 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -11,7 +11,7 @@ previous report of the same topic. """ import asyncio -import json +import logging import re import sys from datetime import datetime, timezone @@ -19,12 +19,14 @@ from pathlib import Path import database as db import embedding -from config import STORAGE_DIR, SUB_DUP_KANDIDAT_COS +from config import JUDGE_CHUNK, STORAGE_DIR, SUB_DUP_KANDIDAT_COS from fsutil import atomic_write_json from jsonio import read_json_file as _json_file from paths import arbeit_dir from textkit import _norm_title +log = logging.getLogger("creator.qa") + QA_DIR = STORAGE_DIR / "qa" JACCARD_FLOOR = 0.5 # title token overlap that makes a pair suspicious EMB_FLOOR = 0.82 # casefolded title cosine (own threshold, NOT the pipeline's 0.65) @@ -281,19 +283,70 @@ def _qa_prompt(name: str, **kwargs) -> str: return (TEMPLATES_DIR / "QA" / f"{name}.md").read_text(encoding="utf-8").format(**kwargs) -async def _llm_verdicts(template: str, topic: str, key: str, items: list[str]) -> dict[int, str]: +async def judge_wave(template: str, topic: str, key: str, slot: str, items: list[str], + *, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA") -> dict[int, str]: + """Gechunkte Ja/Nein-Judge-Welle über ALLE Items, Chunks parallel (die Semaphoren in + agents.py begrenzen); Ergebnis mit globalen 1-basierten Indizes. Fail-open pro Chunk + (Items bleiben ohne Urteil), aber nie stumm. Ersetzt die drei strukturgleichen + Handkopien in repair/qa/guide_qa.""" from agents import run_agent - from pipeline import _yesno_schema + from pipeline import _timeout, _yesno_schema from jsonio import parse_json_text - listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(items, 1)) - slot = {"Dubletten": "pairs", "Luecken": "sections", "Bausteine": "blocks", "Sub": "pairs"}[template.split("-")[1]] - rc, out, _err = await run_agent(f"qa-{topic}-{key}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}), - 600, role="judge", capabilities="none", scope=topic, label=f"QA {key}") - return (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {} + + async def _chunk(lo: int) -> dict[int, str]: + teil = items[lo:lo + chunk] + listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1)) + try: + rc, out, _err = await run_agent( + f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}), + _timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}") + except Exception: + log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo) + return {} + if rc != 0: + log.warning("[%s] %s-Judge %s+%d fehlgeschlagen (rc=%s) — %d Items ohne Urteil", + topic, label, key, lo, rc, len(teil)) + return {} + return _yesno_schema(parse_json_text(out)) or {} + + offsets = range(0, len(items), chunk) + results = await asyncio.gather(*[_chunk(lo) for lo in offsets]) + return {lo + k: urteil for lo, v in zip(offsets, results) for k, urteil in v.items()} # ── Report ────────────────────────────────────────────────────────────────────────── +def report_paths(topic: str, guide: bool = False) -> list[Path]: + """QA-Reports eines Topics, mtime-aufsteigend (Run-ID- und Timestamp-Namen sortieren + lexikographisch nicht). guide=True → die separate guide-*-Serie (guide_qa.py). + freispruch.json teilt den Ordner, ist aber kein Report — immer außen vor.""" + tdir = QA_DIR / topic + if not tdir.is_dir(): + return [] + return sorted((p for p in tdir.glob("*.json") + if p.name.startswith("guide-") == guide and p.name != "freispruch.json"), + key=lambda p: p.stat().st_mtime) + + +_latest_cache: dict[tuple[str, bool], tuple[float, dict]] = {} + + +def latest_report(topic: str, guide: bool = False) -> dict | None: + """Jüngster Report als geparstes dict, mtime-gecacht — die Board-Snapshots lesen das + im 1,2-s-Frontend-Takt, ein JSON-Read je Poll war unnötiges Datei-I/O. glob+stat + bleiben (billig), der Read passiert nur bei geänderter mtime.""" + reports = report_paths(topic, guide) + if not reports: + return None + p = reports[-1] + mtime = p.stat().st_mtime + key = (topic, guide) + cached = _latest_cache.get(key) + if cached is None or cached[0] != mtime: + _latest_cache[key] = (mtime, _json_file(p) or {}) + return _latest_cache[key][1] + + def freispruch_pfad(topic: str) -> Path: return QA_DIR / topic / "freispruch.json" @@ -341,41 +394,37 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None: fr = [t for t in fr if _norm_title(t) not in frei_fremd] if llm and d: - v = await _llm_verdicts("QA-Dubletten", topic, "dubletten", - [f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]]) + v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs", + [f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]]) for k, p in enumerate(d[:LLM_SAMPLE], 1): p["llm"] = v.get(k, "?") if llm and lk: - v = await _llm_verdicts("QA-Luecken", topic, "luecken", - [f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]]) + v = await judge_wave("QA-Luecken", topic, "luecken", "sections", + [f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]]) for k, x in enumerate(lk[:LLM_SAMPLE], 1): x["llm"] = v.get(k, "?") if llm and sd: # full coverage in chunks — a sampled quota would mislead the note - for lo in range(0, len(sd), 40): - chunk = sd[lo:lo + 40] - v = await _llm_verdicts("QA-Sub-Dubletten", topic, f"sub-dubletten-{lo}", - [f"A: {p['a']}\nB: {p['b']}" for p in chunk]) - for k, p in enumerate(chunk, 1): - p["llm"] = v.get(k, "?") + v = await judge_wave("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs", + [f"A: {p['a']}\nB: {p['b']}" for p in sd]) + for k, p in enumerate(sd, 1): + p["llm"] = v.get(k, "?") frei_sub = set(frei.get("sub_dubletten") or []) for p in sd: if p.get("llm") == "ja" and _paar_key(p["a"], p["b"]) in frei_sub: p["freispruch"] = True # 2:1-Urteil „behalten" — sichtbar, aber notenfrei unecht: list[str] | None = None if llm and blocks: - verdacht = [] - for lo in range(0, len(blocks), 80): # ein Call je 80 Titel - chunk = blocks[lo:lo + 80] - v = await _llm_verdicts("QA-Bausteine", topic, f"bausteine-{lo}", - [f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in chunk]) - verdacht += [b for k, b in enumerate(chunk, 1) if v.get(k) == "nein"] + v = await judge_wave("QA-Bausteine", topic, "bausteine", "blocks", + [f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in blocks], + chunk=80) + verdacht = [b for k, b in enumerate(blocks, 1) if v.get(k) == "nein"] # Bestätiger-Pass nur über die Geflaggten: der Einzel-Judge flaggte pro Lauf ANDERE # Blöcke (gemessen aak: Note pendelte 9.3↔10.0 bei identischem Bestand) — nur # doppelt-„nein" zählt; Repair hat als dritte Sicherung die eigene Zweitmeinung unecht = [] if verdacht: - v2 = await _llm_verdicts("QA-Bausteine", topic, "bausteine-b2", - [f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in verdacht]) + v2 = await judge_wave("QA-Bausteine", topic, "bausteine-b2", "blocks", + [f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in verdacht]) unecht = [b["title"] for k, b in enumerate(verdacht, 1) if v2.get(k) == "nein"] frei_unecht = set(frei.get("unecht") or []) unecht = [t for t in unecht if _norm_title(t) not in frei_unecht] @@ -432,10 +481,7 @@ def _diff(prev: dict | None, cur: dict) -> dict: def _write_report(report: dict) -> Path: tdir = QA_DIR / report["topic"] tdir.mkdir(parents=True, exist_ok=True) - # by mtime: run-id names (…-1311-5e5c) and timestamp names don't sort lexicographically. - # guide-* reports share the directory but are a SEPARATE series (guide_qa.py). - older = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")), - key=lambda p: p.stat().st_mtime) + older = report_paths(report["topic"]) prev = _json_file(older[-1]) if older else None report["diff_zum_vorlauf"] = _diff(prev, report) name = report["run_id"] or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") diff --git a/backend/repair.py b/backend/repair.py index 2347718..21d2dd3 100644 --- a/backend/repair.py +++ b/backend/repair.py @@ -6,29 +6,22 @@ deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur n Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche, Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen.""" -import json import logging import re import database as db import qa -from agents import run_agent from blocks import _blocks_files, _evidence_pack, source_folder +from config import EVIDENCE_PER_BLOCK from fsutil import atomic_write_json -from jsonio import parse_json_text, read_json_file as _json_file -from pipeline import _yesno_schema -from textkit import _norm_title, _title, clean_title +from jsonio import read_json_file as _json_file +from textkit import _norm_title, _title, clean_title, parse_facts log = logging.getLogger("creator.repair") -JUDGE_TIMEOUT = 600 -from config import EVIDENCE_PER_BLOCK, JUDGE_CHUNK # noqa: E402 — zentral tunebar - async def repair_befunde(topic: str) -> dict: - tdir = qa.QA_DIR / topic - reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")), - key=lambda p: p.stat().st_mtime) if tdir.is_dir() else [] + reports = qa.report_paths(topic) report = _json_file(reports[-1]) if reports else None if not report: return {"fehler": "kein QA-Report — erst QA laufen lassen"} @@ -53,21 +46,8 @@ async def repair_befunde(topic: str) -> dict: async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]: - """No-Tool-Judge-Wellen über alle Items (fail-open: Fehler → leeres Verdikt = behalten).""" - verdicts: dict[int, str] = {} - for lo in range(0, len(items), JUDGE_CHUNK): - chunk = items[lo:lo + JUDGE_CHUNK] - listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(chunk, 1)) - try: - rc, out, _err = await run_agent( - f"repair-{topic}-{key}-{lo}", qa._qa_prompt(template, topic=topic, extra="", **{slot: listing}), - JUDGE_TIMEOUT, role="judge", capabilities="none", scope=topic, label=f"Repair {key}") - v = (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {} - except Exception: - log.exception("[%s] Repair-Judge %s fehlgeschlagen — Befunde bleiben", topic, key) - v = {} - verdicts.update({lo + k: urteil for k, urteil in v.items()}) - return verdicts + """No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten).""" + return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair") def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None: @@ -165,10 +145,7 @@ _SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S) def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]: """Gewinner = mehr key_points im facts-Feld, dann längerer Titel (Muster Konsolidierung).""" def score(r): - try: - kp = len((json.loads(r.get("facts") or "{}")).get("key_points") or []) - except ValueError: - kp = 0 + kp = len(parse_facts(r.get("facts")).get("key_points") or []) return (kp, len(r.get("sub_title") or "")) return (a, b) if score(a) >= score(b) else (b, a) diff --git a/backend/routes.py b/backend/routes.py index 27eb939..58acf39 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -3,6 +3,7 @@ import json import logging import shutil import uuid +from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from fastapi import APIRouter, HTTPException @@ -19,6 +20,7 @@ from database import ( delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content, get_sub_artefakte, kanban_reset, delete_guide_board, get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm, + list_runs, get_db, ) from textkit import _norm_title from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files @@ -72,6 +74,18 @@ async def get_stats(): return {"topics": len(topics), "formats": formats_stats(guides, levels)} +@router.get("/health") +async def health(): + await (await get_db()).execute("SELECT 1") + return {"ok": True} + + +@router.get("/runs") +async def get_runs(topic: str, limit: int = 10): + """Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.""" + return {"runs": await list_runs(topic, limit)} + + @router.get("/topics/progress") async def topic_progress(topic: str): """Completion status per format + topic completion — for unlocking the next expansion stage.""" @@ -89,14 +103,28 @@ async def add_topic(req: TopicCreateRequest): @router.delete("/topics") async def remove_topic(topic: str): + guides = [g for g in await list_guides() if g["topic"] == topic] + status = await blocks_status(topic) + if status["generating"] or any(g["status"] == "generating" for g in guides): + raise HTTPException(409, "Generierung läuft — erst abbrechen") await delete_topic(topic) await delete_block_data(topic) await delete_topic_pipeline(topic) await delete_source(topic) # topic config (DB) — removed together with the topic await delete_guide_content(topic) - shutil.rmtree(topic_dir(topic), ignore_errors=True) + # guides/Board/Kanban mitlöschen — GET /topics leitet Topics aus guides ab, + # sonst taucht das gelöschte Topic sofort wieder auf + for g in guides: + await delete_guide(g["id"]) + await delete_guide_board(topic) + await kanban_reset(topic) import qa - shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic + # rmtree (potenziell große Topic-Ordner) in den Threadpool — der Event-Loop bedient + # parallel laufende Flows/Polls, blockierendes Datei-I/O friert die alle ein + def _wipe(): + shutil.rmtree(topic_dir(topic), ignore_errors=True) + shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic + await asyncio.to_thread(_wipe) return {"ok": True} @@ -325,9 +353,10 @@ async def blocks_completeness(topic: str): counts = await kanban_stage_counts(topic) inv = counts.get("inventory", {}) blocks = await list_blocks(topic, status="consensus") - subs = 0 - for b in blocks: - subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus") + # ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen + consensus_blocks = {b["title_norm"] for b in blocks} + subs = sum(1 for s in await list_subblocks(topic) + if s["status"] == "consensus" and s["block_norm"] in consensus_blocks) ziele = await list_lernziele(topic) dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead") degradiert = ueberstimmt = 0 @@ -466,15 +495,26 @@ async def block_chat_route(req: BlockChatRequest): # Serialize ratings per (topic, block) — otherwise two simultaneous ratings would # overwrite the absolute score with a stale base (race). -_check_locks: dict[tuple[str, str], asyncio.Lock] = {} +_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {} -def _check_lock(topic: str, block: str) -> asyncio.Lock: +@asynccontextmanager +async def _check_lock(topic: str, block: str): + """Per-(topic,block)-Lock mit Refcount, das den Eintrag nach dem letzten Nutzer + entfernt — die Map wuchs sonst unbegrenzt (ein Lock pro je geprüftem Block).""" key = (topic, block) - lock = _check_locks.get(key) - if lock is None: - lock = _check_locks[key] = asyncio.Lock() - return lock + entry = _check_locks.get(key) + if entry is None: + entry = _check_locks[key] = (asyncio.Lock(), [0]) + lock, ref = entry + ref[0] += 1 + try: + async with lock: + yield + finally: + ref[0] -= 1 + if ref[0] == 0 and _check_locks.get(key) is entry: + del _check_locks[key] def _basis(state: dict, question: str) -> tuple[int, bool]: diff --git a/backend/rules.py b/backend/rules.py index f145838..e0a0cd9 100644 --- a/backend/rules.py +++ b/backend/rules.py @@ -50,14 +50,26 @@ async def load_learnstate() -> tuple[list[dict], dict[str, dict[str, set[str]]]] return await list_guides(), levels +_content_cache: dict[str, tuple[float, dict | None]] = {} + + def _content_json(topic: str, fmt: str) -> dict | None: + """Guide-Content-JSON (kann MB groß sein), mtime-gecacht — /stats und /topics/progress + lasen die Datei bei JEDEM Frontend-Poll neu und synchron im Event-Loop.""" path = guide_content_path(topic, fmt) - if not path.exists(): - return None try: - return json.loads(path.read_text(encoding="utf-8")) - except ValueError: + mtime = path.stat().st_mtime + except OSError: + _content_cache.pop(str(path), None) return None + cached = _content_cache.get(str(path)) + if cached is None or cached[0] != mtime: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except ValueError: + data = None + _content_cache[str(path)] = (mtime, data) + return _content_cache[str(path)][1] diff --git a/backend/tests/test_e2e_fake.py b/backend/tests/test_e2e_fake.py index c7853b9..6cbbc3d 100644 --- a/backend/tests/test_e2e_fake.py +++ b/backend/tests/test_e2e_fake.py @@ -10,7 +10,7 @@ import pytest import board_inventory as bi from pipeline import GenContext -from tests.invarianten import pruefe_invarianten, pruefe_guide_invarianten +from invarianten import pruefe_invarianten, pruefe_guide_invarianten TOPIC = "t" diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index 086a3af..b41032d 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -131,8 +131,8 @@ async def test_guide_error_event(testdb): def test_timeout_calibration_smoke(): from pipeline import _timeout - assert _timeout("subblock", 10) == 400 + 150 - assert _timeout("content", 10) == 450 + 300 + assert _timeout("subblock_check", 10) == 150 + 100 + assert _timeout("writer", 10) == 450 + 600 def test_env_file_wins(tmp_path, monkeypatch): @@ -317,3 +317,71 @@ async def test_events_run_summary_aggregates(testdb): assert s["agents"]["gesamt"] == 2 and s["agents"]["ok"] == 1 and s["agents"]["timeout"] == 1 assert s["agents"]["verlorene_min"] == 2 assert s["tokens"] == {"input": 15, "output": 2, "cache_read": 80, "cache_write": 1} + + +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.""" + import routes, qa + db = testdb + monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t) + monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa") + await db.create_topic(TOPIC) + await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "", + "status": "done", "progress": None, + "created_at": "2026-01-01", "updated_at": "2026-01-01"}) + await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha") + await db.kanban_upsert_card(TOPIC, "inventory", "b-1", "block", "done_block", {"title": "Alpha"}) + res = await routes.remove_topic(TOPIC) + assert res["ok"] + assert all(g["topic"] != TOPIC for g in await db.list_guides()) + assert await db.list_guide_cards(TOPIC, "Guide") == [] + assert await db.kanban_cards(TOPIC, "inventory") == [] + assert TOPIC not in await routes.get_topics() + + +async def test_topic_delete_409_bei_laufendem_guide(testdb, tmp_path, monkeypatch): + """Läuft eine Generierung, wird nicht gelöscht (409) — ein laufender Flow schrieb + sonst nach dem Löschen munter neue Rows/Dateien.""" + import pytest + from fastapi import HTTPException + import routes + db = testdb + monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t) + await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "", + "status": "generating", "progress": None, + "created_at": "2026-01-01", "updated_at": "2026-01-01"}) + with pytest.raises(HTTPException) as e: + await routes.remove_topic(TOPIC) + assert e.value.status_code == 409 + assert any(g["topic"] == TOPIC for g in await db.list_guides()) + + +async def test_runs_endpoint_liefert_bilanz(testdb): + """GET /api/runs: pro run_id Zeitspanne + Agent-/Token-Bilanz + Fails, jüngster zuerst, + aktiv-Flag aus dem Run-Registry.""" + import routes + db = testdb + db.set_current_run(TOPIC, "r1") + await db.add_event(TOPIC, "agent", key="a", status="ok", dur_ms=1000, + meta={"tokens": {"input": 10, "output": 20, "cache_read": 0, "cache_write": 0}}) + await db.add_event(TOPIC, "agent", key="b", status="timeout", dur_ms=120000) + await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"}) + res = await routes.get_runs(TOPIC) + runs = res["runs"] + assert len(runs) == 1 and runs[0]["run_id"] == "r1" and runs[0]["aktiv"] is True + assert runs[0]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1 + assert runs[0]["tokens"]["output"] == 20 + assert runs[0]["fails"][0]["error"] == "kaputt" and runs[0]["fails"][0]["status"] == "dead" + assert runs[0]["start"] <= runs[0]["ende"] + db.set_current_run(TOPIC, "r2") + await db.add_event(TOPIC, "agent", key="c", status="ok") + db.set_current_run(TOPIC, None) + runs = (await routes.get_runs(TOPIC))["runs"] + assert [r["run_id"] for r in runs] == ["r2", "r1"] + assert runs[0]["aktiv"] is False # Registry geräumt → Lauf beendet + + +async def test_health(testdb): + import routes + assert (await routes.health())["ok"] is True diff --git a/backend/tests/test_guide_board.py b/backend/tests/test_guide_board.py index 3125dd9..673273f 100644 --- a/backend/tests/test_guide_board.py +++ b/backend/tests/test_guide_board.py @@ -444,3 +444,78 @@ async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, mo assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "pruefer" assert cards["alpha"]["md"] # Text bleibt — der Prüfer arbeitet auf dem Bestand assert cards["gamma"]["stage"] == "done" + + +async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch): + """Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie + bleiben im gate_info sichtbar (vorher wurde gate_info geleert).""" + db = testdb + await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha") + env = gb._Env(None, "g-ff", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec") + md = "\n\nText." + card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md, + "stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"} + + async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout): + assert "-gfix-" in key + return gb.FAILED, None + + monkeypatch.setattr(gb, "run_single_slot", fake_slot) + assert await gb._stage_fix(env, card) + karte = (await db.list_guide_cards(TOPIC, FMT))[0] + assert karte["stage"] == "done" + assert "offene Befunde" in karte["gate_info"] and "Claim c1" in karte["gate_info"] + + +async def test_repruefer_ausfall_wird_vermerkt(testdb, tmp_path, monkeypatch): + """Fällt der Re-Prüfer aus (kein Cancel), wird das im gate_info vermerkt statt die + Karte stumm als geprüft durchzuwinken.""" + db = testdb + await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha") + env = gb._Env(None, "g-ra", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec") + md = "\n\n" + "Text im Rahmen. " * 20 + card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md, + "stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"} + + async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout): + if "-gfix-" in key: + import re as _re + m = _re.search(r"(/\S+\.md)", prompt) + with open(m.group(1), "w", encoding="utf-8") as f: + f.write(md.replace("Text im Rahmen.", "Korrigiert.")) + return gb.OK, payload(None) + return gb.FAILED, None # Re-Prüfer fällt aus + + monkeypatch.setattr(gb, "run_single_slot", fake_slot) + assert await gb._stage_fix(env, card) + karte = (await db.list_guide_cards(TOPIC, FMT))[0] + assert karte["stage"] == "done" and "Korrigiert." in karte["md"] + assert "Re-Prüfer ohne Ergebnis" in karte["gate_info"] + + +async def test_progress_reporter_ueberlebt_db_fehler(testdb, monkeypatch): + """Ein DB-Fehler beendet den Reporter nicht (der Fortschritt fror sonst still ein); + unveränderter Stand wird nicht erneut geschrieben.""" + import asyncio + db = testdb + await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha") + calls = {"n": 0} + writes = [] + orig = db.guide_stage_counts + + async def flaky(topic, fmt): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("kaputt") + return await orig(topic, fmt) + + async def fake_update(guide_id, **kw): + writes.append(kw["progress"]) + + monkeypatch.setattr(gb.db, "guide_stage_counts", flaky) + monkeypatch.setattr(gb.db, "update_guide", fake_update) + task = asyncio.create_task(gb._progress_reporter("g-pr", TOPIC, FMT, takt=0.01)) + await asyncio.sleep(0.08) + task.cancel() + assert calls["n"] >= 3 # lief nach dem Fehler weiter + assert writes.count(writes[0]) == 1 # gleicher Stand nur einmal geschrieben diff --git a/backend/tests/test_qa.py b/backend/tests/test_qa.py index aa35d5f..63a21b9 100644 --- a/backend/tests/test_qa.py +++ b/backend/tests/test_qa.py @@ -227,7 +227,7 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch): {"title": titel, "description": "d"}) monkeypatch.setattr(qa, "QA_DIR", tmp_path) - async def fake_verdicts(template, topic, key, items): + async def fake_wave(template, topic, key, slot, items, **kw): if template != "QA-Bausteine": return {} if key.startswith("bausteine-b2"): # Bestätiger sieht nur die Geflaggten @@ -235,7 +235,7 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch): return {1: "nein", 2: "ja"} # nur der erste wird bestätigt return {1: "nein", 2: "nein", 3: "ja"} # Pass 1 flaggt zwei - monkeypatch.setattr(qa, "_llm_verdicts", fake_verdicts) + monkeypatch.setattr(qa, "judge_wave", fake_wave) report = await qa.qa_report("t", llm=True) assert report["unecht"] == ["Wackelkandidat"] diff --git a/backend/tests/test_race.py b/backend/tests/test_race.py index e2f7395..9ee04b2 100644 --- a/backend/tests/test_race.py +++ b/backend/tests/test_race.py @@ -82,59 +82,6 @@ async def test_hedge_schwelle_skaliert_mit_timeout(monkeypatch): assert calls == ["k1"] -async def test_late_fold_nachzuegler_zaehlt_nach(monkeypatch): - """Quorum 2 kehrt sofort zurück; der dritte Slot wird nicht gekillt, sein Ergebnis - geht an `late` (ersetzt den grace-Timer der Finder-Runden).""" - import time - killed, spaet = [], [] - - async def fake_agent(key, prompt, timeout, **kw): - if key == "k3": - await asyncio.sleep(0.2) - return (0, "dritter", "") - return (0, key, "") - - async def late(val): - spaet.append(val) - - monkeypatch.setattr(pipeline, "run_agent", fake_agent) - monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k)) - monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0) - slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none", - "payload": lambda r: r[1]} for i in (1, 2, 3)] - t0 = time.monotonic() - res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late) - assert time.monotonic() - t0 < 0.15 # kein Warten auf k3 - assert sorted(res) == ["k1", "k2"] - assert "k3" not in killed - await asyncio.sleep(0.3) - assert spaet == ["dritter"] - - -async def test_late_fold_invalider_nachzuegler_ignoriert(monkeypatch): - """Nachzügler mit invalidem Payload löst late NICHT aus (best-effort).""" - spaet = [] - - async def fake_agent(key, prompt, timeout, **kw): - if key == "k3": - await asyncio.sleep(0.1) - return (1, "", "kaputt") - return (0, key, "") - - async def late(val): - spaet.append(val) - - monkeypatch.setattr(pipeline, "run_agent", fake_agent) - monkeypatch.setattr(pipeline, "kill_process", lambda k: None) - monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0) - slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none", - "payload": lambda r: r[1]} for i in (1, 2, 3)] - res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late) - assert res is not None - await asyncio.sleep(0.25) - assert spaet == [] - - async def test_hedge_zwilling_ersetzt_restart(monkeypatch): """Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN zusätzlichen Restart — der Zwilling ist der Retry.""" diff --git a/backend/tests/test_repair.py b/backend/tests/test_repair.py index 74e2382..f952211 100644 --- a/backend/tests/test_repair.py +++ b/backend/tests/test_repair.py @@ -67,7 +67,7 @@ async def test_merge_confirmed_duplicate(env, monkeypatch): calls.append(prompt) return 0, '{"relevant": {"1": "ja"}}', "" - monkeypatch.setattr(repair, "run_agent", fake_agent) + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) res = await repair.repair_befunde(TOPIC) assert res["merges"] == ["Alpha → Alpha Problem"] assert len(calls) == 1 and "Beta" not in calls[0] # nur das llm=ja-Paar zum Judge @@ -90,7 +90,7 @@ async def test_fremd_removed_only_on_nein(env, monkeypatch): return 0, '{"relevant": {"1": "ja"}}', "" return 0, '{"relevant": {"1": "nein", "2": "ja"}}', "" - monkeypatch.setattr(repair, "run_agent", fake_agent) + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) monkeypatch.setattr(repair, "source_folder", lambda t: None) res = await repair.repair_befunde(TOPIC) assert res["entfernt"] == ["Fremdling"] @@ -108,7 +108,7 @@ async def test_judge_failure_keeps_everything(env, monkeypatch): async def broken_agent(key, prompt, timeout, **kw): raise RuntimeError("boom") - monkeypatch.setattr(repair, "run_agent", broken_agent) + import agents; monkeypatch.setattr(agents, "run_agent", broken_agent) res = await repair.repair_befunde(TOPIC) assert res["entfernt"] == [] card = await db.kanban_get_card(TOPIC, "inventory", cid) @@ -124,7 +124,7 @@ async def test_hygiene_cleans_title_norm_invariant(env, monkeypatch): async def no_agent(*a, **kw): raise AssertionError("Hygiene braucht keinen Agenten") - monkeypatch.setattr(repair, "run_agent", no_agent) + import agents; monkeypatch.setattr(agents, "run_agent", no_agent) res = await repair.repair_befunde(TOPIC) assert res["hygiene"] == ["**Fetter Titel** → Fetter Titel"] card = await db.kanban_get_card(TOPIC, "inventory", cid) @@ -177,7 +177,7 @@ async def test_sub_dubletten_merge(env, monkeypatch): assert "Gibtsnicht" not in prompt return 0, '{"relevant": {"1": "ja"}}', "" - monkeypatch.setattr(repair, "run_agent", fake_agent) + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) res = await repair.repair_befunde(TOPIC) assert res["sub_merges"] == ["Verlierer Sub → Gewinner Sub"] rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)} @@ -199,7 +199,7 @@ async def test_sub_dubletten_zweitmeinung_nein(env, monkeypatch): async def fake_agent(key, prompt, timeout, **kw): return 0, '{"relevant": {"1": "nein"}}', "" - monkeypatch.setattr(repair, "run_agent", fake_agent) + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) res = await repair.repair_befunde(TOPIC) assert res["sub_merges"] == [] rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)} @@ -223,7 +223,7 @@ async def test_sub_dubletten_stichentscheid_faltet(env, monkeypatch): return 0, '{"relevant": {"1": "ja"}}', "" return 0, '{"relevant": {"1": "nein"}}', "" # Zweitmeinung widerspricht - monkeypatch.setattr(repair, "run_agent", fake_agent) + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) res = await repair.repair_befunde(TOPIC) assert res["sub_merges"] == ["Sub B → Sub A"] rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)} @@ -245,7 +245,7 @@ async def test_stichentscheid_behalten_persistiert_freispruch(env, monkeypatch): async def fake_agent(key, prompt, timeout, **kw): return 0, '{"relevant": {"1": "nein"}}', "" # beide Repair-Judges: behalten - monkeypatch.setattr(repair, "run_agent", fake_agent) + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) res = await repair.repair_befunde(TOPIC) assert res["sub_merges"] == [] and len(res["freigesprochen"]) == 1 frei = qa_mod.lade_freispruch(TOPIC) @@ -269,7 +269,7 @@ async def test_fremd_stichentscheid_behalten(env, monkeypatch): async def fake_agent(key, prompt, timeout, **kw): return 0, '{"relevant": {"1": "ja"}}', "" # beide: belegt/behalten - monkeypatch.setattr(repair, "run_agent", fake_agent) + import agents; monkeypatch.setattr(agents, "run_agent", fake_agent) res = await repair.repair_befunde(TOPIC) assert res["entfernt"] == [] @@ -292,7 +292,7 @@ async def test_waisen_cleanup(env, monkeypatch): async def no_agent(*a, **kw): raise AssertionError("Aufräumen braucht keinen Agenten") - monkeypatch.setattr(repair, "run_agent", no_agent) + import agents; monkeypatch.setattr(agents, "run_agent", no_agent) res = await repair.repair_befunde(TOPIC) assert res["aufgeraeumt"] == 3 rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} diff --git a/backend/tests/test_subblocks.py b/backend/tests/test_subblocks.py index 09b11b5..e1f6622 100644 --- a/backend/tests/test_subblocks.py +++ b/backend/tests/test_subblocks.py @@ -143,8 +143,10 @@ def test_cited_evidence_lines_and_fallback(tmp_path): def test_sink_json_writes_only_valid(tmp_path): p = tmp_path / "level-final-c1.json" + from pipeline import _enum_map_schema + levels = _enum_map_schema("levels", ("beginner", "advanced", "expert")) ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p, - lambda d: blx._levels_schema(d, {1})) + lambda d: levels(d, {1})) assert ok == {1: "beginner"} assert json.loads(p.read_text(encoding="utf-8"))["levels"]["1"] == "beginner" bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d) diff --git a/backend/tests/test_train.py b/backend/tests/test_train.py index 1613c30..9fbe3ce 100644 --- a/backend/tests/test_train.py +++ b/backend/tests/test_train.py @@ -25,9 +25,9 @@ def test_registry_spiegelt_config(): def test_creator_params_override_wirkt_im_subprozess(): out = subprocess.run( - [sys.executable, "-c", "import config; print(config.FACTS_CHUNK_SUBS, config.TIMEOUTS['subblock_check'][0])"], + [sys.executable, "-c", "import config; print(config.GATE_FIX_MIN, config.TIMEOUTS['subblock_check'][0])"], capture_output=True, text=True, cwd=BACKEND, - env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"FACTS_CHUNK_SUBS": 6, "TIMEOUT_subblock_check_base": 77}'}) + env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"GATE_FIX_MIN": 6, "TIMEOUT_subblock_check_base": 77}'}) assert out.stdout.split() == ["6", "77"], out.stderr diff --git a/backend/textkit.py b/backend/textkit.py index 5d45ea0..e69d04b 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -3,12 +3,22 @@ No state, no IO — safe to import anywhere. """ +import json import re import unicodedata _CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now +def parse_facts(raw) -> dict: + """subblocks.facts ist ein JSON-Blob aus LLM-Hand — leer/kaputt/kein dict → {}.""" + try: + d = json.loads(raw) if raw else {} + except (ValueError, TypeError): + return {} + return d if isinstance(d, dict) else {} + + def _norm_title(s: str) -> str: """Normalize a title for key comparison. diff --git a/backend/train_f0.py b/backend/train_f0.py index a44b69b..9c270c7 100644 --- a/backend/train_f0.py +++ b/backend/train_f0.py @@ -46,7 +46,7 @@ async def f0(out: str) -> None: ok = await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=True, qa_force=True), timeout=180) - from tests.invarianten import pruefe_invarianten + from invarianten import pruefe_invarianten fehler = await pruefe_invarianten("f0", files) atomic_write_json(Path(out), { "ok": bool(ok), "invarianten_fehler": fehler, "calls": len(welt.calls), diff --git a/docker-compose.yml b/docker-compose.yml index 40df18f..93980bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,14 @@ services: context: . container_name: creator restart: unless-stopped - environment: - - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} - - MINIMAX_API_KEY=${MINIMAX_API_KEY:-} - - DEFAULT_PROVIDER=${DEFAULT_PROVIDER:-} + # komplette .env durchreichen — 3 Einzel-Vars ließen ROLE_*/MAX_CONCURRENT_* etc. + # still weg (Dev sourct die ganze .env, Prod bekam nur einen Teil) + env_file: .env + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"] + interval: 30s + timeout: 10s + retries: 3 networks: - web volumes: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6a04bcf..ee0cf2e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -21,7 +21,7 @@ const darkMode = ref( ? window.matchMedia('(prefers-color-scheme: dark)').matches : localStorage.getItem('darkMode') === 'true', ) -const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] } +const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false } const blocks = ref({ ...EMPTY_BLOCKS }) const activeBlocks = ref([]) const provider = ref(localStorage.getItem('provider') || 'claude') @@ -41,6 +41,13 @@ async function guard(label, fn) { try { return await fn() } catch (e) { console.error(label, e) } } +// Aktion ausführen und einen Backend-Fehler (409/400/500) in der Sidebar-Fehlerzeile +// zeigen statt als unhandled rejection zu verschlucken. → true bei Erfolg. +async function withUiError(fn) { + uiError.value = null + try { await fn(); return true } catch (e) { uiError.value = e.message; return false } +} + async function loadStats() { await guard('Failed to load stats:', async () => { stats.value = await fetchStats() }) } @@ -192,14 +199,12 @@ watch(previewGuide, (g) => { async function handleCancelBlocks() { if (!selectedTopic.value) return - await apiCancelBausteine(selectedTopic.value) - await loadBlocks() + if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks() } async function handleResetBlocks() { if (!selectedTopic.value) return - await apiDeleteBausteine(selectedTopic.value) - await loadBlocks() + if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks() } async function handleResetStage({ board, stage, restart = false }) { @@ -231,8 +236,11 @@ async function handleAddResearch() { async function handleRequeueDead() { if (!selectedTopic.value) return - await apiRequeueDead(selectedTopic.value) - await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) + const ok = await withUiError(async () => { + await apiRequeueDead(selectedTopic.value) + await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) + }) + if (!ok) return await loadBlocks() startPolling() } @@ -407,17 +415,12 @@ const polling = usePolling( const startPolling = polling.start async function handleCancel(guideId) { - await apiCancel(guideId) - await loadGuides() + if (await withUiError(() => apiCancel(guideId))) await loadGuides() } async function handleDeleteTopic(topic) { - const topicGuides = guides.value.filter((g) => g.topic === topic) - for (const g of topicGuides) { - await deleteGuide(g.id) - } - await apiDeleteBausteine(topic) - await apiDeleteTopic(topic) + // Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409). + if (!await withUiError(() => apiDeleteTopic(topic))) return await loadTopics() if (selectedTopic.value === topic) { selectedTopic.value = null diff --git a/frontend/src/api.js b/frontend/src/api.js index cd39cab..f3b3e44 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,315 +1,165 @@ const BASE = '/api' -// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken -async function jsonOrThrow(res) { +function qs(query) { + if (!query) return '' + const p = new URLSearchParams() + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null) p.set(k, v) + } + const s = p.toString() + return s ? `?${s}` : '' +} + +// Ein Request-Weg für ALLE Aufrufe: Backend-Fehler (400/409/500) werfen statt sie zu +// verschlucken, mit err.status für Aufrufer, die 404 ("noch nichts da") gesondert behandeln. +async function req(path, { method = 'GET', body, query } = {}) { + const opts = { method } + if (body !== undefined) { + opts.headers = { 'Content-Type': 'application/json' } + opts.body = JSON.stringify(body) + } + const res = await fetch(`${BASE}${path}${qs(query)}`, opts) if (!res.ok) { let detail = `Fehler (HTTP ${res.status})` try { const data = await res.json() if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail) } catch { /* kein JSON-Body */ } - throw new Error(detail) + const err = new Error(detail) + err.status = res.status + throw err } - return res.json() + const text = await res.text() // DELETE/manche POSTs liefern keinen Body + return text ? JSON.parse(text) : null } -export async function fetchGuides() { - const res = await fetch(`${BASE}/guides`) - return res.json() -} +export const fetchGuides = () => req('/guides') -export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) { - const res = await fetch(`${BASE}/guides`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }), - }) - return jsonOrThrow(res) -} +export const createGuide = (topic, format, instructions = '', provider = 'claude', abStep = null) => + req('/guides', { method: 'POST', body: { topic, format, instructions, provider, ab_step: abStep } }) -export async function fetchActiveBlocks() { - const res = await fetch(`${BASE}/blocks/active`) - return res.json() -} +export const fetchActiveBlocks = () => req('/blocks/active') -export async function fetchBlocksStatus(topic) { - const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`) - return res.json() -} +export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } }) -export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) { - const res = await fetch(`${BASE}/blocks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce }), - }) - return jsonOrThrow(res) -} +export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) => + req('/blocks', { method: 'POST', body: { topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce } }) // Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter). -export async function fetchBlocksBoard(topic) { - const res = await fetch(`${BASE}/blocks/board?topic=${encodeURIComponent(topic)}`) - return jsonOrThrow(res) -} +export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } }) // Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report. -export async function runQa(topic, llm = true) { - const res = await fetch(`${BASE}/blocks/qa`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, llm }), - }) - return jsonOrThrow(res) -} +export const runQa = (topic, llm = true) => req('/blocks/qa', { method: 'POST', body: { topic, llm } }) // QA-Befunde gezielt beheben (Hygiene, bestätigte Dubletten, Fremd/Unecht nach Gegen-Judge). -export async function runRepair(topic) { - const res = await fetch(`${BASE}/blocks/repair`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic }), - }) - return jsonOrThrow(res) -} +export const runRepair = (topic) => req('/blocks/repair', { method: 'POST', body: { topic } }) // Karten ab Spalte zurücksetzen (keine Generierung). -export async function resetBlocksStage(topic, board, stage) { - const res = await fetch(`${BASE}/blocks/reset-stage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, board, stage }), - }) - return jsonOrThrow(res) -} +export const resetBlocksStage = (topic, board, stage) => + req('/blocks/reset-stage', { method: 'POST', body: { topic, board, stage } }) // Einen weiteren Research-Agenten anhängen (Attach-or-Start). -export async function addBlocksResearch(topic, provider = 'claude') { - const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}&provider=${encodeURIComponent(provider)}`, { method: 'POST' }) - return jsonOrThrow(res) -} +export const addBlocksResearch = (topic, provider = 'claude') => + req('/blocks/research', { method: 'POST', query: { topic, provider } }) -export async function restartBlocksCard(topic, cardId) { - const res = await fetch(`${BASE}/blocks/card-restart`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, card_id: cardId }), - }) - return jsonOrThrow(res) -} +export const restartBlocksCard = (topic, cardId) => + req('/blocks/card-restart', { method: 'POST', body: { topic, card_id: cardId } }) -export async function removeGuideFormat(topic, format) { - const res = await fetch(`${BASE}/guides/board/remove`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, format }), - }) - return jsonOrThrow(res) -} +export const removeGuideFormat = (topic, format) => + req('/guides/board/remove', { method: 'POST', body: { topic, format } }) -export async function resetGuideCard(topic, format, blockNorm, abStage) { - const res = await fetch(`${BASE}/guides/board/card-reset`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, format, block_norm: blockNorm, ab_stage: abStage }), - }) - return jsonOrThrow(res) -} +export const resetGuideCard = (topic, format, blockNorm, abStage) => + req('/guides/board/card-reset', { method: 'POST', body: { topic, format, block_norm: blockNorm, ab_stage: abStage } }) -export async function requeueBlocksDead(topic) { - const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' }) - return jsonOrThrow(res) -} +export const requeueBlocksDead = (topic) => + req('/blocks/requeue-dead', { method: 'POST', query: { topic } }) // Live-Board der Guide-Erzeugung. -export async function fetchGuideBoard(topic, format = 'Guide') { - const res = await fetch(`${BASE}/guides/board?topic=${encodeURIComponent(topic)}&format=${encodeURIComponent(format)}`) - return jsonOrThrow(res) -} +export const fetchGuideBoard = (topic, format = 'Guide') => + req('/guides/board', { query: { topic, format } }) -export async function resetGuideBoard(topic, format, abStage) { - const res = await fetch(`${BASE}/guides/board/reset`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, format, ab_stage: abStage }), - }) - return jsonOrThrow(res) -} +export const resetGuideBoard = (topic, format, abStage) => + req('/guides/board/reset', { method: 'POST', body: { topic, format, ab_stage: abStage } }) // Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf. -export async function repairGuideBoard(topic, format) { - const res = await fetch(`${BASE}/guides/board/repair`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, format, ab_stage: 0 }), - }) - return jsonOrThrow(res) -} +export const repairGuideBoard = (topic, format) => + req('/guides/board/repair', { method: 'POST', body: { topic, format, ab_stage: 0 } }) -export async function cancelBlocks(topic) { - await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' }) -} +export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } }) -export async function deleteBlocks(topic) { - await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' }) -} +export const deleteBlocks = (topic) => req('/blocks', { method: 'DELETE', query: { topic } }) + +// Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id. +export const fetchRuns = (topic, limit = 10) => req('/runs', { query: { topic, limit } }) // --- Block-Learning: Chat, Exam --- -export async function fetchBlockLearnState(topic) { - const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`) - return jsonOrThrow(res) -} +export const fetchBlockLearnState = (topic) => req('/blocks/learnstate', { query: { topic } }) -export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) { - const res = await fetch(`${BASE}/blocks/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, block, section, section_compact, messages, provider }), - }) - return jsonOrThrow(res) -} +export const chatBlock = ({ topic, block, section, section_compact = '', messages, provider }) => + req('/blocks/chat', { method: 'POST', body: { topic, block, section, section_compact, messages, provider } }) -export async function examBlock({ +export const examBlock = ({ topic, block, section, section_compact = '', provider, action = 'question', question = '', last_rating = '', avoid = [], asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false, selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false, -}) { - const res = await fetch(`${BASE}/blocks/exam`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }), - }) - return jsonOrThrow(res) -} +}) => req('/blocks/exam', { + method: 'POST', + body: { topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }, +}) -export async function fetchQuestionPattern(topic, block) { - const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`) - return jsonOrThrow(res) -} +export const fetchQuestionPattern = (topic, block) => + req('/blocks/question-pattern', { query: { topic, block } }) -export async function fetchTopicProgress(topic) { - const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`) - return res.json() -} +export const fetchTopicProgress = (topic) => req('/topics/progress', { query: { topic } }) -export async function fetchStats() { - const res = await fetch(`${BASE}/stats`) - return res.json() -} +export const fetchStats = () => req('/stats') -export async function fetchProviders() { - const res = await fetch(`${BASE}/providers`) - return res.json() -} +export const fetchProviders = () => req('/providers') -export async function fetchFolders(kind) { - const res = await fetch(`${BASE}/folders?kind=${encodeURIComponent(kind)}`) - return jsonOrThrow(res) -} +export const fetchFolders = (kind) => req('/folders', { query: { kind } }) -export async function fetchSource(topic) { - const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`) - return jsonOrThrow(res) -} +export const fetchSource = (topic) => req('/blocks/source', { query: { topic } }) -export async function updateSource(topic, { type, ort = '', spec = '' }) { - const res = await fetch(`${BASE}/blocks/source`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, type, location: ort, spec }), - }) - return jsonOrThrow(res) -} +export const updateSource = (topic, { type, ort = '', spec = '' }) => + req('/blocks/source', { method: 'PUT', body: { topic, type, location: ort, spec } }) -export async function fetchBlocksCompleteness(topic) { - const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`) - return jsonOrThrow(res) -} +export const fetchBlocksCompleteness = (topic) => req('/blocks/completeness', { query: { topic } }) -export async function fetchBlocksOverview(topic) { - const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`) - return jsonOrThrow(res) -} +export const fetchBlocksOverview = (topic) => req('/blocks/overview', { query: { topic } }) -export async function cancelGuide(id) { - await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' }) -} +export const cancelGuide = (id) => req(`/guides/${id}/cancel`, { method: 'POST' }) -export async function deleteGuide(id, slots = false) { - await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' }) -} +export const deleteGuide = (id, slots = false) => + req(`/guides/${id}`, { method: 'DELETE', query: slots ? { slots: 1 } : undefined }) -export async function fetchGuideContent(id, level = 4) { - const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`) - if (!res.ok) throw new Error(`Content not available (${res.status})`) - return res.json() -} +export const fetchGuideContent = (id, level = 4) => req(`/guides/${id}/content`, { query: { level } }) // Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel). -export async function fetchPracticeDeck(topic) { - const res = await fetch(`${BASE}/practice/deck?topic=${encodeURIComponent(topic)}`) - return jsonOrThrow(res) -} +export const fetchPracticeDeck = (topic) => req('/practice/deck', { query: { topic } }) // Leitner-Schritt buchen (correct = „Gewusst"). -export async function answerPracticeCard({ topic, block_norm, sub_norm, correct }) { - const res = await fetch(`${BASE}/practice/answer`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, block_norm, sub_norm, correct }), - }) - return jsonOrThrow(res) -} +export const answerPracticeCard = ({ topic, block_norm, sub_norm, correct }) => + req('/practice/answer', { method: 'POST', body: { topic, block_norm, sub_norm, correct } }) // Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick). -export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) { - const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ block, spot, snippet, hint, provider }), - }) - return jsonOrThrow(res) -} +export const pruefeBlock = (id, { block, spot, snippet, hint = '', provider }) => + req(`/guides/${id}/block/pruefen`, { method: 'POST', body: { block, spot, snippet, hint, provider } }) // Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld). -export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) { - const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ block, spot, alt, revised, provider }), - }) - return jsonOrThrow(res) -} +export const uebernehmeBlock = (id, { block, spot, alt, revised, provider }) => + req(`/guides/${id}/block/uebernehmen`, { method: 'POST', body: { block, spot, alt, revised, provider } }) // Reset a block's learning progress to zero (score/streak/flags/open question). -export async function resetBlockProgress(topic, block) { - const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, { - method: 'DELETE', - }) - return jsonOrThrow(res) -} +export const resetBlockProgress = (topic, block) => + req('/blocks/progress', { method: 'DELETE', query: { topic, block } }) -export async function fetchTopics() { - const res = await fetch(`${BASE}/topics`) - return res.json() -} +export const fetchTopics = () => req('/topics') -export async function createTopic(name) { - await fetch(`${BASE}/topics`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }) -} +export const createTopic = (name) => req('/topics', { method: 'POST', body: { name } }) -export async function deleteTopic(name) { - await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' }) -} - -export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) { - const res = await fetch(`${BASE}/guides/${id}/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ section, outline, messages, provider }), - }) - return res.json() -} +export const deleteTopic = (name) => req('/topics', { method: 'DELETE', query: { topic: name } }) +export const chatGuide = (id, { section, outline, messages, provider = 'claude' }) => + req(`/guides/${id}/chat`, { method: 'POST', body: { section, outline, messages, provider } }) diff --git a/frontend/src/assets/shared.css b/frontend/src/assets/shared.css new file mode 100644 index 0000000..4370a97 --- /dev/null +++ b/frontend/src/assets/shared.css @@ -0,0 +1,7 @@ +/* Geteilte, komponentenübergreifende Stile (global, NICHT scoped). */ + +/* Live-Puls für „läuft"-Indikatoren — vorher als bk-/gb-/gen-/gen-side-pulse 4× kopiert. */ +@keyframes pulse-soft { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} diff --git a/frontend/src/components/BlockFocus.vue b/frontend/src/components/BlockFocus.vue index 566773e..0855f45 100644 --- a/frontend/src/components/BlockFocus.vue +++ b/frontend/src/components/BlockFocus.vue @@ -207,12 +207,12 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}

{{ suggestions[b.i].error }}

- - + +
- +
@@ -282,10 +282,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''} font-size: 0.72rem; font-weight: 600; border-radius: 999px; border: 1px solid; white-space: nowrap; } -.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); } -.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; } -.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; } - /* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */ .fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); } /* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */ @@ -302,9 +298,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''} ); } .xp-seg { height: 100%; transition: width 0.3s ease; } -.xp-seg.gold { background: #d4af37; } -.xp-seg.lila { background: #8b5cf6; } -.xp-seg.gruen { background: var(--success-border); } .fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; } .fokus-btn { display: inline-flex; align-items: center; justify-content: center; diff --git a/frontend/src/components/BlockPanel.vue b/frontend/src/components/BlockPanel.vue index 7c597d1..e55a571 100644 --- a/frontend/src/components/BlockPanel.vue +++ b/frontend/src/components/BlockPanel.vue @@ -5,12 +5,13 @@ import { usePruefSlot } from '../pruefungCache.js' import { renderMarkdown, renderMarkdownInline } from '../markdown.js' import { stufeFuer, malusRegel } from '../levels.js' import { useChat, istUnten } from '../composables/useChat.js' +import ChatTranscript from './ChatTranscript.vue' const props = defineProps({ topic: { type: String, required: true }, block: { type: String, required: true }, section: { type: String, default: '' }, // detailed version - sectionKompakt: { type: String, default: '' }, // compact version (key points) — exam/chat context + sectionCompact: { type: String, default: '' }, // compact version (key points) — exam/chat context provider: { type: String, default: 'claude' }, status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered} cap: { type: Number, default: 6 }, // score cap = max of the highest format (6/12/18/30) @@ -70,7 +71,7 @@ function tabClick(tab) { // --- Block chat (ephemeral) --- const chat = useChat((msgs) => chatBlock({ - topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt, + topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact, messages: msgs, provider: props.provider, })) @@ -130,7 +131,7 @@ async function examSend(payload, onOk) { examScroll() try { const res = await examBlock({ - topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt, + topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact, provider: props.provider, messages: examDialog(), ...payload, }) if (run !== examRun) return @@ -243,7 +244,7 @@ function buildSingleQuestion(mode = nextMode()) { const pattern = takePattern() const base = { topic: props.topic, block: props.block, section: props.section, - section_compact: props.sectionKompakt, provider: props.provider, + section_compact: props.sectionCompact, provider: props.provider, } if (form === 'quiz' && pattern) { return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls @@ -381,7 +382,7 @@ async function quizAnswer() { try { const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) const res = await examBlock({ - topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt, + topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact, provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap, selection: q.gewaehlt, correct, schwer: q.schwer, }) @@ -412,7 +413,7 @@ async function clozeAnswer() { ? { schwer: true, solution: l.solution, alternatives: l.alternatives, input: l.input } : { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) } const res = await examBlock({ - topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt, + topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact, provider: props.provider, action: 'gap_answer', question: l.sentence, cap: props.cap, ...specific, }) l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback @@ -451,7 +452,7 @@ async function quickEvaluate() { examScroll() try { const res = await examBlock({ - topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt, + topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact, provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(), }) if (mine !== evalRun) return @@ -477,7 +478,7 @@ async function preciseEvaluate(thorough = false, reason = '') { if (thorough) examLoading.value = true try { const res = await examBlock({ - topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt, + topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact, provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason, }) applyExam(res) @@ -629,26 +630,8 @@ function onExamKey(e) {
-
-

Ask something about this block. The history is not saved.

- -
Thinking…
-
-
- - -
+
@@ -663,7 +646,7 @@ function onExamKey(e) {