This commit is contained in:
Team3
2026-07-05 15:26:22 +02:00
parent 07e14fb82e
commit 250ea0b764
45 changed files with 1468 additions and 1452 deletions

View File

@@ -47,10 +47,11 @@ stop:
logs: logs:
$(COMPOSE) logs -f $(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..." @echo "Lösche Datenbank und generierte Dateien..."
rm -rf storage/* rm -rf storage/*
@echo "Fertig." @echo "Fertig. (Server ggf. separat stoppen: make stop)"
searxng: searxng:
docker run -d --name searxng --restart unless-stopped -p 8888:8080 searxng/searxng docker run -d --name searxng --restart unless-stopped -p 8888:8080 searxng/searxng

View File

@@ -13,7 +13,6 @@ relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashca
import asyncio import asyncio
import hashlib import hashlib
import json
import logging import logging
import database as db import database as db

View File

@@ -18,33 +18,25 @@ import math
import re import re
import shutil import shutil
import subprocess import subprocess
import time
import unicodedata import unicodedata
from pathlib import Path from pathlib import Path
import database as db import database as db
import embedding import embedding
from agents import kill_process, cancel_scope, clear_scope, run_agent 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, 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 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_text, atomic_write_json from fsutil import atomic_write_json
from jsonio import parse_json_text, read_json_file as _json_file 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 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 crawl import crawl
from pipeline import ( from pipeline import (
CANCELLED, FAILED, OK, GenContext, _detached, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race, GenContext, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race,
_relevance_schema, _runde_schema, _semaphore, _str_list, _levels_schema, _timeout, run_single_slot, _semaphore, _timeout, run_single_slot,
)
from textkit import (
_unique_title, _load_blocks, _norm_title, _parse_selection, _parse_subblocks, _title,
_resolve_title, _title_index, clean_title,
) )
from textkit import _load_blocks, _norm_title, _parse_selection, _title
# Pipeline-Tuning-Konstanten liegen zentral in config.py (tunebar via CREATOR_PARAMS). # Pipeline-Tuning-Konstanten liegen zentral in config.py (tunebar via CREATOR_PARAMS).
from config import ( # noqa: E402 from config import RESEARCH_SECTION_CHARS # 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)
log = logging.getLogger("creator.blocks") log = logging.getLogger("creator.blocks")
@@ -269,8 +261,6 @@ async def blocks_status(topic: str) -> dict:
"progress": _blocks_progress.get(topic), "progress": _blocks_progress.get(topic),
"error": _blocks_errors.get(topic), "error": _blocks_errors.get(topic),
"partial": not generating and open_cards > 0, "partial": not generating and open_cards > 0,
"steps": [], # legacy phase pills — replaced by the live board
"feine_steps": [],
} }

View File

@@ -24,7 +24,7 @@ from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file from jsonio import read_json_file as _json_file
from kanban import Flow, Stage from kanban import Flow, Stage
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot 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") 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) results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
errs = [r for r in results if isinstance(r, Exception)] errs = [r for r in results if isinstance(r, Exception)]
if errs: 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] raise errs[0]
flow.wake.set() flow.wake.set()
@@ -316,10 +318,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
return return
def _kp(r: dict) -> list: def _kp(r: dict) -> list:
try: return parse_facts(r.get("facts")).get("key_points") or []
return (json.loads(r.get("facts") or "{}")).get("key_points") or []
except ValueError:
return []
def _side(tag: str, r: dict) -> str: 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)) return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r))

View File

@@ -39,9 +39,7 @@ import kanban
from kanban import Flow, Stage, chain_stages from kanban import Flow, Stage, chain_stages
import blocks import blocks
from blocks import ( from blocks import (
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK, _FILTER_NOTATION, _GROUP_STANDALONE,
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
RESEARCH_THEMA_AGENTS, _FILTER_NOTATION, _GROUP_STANDALONE,
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques, _build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
_completion_schema, _containment_parent, _crawl_index, _file_payload, _completion_schema, _containment_parent, _crawl_index, _file_payload,
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement, _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, _aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder,
) )
from config import (QA_GATE_NOTE, QA_GATE_LLM, 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, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS, EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR, 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): flow and flow.state.get("qa_note") is not None):
return None return None
import qa import qa
tdir = qa.QA_DIR / topic r = qa.latest_report(topic)
# by mtime: a re-run overwrites the run-id-named file, which sorts before timestamp names. if r is None:
# 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:
return None return None
r = _json_file(reports[-1]) or {}
note = r.get("note") note = r.get("note")
if note is None: if note is None:
return 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 _requeue(r, stage)
await db.kanban_delete_cards(topic, "inventory", "cluster") await db.kanban_delete_cards(topic, "inventory", "cluster")
await db.kanban_delete_cards(topic, "inventory", "block") await db.kanban_delete_cards(topic, "inventory", "block")
dbc = await db.get_db() await db.kanban_delete_members(topic)
await dbc.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,))
await dbc.commit()
await db.delete_blocks(topic) await db.delete_blocks(topic)
await _clean_artefact_state(topic, files) await _clean_artefact_state(topic, files)
elif board == "inventory" and stage in _CLUSTER_STAGES: elif board == "inventory" and stage in _CLUSTER_STAGES:

View File

@@ -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; # ── 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 # 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. ───────────────────────── # 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 SUBBLOCK_MAX = 40 # chunk cap
LEVEL_CHUNK = 100 # classifying is cheap → large packages
RESEARCH_BATCH = 20 # crawl pages per batch RESEARCH_BATCH = 20 # crawl pages per batch
RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2) RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2)
RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema") RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema")
RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard) RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard)
RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live) 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_PAIR_FLOOR = 0.6 # min cosine for a candidate pair
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package DEDUP_PAIRS_CHUNK = 40 # pairs per judge package
DEDUP_TITLE_AUTO = 0.95 # near-identical TITLE cosine → merge without judge DEDUP_TITLE_AUTO = 0.95 # near-identical TITLE cosine → merge without judge
DEDUP_GLOBAL_FLOOR = 0.65 # global post-naming dedup candidate floor DEDUP_GLOBAL_FLOOR = 0.65 # global post-naming dedup candidate floor
FILTER_CHUNK = 35 # blocks per judge in the degrade pass 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 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. # 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, # Konsens braucht ≥2 unabhängige Nennungen bzw. Einstimmigkeit — 2 ist das Minimum,
# 3 kauft Robustheit für +50 % Tokens auf dem jeweiligen Segment. # 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 ART_SPLIT_SUBS = 20 # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls
FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck 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) 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.351.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 WRITER_SPLIT_SUBS = 30 # guide writer splits sections above this sub count
KANBAN_BATCH = 5 # cards a worker pulls per micro-batch KANBAN_BATCH = 5 # cards a worker pulls per micro-batch
MAX_CARD_RETRIES = 3 # failures per card → dead-letter 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 110135 s). 0 = aus. # Fix-/Gate-Call (die laufen normal 110135 s). 0 = aus.
HEDGE_NACH_S = 90 HEDGE_NACH_S = 90
JUDGE_CHUNK = 40 # repair: findings per judge call 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 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) 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). # Timeouts per agent step: (base seconds, seconds per block/section).
# Applies equally to all providers — whoever is too slow gets restarted or overtaken. # Applies equally to all providers — whoever is too slow gets restarted or overtaken.
TIMEOUTS = { TIMEOUTS = {
"research": (900, 0), # p95 measured 125 s (web mode); uni/link sections need headroom
"research_mapping": (600, 3), # n = pre-merged entries "research_mapping": (600, 3), # n = pre-merged entries
"selection_mapping": (600, 2), # n = remaining entries (block inventory) "selection_mapping": (600, 2), # n = remaining entries (block inventory)
"ergaenzung": (600, 0), # subject-field extension for projects (web research) "ergaenzung": (600, 0), # subject-field extension for projects (web research)
"plan": (300, 5), "plan": (300, 5),
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections "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 672 s; a stalled call burns the whole # Judge caps tightened 2026-07-04: judge p50 is 672 s; a stalled call burns the whole
# cap and its retry heals in seconds — the old 300 s base tripled the stall cost. # 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 "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": (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 # 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) "generate": (450, 0), # Subs+Facts+Level in einem (Sub-Zahl vorab unbekannt)
"verify": (300, 10), # Audit über alle Subs (n = Subs), key points gekappt "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": (450, 15), # Fragen+Karten+Beispiele (n = Subs)
"artefakt_check": (200, 8), # Beispiel-Verifikation + Fragen-Kritik (n = Subs) "artefakt_check": (200, 8), # Beispiel-Verifikation + Fragen-Kritik (n = Subs)
"writer": (450, 60), # per section — split keeps sections ≤30 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) # guide board (per card = one block)
"lernziele": (300, 5), # backward-design objectives per block "lernziele": (300, 5), # backward-design objectives per block
"fakten_gate": (600, 5), # CoVe claim check per block "pruefer": (600, 5), # verschmolzener Qualitäts-Pass (Gate+Coverage+Lese) per block
"coverage": (300, 5), # objective↔section mapping per block # QA/Repair-Judge-Wellen (qa.judge_wave) — außerhalb der Boards, keine n-Skalierung
} "qa_judge": (600, 0),
# 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",
} }
# Provider stacks: completely independent, any one can be removed at any time. # Provider stacks: completely independent, any one can be removed at any time.

View File

@@ -1,4 +1,6 @@
import asyncio
import json import json
from contextlib import asynccontextmanager
import aiosqlite import aiosqlite
from config import DB_PATH from config import DB_PATH
@@ -222,6 +224,11 @@ CREATE_EVENTS_INDEX = """
CREATE INDEX IF NOT EXISTS idx_events ON events(topic, ts) 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_KANBAN_PULL_INDEX = """
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at) 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 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(): async def init_db():
db = await get_db() db = await get_db()
# WAL survives crashes much better; busy_timeout absorbs short locks. # 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 ''") await db.execute("ALTER TABLE events ADD COLUMN run_id TEXT NOT NULL DEFAULT ''")
except aiosqlite.OperationalError: except aiosqlite.OperationalError:
pass 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( await db.execute(
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' " "UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
"WHERE status IN ('queued', 'generating')" "WHERE status IN ('queued', 'generating')"
@@ -379,13 +423,12 @@ def _row_to_dict(row, cursor):
async def create_guide(guide: dict) -> dict: async def create_guide(guide: dict) -> dict:
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at) """INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at)
VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""", VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""",
guide, guide,
) )
await db.commit()
return guide return guide
@@ -411,9 +454,8 @@ async def _update(table: str, fields: dict, where: dict) -> None:
rename (SET new norm WHERE old norm).""" rename (SET new norm WHERE old norm)."""
sets = ", ".join(f"{k} = :{k}" for k in fields) sets = ", ".join(f"{k} = :{k}" for k in fields)
cond = " AND ".join(f"{k} = :w_{k}" for k in where) cond = " AND ".join(f"{k} = :w_{k}" for k in where)
db = await get_db() 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()}}) 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 def update_guide(guide_id: str, **fields) -> None: async def update_guide(guide_id: str, **fields) -> None:
@@ -421,9 +463,8 @@ async def update_guide(guide_id: str, **fields) -> None:
async def delete_guide(guide_id: str) -> bool: async def delete_guide(guide_id: str) -> bool:
db = await get_db() async with _tx() as db:
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,)) cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
await db.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
@@ -431,12 +472,11 @@ async def delete_guide(guide_id: str) -> bool:
async def create_topic(name: str) -> None: async def create_topic(name: str) -> None:
from datetime import datetime, timezone from datetime import datetime, timezone
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"INSERT OR IGNORE INTO topics (name, created_at) VALUES (?, ?)", "INSERT OR IGNORE INTO topics (name, created_at) VALUES (?, ?)",
(name, datetime.now(timezone.utc).isoformat()), (name, datetime.now(timezone.utc).isoformat()),
) )
await db.commit()
async def list_topics() -> list[str]: async def list_topics() -> list[str]:
@@ -447,9 +487,8 @@ async def list_topics() -> list[str]:
async def delete_topic(name: str) -> None: async def delete_topic(name: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM topics WHERE name = ?", (name,)) await db.execute("DELETE FROM topics WHERE name = ?", (name,))
await db.commit()
# --- Block learning: deep-dives + exam progress --- # --- Block learning: deep-dives + exam progress ---
@@ -487,7 +526,7 @@ 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: 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).""" """Freeze base + streak BEFORE the now-open question (anchor for idempotent re-rating)."""
db = await get_db() async with _tx() as db:
now = _now() now = _now()
await db.execute( await db.execute(
"""INSERT INTO block_progress (topic, block, offene_question, offene_basis, offene_streak, updated_at) """INSERT INTO block_progress (topic, block, offene_question, offene_basis, offene_streak, updated_at)
@@ -497,12 +536,11 @@ async def set_open_question(topic: str, block: str, question: str, basis: int, s
offene_streak = excluded.offene_streak, updated_at = excluded.updated_at""", offene_streak = excluded.offene_streak, updated_at = excluded.updated_at""",
(topic, block, question, basis, streak, now), (topic, block, question, basis, streak, now),
) )
await db.commit()
async def set_block_score_and_streak(topic: str, block: str, score: int, streak: int) -> tuple[int, int]: 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).""" """Set score + streak atomically (clamped by the caller). Returns (score, streak)."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO block_progress (topic, block, good_answers, streak, updated_at) """INSERT INTO block_progress (topic, block, good_answers, streak, updated_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
@@ -511,16 +549,14 @@ async def set_block_score_and_streak(topic: str, block: str, score: int, streak:
updated_at = excluded.updated_at""", updated_at = excluded.updated_at""",
(topic, block, score, streak, _now()), (topic, block, score, streak, _now()),
) )
await db.commit()
return score, streak return score, streak
async def delete_block_progress(topic: str, block: str) -> None: async def delete_block_progress(topic: str, block: str) -> None:
"""Reset the progress of ONE block: delete the row (score/streak/flags/open question). """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.""" If the row is missing, get_block_progress returns defaults (0) — i.e. a full reset."""
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM block_progress WHERE topic = ? AND block = ?", (topic, block)) await db.execute("DELETE FROM block_progress WHERE topic = ? AND block = ?", (topic, block))
await db.commit()
# Sub-level from the two orthogonal columns: peripheral → 4 (V), otherwise level (learning-path position): # 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: async def delete_block_data(topic: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM block_progress WHERE topic = ?", (topic,)) await db.execute("DELETE FROM block_progress WHERE topic = ?", (topic,))
await db.commit()
# --- Blocks pipeline content: inventory / subblocks / question pattern / coverage / state / source --- # --- Blocks pipeline content: inventory / subblocks / question pattern / coverage / state / source ---
@@ -617,7 +652,7 @@ async def upsert_block(topic: str, title_norm: str, title: str, description: str
ONE statement (json1), because several reader coroutines upsert concurrently — a ONE statement (json1), because several reader coroutines upsert concurrently — a
read-modify-write across `await` would lose members. `mentions` stays in sync 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.""" with `len(reader)`. `reader=None` (e.g. from `_set_inventar`) leaves the set unchanged."""
db = await get_db() async with _tx() as db:
rid = reader if isinstance(reader, str) and reader else None rid = reader if isinstance(reader, str) and reader else None
await db.execute( await db.execute(
"""INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at) """INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at)
@@ -635,7 +670,6 @@ async def upsert_block(topic: str, title_norm: str, title: str, description: str
json.dumps([rid] if rid else [], ensure_ascii=False), _now(), json.dumps([rid] if rid else [], ensure_ascii=False), _now(),
rid, rid, rid, rid), rid, rid, rid, rid),
) )
await db.commit()
async def list_blocks(topic: str, status: str | None = None) -> list[dict]: 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: async def delete_blocks(topic: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM blocks WHERE topic = ?", (topic,)) await db.execute("DELETE FROM blocks WHERE topic = ?", (topic,))
await db.commit()
# ── Kanban dataflow (generic card layer, boards 'inventory' + 'artefacts') ──────── # ── 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 = "", 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: 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.""" """One pipeline-history row, own commit. Callers treat this as fire-and-forget."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)", "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, (topic, _now(), kind, key, label, status, dur_ms, wait_ms,
json.dumps(meta or {}, ensure_ascii=False), _current_run.get(topic, ""))) json.dumps(meta or {}, ensure_ascii=False), _current_run.get(topic, "")))
await db.commit()
async def _add_events_many(db, topic: str, rows: list[tuple]) -> None: async def _add_events_many(db, topic: str, rows: list[tuple]) -> None:
@@ -766,21 +798,20 @@ 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).""" """Batch stage moves in ONE commit (the flow advances whole packages)."""
if not moves: if not moves:
return return
db = await get_db() async with _tx() as db:
now = _now() now = _now()
await db.executemany( await db.executemany(
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ? """UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
WHERE topic = ? AND board = ? AND card_id = ?""", WHERE topic = ? AND board = ? AND card_id = ?""",
[(stage, now, topic, board, cid) for cid, stage in moves]) [(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 _add_events_many(db, topic, [("stage", f"{board}:{cid}", "", stage) for cid, stage in moves])
await db.commit()
async def kanban_upsert_card(topic: str, board: str, card_id: str, kind: str, stage: str, async def kanban_upsert_card(topic: str, board: str, card_id: str, kind: str, stage: str,
payload: dict | None = None) -> None: payload: dict | None = None) -> None:
"""Insert or overwrite a card (stable ids → growing clusters upsert, never duplicate). """Insert or overwrite a card (stable ids → growing clusters upsert, never duplicate).
payload=None keeps the existing payload on conflict.""" payload=None keeps the existing payload on conflict."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at) """INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at)
VALUES (?, ?, ?, ?, ?, COALESCE(?, '{}'), ?) VALUES (?, ?, ?, ?, ?, COALESCE(?, '{}'), ?)
@@ -791,15 +822,13 @@ async def kanban_upsert_card(topic: str, board: str, card_id: str, kind: str, st
(topic, board, card_id, kind, stage, (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, _now(),
json.dumps(payload, ensure_ascii=False) if payload is not None else None)) json.dumps(payload, ensure_ascii=False) if payload is not None else None))
await db.commit()
async def kanban_set_payload(topic: str, board: str, card_id: str, payload: dict) -> None: async def kanban_set_payload(topic: str, board: str, card_id: str, payload: dict) -> None:
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"UPDATE kanban_cards SET payload = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?", "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)) (json.dumps(payload, ensure_ascii=False), _now(), topic, board, card_id))
await db.commit()
async def kanban_get_card(topic: str, board: str, card_id: str) -> dict | None: async def kanban_get_card(topic: str, board: str, card_id: str) -> dict | None:
@@ -826,7 +855,7 @@ async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
max_retries: int, backoff_base: float = 30.0) -> bool: max_retries: int, backoff_base: float = 30.0) -> bool:
"""Register a processing failure: retries++, exponential backoff (not_before), and after """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.""" `max_retries` → stage 'dead' (dead-letter, requeue-able). → True if the card went dead."""
db = await get_db() async with _tx() as db:
cursor = await db.execute( cursor = await db.execute(
"SELECT retries FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?", "SELECT retries FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?",
(topic, board, card_id)) (topic, board, card_id))
@@ -850,7 +879,6 @@ async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)", "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}", (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, ""))) None, None, json.dumps({"error": error[:200]}, ensure_ascii=False), _current_run.get(topic, "")))
await db.commit()
return dead return dead
@@ -880,6 +908,27 @@ async def events_run_summary(topic: str, run_id: str) -> dict:
return {"agents": agents, "tokens": tokens} 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]: async def kanban_dead(topic: str) -> list[dict]:
"""Dead-letter cards across boards (for the board UI + requeue).""" """Dead-letter cards across boards (for the board UI + requeue)."""
return await kanban_cards(topic, stage="dead") return await kanban_cards(topic, stage="dead")
@@ -887,13 +936,12 @@ async def kanban_dead(topic: str) -> list[dict]:
async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int: async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int:
"""dead → `stage` (fresh retries). → number of requeued cards.""" """dead → `stage` (fresh retries). → number of requeued cards."""
db = await get_db() async with _tx() as db:
cursor = await db.execute( cursor = await db.execute(
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ? """UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
WHERE topic = ? AND board = ? AND stage = 'dead'""", WHERE topic = ? AND board = ? AND stage = 'dead'""",
(stage, _now(), topic, board)) (stage, _now(), topic, board))
await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)]) await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)])
await db.commit()
return cursor.rowcount return cursor.rowcount
@@ -920,25 +968,23 @@ 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: 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).""" """Delete ONE card (repair: the merged-away/removed block's board-2 card)."""
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?", await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?",
(topic, board, card_id)) (topic, board, card_id))
await db.commit()
async def kanban_delete_cards(topic: str, board: str, kind: str | None = None) -> None: 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.""" """Delete derived cards (board reset) — kind=None wipes the whole board."""
db = await get_db() async with _tx() as db:
if kind: if kind:
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND kind = ?", await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND kind = ?",
(topic, board, kind)) (topic, board, kind))
else: else:
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board)) await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board))
await db.commit()
async def kanban_reset(topic: str, board: str | None = None) -> None: async def kanban_reset(topic: str, board: str | None = None) -> None:
db = await get_db() async with _tx() as db:
if board: if board:
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board)) await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board))
if board == "inventory": if board == "inventory":
@@ -946,18 +992,21 @@ async def kanban_reset(topic: str, board: str | None = None) -> None:
else: else:
await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (topic,)) await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (topic,))
await db.execute("DELETE FROM kanban_members 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: 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).""" """Replace the member set of a cluster (one member belongs to exactly one cluster)."""
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM kanban_members WHERE topic = ? AND group_id = ?", (topic, group_id)) await db.execute("DELETE FROM kanban_members WHERE topic = ? AND group_id = ?", (topic, group_id))
await db.executemany( await db.executemany(
"""INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?) """INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?)
ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""", ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""",
[(topic, m, group_id) for m in members]) [(topic, m, group_id) for m in members])
await db.commit()
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]: 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, async def upsert_guide_card(topic: str, format: str, block_norm: str, block: str,
stage: str = "lernziele") -> None: stage: str = "lernziele") -> None:
"""Insert a card; an existing one keeps its stage/progress (resume).""" """Insert a card; an existing one keeps its stage/progress (resume)."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO guide_cards (topic, format, block_norm, block, stage, updated_at) """INSERT INTO guide_cards (topic, format, block_norm, block, stage, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, format, block_norm) DO UPDATE SET ON CONFLICT(topic, format, block_norm) DO UPDATE SET
block = excluded.block, updated_at = excluded.updated_at""", block = excluded.block, updated_at = excluded.updated_at""",
(topic, format, block_norm, block, stage, _now())) (topic, format, block_norm, block, stage, _now()))
await db.commit()
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]: async def list_guide_cards(topic: str, format: str | None = None) -> list[dict]:
@@ -997,7 +1064,7 @@ 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: async def set_guide_card(topic: str, format: str, block_norm: str, **fields) -> None:
if not fields: if not fields:
return return
db = await get_db() async with _tx() as db:
cols = ", ".join(f"{k} = ?" for k in fields) cols = ", ".join(f"{k} = ?" for k in fields)
await db.execute( await db.execute(
f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?", f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?",
@@ -1007,7 +1074,6 @@ async def set_guide_card(topic: str, format: str, block_norm: str, **fields) ->
elif fields.get("status") == "error": # guide cards fail here, not via kanban_fail_card 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}", "", await _add_events_many(db, topic, [("fail", f"guide:{format}:{block_norm}", "",
str(fields.get("gate_info", ""))[:200])]) str(fields.get("gate_info", ""))[:200])])
await db.commit()
async def guide_stage_counts(topic: str, format: str) -> dict[str, int]: async def guide_stage_counts(topic: str, format: str) -> dict[str, int]:
@@ -1023,7 +1089,7 @@ 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).""" """Cards sitting in any of `stages` → back to `to_stage` (fresh rounds/gate info)."""
if not stages: if not stages:
return 0 return 0
db = await get_db() async with _tx() as db:
ph = ",".join("?" * len(stages)) ph = ",".join("?" * len(stages))
md = ", md = ''" if clear_md else "" md = ", md = ''" if clear_md else ""
cursor = await db.execute( cursor = await db.execute(
@@ -1031,12 +1097,11 @@ async def reset_guide_cards_from_stage(topic: str, format: str, stages: list[str
gate_info = ''{md}, updated_at = ? gate_info = ''{md}, updated_at = ?
WHERE topic = ? AND format = ? AND stage IN ({ph})""", WHERE topic = ? AND format = ? AND stage IN ({ph})""",
(to_stage, _now(), topic, format, *stages)) (to_stage, _now(), topic, format, *stages))
await db.commit()
return cursor.rowcount return cursor.rowcount
async def delete_guide_board(topic: str, format: str | None = None) -> None: async def delete_guide_board(topic: str, format: str | None = None) -> None:
db = await get_db() async with _tx() as db:
if format: if format:
await db.execute("DELETE FROM guide_cards WHERE topic = ? AND format = ?", (topic, 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,)) cursor = await db.execute("SELECT count(*) FROM guide_cards WHERE topic = ?", (topic,))
@@ -1045,18 +1110,16 @@ async def delete_guide_board(topic: str, format: str | None = None) -> None:
else: else:
await db.execute("DELETE FROM guide_cards WHERE topic = ?", (topic,)) await db.execute("DELETE FROM guide_cards WHERE topic = ?", (topic,))
await db.execute("DELETE FROM guide_lernziele 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: async def put_lernziel(topic: str, block_norm: str, ziel_id: str, text: str, sub_norm: str = "") -> None:
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO guide_lernziele (topic, block_norm, ziel_id, text, sub_norm, covered, updated_at) """INSERT INTO guide_lernziele (topic, block_norm, ziel_id, text, sub_norm, covered, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?) VALUES (?, ?, ?, ?, ?, 0, ?)
ON CONFLICT(topic, block_norm, ziel_id) DO UPDATE SET ON CONFLICT(topic, block_norm, ziel_id) DO UPDATE SET
text = excluded.text, sub_norm = excluded.sub_norm, updated_at = excluded.updated_at""", text = excluded.text, sub_norm = excluded.sub_norm, updated_at = excluded.updated_at""",
(topic, block_norm, ziel_id, text, sub_norm, _now())) (topic, block_norm, ziel_id, text, sub_norm, _now()))
await db.commit()
async def list_lernziele(topic: str, block_norm: str | None = None) -> list[dict]: 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: async def set_ziel_covered(topic: str, block_norm: str, ziel_id: str, covered: bool) -> None:
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"UPDATE guide_lernziele SET covered = ?, updated_at = ? WHERE topic = ? AND block_norm = ? AND ziel_id = ?", "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)) (1 if covered else 0, _now(), topic, block_norm, ziel_id))
await db.commit()
async def delete_lernziele(topic: str, block_norm: str) -> None: async def delete_lernziele(topic: str, block_norm: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM guide_lernziele WHERE topic = ? AND block_norm = ?", (topic, block_norm)) await db.execute("DELETE FROM guide_lernziele WHERE topic = ? AND block_norm = ?", (topic, block_norm))
await db.commit()
async def kanban_membership(topic: str) -> dict[str, str]: 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: async def kanban_set_member(topic: str, member_id: str, group_id: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?) """INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?)
ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""", ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""",
(topic, member_id, group_id)) (topic, member_id, group_id))
await db.commit()
async def kanban_add_title(topic: str, board: str, card_id: str, title: str, 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: if row is None:
payload = {"title": title, "description": description, payload = {"title": title, "description": description,
"sources": [source] if source else [], "readers": [reader]} "sources": [source] if source else [], "readers": [reader]}
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at) """INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at)
VALUES (?, ?, ?, 'title', 'ingest', ?, ?)""", VALUES (?, ?, ?, 'title', 'ingest', ?, ?)""",
(topic, board, card_id, json.dumps(payload, ensure_ascii=False), _now())) (topic, board, card_id, json.dumps(payload, ensure_ascii=False), _now()))
await db.commit()
return True return True
p = row["payload"] p = row["payload"]
p["readers"] = list(dict.fromkeys((p.get("readers") or []) + [reader])) p["readers"] = list(dict.fromkeys((p.get("readers") or []) + [reader]))
@@ -1128,7 +1187,7 @@ 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: async def upsert_subblock(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, status, updated_at) """INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, status, updated_at)
VALUES (?, ?, ?, ?, ?, 1, 'candidate', ?) VALUES (?, ?, ?, ?, ?, 1, 'candidate', ?)
@@ -1136,7 +1195,6 @@ async def upsert_subblock(topic: str, block_norm: str, sub_norm: str, block: str
mentions = mentions + 1, updated_at = excluded.updated_at""", mentions = mentions + 1, updated_at = excluded.updated_at""",
(topic, block_norm, sub_norm, block, sub_title, _now()), (topic, block_norm, sub_norm, block, sub_title, _now()),
) )
await db.commit()
async def put_subblock(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str, async def put_subblock(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str,
@@ -1144,7 +1202,7 @@ async def put_subblock(topic: str, block_norm: str, sub_norm: str, block: str, s
facts: str | None = None, status: str = "consensus") -> None: facts: str | None = None, status: str = "consensus") -> None:
"""Insert/update WITHOUT a mention counter (mirror from the sidecar). Overwrite """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).""" level/relevance/facts only when a new value is passed (COALESCE protects existing data)."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, level, relevance, facts, status, updated_at) """INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, level, relevance, facts, status, updated_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, COALESCE(?, ''), ?, ?) VALUES (?, ?, ?, ?, ?, 1, ?, ?, COALESCE(?, ''), ?, ?)
@@ -1156,7 +1214,6 @@ async def put_subblock(topic: str, block_norm: str, sub_norm: str, block: str, s
status = excluded.status, updated_at = excluded.updated_at""", status = excluded.status, updated_at = excluded.updated_at""",
(topic, block_norm, sub_norm, block, sub_title, level, relevance, facts, status, _now()), (topic, block_norm, sub_norm, block, sub_title, level, relevance, facts, status, _now()),
) )
await db.commit()
async def list_subblocks(topic: str, block_norm: str | None = None) -> list[dict]: async def list_subblocks(topic: str, block_norm: str | None = None) -> list[dict]:
@@ -1174,7 +1231,7 @@ async def list_subblocks(topic: str, block_norm: str | None = None) -> list[dict
async def default_subblock_levels(topic: str, block_norm: str) -> None: async def default_subblock_levels(topic: str, block_norm: str) -> None:
"""Classify stragglers after finalize: consensus rows without a valid level fall out of """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).""" the guide/practice/level queries (re-run resume left 25 such rows — invisible content)."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""UPDATE subblocks SET level = 'advanced' WHERE topic = ? AND block_norm = ? """UPDATE subblocks SET level = 'advanced' WHERE topic = ? AND block_norm = ?
AND status = 'consensus' AND (level IS NULL OR level NOT IN ('beginner', 'advanced', 'expert'))""", AND status = 'consensus' AND (level IS NULL OR level NOT IN ('beginner', 'advanced', 'expert'))""",
@@ -1183,14 +1240,13 @@ async def default_subblock_levels(topic: str, block_norm: str) -> None:
"""UPDATE subblocks SET relevance = 'relevant' WHERE topic = ? AND block_norm = ? """UPDATE subblocks SET relevance = 'relevant' WHERE topic = ? AND block_norm = ?
AND status = 'consensus' AND relevance IS NULL""", AND status = 'consensus' AND relevance IS NULL""",
(topic, block_norm)) (topic, block_norm))
await db.commit()
async def copy_topic(quelle: str, ziel: str) -> None: async def copy_topic(quelle: str, ziel: str) -> None:
"""Trainings-Helfer: Kanban-Karten + Block-Rows der Quelle unter neuem Topic duplizieren """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; (Frozen-Inventar-Trials — Board 2 läuft auf identischem Board-1-Stand neu). Nur DB;
Dateien (source.json/blocks.md) kopiert der Runner.""" Dateien (source.json/blocks.md) kopiert der Runner."""
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (ziel,)) await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (ziel,))
await db.execute("DELETE FROM blocks WHERE topic = ?", (ziel,)) await db.execute("DELETE FROM blocks WHERE topic = ?", (ziel,))
await db.execute( await db.execute(
@@ -1201,14 +1257,13 @@ async def copy_topic(quelle: str, ziel: str) -> None:
"""INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at) """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 SELECT ?, title_norm, title, description, mentions, status, sources, reader, updated_at
FROM blocks WHERE topic = ?""", (ziel, quelle)) FROM blocks WHERE topic = ?""", (ziel, quelle))
await db.commit()
async def delete_stale_consensus(topic: str, block_norm: str, keep: set[str]) -> None: 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 """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 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.""" output). variant/discarded rows stay — QA reads those statuses."""
db = await get_db() async with _tx() as db:
cursor = await db.execute( cursor = await db.execute(
"SELECT sub_norm FROM subblocks WHERE topic = ? AND block_norm = ? AND status = 'consensus'", "SELECT sub_norm FROM subblocks WHERE topic = ? AND block_norm = ? AND status = 'consensus'",
(topic, block_norm)) (topic, block_norm))
@@ -1217,8 +1272,6 @@ async def delete_stale_consensus(topic: str, block_norm: str, keep: set[str]) ->
for sn in stale: for sn in stale:
await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ? AND sub_norm = ?", await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ? AND sub_norm = ?",
(topic, block_norm, sn)) (topic, block_norm, sn))
if stale:
await db.commit()
async def set_subblock_fields(topic: str, block_norm: str, sub_norm: str, **fields) -> None: async def set_subblock_fields(topic: str, block_norm: str, sub_norm: str, **fields) -> None:
@@ -1228,16 +1281,15 @@ 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: async def delete_subblocks(topic: str, block_norm: str | None = None) -> None:
db = await get_db() async with _tx() as db:
if block_norm is None: if block_norm is None:
await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,)) await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,))
else: else:
await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ?", (topic, block_norm)) await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ?", (topic, block_norm))
await db.commit()
async def upsert_question_pattern(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str, question: str) -> None: 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() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO question_pattern (topic, block_norm, sub_norm, block, sub_title, question, updated_at) """INSERT INTO question_pattern (topic, block_norm, sub_norm, block, sub_title, question, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -1245,7 +1297,6 @@ async def upsert_question_pattern(topic: str, block_norm: str, sub_norm: str, bl
sub_title = excluded.sub_title, question = excluded.question, updated_at = excluded.updated_at""", sub_title = excluded.sub_title, question = excluded.question, updated_at = excluded.updated_at""",
(topic, block_norm, sub_norm, block, sub_title, question, _now()), (topic, block_norm, sub_norm, block, sub_title, question, _now()),
) )
await db.commit()
async def list_question_pattern(topic: str, block_norm: str | None = None) -> list[dict]: async def list_question_pattern(topic: str, block_norm: str | None = None) -> list[dict]:
@@ -1285,31 +1336,29 @@ async def event_span(topic: str) -> int:
async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None: async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None:
db = await get_db() async with _tx() as db:
if block_norm is None: if block_norm is None:
await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,)) await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,))
else: else:
await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ?", (topic, block_norm)) await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ?", (topic, block_norm))
await db.commit()
async def mark_sources_read_done(topic: str, sources: list[str]) -> None: async def mark_sources_read_done(topic: str, sources: list[str]) -> None:
"""Mark the cited crawl pages as read_done (research-loop coverage).""" """Mark the cited crawl pages as read_done (research-loop coverage)."""
if not sources: if not sources:
return return
db = await get_db() async with _tx() as db:
now = _now() now = _now()
await db.executemany( await db.executemany(
"""INSERT INTO research_coverage (topic, source, read_done, updated_at) VALUES (?, ?, 1, ?) """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""", ON CONFLICT(topic, source) DO UPDATE SET read_done = 1, updated_at = excluded.updated_at""",
[(topic, q, now) for q in sources], [(topic, q, now) for q in sources],
) )
await db.commit()
async def mark_content(topic: str, content: list[str], noise: list[str]) -> None: 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).""" """Store the triage result per crawl page: content=1 (content) or 0 (noise)."""
db = await get_db() async with _tx() as db:
now = _now() now = _now()
rows = [(topic, q, 1, now) for q in content] + [(topic, q, 0, now) for q in noise] rows = [(topic, q, 1, now) for q in content] + [(topic, q, 0, now) for q in noise]
if not rows: if not rows:
@@ -1319,7 +1368,6 @@ async def mark_content(topic: str, content: list[str], noise: list[str]) -> None
ON CONFLICT(topic, source) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at""", ON CONFLICT(topic, source) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at""",
rows, rows,
) )
await db.commit()
async def list_content(topic: str) -> list[str]: 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: async def delete_coverage(topic: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM research_coverage WHERE topic = ?", (topic,)) await db.execute("DELETE FROM research_coverage WHERE topic = ?", (topic,))
await db.commit()
async def set_step_status(topic: str, step: str, status: str) -> None: async def set_step_status(topic: str, step: str, status: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO pipeline_state (topic, step, status, updated_at) VALUES (?, ?, ?, ?) """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""", ON CONFLICT(topic, step) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at""",
(topic, step, status, _now()), (topic, step, status, _now()),
) )
await db.commit()
async def get_step_status(topic: str, step: str) -> str: 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: async def delete_source(topic: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM source WHERE topic = ?", (topic,)) await db.execute("DELETE FROM source WHERE topic = ?", (topic,))
await db.commit()
async def set_guide_content(topic: str, format: str, content_json: str) -> None: async def set_guide_content(topic: str, format: str, content_json: str) -> None:
"""Store finished guide content (JSON blob) per topic+format.""" """Store finished guide content (JSON blob) per topic+format."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO guide_content (topic, format, json, updated_at) VALUES (?, ?, ?, ?) """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""", ON CONFLICT(topic, format) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""",
(topic, format, content_json, _now()), (topic, format, content_json, _now()),
) )
await db.commit()
async def get_guide_content(topic: str, format: str) -> str | None: 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: async def delete_guide_content(topic: str, format: str | None = None) -> None:
db = await get_db() async with _tx() as db:
if format is None: if format is None:
await db.execute("DELETE FROM guide_content WHERE topic = ?", (topic,)) await db.execute("DELETE FROM guide_content WHERE topic = ?", (topic,))
else: else:
await db.execute("DELETE FROM guide_content WHERE topic = ? AND format = ?", (topic, format)) await db.execute("DELETE FROM guide_content WHERE topic = ? AND format = ?", (topic, format))
await db.commit()
async def set_outline(topic: str, outline_json: str) -> None: async def set_outline(topic: str, outline_json: str) -> None:
"""Store the outline (chapter→numbers, JSON) per topic — blocks artifact for the guide.""" """Store the outline (chapter→numbers, JSON) per topic — blocks artifact for the guide."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO guide_outline (topic, json, updated_at) VALUES (?, ?, ?) """INSERT INTO guide_outline (topic, json, updated_at) VALUES (?, ?, ?)
ON CONFLICT(topic) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""", ON CONFLICT(topic) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""",
(topic, outline_json, _now()), (topic, outline_json, _now()),
) )
await db.commit()
async def get_outline(topic: str) -> str | None: async def get_outline(topic: str) -> str | None:
@@ -1414,15 +1456,14 @@ async def get_outline(topic: str) -> str | None:
async def delete_outline(topic: str) -> None: async def delete_outline(topic: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM guide_outline WHERE topic = ?", (topic,)) await db.execute("DELETE FROM guide_outline WHERE topic = ?", (topic,))
await db.commit()
async def put_sub_artifact(topic: str, block_norm: str, sub_norm: str, type: str, async def put_sub_artifact(topic: str, block_norm: str, sub_norm: str, type: str,
data: str, block: str = "", sub_title: str = "") -> None: data: str, block: str = "", sub_title: str = "") -> None:
"""Store one learning artifact (flashcard/example) as JSON in `data`.""" """Store one learning artifact (flashcard/example) as JSON in `data`."""
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO sub_artefakte (topic, block_norm, sub_norm, type, block, sub_title, data, updated_at) """INSERT INTO sub_artefakte (topic, block_norm, sub_norm, type, block, sub_title, data, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -1431,7 +1472,6 @@ async def put_sub_artifact(topic: str, block_norm: str, sub_norm: str, type: str
data = excluded.data, updated_at = excluded.updated_at""", data = excluded.data, updated_at = excluded.updated_at""",
(topic, block_norm, sub_norm, type, block, sub_title, data, _now()), (topic, block_norm, sub_norm, type, block, sub_title, data, _now()),
) )
await db.commit()
async def get_sub_artefakte(topic: str, type: str | None = None, async def get_sub_artefakte(topic: str, type: str | None = None,
@@ -1460,7 +1500,7 @@ async def get_practice_progress(topic: str) -> list[dict]:
async def upsert_practice_progress(topic: str, block_norm: str, sub_norm: str, async def upsert_practice_progress(topic: str, block_norm: str, sub_norm: str,
box: int, due_at: str) -> None: box: int, due_at: str) -> None:
db = await get_db() async with _tx() as db:
await db.execute( await db.execute(
"""INSERT INTO practice_progress (topic, block_norm, sub_norm, box, due_at, updated_at) """INSERT INTO practice_progress (topic, block_norm, sub_norm, box, due_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
@@ -1468,7 +1508,6 @@ async def upsert_practice_progress(topic: str, block_norm: str, sub_norm: str,
DO UPDATE SET box = excluded.box, due_at = excluded.due_at, DO UPDATE SET box = excluded.box, due_at = excluded.due_at,
updated_at = excluded.updated_at""", updated_at = excluded.updated_at""",
(topic, block_norm, sub_norm, box, due_at, _now())) (topic, block_norm, sub_norm, box, due_at, _now()))
await db.commit()
async def sub_levels_norm(topic: str) -> dict[tuple[str, str], int]: 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: 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).""" """Remove ONE artefact row (repair: dead target — sub discarded or gone)."""
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ? AND sub_norm = ? AND type = ?", await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ? AND sub_norm = ? AND type = ?",
(topic, block_norm, sub_norm, type)) (topic, block_norm, sub_norm, type))
await db.commit()
async def delete_frage_row(topic: str, block_norm: str, sub_norm: str) -> None: async def delete_frage_row(topic: str, block_norm: str, sub_norm: str) -> None:
"""Remove ONE question_pattern row (repair: dead target).""" """Remove ONE question_pattern row (repair: dead target)."""
db = await get_db() async with _tx() as db:
await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ? AND sub_norm = ?", await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ? AND sub_norm = ?",
(topic, block_norm, sub_norm)) (topic, block_norm, sub_norm))
await db.commit()
async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None: async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None:
db = await get_db() async with _tx() as db:
if block_norm is None: if block_norm is None:
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,)) await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
else: else:
await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ?", (topic, block_norm)) await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ?", (topic, block_norm))
await db.commit()
async def get_block_hurdles(topic: str, block_norm: str) -> list[str]: 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: async def delete_topic_pipeline(topic: str) -> None:
"""Discard the blocks area of a topic (inventory/subs/pattern/coverage/state/artifacts). """Discard the blocks area of a topic (inventory/subs/pattern/coverage/state/artifacts).
NOT the topic config `source` — that is managed separately (delete_source).""" NOT the topic config `source` — that is managed separately (delete_source)."""
db = await get_db() async with _tx() as db:
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage", for tab in ("blocks", "subblocks", "question_pattern", "research_coverage",
"pipeline_state", "guide_outline", "sub_artefakte", "practice_progress", "events"): "pipeline_state", "guide_outline", "sub_artefakte", "practice_progress", "events"):
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,)) await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
await db.commit()

View File

@@ -288,13 +288,12 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
import kanban import kanban
import pipeline import pipeline
import qa import qa
import repair
async def fake_run_agent(agent_key, prompt, timeout, provider="", role="fast", async def fake_run_agent(agent_key, prompt, timeout, provider="", role="fast",
capabilities="none", lane="batch", scope=None, on_line=None, label=""): capabilities="none", lane="batch", scope=None, on_line=None, label=""):
return welt.respond(agent_key, prompt, capabilities) 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(mod, "run_agent", fake_run_agent)
setattr_fn(blocks, "CONSENSUS_GRACE", 0) setattr_fn(blocks, "CONSENSUS_GRACE", 0)
setattr_fn(bi, "_QA_GATE_POLL", 0.05) setattr_fn(bi, "_QA_GATE_POLL", 0.05)

View File

@@ -11,7 +11,6 @@ Step files are kept → an abort preserves progress, ▶ resumes at the open ste
import asyncio import asyncio
import json import json
import logging import logging
import math
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -19,26 +18,14 @@ import uuid
from agents import run_agent from agents import run_agent
from blocks import _convert_pdfs, source_folder from blocks import _convert_pdfs, source_folder
from config import ( from config import DEFAULT_PROVIDER, TEMPLATES_DIR
DEFAULT_PROVIDER, FORMAT_PURPOSE, CONSENSUS_GRACE,
READABILITY_ACTIVE, TEMPLATES_DIR,
)
import readability
from database import (list_guides, update_guide, list_blocks, list_subblocks, set_guide_content, 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) get_guide_content, get_outline, guide_stage_counts, delete_guide_board)
from fsutil import atomic_write_json, atomic_write_text from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file, parse_json_text as _parse_json_text from jsonio import read_json_file as _json_file
from paths import blocks_path, guide_content_path, project_dir, subblocks_path from paths import blocks_path, guide_content_path, subblocks_path
from pipeline import ( from pipeline import _fail, _prompt, _semaphore, clear_guide_cancelled, is_guide_cancelled
CANCELLED, FAILED, GenContext, _claude_error, _extra, from textkit import _unique_title, _load_blocks, _norm_title, _title, parse_facts
_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,
)
log = logging.getLogger("creator.guide") log = logging.getLogger("creator.guide")
@@ -66,10 +53,7 @@ async def _load_subblocks(topic: str) -> dict[str, list[dict]]:
out: dict[str, list[dict]] = {} out: dict[str, list[dict]] = {}
for r in await list_subblocks(topic): for r in await list_subblocks(topic):
if r["status"] == "consensus" and r["sub_title"]: if r["status"] == "consensus" and r["sub_title"]:
try: facts = parse_facts(r.get("facts"))
facts = json.loads(r["facts"]) if r.get("facts") else {}
except (ValueError, TypeError):
facts = {}
level = r["level"] if r["level"] in _LEVELS_OK else "advanced" level = r["level"] if r["level"] in _LEVELS_OK else "advanced"
out.setdefault(r["block"], []).append( out.setdefault(r["block"], []).append(
{"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts}) {"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts})
@@ -234,8 +218,14 @@ async def reconcile_guides() -> None:
file write and status update. file write and status update.
""" """
for g in await list_guides(): for g in await list_guides():
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists(): if g["status"] != "done":
log.warning("[%s] Guide %s: done without content file — set to error", g["topic"], g["id"]) 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() now = datetime.now(timezone.utc).isoformat()
await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now) await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now)

View File

@@ -22,10 +22,9 @@ import re
import database as db import database as db
import readability import readability
from blocks import _sink_json from blocks import _sink_json
from config import (FORMAT_PURPOSE, READABILITY_ACTIVE, from config import (FIX_LAENGE_BAND, READABILITY_ACTIVE, TEMPLATES_DIR,
TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC) MAX_CONCURRENT_AGENTS_PER_TOPIC, ZIELE_MAX)
from guide_qa import block_budget from guide_qa import block_budget
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt, from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
_timeout, is_guide_cancelled, run_single_slot) _timeout, is_guide_cancelled, run_single_slot)
@@ -57,7 +56,7 @@ def _ziele_schema(data):
return None return None
zid = str(z.get("id", "")).strip() zid = str(z.get("id", "")).strip()
text = str(z.get("text", "")).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 continue
seen.add(zid) seen.add(zid)
out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()}) 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}" 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
23× 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: def _card_facts(env: _Env, block_title: str) -> str:
cache = _memo(env, "_facts_cache")
if block_title not in cache:
from guide import _facts_grounding # lazy: guide imports this module from guide import _facts_grounding # lazy: guide imports this module
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])}) grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
return grounding or env.fallback_facts cache[block_title] = grounding or env.fallback_facts
return cache[block_title]
async def _card_examples(env: _Env, block_norm: str, subs: list[dict], 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 """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 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.""" 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: if not rows:
return "" return ""
wanted = {_norm_title(s["title"]) for s in subs} 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: async def _stage_writer(env: _Env, card: dict) -> bool:
norm = card["block_norm"] norm = card["block_norm"]
ziele = await db.list_lernziele(env.topic, norm) ziele_text = _ziele_text(await _ziele(env, norm))
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
# oversized first drafts: two halves, merged into one canonical section # 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: 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) 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) budget = block_budget(subs_all)
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1) aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
zeichen = len(aus[1] if len(aus) == 2 else sec["md"]) 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( 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"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und "
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten") f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
return out 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 Section-Text in drei seriellen Calls). Text-Antwort + Engine-Sink (Datei-schreibende
Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED).""" Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED)."""
norm = card["block_norm"] norm = card["block_norm"]
ziele = await db.list_lernziele(env.topic, norm) ziele = await _ziele(env, norm)
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)" ziele_text = _ziele_text(ziele)
ids = {z["ziel_id"] for z in ziele} ids = {z["ziel_id"] for z in ziele}
facts = _card_facts(env, card["block"]) facts = _card_facts(env, card["block"])
ex = await _card_examples(env, norm, env.subs_by_title.get(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)), hinweise=hinweise, extra=_extra(env.instructions)),
role="judge", capabilities="none", role="judge", capabilities="none",
payload=lambda result: _sink_json(result, path, lambda d: _pruefer_schema(d, ids)), 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: if status != OK or verdict is None:
return None return None
for zid, ok in verdict["ziele"].items(): for zid, ok in verdict["ziele"].items():
@@ -520,12 +547,19 @@ async def _stage_fix(env: _Env, card: dict) -> bool:
card["md"] = fixed card["md"] = fixed
angewandt = True angewandt = True
rest = "" 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: if kritisch and angewandt:
sec2 = _first_section(card["md"]) sec2 = _first_section(card["md"])
verdict = await _pruefer_call(env, card, sec2, "re", []) verdict = await _pruefer_call(env, card, sec2, "re", [])
if verdict is None and is_guide_cancelled(env.guide_id): if verdict is None and is_guide_cancelled(env.guide_id):
return False 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)) zeilen, _k = _auftraege(verdict, [], _n_rel(env, card))
if zeilen: if zeilen:
rest = "Rest-Befunde nach Fix:\n" + "\n".join(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 ────────────────────────────────────────────────────────────────── # ── 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]]: 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.""" """block_norm → (chapter title, global order) from the outline artefact."""
from guide import _outline_from_db, _fallback_outline, _with_remainder 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")) else _prompt("Guide-Facts-Thema"))
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path, env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
subs_raw, await _chapter_map(topic, entries), fallback, spec) subs_raw, await _chapter_map(topic, entries), fallback, spec)
for num, line in entries.items(): await db.upsert_guide_cards_many(
title = _title(line) topic, format_name,
await db.upsert_guide_card(topic, format_name, _norm_title(title), title) [(_norm_title(_title(line)), _title(line)) for line in entries.values()])
cards = await db.list_guide_cards(topic, format_name) cards = await db.list_guide_cards(topic, format_name)
open_cards = [c for c in cards if c["stage"] != "done"] open_cards = [c for c in cards if c["stage"] != "done"]
if open_cards: if open_cards:
sem = asyncio.Semaphore(CARD_CONCURRENCY) sem = asyncio.Semaphore(CARD_CONCURRENCY)
reporter = asyncio.create_task(_progress_reporter(guide_id, topic, format_name))
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())
try: 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: finally:
reporter.cancel() reporter.cancel()
if is_guide_cancelled(guide_id): 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], columns.append({"key": stage, "label": STAGE_LABELS[stage],
"total": len(in_stage), "cards": views}) "total": len(in_stage), "cards": views})
import qa as qa_mod # lazy wie in board_inventory import qa as qa_mod # lazy wie in board_inventory
tdir = qa_mod.QA_DIR / topic note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide")
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
return {"columns": columns, "qa_guide": 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 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.""" Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel."""
import qa as qa_mod import qa as qa_mod
tdir = qa_mod.QA_DIR / topic reports = qa_mod.report_paths(topic, guide=True)
reports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
rep = _json_file(reports[-1]) if reports else None rep = _json_file(reports[-1]) if reports else None
if not rep: if not rep:
return [] return []
@@ -762,8 +804,7 @@ async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int:
target = GUIDE_STAGES[ab_stage] target = GUIDE_STAGES[ab_stage]
stages = list(GUIDE_STAGES[ab_stage:]) + ["done"] stages = list(GUIDE_STAGES[ab_stage:]) + ["done"]
if ab_stage == 0: if ab_stage == 0:
for c in await db.list_guide_cards(topic, format_name): await db.delete_lernziele_all(topic)
await db.delete_lernziele(topic, c["block_norm"])
moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target, moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target,
clear_md=ab_stage <= 2) clear_md=ab_stage <= 2)
return moved return moved

View File

@@ -10,7 +10,7 @@ Report: storage/qa/<topic>/guide-<ts>.json + Konsolen-Digest.
""" """
import asyncio import asyncio
import json import logging
import re import re
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -19,7 +19,9 @@ import database as db
import qa import qa
import readability import readability
from fsutil import atomic_write_json 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 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 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 """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 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.""" 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]: async def _pass(kandidaten: list[dict], tag: str) -> list[str]:
out = [] items = [f"SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}" for c in kandidaten]
for lo in range(0, len(kandidaten), 5): v = await qa.judge_wave("QA-Guide-Fakten", topic, f"fakten{tag}", "sections", items,
chunk = kandidaten[lo:lo + 5] chunk=5, prefix="qa-guide", label="Guide-QA")
listing = "\n\n".join(f"{k}. SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}" return [c["block"] for k, c in enumerate(kandidaten, 1) if v.get(k) == "ja"]
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
verdacht = await _pass(cards, "") verdacht = await _pass(cards, "")
if not verdacht: 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): for r in await db.list_subblocks(topic):
if r["status"] != "consensus": if r["status"] != "consensus":
continue continue
try:
facts = json.loads(r["facts"]) if r["facts"] else {}
except (ValueError, TypeError):
facts = {}
subs_by_norm.setdefault(r["block_norm"], []).append( 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": if r["relevance"] != "peripheral":
subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"]) subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"])
ziele = [dict(r) for r in await db.list_lernziele(topic)] ziele = [dict(r) for r in await db.list_lernziele(topic)]

View File

@@ -81,25 +81,12 @@ class BlocksResetStageRequest(BaseModel):
stage: str = Field(min_length=1, max_length=40) # kanban column to reset back to 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): class BlocksStatusResponse(BaseModel):
ready: bool ready: bool
generating: bool generating: bool
progress: str | None = None progress: str | None = None
error: str | None = None error: str | None = None
partial: bool = False partial: bool = False
steps: list[BlocksStep] = []
feine_steps: list[BlocksFineStep] = []
class FolderResponse(BaseModel): class FolderResponse(BaseModel):

View File

@@ -9,14 +9,11 @@ import asyncio
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Callable from typing import Callable
from agents import run_agent, kill_process, cancel_scope, clear_scope from agents import run_agent, kill_process, cancel_scope, clear_scope
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
from database import update_guide from database import update_guide
from jsonio import read_json_file as _json_file
from textkit import _STUFEN
log = logging.getLogger("creator.pipeline") log = logging.getLogger("creator.pipeline")
@@ -132,7 +129,6 @@ def _runde_schema(data, final: bool = False):
return include, rest return include, rest
_RELEVANCE = ("relevant", "peripheral")
_YESNO = ("ja", "nein") _YESNO = ("ja", "nein")
@@ -159,23 +155,13 @@ def _enum_map_schema(key: str, allowed):
return parse 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 _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 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()
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, cancelled=None, *, grace: int | None = None) -> list | None:
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:
"""Starts all slots in parallel and collects `quorum` valid results. """Starts all slots in parallel and collects `quorum` valid results.
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)` 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 a timer of `grace` seconds. After it expires, running agents are only
killed if the minimum stands — otherwise the race, including restarts, killed if the minimum stands — otherwise the race, including restarts,
keeps running until it stands. Returns: `quorum` to `len(slots)` results. 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))} attempts = {i: 0 for i in range(len(slots))}
tasks: dict[asyncio.Task, int] = {} 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 110135 s laufen. # hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110135 s laufen.
hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0 hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0
loop = asyncio.get_running_loop() 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 deadline: float | None = None
def spawn(i: int, suffix: str = "") -> 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 keys[task] = key
born[task] = loop.time() 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)): for i in range(len(slots)):
spawn(i) spawn(i)
@@ -258,13 +208,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
while tasks: while tasks:
if cancelled and cancelled(): if cancelled and cancelled():
return None return None
# Hard wall-clock cap: return whatever we have (None if empty), kill the rest. if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
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()
return results return results
# Hedge: a slot running HEDGE_NACH_S without result gets ONE parallel twin # 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 # (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) hedged.add(i)
spawn(i, suffix="-h") spawn(i, suffix="-h")
_log(topic, f"{label} {i + 1}: {round(hedge_s)}s ohne Ergebnis — Hedge-Zwilling gestartet") _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 = [] waits = []
if deadline is not None and len(results) >= quorum: if deadline is not None and len(results) >= quorum:
waits.append(deadline - loop.time()) 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: if hedge_s:
naechste = [born[t] + hedge_s - loop.time() for t in tasks naechste = [born[t] + hedge_s - loop.time() for t in tasks
if tasks[t] not in hedged | fertig] 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: if grace is not None and deadline is None:
deadline = loop.time() + grace deadline = loop.time() + grace
_log(topic, f"{label}: first result — grace {grace}s running") _log(topic, f"{label}: first result — grace {grace}s running")
if on_update: if len(results) >= quorum and (grace is None or loop.time() >= deadline):
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()
return results return results
continue 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()): if attempts[i] <= _MAX_RESTARTS and not enough and not zwilling and not (cancelled and cancelled()):
spawn(i) spawn(i)
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace) if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
_detach_rest()
return results return results
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)") _log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
return None return None

View File

@@ -11,7 +11,7 @@ previous report of the same topic.
""" """
import asyncio import asyncio
import json import logging
import re import re
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -19,12 +19,14 @@ from pathlib import Path
import database as db import database as db
import embedding 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 fsutil import atomic_write_json
from jsonio import read_json_file as _json_file from jsonio import read_json_file as _json_file
from paths import arbeit_dir from paths import arbeit_dir
from textkit import _norm_title from textkit import _norm_title
log = logging.getLogger("creator.qa")
QA_DIR = STORAGE_DIR / "qa" QA_DIR = STORAGE_DIR / "qa"
JACCARD_FLOOR = 0.5 # title token overlap that makes a pair suspicious 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) 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) 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 agents import run_agent
from pipeline import _yesno_schema from pipeline import _timeout, _yesno_schema
from jsonio import parse_json_text 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]] async def _chunk(lo: int) -> dict[int, str]:
rc, out, _err = await run_agent(f"qa-{topic}-{key}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}), teil = items[lo:lo + chunk]
600, role="judge", capabilities="none", scope=topic, label=f"QA {key}") listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1))
return (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {} 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 ────────────────────────────────────────────────────────────────────────── # ── 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: def freispruch_pfad(topic: str) -> Path:
return QA_DIR / topic / "freispruch.json" return QA_DIR / topic / "freispruch.json"
@@ -341,21 +394,19 @@ 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] fr = [t for t in fr if _norm_title(t) not in frei_fremd]
if llm and d: if llm and d:
v = await _llm_verdicts("QA-Dubletten", topic, "dubletten", v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]]) [f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]])
for k, p in enumerate(d[:LLM_SAMPLE], 1): for k, p in enumerate(d[:LLM_SAMPLE], 1):
p["llm"] = v.get(k, "?") p["llm"] = v.get(k, "?")
if llm and lk: if llm and lk:
v = await _llm_verdicts("QA-Luecken", topic, "luecken", v = await judge_wave("QA-Luecken", topic, "luecken", "sections",
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]]) [f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]])
for k, x in enumerate(lk[:LLM_SAMPLE], 1): for k, x in enumerate(lk[:LLM_SAMPLE], 1):
x["llm"] = v.get(k, "?") x["llm"] = v.get(k, "?")
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note if llm and sd: # full coverage in chunks — a sampled quota would mislead the note
for lo in range(0, len(sd), 40): v = await judge_wave("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
chunk = sd[lo:lo + 40] [f"A: {p['a']}\nB: {p['b']}" for p in sd])
v = await _llm_verdicts("QA-Sub-Dubletten", topic, f"sub-dubletten-{lo}", for k, p in enumerate(sd, 1):
[f"A: {p['a']}\nB: {p['b']}" for p in chunk])
for k, p in enumerate(chunk, 1):
p["llm"] = v.get(k, "?") p["llm"] = v.get(k, "?")
frei_sub = set(frei.get("sub_dubletten") or []) frei_sub = set(frei.get("sub_dubletten") or [])
for p in sd: for p in sd:
@@ -363,18 +414,16 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
p["freispruch"] = True # 2:1-Urteil „behalten" — sichtbar, aber notenfrei p["freispruch"] = True # 2:1-Urteil „behalten" — sichtbar, aber notenfrei
unecht: list[str] | None = None unecht: list[str] | None = None
if llm and blocks: if llm and blocks:
verdacht = [] v = await judge_wave("QA-Bausteine", topic, "bausteine", "blocks",
for lo in range(0, len(blocks), 80): # ein Call je 80 Titel [f"{b['title']}{b['description'] or '(ohne Beschreibung)'}" for b in blocks],
chunk = blocks[lo:lo + 80] chunk=80)
v = await _llm_verdicts("QA-Bausteine", topic, f"bausteine-{lo}", verdacht = [b for k, b in enumerate(blocks, 1) if v.get(k) == "nein"]
[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"]
# Bestätiger-Pass nur über die Geflaggten: der Einzel-Judge flaggte pro Lauf ANDERE # 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 # 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 # doppelt-„nein" zählt; Repair hat als dritte Sicherung die eigene Zweitmeinung
unecht = [] unecht = []
if verdacht: if verdacht:
v2 = await _llm_verdicts("QA-Bausteine", topic, "bausteine-b2", v2 = await judge_wave("QA-Bausteine", topic, "bausteine-b2", "blocks",
[f"{b['title']}{b['description'] or '(ohne Beschreibung)'}" for b in verdacht]) [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"] unecht = [b["title"] for k, b in enumerate(verdacht, 1) if v2.get(k) == "nein"]
frei_unecht = set(frei.get("unecht") or []) frei_unecht = set(frei.get("unecht") or [])
@@ -432,10 +481,7 @@ def _diff(prev: dict | None, cur: dict) -> dict:
def _write_report(report: dict) -> Path: def _write_report(report: dict) -> Path:
tdir = QA_DIR / report["topic"] tdir = QA_DIR / report["topic"]
tdir.mkdir(parents=True, exist_ok=True) tdir.mkdir(parents=True, exist_ok=True)
# by mtime: run-id names (…-1311-5e5c) and timestamp names don't sort lexicographically. older = report_paths(report["topic"])
# 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)
prev = _json_file(older[-1]) if older else None prev = _json_file(older[-1]) if older else None
report["diff_zum_vorlauf"] = _diff(prev, report) report["diff_zum_vorlauf"] = _diff(prev, report)
name = report["run_id"] or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") name = report["run_id"] or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")

View File

@@ -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, Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen.""" Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
import json
import logging import logging
import re import re
import database as db import database as db
import qa import qa
from agents import run_agent
from blocks import _blocks_files, _evidence_pack, source_folder from blocks import _blocks_files, _evidence_pack, source_folder
from config import EVIDENCE_PER_BLOCK
from fsutil import atomic_write_json from fsutil import atomic_write_json
from jsonio import parse_json_text, read_json_file as _json_file from jsonio import read_json_file as _json_file
from pipeline import _yesno_schema from textkit import _norm_title, _title, clean_title, parse_facts
from textkit import _norm_title, _title, clean_title
log = logging.getLogger("creator.repair") 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: async def repair_befunde(topic: str) -> dict:
tdir = qa.QA_DIR / topic reports = qa.report_paths(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 []
report = _json_file(reports[-1]) if reports else None report = _json_file(reports[-1]) if reports else None
if not report: if not report:
return {"fehler": "kein QA-Report — erst QA laufen lassen"} 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]: 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).""" """No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten)."""
verdicts: dict[int, str] = {} return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair")
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
def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None: 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]: def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]:
"""Gewinner = mehr key_points im facts-Feld, dann längerer Titel (Muster Konsolidierung).""" """Gewinner = mehr key_points im facts-Feld, dann längerer Titel (Muster Konsolidierung)."""
def score(r): def score(r):
try: kp = len(parse_facts(r.get("facts")).get("key_points") or [])
kp = len((json.loads(r.get("facts") or "{}")).get("key_points") or [])
except ValueError:
kp = 0
return (kp, len(r.get("sub_title") or "")) return (kp, len(r.get("sub_title") or ""))
return (a, b) if score(a) >= score(b) else (b, a) return (a, b) if score(a) >= score(b) else (b, a)

View File

@@ -3,6 +3,7 @@ import json
import logging import logging
import shutil import shutil
import uuid import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@@ -19,6 +20,7 @@ from database import (
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content, delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, kanban_reset, delete_guide_board, get_sub_artefakte, kanban_reset, delete_guide_board,
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm, get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
list_runs, get_db,
) )
from textkit import _norm_title 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 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)} 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") @router.get("/topics/progress")
async def topic_progress(topic: str): async def topic_progress(topic: str):
"""Completion status per format + topic completion — for unlocking the next expansion stage.""" """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") @router.delete("/topics")
async def remove_topic(topic: str): 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_topic(topic)
await delete_block_data(topic) await delete_block_data(topic)
await delete_topic_pipeline(topic) await delete_topic_pipeline(topic)
await delete_source(topic) # topic config (DB) — removed together with the topic await delete_source(topic) # topic config (DB) — removed together with the topic
await delete_guide_content(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 import qa
# 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 shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
await asyncio.to_thread(_wipe)
return {"ok": True} return {"ok": True}
@@ -325,9 +353,10 @@ async def blocks_completeness(topic: str):
counts = await kanban_stage_counts(topic) counts = await kanban_stage_counts(topic)
inv = counts.get("inventory", {}) inv = counts.get("inventory", {})
blocks = await list_blocks(topic, status="consensus") blocks = await list_blocks(topic, status="consensus")
subs = 0 # ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen
for b in blocks: consensus_blocks = {b["title_norm"] for b in blocks}
subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus") 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) ziele = await list_lernziele(topic)
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead") dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
degradiert = ueberstimmt = 0 degradiert = ueberstimmt = 0
@@ -466,15 +495,26 @@ async def block_chat_route(req: BlockChatRequest):
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would # Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
# overwrite the absolute score with a stale base (race). # 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) key = (topic, block)
lock = _check_locks.get(key) entry = _check_locks.get(key)
if lock is None: if entry is None:
lock = _check_locks[key] = asyncio.Lock() entry = _check_locks[key] = (asyncio.Lock(), [0])
return lock 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]: def _basis(state: dict, question: str) -> tuple[int, bool]:

View File

@@ -50,14 +50,26 @@ async def load_learnstate() -> tuple[list[dict], dict[str, dict[str, set[str]]]]
return await list_guides(), levels return await list_guides(), levels
_content_cache: dict[str, tuple[float, dict | None]] = {}
def _content_json(topic: str, fmt: str) -> 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) path = guide_content_path(topic, fmt)
if not path.exists():
return None
try: try:
return json.loads(path.read_text(encoding="utf-8")) mtime = path.stat().st_mtime
except ValueError: except OSError:
_content_cache.pop(str(path), None)
return 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]

View File

@@ -10,7 +10,7 @@ import pytest
import board_inventory as bi import board_inventory as bi
from pipeline import GenContext from pipeline import GenContext
from tests.invarianten import pruefe_invarianten, pruefe_guide_invarianten from invarianten import pruefe_invarianten, pruefe_guide_invarianten
TOPIC = "t" TOPIC = "t"

View File

@@ -131,8 +131,8 @@ async def test_guide_error_event(testdb):
def test_timeout_calibration_smoke(): def test_timeout_calibration_smoke():
from pipeline import _timeout from pipeline import _timeout
assert _timeout("subblock", 10) == 400 + 150 assert _timeout("subblock_check", 10) == 150 + 100
assert _timeout("content", 10) == 450 + 300 assert _timeout("writer", 10) == 450 + 600
def test_env_file_wins(tmp_path, monkeypatch): 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"]["gesamt"] == 2 and s["agents"]["ok"] == 1 and s["agents"]["timeout"] == 1
assert s["agents"]["verlorene_min"] == 2 assert s["agents"]["verlorene_min"] == 2
assert s["tokens"] == {"input": 15, "output": 2, "cache_read": 80, "cache_write": 1} 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

View File

@@ -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"]["stage"] == cards["beta"]["stage"] == "pruefer"
assert cards["alpha"]["md"] # Text bleibt — der Prüfer arbeitet auf dem Bestand assert cards["alpha"]["md"] # Text bleibt — der Prüfer arbeitet auf dem Bestand
assert cards["gamma"]["stage"] == "done" 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 = "<!-- section: Alpha -->\n<!-- ausführlich -->\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 = "<!-- section: Alpha -->\n<!-- ausführlich -->\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

View File

@@ -227,7 +227,7 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
{"title": titel, "description": "d"}) {"title": titel, "description": "d"})
monkeypatch.setattr(qa, "QA_DIR", tmp_path) 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": if template != "QA-Bausteine":
return {} return {}
if key.startswith("bausteine-b2"): # Bestätiger sieht nur die Geflaggten 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: "ja"} # nur der erste wird bestätigt
return {1: "nein", 2: "nein", 3: "ja"} # Pass 1 flaggt zwei 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) report = await qa.qa_report("t", llm=True)
assert report["unecht"] == ["Wackelkandidat"] assert report["unecht"] == ["Wackelkandidat"]

View File

@@ -82,59 +82,6 @@ async def test_hedge_schwelle_skaliert_mit_timeout(monkeypatch):
assert calls == ["k1"] 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): async def test_hedge_zwilling_ersetzt_restart(monkeypatch):
"""Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN """Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN
zusätzlichen Restart — der Zwilling ist der Retry.""" zusätzlichen Restart — der Zwilling ist der Retry."""

View File

@@ -67,7 +67,7 @@ async def test_merge_confirmed_duplicate(env, monkeypatch):
calls.append(prompt) calls.append(prompt)
return 0, '{"relevant": {"1": "ja"}}', "" 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) res = await repair.repair_befunde(TOPIC)
assert res["merges"] == ["Alpha → Alpha Problem"] assert res["merges"] == ["Alpha → Alpha Problem"]
assert len(calls) == 1 and "Beta" not in calls[0] # nur das llm=ja-Paar zum Judge 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": "ja"}}', ""
return 0, '{"relevant": {"1": "nein", "2": "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) monkeypatch.setattr(repair, "source_folder", lambda t: None)
res = await repair.repair_befunde(TOPIC) res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == ["Fremdling"] 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): async def broken_agent(key, prompt, timeout, **kw):
raise RuntimeError("boom") 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) res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == [] assert res["entfernt"] == []
card = await db.kanban_get_card(TOPIC, "inventory", cid) 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): async def no_agent(*a, **kw):
raise AssertionError("Hygiene braucht keinen Agenten") 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) res = await repair.repair_befunde(TOPIC)
assert res["hygiene"] == ["**Fetter Titel** → Fetter Titel"] assert res["hygiene"] == ["**Fetter Titel** → Fetter Titel"]
card = await db.kanban_get_card(TOPIC, "inventory", cid) 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 assert "Gibtsnicht" not in prompt
return 0, '{"relevant": {"1": "ja"}}', "" 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) res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == ["Verlierer Sub → Gewinner Sub"] assert res["sub_merges"] == ["Verlierer Sub → Gewinner Sub"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)} 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): async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" 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) res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == [] assert res["sub_merges"] == []
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)} 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": "ja"}}', ""
return 0, '{"relevant": {"1": "nein"}}', "" # Zweitmeinung widerspricht 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) res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == ["Sub B → Sub A"] assert res["sub_merges"] == ["Sub B → Sub A"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)} 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): async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # beide Repair-Judges: behalten 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) res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == [] and len(res["freigesprochen"]) == 1 assert res["sub_merges"] == [] and len(res["freigesprochen"]) == 1
frei = qa_mod.lade_freispruch(TOPIC) 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): async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "ja"}}', "" # beide: belegt/behalten 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) res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == [] assert res["entfernt"] == []
@@ -292,7 +292,7 @@ async def test_waisen_cleanup(env, monkeypatch):
async def no_agent(*a, **kw): async def no_agent(*a, **kw):
raise AssertionError("Aufräumen braucht keinen Agenten") 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) res = await repair.repair_befunde(TOPIC)
assert res["aufgeraeumt"] == 3 assert res["aufgeraeumt"] == 3
rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}

View File

@@ -143,8 +143,10 @@ def test_cited_evidence_lines_and_fallback(tmp_path):
def test_sink_json_writes_only_valid(tmp_path): def test_sink_json_writes_only_valid(tmp_path):
p = tmp_path / "level-final-c1.json" 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, 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 ok == {1: "beginner"}
assert json.loads(p.read_text(encoding="utf-8"))["levels"]["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) bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d)

View File

@@ -25,9 +25,9 @@ def test_registry_spiegelt_config():
def test_creator_params_override_wirkt_im_subprozess(): def test_creator_params_override_wirkt_im_subprozess():
out = subprocess.run( 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, 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 assert out.stdout.split() == ["6", "77"], out.stderr

View File

@@ -3,12 +3,22 @@
No state, no IO — safe to import anywhere. No state, no IO — safe to import anywhere.
""" """
import json
import re import re
import unicodedata import unicodedata
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now _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: def _norm_title(s: str) -> str:
"""Normalize a title for key comparison. """Normalize a title for key comparison.

View File

@@ -46,7 +46,7 @@ async def f0(out: str) -> None:
ok = await asyncio.wait_for( ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
research=True, qa_force=True), timeout=180) research=True, qa_force=True), timeout=180)
from tests.invarianten import pruefe_invarianten from invarianten import pruefe_invarianten
fehler = await pruefe_invarianten("f0", files) fehler = await pruefe_invarianten("f0", files)
atomic_write_json(Path(out), { atomic_write_json(Path(out), {
"ok": bool(ok), "invarianten_fehler": fehler, "calls": len(welt.calls), "ok": bool(ok), "invarianten_fehler": fehler, "calls": len(welt.calls),

View File

@@ -4,10 +4,14 @@ services:
context: . context: .
container_name: creator container_name: creator
restart: unless-stopped restart: unless-stopped
environment: # komplette .env durchreichen — 3 Einzel-Vars ließen ROLE_*/MAX_CONCURRENT_* etc.
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-} # still weg (Dev sourct die ganze .env, Prod bekam nur einen Teil)
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-} env_file: .env
- DEFAULT_PROVIDER=${DEFAULT_PROVIDER:-} 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: networks:
- web - web
volumes: volumes:

View File

@@ -21,7 +21,7 @@ const darkMode = ref(
? window.matchMedia('(prefers-color-scheme: dark)').matches ? window.matchMedia('(prefers-color-scheme: dark)').matches
: localStorage.getItem('darkMode') === 'true', : 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 blocks = ref({ ...EMPTY_BLOCKS })
const activeBlocks = ref([]) const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude') 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) } 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() { async function loadStats() {
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() }) await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
} }
@@ -192,14 +199,12 @@ watch(previewGuide, (g) => {
async function handleCancelBlocks() { async function handleCancelBlocks() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
await apiCancelBausteine(selectedTopic.value) if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
await loadBlocks()
} }
async function handleResetBlocks() { async function handleResetBlocks() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
await apiDeleteBausteine(selectedTopic.value) if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
await loadBlocks()
} }
async function handleResetStage({ board, stage, restart = false }) { async function handleResetStage({ board, stage, restart = false }) {
@@ -231,8 +236,11 @@ async function handleAddResearch() {
async function handleRequeueDead() { async function handleRequeueDead() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
const ok = await withUiError(async () => {
await apiRequeueDead(selectedTopic.value) await apiRequeueDead(selectedTopic.value)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
})
if (!ok) return
await loadBlocks() await loadBlocks()
startPolling() startPolling()
} }
@@ -407,17 +415,12 @@ const polling = usePolling(
const startPolling = polling.start const startPolling = polling.start
async function handleCancel(guideId) { async function handleCancel(guideId) {
await apiCancel(guideId) if (await withUiError(() => apiCancel(guideId))) await loadGuides()
await loadGuides()
} }
async function handleDeleteTopic(topic) { async function handleDeleteTopic(topic) {
const topicGuides = guides.value.filter((g) => g.topic === topic) // Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409).
for (const g of topicGuides) { if (!await withUiError(() => apiDeleteTopic(topic))) return
await deleteGuide(g.id)
}
await apiDeleteBausteine(topic)
await apiDeleteTopic(topic)
await loadTopics() await loadTopics()
if (selectedTopic.value === topic) { if (selectedTopic.value === topic) {
selectedTopic.value = null selectedTopic.value = null

View File

@@ -1,315 +1,165 @@
const BASE = '/api' const BASE = '/api'
// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken function qs(query) {
async function jsonOrThrow(res) { 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) { if (!res.ok) {
let detail = `Fehler (HTTP ${res.status})` let detail = `Fehler (HTTP ${res.status})`
try { try {
const data = await res.json() const data = await res.json()
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail) if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
} catch { /* kein JSON-Body */ } } 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() { export const fetchGuides = () => req('/guides')
const res = await fetch(`${BASE}/guides`)
return res.json()
}
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) { export const createGuide = (topic, format, instructions = '', provider = 'claude', abStep = null) =>
const res = await fetch(`${BASE}/guides`, { req('/guides', { method: 'POST', body: { topic, format, instructions, provider, ab_step: abStep } })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }),
})
return jsonOrThrow(res)
}
export async function fetchActiveBlocks() { export const fetchActiveBlocks = () => req('/blocks/active')
const res = await fetch(`${BASE}/blocks/active`)
return res.json()
}
export async function fetchBlocksStatus(topic) { export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) { export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) =>
const res = await fetch(`${BASE}/blocks`, { req('/blocks', { method: 'POST', body: { topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce } })
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)
}
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter). // Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
export async function fetchBlocksBoard(topic) { export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
const res = await fetch(`${BASE}/blocks/board?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
// Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report. // Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report.
export async function runQa(topic, llm = true) { export const runQa = (topic, llm = true) => req('/blocks/qa', { method: 'POST', body: { topic, llm } })
const res = await fetch(`${BASE}/blocks/qa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, llm }),
})
return jsonOrThrow(res)
}
// QA-Befunde gezielt beheben (Hygiene, bestätigte Dubletten, Fremd/Unecht nach Gegen-Judge). // QA-Befunde gezielt beheben (Hygiene, bestätigte Dubletten, Fremd/Unecht nach Gegen-Judge).
export async function runRepair(topic) { export const runRepair = (topic) => req('/blocks/repair', { method: 'POST', body: { topic } })
const res = await fetch(`${BASE}/blocks/repair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic }),
})
return jsonOrThrow(res)
}
// Karten ab Spalte zurücksetzen (keine Generierung). // Karten ab Spalte zurücksetzen (keine Generierung).
export async function resetBlocksStage(topic, board, stage) { export const resetBlocksStage = (topic, board, stage) =>
const res = await fetch(`${BASE}/blocks/reset-stage`, { req('/blocks/reset-stage', { method: 'POST', body: { topic, board, stage } })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, board, stage }),
})
return jsonOrThrow(res)
}
// Einen weiteren Research-Agenten anhängen (Attach-or-Start). // Einen weiteren Research-Agenten anhängen (Attach-or-Start).
export async function addBlocksResearch(topic, provider = 'claude') { export const addBlocksResearch = (topic, provider = 'claude') =>
const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}&provider=${encodeURIComponent(provider)}`, { method: 'POST' }) req('/blocks/research', { method: 'POST', query: { topic, provider } })
return jsonOrThrow(res)
}
export async function restartBlocksCard(topic, cardId) { export const restartBlocksCard = (topic, cardId) =>
const res = await fetch(`${BASE}/blocks/card-restart`, { req('/blocks/card-restart', { method: 'POST', body: { topic, card_id: cardId } })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, card_id: cardId }),
})
return jsonOrThrow(res)
}
export async function removeGuideFormat(topic, format) { export const removeGuideFormat = (topic, format) =>
const res = await fetch(`${BASE}/guides/board/remove`, { req('/guides/board/remove', { method: 'POST', body: { topic, format } })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format }),
})
return jsonOrThrow(res)
}
export async function resetGuideCard(topic, format, blockNorm, abStage) { export const resetGuideCard = (topic, format, blockNorm, abStage) =>
const res = await fetch(`${BASE}/guides/board/card-reset`, { req('/guides/board/card-reset', { method: 'POST', body: { topic, format, block_norm: blockNorm, ab_stage: abStage } })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, block_norm: blockNorm, ab_stage: abStage }),
})
return jsonOrThrow(res)
}
export async function requeueBlocksDead(topic) { export const requeueBlocksDead = (topic) =>
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' }) req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
return jsonOrThrow(res)
}
// Live-Board der Guide-Erzeugung. // Live-Board der Guide-Erzeugung.
export async function fetchGuideBoard(topic, format = 'Guide') { export const fetchGuideBoard = (topic, format = 'Guide') =>
const res = await fetch(`${BASE}/guides/board?topic=${encodeURIComponent(topic)}&format=${encodeURIComponent(format)}`) req('/guides/board', { query: { topic, format } })
return jsonOrThrow(res)
}
export async function resetGuideBoard(topic, format, abStage) { export const resetGuideBoard = (topic, format, abStage) =>
const res = await fetch(`${BASE}/guides/board/reset`, { req('/guides/board/reset', { method: 'POST', body: { topic, format, ab_stage: abStage } })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, ab_stage: abStage }),
})
return jsonOrThrow(res)
}
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf. // Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
export async function repairGuideBoard(topic, format) { export const repairGuideBoard = (topic, format) =>
const res = await fetch(`${BASE}/guides/board/repair`, { req('/guides/board/repair', { method: 'POST', body: { topic, format, ab_stage: 0 } })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, ab_stage: 0 }),
})
return jsonOrThrow(res)
}
export async function cancelBlocks(topic) { export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
}
export async function deleteBlocks(topic) { export const deleteBlocks = (topic) => req('/blocks', { method: 'DELETE', query: { topic } })
await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
} // 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 --- // --- Block-Learning: Chat, Exam ---
export async function fetchBlockLearnState(topic) { export const fetchBlockLearnState = (topic) => req('/blocks/learnstate', { query: { topic } })
const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) { export const chatBlock = ({ topic, block, section, section_compact = '', messages, provider }) =>
const res = await fetch(`${BASE}/blocks/chat`, { req('/blocks/chat', { method: 'POST', body: { topic, block, section, section_compact, messages, provider } })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
})
return jsonOrThrow(res)
}
export async function examBlock({ export const examBlock = ({
topic, block, section, section_compact = '', provider, topic, block, section, section_compact = '', provider,
action = 'question', question = '', last_rating = '', avoid = [], action = 'question', question = '', last_rating = '', avoid = [],
asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false, asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false, selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
}) { }) => req('/blocks/exam', {
const res = await fetch(`${BASE}/blocks/exam`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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 },
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)
}
export async function fetchQuestionPattern(topic, block) { export const fetchQuestionPattern = (topic, block) =>
const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`) req('/blocks/question-pattern', { query: { topic, block } })
return jsonOrThrow(res)
}
export async function fetchTopicProgress(topic) { export const fetchTopicProgress = (topic) => req('/topics/progress', { query: { topic } })
const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function fetchStats() { export const fetchStats = () => req('/stats')
const res = await fetch(`${BASE}/stats`)
return res.json()
}
export async function fetchProviders() { export const fetchProviders = () => req('/providers')
const res = await fetch(`${BASE}/providers`)
return res.json()
}
export async function fetchFolders(kind) { export const fetchFolders = (kind) => req('/folders', { query: { kind } })
const res = await fetch(`${BASE}/folders?kind=${encodeURIComponent(kind)}`)
return jsonOrThrow(res)
}
export async function fetchSource(topic) { export const fetchSource = (topic) => req('/blocks/source', { query: { topic } })
const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function updateSource(topic, { type, ort = '', spec = '' }) { export const updateSource = (topic, { type, ort = '', spec = '' }) =>
const res = await fetch(`${BASE}/blocks/source`, { req('/blocks/source', { method: 'PUT', body: { topic, type, location: ort, spec } })
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, type, location: ort, spec }),
})
return jsonOrThrow(res)
}
export async function fetchBlocksCompleteness(topic) { export const fetchBlocksCompleteness = (topic) => req('/blocks/completeness', { query: { topic } })
const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function fetchBlocksOverview(topic) { export const fetchBlocksOverview = (topic) => req('/blocks/overview', { query: { topic } })
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function cancelGuide(id) { export const cancelGuide = (id) => req(`/guides/${id}/cancel`, { method: 'POST' })
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
}
export async function deleteGuide(id, slots = false) { export const deleteGuide = (id, slots = false) =>
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' }) req(`/guides/${id}`, { method: 'DELETE', query: slots ? { slots: 1 } : undefined })
}
export async function fetchGuideContent(id, level = 4) { export const fetchGuideContent = (id, level = 4) => req(`/guides/${id}/content`, { query: { level } })
const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
if (!res.ok) throw new Error(`Content not available (${res.status})`)
return res.json()
}
// Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel). // Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
export async function fetchPracticeDeck(topic) { export const fetchPracticeDeck = (topic) => req('/practice/deck', { query: { topic } })
const res = await fetch(`${BASE}/practice/deck?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
// Leitner-Schritt buchen (correct = „Gewusst"). // Leitner-Schritt buchen (correct = „Gewusst").
export async function answerPracticeCard({ topic, block_norm, sub_norm, correct }) { export const answerPracticeCard = ({ topic, block_norm, sub_norm, correct }) =>
const res = await fetch(`${BASE}/practice/answer`, { req('/practice/answer', { method: 'POST', body: { topic, block_norm, sub_norm, correct } })
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, block_norm, sub_norm, correct }),
})
return jsonOrThrow(res)
}
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick). // Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) { export const pruefeBlock = (id, { block, spot, snippet, hint = '', provider }) =>
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, { req(`/guides/${id}/block/pruefen`, { method: 'POST', body: { block, spot, snippet, hint, provider } })
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ block, spot, snippet, hint, provider }),
})
return jsonOrThrow(res)
}
// Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld). // Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) { export const uebernehmeBlock = (id, { block, spot, alt, revised, provider }) =>
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, { req(`/guides/${id}/block/uebernehmen`, { method: 'POST', body: { block, spot, alt, revised, provider } })
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ block, spot, alt, revised, provider }),
})
return jsonOrThrow(res)
}
// Reset a block's learning progress to zero (score/streak/flags/open question). // Reset a block's learning progress to zero (score/streak/flags/open question).
export async function resetBlockProgress(topic, block) { export const resetBlockProgress = (topic, block) =>
const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, { req('/blocks/progress', { method: 'DELETE', query: { topic, block } })
method: 'DELETE',
})
return jsonOrThrow(res)
}
export async function fetchTopics() { export const fetchTopics = () => req('/topics')
const res = await fetch(`${BASE}/topics`)
return res.json()
}
export async function createTopic(name) { export const createTopic = (name) => req('/topics', { method: 'POST', body: { name } })
await fetch(`${BASE}/topics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
}
export async function deleteTopic(name) { export const deleteTopic = (name) => req('/topics', { method: 'DELETE', query: { topic: 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 chatGuide = (id, { section, outline, messages, provider = 'claude' }) =>
req(`/guides/${id}/chat`, { method: 'POST', body: { section, outline, messages, provider } })

View File

@@ -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; }
}

View File

@@ -207,12 +207,12 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
<p v-if="suggestions[b.i].error" class="bv-fehler">{{ suggestions[b.i].error }}</p> <p v-if="suggestions[b.i].error" class="bv-fehler">{{ suggestions[b.i].error }}</p>
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[b.i].revised)"></div> <div class="markdown bv-new" v-html="renderMarkdown(suggestions[b.i].revised)"></div>
<div class="bv-aktionen"> <div class="bv-aktionen">
<button class="bv-btn ja" title="Apply" @click="applyBlock(b.i)"></button> <button class="bv-btn ja" title="Übernehmen" @click="applyBlock(b.i)"></button>
<button class="bv-btn" title="Discard" @click="discardBlock(b.i)"></button> <button class="bv-btn" title="Verwerfen" @click="discardBlock(b.i)"></button>
<button class="bv-btn" :class="{ aktiv: suggestions[b.i].editOpen }" title="Add hint" @click="editBlock(b.i)"></button> <button class="bv-btn" :class="{ aktiv: suggestions[b.i].editOpen }" title="Add hint" @click="editBlock(b.i)"></button>
</div> </div>
<div v-if="suggestions[b.i].editOpen" class="bv-edit"> <div v-if="suggestions[b.i].editOpen" class="bv-edit">
<input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(b.i)" /> <input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Zusatzinfo → erneut prüfen" @keyup.enter="sendBlockEdit(b.i)" />
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(b.i)"></button> <button class="bv-btn ja" title="Check again" @click="sendBlockEdit(b.i)"></button>
</div> </div>
</template> </template>
@@ -282,10 +282,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
font-size: 0.72rem; font-weight: 600; font-size: 0.72rem; font-weight: 600;
border-radius: 999px; border: 1px solid; white-space: nowrap; 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). */ /* 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); } .fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */ /* 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 { 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-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
.fokus-btn { .fokus-btn {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;

View File

@@ -5,12 +5,13 @@ import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown, renderMarkdownInline } from '../markdown.js' import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
import { stufeFuer, malusRegel } from '../levels.js' import { stufeFuer, malusRegel } from '../levels.js'
import { useChat, istUnten } from '../composables/useChat.js' import { useChat, istUnten } from '../composables/useChat.js'
import ChatTranscript from './ChatTranscript.vue'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
block: { type: String, required: true }, block: { type: String, required: true },
section: { type: String, default: '' }, // detailed version 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' }, provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered} 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) 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) --- // --- Block chat (ephemeral) ---
const chat = useChat((msgs) => chatBlock({ 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, messages: msgs, provider: props.provider,
})) }))
@@ -130,7 +131,7 @@ async function examSend(payload, onOk) {
examScroll() examScroll()
try { try {
const res = await examBlock({ 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, provider: props.provider, messages: examDialog(), ...payload,
}) })
if (run !== examRun) return if (run !== examRun) return
@@ -243,7 +244,7 @@ function buildSingleQuestion(mode = nextMode()) {
const pattern = takePattern() const pattern = takePattern()
const base = { const base = {
topic: props.topic, block: props.block, section: props.section, 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) { if (form === 'quiz' && pattern) {
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
@@ -381,7 +382,7 @@ async function quizAnswer() {
try { try {
const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0)
const res = await examBlock({ 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, provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap,
selection: q.gewaehlt, correct, schwer: q.schwer, 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: 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) } : { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) }
const res = await examBlock({ 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, 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 l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback
@@ -451,7 +452,7 @@ async function quickEvaluate() {
examScroll() examScroll()
try { try {
const res = await examBlock({ 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(), provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(),
}) })
if (mine !== evalRun) return if (mine !== evalRun) return
@@ -477,7 +478,7 @@ async function preciseEvaluate(thorough = false, reason = '') {
if (thorough) examLoading.value = true if (thorough) examLoading.value = true
try { try {
const res = await examBlock({ 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, provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason,
}) })
applyExam(res) applyExam(res)
@@ -629,26 +630,8 @@ function onExamKey(e) {
<div v-if="mode === 'full' && activeTab" class="bp-panel"> <div v-if="mode === 'full' && activeTab" class="bp-panel">
<!-- Block chat --> <!-- Block chat -->
<div v-if="activeTab === 'chat'"> <div v-if="activeTab === 'chat'">
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll"> <ChatTranscript :chat="chat" hint="Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert."
<p v-if="!chat.messages.value.length" class="bp-hint">Ask something about this block. The history is not saved.</p> placeholder="Frage zum Baustein…" />
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Thinking</div>
</div>
<div class="bp-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
rows="2"
placeholder="Question about the block…"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
{{ chat.loading.value ? '' : '' }}
</button>
</div>
</div> </div>
<!-- Exam: guided dialog --> <!-- Exam: guided dialog -->
@@ -663,7 +646,7 @@ function onExamKey(e) {
<!-- Quiz: question + multiple choice (widget stays even at the cap practice without points) --> <!-- Quiz: question + multiple choice (widget stays even at the cap practice without points) -->
<template v-if="shownForm === 'quiz'"> <template v-if="shownForm === 'quiz'">
<div v-if="!quizCurrent" class="bp-actions"> <div v-if="!quizCurrent" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button> <button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button> <button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div> </div>
<div v-else class="bp-quiz"> <div v-else class="bp-quiz">
@@ -696,7 +679,7 @@ function onExamKey(e) {
<!-- Cloze: sentence with gap + input --> <!-- Cloze: sentence with gap + input -->
<template v-else-if="shownForm === 'gaptext'"> <template v-else-if="shownForm === 'gaptext'">
<div v-if="!clozeCurrent" class="bp-actions"> <div v-if="!clozeCurrent" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button> <button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button> <button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div> </div>
<div v-else class="bp-gap"> <div v-else class="bp-gap">
@@ -707,7 +690,7 @@ function onExamKey(e) {
id="bp-gap-input" id="bp-gap-input"
v-model="clozeCurrent.input" v-model="clozeCurrent.input"
:disabled="clozeCurrent.done" :disabled="clozeCurrent.done"
placeholder="Term for the gap…" placeholder="Begriff für die Lücke…"
@keyup.enter="clozeAnswer" @keyup.enter="clozeAnswer"
/> />
<p v-if="clozeCurrent.schwer && clozeCurrent.done && !clozeCurrent.feedback.startsWith('Correct')" class="bp-gap-solution">Solution: <span class="markdown" v-html="renderMarkdownInline(clozeCurrent.solution)"></span></p> <p v-if="clozeCurrent.schwer && clozeCurrent.done && !clozeCurrent.feedback.startsWith('Correct')" class="bp-gap-solution">Solution: <span class="markdown" v-html="renderMarkdownInline(clozeCurrent.solution)"></span></p>
@@ -744,7 +727,7 @@ function onExamKey(e) {
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.rating" title="Click: check thoroughly" @click="openThorough(m)"> <div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.rating" title="Click: check thoroughly" @click="openThorough(m)">
<span v-if="m.points != null" class="bp-tier">{{ pointsLabel(m.points) }}</span>{{ m.content }}<span v-if="!m.checked" class="bp-pruefend"> · being checked</span> <span v-if="m.points != null" class="bp-tier">{{ pointsLabel(m.points) }}</span>{{ m.content }}<span v-if="!m.checked" class="bp-pruefend"> · being checked</span>
<div v-if="thoroughMsg === m" class="bp-thorough" @click.stop> <div v-if="thoroughMsg === m" class="bp-thorough" @click.stop>
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Why unsatisfied? (optional)" @keyup.enter="submitThorough" /> <input id="bp-thorough-input" v-model="thoroughText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="submitThorough" />
<button class="bp-action primary" @click="submitThorough">Check thoroughly</button> <button class="bp-action primary" @click="submitThorough">Check thoroughly</button>
<button class="bp-action" @click="thoroughMsg = null">×</button> <button class="bp-action" @click="thoroughMsg = null">×</button>
</div> </div>
@@ -757,7 +740,7 @@ function onExamKey(e) {
</div> </div>
<div v-if="examPhase === 'idle'" class="bp-actions"> <div v-if="examPhase === 'idle'" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button> <button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button> <button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div> </div>
@@ -767,11 +750,11 @@ function onExamKey(e) {
ref="examInputEl" ref="examInputEl"
v-model="examInput" v-model="examInput"
rows="2" rows="2"
placeholder="Answer — or ask if unclear…" placeholder="Antwort — oder nachfragen…"
></textarea> ></textarea>
</div> </div>
<div class="bp-actions"> <div class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button> <button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<template v-else-if="examPhase === 'question_offen'"> <template v-else-if="examPhase === 'question_offen'">
<button class="bp-action" title="Alt+1" :disabled="!examInput.trim()" @click="askFollowUp"><span class="bp-kbd">1</span>Ask</button> <button class="bp-action" title="Alt+1" :disabled="!examInput.trim()" @click="askFollowUp"><span class="bp-kbd">1</span>Ask</button>
<button class="bp-action primary" title="Alt+2" :disabled="!examInput.trim()" @click="submitAnswer"><span class="bp-kbd">2</span>Submit answer</button> <button class="bp-action primary" title="Alt+2" :disabled="!examInput.trim()" @click="submitAnswer"><span class="bp-kbd">2</span>Submit answer</button>
@@ -831,8 +814,8 @@ function onExamKey(e) {
color: var(--text-muted); color: var(--text-muted);
} }
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); } .bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; } .bp-chip.lila { background: color-mix(in srgb, var(--level-expert) 16%, var(--panel)); border-color: var(--level-expert); color: #6d28d9; }
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; } .bp-chip.gold { background: color-mix(in srgb, var(--level-master) 20%, var(--panel)); border-color: var(--level-master); color: #8a6d12; }
.bp-panel { .bp-panel {
margin-top: 0.6rem; margin-top: 0.6rem;

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch } from 'vue'
import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js' import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
@@ -26,13 +27,10 @@ async function loadCompleteness() {
watch(() => [props.topic, props.ready, props.generating], loadCompleteness, { immediate: true }) watch(() => [props.topic, props.ready, props.generating], loadCompleteness, { immediate: true })
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll, // Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
// das Kanban-Board selbst lebt in der Generierungs-View). // das Kanban-Board selbst lebt in der Generierungs-View). Visibility-Pause via usePolling.
let timer = null const { start: startPoll } = usePolling(load, () => props.generating, 5000)
function startPoll() { stopPoll(); timer = setInterval(load, 5000) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true }) watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true }) watch(() => props.generating, (g) => { if (g) startPoll(); else load() }, { immediate: true })
onUnmounted(stopPoll)
// ── Fertige Blöcke (Grid) ────────────────────────────────────────────────────── // ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
const LEVELS = [ const LEVELS = [
@@ -73,10 +71,10 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<div class="bk-view"> <div class="bk-view">
<header class="bk-head"> <header class="bk-head">
<h1>{{ topic }}</h1> <h1>{{ topic }}</h1>
<span class="bk-sub">Blocks overview</span> <span class="bk-sub">Bausteine-Übersicht</span>
<span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span> <span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
<span class="bk-spacer"></span> <span class="bk-spacer"></span>
<button class="bk-close" title="Close" @click="emit('close')"></button> <button class="bk-close" title="Schließen" @click="emit('close')"></button>
</header> </header>
<button v-if="generating" class="bk-banner" @click="emit('openGeneration')"> <button v-if="generating" class="bk-banner" @click="emit('openGeneration')">
@@ -107,9 +105,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
</div> </div>
</section> </section>
<div v-if="loading" class="bk-empty-state">Loading</div> <div v-if="loading" class="bk-empty-state">Lädt</div>
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div> <div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div> <div v-else-if="!items.length" class="bk-empty-state">Noch keine Bausteine.</div>
<div v-else class="bk-grid"> <div v-else class="bk-grid">
<article <article
@@ -126,12 +124,12 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<span class="bk-level-label">{{ g.label }}</span> <span class="bk-level-label">{{ g.label }}</span>
<ul> <ul>
<li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }"> <li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Peripheral topiccomes later in the 'Rest'">Edge</span> {{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Randthemakommt später im „Rest">Rand</span>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<p v-else class="bk-no-subs">No subblocks.</p> <p v-else class="bk-no-subs">Keine Subbausteine.</p>
</article> </article>
</div> </div>
</div> </div>
@@ -180,9 +178,8 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
animation: bk-pulse 1.2s ease-in-out infinite; animation: pulse-soft 1.2s ease-in-out infinite;
} }
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }

View File

@@ -0,0 +1,88 @@
<script setup>
// Präsentationaler Chat (Nachrichtenliste + Typing + Eingabe). Die Mechanik (send/cancel/
// scroll/focus) lebt in useChat; hier wird nur das `chat`-Objekt gerendert. Ersetzt die
// zeichengleichen Chat-Blöcke in BlockPanel und TopicDetail.
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
chat: { type: Object, required: true }, // Rückgabe von useChat()
hint: { type: String, default: '' }, // Platzhaltertext bei leerer Historie
placeholder: { type: String, default: '' },
rows: { type: Number, default: 2 },
autoGrow: { type: Boolean, default: false },
maxHeight: { type: String, default: '320px' },
})
function onInput() {
if (props.autoGrow) props.chat.autoGrow()
}
</script>
<template>
<div :ref="chat.messagesEl" class="ct-messages" :style="{ maxHeight }" @scroll="chat.onScroll">
<p v-if="!chat.messages.value.length" class="ct-hint">{{ hint }}</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="ct-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="ct-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="ct-msg assistant ct-typing">Denkt nach</div>
</div>
<div class="ct-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
:rows="rows"
:placeholder="placeholder"
@input="onInput"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button
:disabled="!chat.input.value.trim() && !chat.loading.value"
:class="{ cancel: chat.loading.value }"
:title="chat.loading.value ? 'Abbrechen' : 'Senden'"
@click="chat.send"
>{{ chat.loading.value ? '✕' : '➤' }}</button>
</div>
</template>
<style scoped>
/* flex:1 füllt ein Panel mit fester Höhe (TopicDetail); maxHeight begrenzt sonst (BlockPanel-Tab). */
.ct-messages { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.4rem; overflow-y: auto; padding: 0.2rem; }
.ct-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.ct-msg {
max-width: 85%;
padding: 0.4rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.4;
word-break: break-word;
}
.ct-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
.ct-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.ct-typing { color: var(--text-faint); font-style: italic; }
.ct-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.ct-input textarea {
flex: 1;
resize: none;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--input-bg, var(--panel));
color: var(--text);
font-family: inherit;
font-size: 0.85rem;
}
.ct-input textarea:focus { outline: none; border-color: var(--accent); }
.ct-input button {
flex: 0 0 auto;
width: 38px;
border: none;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
font-size: 1rem;
}
.ct-input button:disabled { opacity: 0.5; cursor: default; }
.ct-input button.cancel { background: var(--danger); }
</style>

View File

@@ -1,8 +1,12 @@
<script setup> <script setup>
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard, runQa, runRepair } from '../api.js' import { fetchBlocksBoard, fetchRuns, runQa, runRepair } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
import KanbanBoard from './KanbanBoard.vue' import KanbanBoard from './KanbanBoard.vue'
import GuideBoardSection from './GuideBoardSection.vue' import GuideBoardSection from './GuideBoardSection.vue'
import ProgressBar from './ProgressBar.vue'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
@@ -15,48 +19,101 @@ const props = defineProps({
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch', const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard']) 'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ────────────────────────────── // ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
const board = ref(null) const board = ref(null)
let timer = null const pollError = ref(null)
async function pollBoard() { async function pollBoard() {
try { try {
board.value = await fetchBlocksBoard(props.topic) board.value = await fetchBlocksBoard(props.topic)
} catch { /* Board noch leer */ } pollError.value = null
} catch (e) {
// 404 = Board noch nicht gebaut (kein Fehler); alles andere (500/Netz) sichtbar machen,
// statt es als „leeres Board" zu tarnen
if (e.status === 404) board.value = null
else pollError.value = e.message
} }
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) } }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } } const { start: startPoll } = usePolling(pollBoard, () => props.generating, 1200)
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true }) watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
watch(() => props.generating, (g) => { watch(() => props.generating, (g) => {
if (g) startPoll() if (g) startPoll()
else { stopPoll(); pollBoard() } // Endstand nachladen else pollBoard() // Endstand nachladen
}, { immediate: true }) }, { immediate: true })
onUnmounted(stopPoll)
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory')) const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts')) const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
const dead = computed(() => board.value?.dead || []) const dead = computed(() => board.value?.dead || [])
const qa = computed(() => board.value?.qa || null) const qa = computed(() => board.value?.qa || null)
// Ehrlicher Fortschritt aus den Spaltenzahlen: gewichteter Spaltenindex (Terminal = 1,0).
// Kein einzelner „100%"-Wert wird geraten — wächst der Umfang (Research legt Karten nach),
// SINKT der Wert; das ist die Wahrheit und wird mit „Umfang wächst noch" gekennzeichnet.
const TERMINAL = new Set(['done_block', 'done_artefact', 'rejected', 'grouped'])
function boardProgress(cols) {
const total = cols.reduce((n, c) => n + c.total, 0)
if (!total) return { value: 0, done: 0, total: 0 }
const last = Math.max(1, cols.length - 1)
let acc = 0
cols.forEach((c, i) => { acc += (TERMINAL.has(c.key) ? 1 : i / last) * c.total })
const done = cols.filter((c) => TERMINAL.has(c.key)).reduce((n, c) => n + c.total, 0)
return { value: acc / total, done, total }
}
const inventoryProgress = computed(() => boardProgress(inventoryCols.value))
const artefactProgress = computed(() => boardProgress(artefactCols.value))
const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0)
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
const run = ref(null)
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
const { runs } = await fetchRuns(props.topic, 1)
run.value = runs[0] || null
} catch { run.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
const runLaufzeit = computed(() => {
if (!run.value?.start) return null
const start = Date.parse(run.value.start)
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
return fmtRuntime((ende - start) / 1000)
})
const runTokens = computed(() => {
const t = run.value?.tokens
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
})
const runTokenTitel = computed(() => {
const t = run.value?.tokens || {}
return `Input ${t.input || 0} · Output ${t.output || 0} · Cache ${(t.cache_read || 0) + (t.cache_write || 0)}`
})
watch(() => props.generating, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel). // Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel).
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming', const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming',
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done', 'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
'subblocks', 'facts', 'konsolidierung', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline']) 'subblocks', 'facts', 'konsolidierung', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label} const sel = ref(null) // gewählte Spalte {board, key, label}
const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts) const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts)
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung (mit 3s-Auto-Reset)
function stageClick(c) { function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return if (props.generating || !RESETTABLE.has(c.key)) return
confirm.value = null resetConfirm()
selCard.value = null selCard.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label } sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
} }
function cardClick(k) { function cardClick(k) {
if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten
confirm.value = null resetConfirm()
sel.value = null sel.value = null
selCard.value = selCard.value?.card_id === k.card_id ? null : k selCard.value = selCard.value?.card_id === k.card_id ? null : k
} }
@@ -64,13 +121,9 @@ function cardClick(k) {
function restartCard() { function restartCard() {
const k = selCard.value const k = selCard.value
selCard.value = null selCard.value = null
confirm.value = null resetConfirm()
later(() => emit('restartCard', k.card_id)) later(() => emit('restartCard', k.card_id))
} }
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv) function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
fn() fn()
setTimeout(pollBoard, 600) setTimeout(pollBoard, 600)
@@ -79,7 +132,7 @@ function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein gener
function resetHere(restart) { function resetHere(restart) {
const s = sel.value const s = sel.value
sel.value = null sel.value = null
confirm.value = null resetConfirm()
later(() => emit('resetStage', { board: s.board, stage: s.key, restart })) later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
} }
@@ -125,18 +178,21 @@ async function repairClick() {
<h1>{{ topic }}</h1> <h1>{{ topic }}</h1>
<span class="gen-sub">Generierung</span> <span class="gen-sub">Generierung</span>
<span class="gen-spacer"></span> <span class="gen-spacer"></span>
<button class="gen-close" title="Close" @click="emit('close')"></button> <button class="gen-close" title="Schließen" @click="emit('close')"></button>
</header> </header>
<div v-if="qa && qa.pausiert" class="qa-pause"> <div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</div>
<div v-if="qa && qa.pausiert && qa.note != null" class="qa-pause">
<strong>QA-Gate: Note {{ qa.note.toFixed(1) }} unter Schwelle {{ qa.schwelle }} pausiert.</strong> <strong>QA-Gate: Note {{ qa.note.toFixed(1) }} unter Schwelle {{ qa.schwelle }} pausiert.</strong>
<span v-if="qa.befunde.length"> Befunde: {{ qa.befunde.join(' · ') }}</span> <span v-if="qa.befunde?.length"> Befunde: {{ qa.befunde.join(' · ') }}</span>
<button class="gen-act" @click="emit('continueAll', { qaForce: true })">Trotzdem fortsetzen</button> <button class="gen-act" @click="emit('continueAll', { qaForce: true })">Trotzdem fortsetzen</button>
</div> </div>
<section class="gen-section"> <section class="gen-section">
<div class="gen-steps-top"> <div class="gen-steps-top">
<span class="gen-title">Bausteine</span> <span class="gen-title">Bausteine</span>
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div> <div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
<span v-if="runLaufzeit" class="gen-run" :title="runTokenTitel">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<span v-if="run?.fails?.length" class="gen-run-fail" :title="run.fails.map((f) => f.key + ': ' + f.error).join('\n')"> {{ run.fails.length }} Fehler</span>
<div v-if="!generating" class="gen-actions"> <div v-if="!generating" class="gen-actions">
<button class="gen-act" :disabled="qaBusy" title="QA-Lauf wie am Gate (inkl. LLM-Stichprobe)" <button class="gen-act" :disabled="qaBusy" title="QA-Lauf wie am Gate (inkl. LLM-Stichprobe)"
@click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button> @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
@@ -144,18 +200,18 @@ async function repairClick() {
title="QA-Befunde gezielt beheben: Hygiene, bestätigte Dubletten mergen, Fremd/Unecht nach Gegen-Judge entfernen" title="QA-Befunde gezielt beheben: Hygiene, bestätigte Dubletten mergen, Fremd/Unecht nach Gegen-Judge entfernen"
@click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button> @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span> <span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button> <button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Recherche' : 'Generieren' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Continue</button> <button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
<button <button
v-if="ready || partial" v-if="ready || partial"
class="gen-act danger" class="gen-act danger"
:class="{ armed: confirm === 'remove' }" :class="{ armed: isArmed('remove') }"
@click="arm('remove', () => later(() => emit('removeAll')))" @click="armOrRun('remove', () => later(() => emit('removeAll')))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button> >{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
</div> </div>
<div v-else class="gen-actions"> <div v-else class="gen-actions">
<button class="gen-act" @click="emit('addResearch')">+ Research</button> <button class="gen-act" @click="emit('addResearch')">+ Recherche</button>
<button class="gen-act danger" @click="emit('cancel')">Cancel</button> <button class="gen-act danger" @click="emit('cancel')">Abbrechen</button>
</div> </div>
<button <button
v-if="dead.length" v-if="dead.length"
@@ -167,9 +223,12 @@ async function repairClick() {
<div class="gen-board-label"> <div class="gen-board-label">
Inventar Inventar
<span v-if="qa" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'" <span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'"
:title="'QA-Schwelle ' + qa.schwelle">QA {{ qa.note.toFixed(1) }}/10</span> :title="'QA-Schwelle ' + qa.schwelle">QA {{ qa.note.toFixed(1) }}/10</span>
</div> </div>
<ProgressBar v-if="inventoryProgress.total" :value="inventoryProgress.value"
:label="`${inventoryProgress.done}/${inventoryProgress.total} Karten fertig · ${Math.round(inventoryProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard <KanbanBoard
:columns="inventoryCols" :columns="inventoryCols"
:agents="board?.agents || []" :agents="board?.agents || []"
@@ -184,6 +243,9 @@ async function repairClick() {
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'" :class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'"
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span> title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span>
</div> </div>
<ProgressBar v-if="artefactProgress.total" :value="artefactProgress.value"
:label="`${artefactProgress.done}/${artefactProgress.total} Karten fertig · ${Math.round(artefactProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard <KanbanBoard
:columns="artefactCols" :columns="artefactCols"
:generating="generating" :generating="generating"
@@ -196,14 +258,14 @@ async function repairClick() {
<div v-if="selCard && !generating" class="gen-step-actions"> <div v-if="selCard && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Karte «{{ selCard.title }}»:</span> <span class="gen-step-actions-label">Karte «{{ selCard.title }}»:</span>
<button class="gen-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? restartCard() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu generieren' }}</button> <button class="gen-act play" :class="{ armed: isArmed('card') }" @click="armOrRun('card', restartCard)">{{ isArmed('card') ? 'Sicher?' : ' Karte neu generieren' }}</button>
<button class="gen-act ghost" @click="selCard = null; confirm = null">Abbrechen</button> <button class="gen-act ghost" @click="selCard = null; resetConfirm()">Abbrechen</button>
</div> </div>
<div v-if="sel && !generating" class="gen-step-actions"> <div v-if="sel && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Ab «{{ sel.label }}»:</span> <span class="gen-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="gen-act play" @click="resetHere(true)"> neu generieren</button> <button class="gen-act play" @click="resetHere(true)"> neu generieren</button>
<button class="gen-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button> <button class="gen-act danger" :class="{ armed: isArmed('reset') }" @click="armOrRun('reset', () => resetHere(false))">{{ isArmed('reset') ? 'Sicher?' : ' nur zurücksetzen' }}</button>
<button class="gen-act ghost" @click="sel = null; confirm = null">Abbrechen</button> <button class="gen-act ghost" @click="sel = null; resetConfirm()">Abbrechen</button>
</div> </div>
</section> </section>
@@ -286,9 +348,8 @@ async function repairClick() {
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
animation: gen-pulse 1.2s ease-in-out infinite; animation: pulse-soft 1.2s ease-in-out infinite;
} }
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; } .gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.gen-actions { margin-left: auto; display: flex; gap: 0.4rem; } .gen-actions { margin-left: auto; display: flex; gap: 0.4rem; }
@@ -328,6 +389,9 @@ async function repairClick() {
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; } .qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; } .qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.repair-info { font-size: 0.78rem; color: var(--text-muted); } .repair-info { font-size: 0.78rem; color: var(--text-muted); }
.gen-poll-error { color: var(--danger); font-size: 0.82rem; padding: 0.3rem 0; }
.gen-run { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.gen-run-fail { font-size: 0.78rem; color: var(--danger); white-space: nowrap; }
.qa-pause { .qa-pause {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,7 +1,11 @@
<script setup> <script setup>
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard, repairGuideBoard } from '../api.js' import { fetchGuideBoard, fetchRuns, repairGuideBoard } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
import KanbanBoard from './KanbanBoard.vue' import KanbanBoard from './KanbanBoard.vue'
import ProgressBar from './ProgressBar.vue'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
@@ -11,42 +15,76 @@ const props = defineProps({
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard']) const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard'])
const board = ref(null) const board = ref(null)
let timer = null const pollError = ref(null)
async function poll() { async function poll() {
try { try {
board.value = await fetchGuideBoard(props.topic, props.format) board.value = await fetchGuideBoard(props.topic, props.format)
} catch { /* Board noch leer */ } pollError.value = null
} catch (e) {
// 404 = Board noch nicht gebaut; echte Fehler (500/Netz) sichtbar machen
if (e.status === 404) board.value = null
else pollError.value = e.message
} }
function startPoll() { stopPoll(); timer = setInterval(poll, 1200) } }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } } // startPoll bleibt für die manuellen Starts nach startGuide/repairClick exponiert;
// usePolling pausiert im Hintergrund-Tab und stoppt selbst, sobald generating false wird.
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true }) watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
watch(() => props.refresh, () => poll()) watch(() => props.refresh, () => poll())
watch(() => board.value?.generating, (g) => { if (g) startPoll(); else stopPoll() }) watch(() => board.value?.generating, (g) => { if (g) startPoll() })
onUnmounted(stopPoll)
const generating = computed(() => !!board.value?.generating) const generating = computed(() => !!board.value?.generating)
const columns = computed(() => board.value?.columns || []) const columns = computed(() => board.value?.columns || [])
const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0)) const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0) const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
const progressValue = computed(() => (total.value ? done.value / total.value : 0))
// Laufzeit + Tokens aus /api/runs (deckt auch Guide-Läufe ab — beide setzen run_id)
const run = ref(null)
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
const { runs } = await fetchRuns(props.topic, 1)
run.value = runs[0] || null
} catch { run.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => generating.value, 5000)
const runLaufzeit = computed(() => {
if (!run.value?.start) return null
const start = Date.parse(run.value.start)
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
return fmtRuntime((ende - start) / 1000)
})
const runTokens = computed(() => {
const t = run.value?.tokens
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
})
watch(() => generating.value, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } }
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done"). // Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
const STAGES = ['lernziele', 'zuweisung', 'writer', 'pruefer', 'fix'] const STAGES = ['lernziele', 'zuweisung', 'writer', 'pruefer', 'fix']
const sel = ref(null) const sel = ref(null)
const selCard = ref(null) const selCard = ref(null)
const confirm = ref(null) const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung (mit 3s-Auto-Reset)
function stageClick(c) { function stageClick(c) {
if (generating.value || !STAGES.includes(c.key)) return if (generating.value || !STAGES.includes(c.key)) return
confirm.value = null resetConfirm()
selCard.value = null selCard.value = null
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) } sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
} }
function cardClick(k) { function cardClick(k) {
if (generating.value || !k.card_id) return if (generating.value || !k.card_id) return
confirm.value = null resetConfirm()
sel.value = null sel.value = null
const idx = Math.max(0, STAGES.indexOf(k.column)) const idx = Math.max(0, STAGES.indexOf(k.column))
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx } selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
@@ -55,14 +93,10 @@ function cardClick(k) {
function resetCardHere() { function resetCardHere() {
const k = selCard.value const k = selCard.value
selCard.value = null selCard.value = null
confirm.value = null resetConfirm()
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 }) emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
setTimeout(poll, 400) setTimeout(poll, 400)
} }
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
const repairBusy = ref(false) const repairBusy = ref(false)
const repairInfo = ref('') const repairInfo = ref('')
async function repairClick() { async function repairClick() {
@@ -101,8 +135,10 @@ function resetHere() {
<span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 9 ? 'ok' : 'bad'" <span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 9 ? 'ok' : 'bad'"
title="Guide-QA (make qa-guide)">QA {{ board.qa_guide.toFixed(1) }}/10</span> title="Guide-QA (make qa-guide)">QA {{ board.qa_guide.toFixed(1) }}/10</span>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span> <span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
<span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div> <div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
<div v-if="board?.error" class="gb-error">{{ board.error }}</div> <div v-if="board?.error" class="gb-error">{{ board.error }}</div>
<div v-if="pollError" class="gb-error">Board nicht erreichbar: {{ pollError }}</div>
<div class="gb-actions"> <div class="gb-actions">
<template v-if="generating"> <template v-if="generating">
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button> <button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
@@ -113,11 +149,14 @@ function resetHere() {
<button v-if="total && board?.qa_guide != null && board.qa_guide < 10" class="gb-act" <button v-if="total && board?.qa_guide != null && board.qa_guide < 10" class="gb-act"
:disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button> :disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span> <span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span>
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button> <button v-if="total" class="gb-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button>
</template> </template>
</div> </div>
</div> </div>
<ProgressBar v-if="total" :value="progressValue"
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
<KanbanBoard <KanbanBoard
:columns="columns" :columns="columns"
:agents="board?.agents || []" :agents="board?.agents || []"
@@ -131,14 +170,14 @@ function resetHere() {
<div v-if="selCard && !generating" class="gb-stage-actions"> <div v-if="selCard && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Karte «{{ selCard.title }}»:</span> <span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
<button class="gb-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? resetCardHere() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu (ab Lernziele)' }}</button> <button class="gb-act play" :class="{ armed: isArmed('card') }" @click="armOrRun('card', resetCardHere)">{{ isArmed('card') ? 'Sicher?' : ' Karte neu (ab Lernziele)' }}</button>
<button class="gb-act ghost" @click="selCard = null; confirm = null">Abbrechen</button> <button class="gb-act ghost" @click="selCard = null; resetConfirm()">Abbrechen</button>
</div> </div>
<div v-if="sel && !generating" class="gb-stage-actions"> <div v-if="sel && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span> <span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
<button class="gb-act play" @click="restartHere"> neu generieren</button> <button class="gb-act play" @click="restartHere"> neu generieren</button>
<button class="gb-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button> <button class="gb-act danger" :class="{ armed: isArmed('reset') }" @click="armOrRun('reset', resetHere)">{{ isArmed('reset') ? 'Sicher?' : ' nur zurücksetzen' }}</button>
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button> <button class="gb-act ghost" @click="sel = null; resetConfirm()">Abbrechen</button>
</div> </div>
<div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div> <div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
@@ -171,9 +210,8 @@ function resetHere() {
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
animation: gb-pulse 1.2s ease-in-out infinite; animation: pulse-soft 1.2s ease-in-out infinite;
} }
@keyframes gb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.gb-error { color: var(--danger); font-size: 0.82rem; } .gb-error { color: var(--danger); font-size: 0.82rem; }
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; } .gb-actions { margin-left: auto; display: flex; gap: 0.4rem; }

View File

@@ -1,6 +1,8 @@
<script setup> <script setup>
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und // Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
// Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen. // Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen.
import { fmtRuntime } from '../format.js'
const props = defineProps({ const props = defineProps({
columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}] columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}]
agents: { type: Array, default: () => [] }, // [{label, runtime}] agents: { type: Array, default: () => [] }, // [{label, runtime}]
@@ -8,17 +10,8 @@ const props = defineProps({
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte) selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart) cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
selectedKey: { type: String, default: null }, selectedKey: { type: String, default: null },
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
}) })
const emit = defineEmits(['stageClick', 'cardClick']) const emit = defineEmits(['stageClick', 'cardClick'])
function visible(c) {
return !props.hideEmpty || c.total > 0
}
function fmtRuntime(s) {
return s >= 60 ? `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s` : `${Math.round(s)}s`
}
</script> </script>
<template> <template>
@@ -29,7 +22,7 @@ function fmtRuntime(s) {
</div> </div>
<div class="kb-cols"> <div class="kb-cols">
<div <div
v-for="c in columns.filter(visible)" v-for="c in columns"
:key="(c.board || '') + c.key" :key="(c.board || '') + c.key"
class="kb-col" class="kb-col"
:class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }" :class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }"

View File

@@ -0,0 +1,26 @@
<script setup>
// Schmaler Fortschrittsbalken (01) mit Beschriftung und optionalem Hinweis.
defineProps({
value: { type: Number, default: 0 }, // 0..1
label: { type: String, default: '' },
hint: { type: String, default: '' }, // z. B. „Umfang wächst noch"
})
</script>
<template>
<div class="pb">
<div class="pb-track">
<div class="pb-fill" :style="{ width: Math.round(Math.min(1, Math.max(0, value)) * 100) + '%' }"></div>
</div>
<span v-if="label" class="pb-label">{{ label }}</span>
<span v-if="hint" class="pb-hint">{{ hint }}</span>
</div>
</template>
<style scoped>
.pb { display: flex; align-items: center; gap: 0.5rem; }
.pb-track { flex: 1; height: 6px; border-radius: 999px; background: var(--panel-soft); overflow: hidden; }
.pb-fill { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.4s ease; }
.pb-label { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; }
.pb-hint { font-size: 0.74rem; color: var(--text-faint); white-space: nowrap; }
</style>

View File

@@ -0,0 +1,72 @@
<script setup>
// Quellenauswahl (Typ-Buttons + Link-Feld/Ordnerwahl), geteilt zwischen dem Anlegen-Panel
// und dem Bearbeiten-Panel der Sidebar. v-model trägt { type, ort }.
const props = defineProps({
modelValue: { type: Object, required: true }, // { type, ort }
folders: { type: Object, default: () => ({}) }, // { projekt: [...], uni: [...] }
})
const emit = defineEmits(['update:modelValue', 'submit'])
const TYPES = [
{ key: 'thema', label: 'Thema' },
{ key: 'link', label: 'Link' },
{ key: 'projekt', label: 'Projekt' },
{ key: 'uni', label: 'Uni' },
]
function setType(key) {
emit('update:modelValue', { type: key, ort: '' }) // Typwechsel verwirft den alten Ort
}
function setOrt(ort) {
emit('update:modelValue', { ...props.modelValue, ort })
}
</script>
<template>
<div class="dlg-sources">
<button v-for="t in TYPES" :key="t.key" :class="{ active: modelValue.type === t.key }"
@click="setType(t.key)">{{ t.label }}</button>
</div>
<input
v-if="modelValue.type === 'link'"
class="dlg-input" :value="modelValue.ort"
placeholder="https://…"
@input="setOrt($event.target.value)"
@keyup.enter="emit('submit')"
/>
<select
v-else-if="modelValue.type === 'projekt' || modelValue.type === 'uni'"
class="dlg-input" :value="modelValue.ort"
@change="setOrt($event.target.value)"
>
<option value="" disabled>Ordner wählen</option>
<option v-for="fo in (folders[modelValue.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select>
</template>
<style scoped>
.dlg-sources { display: flex; gap: 0.3rem; }
.dlg-sources button {
flex: 1;
padding: 0.35rem 0.2rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
}
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-input {
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.dlg-input:focus { outline: none; border-color: var(--accent); }
select.dlg-input { cursor: pointer; }
</style>

View File

@@ -6,6 +6,7 @@ import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, viewLevelFuer } f
import { useChat } from '../composables/useChat.js' import { useChat } from '../composables/useChat.js'
import BlockPanel from './BlockPanel.vue' import BlockPanel from './BlockPanel.vue'
import BlockFocus from './BlockFocus.vue' import BlockFocus from './BlockFocus.vue'
import ChatTranscript from './ChatTranscript.vue'
const props = defineProps({ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
@@ -199,8 +200,7 @@ const chat = useChat((msgs) => {
section, outline, messages: msgs, provider: props.provider, section, outline, messages: msgs, provider: props.provider,
}) })
}) })
const { messages, input, loading, messagesEl, inputEl, onScroll, send } = chat const { inputEl } = chat // fürs Fokussieren beim Öffnen; Rendering übernimmt ChatTranscript
const autoGrow = () => chat.autoGrow()
const chatOpen = ref(false) const chatOpen = ref(false)
const panelEl = ref(null) const panelEl = ref(null)
@@ -355,30 +355,8 @@ function extractContext() {
<span>Questions about the guide</span> <span>Questions about the guide</span>
<button class="chat-close" title="Close chat" @click="closeChat">×</button> <button class="chat-close" title="Close chat" @click="closeChat">×</button>
</header> </header>
<div ref="messagesEl" class="chat-messages" @scroll="onScroll"> <ChatTranscript :chat="chat" hint="Stelle eine Frage zum aktuellen Abschnitt."
<p v-if="!messages.length" class="chat-hint">Ask a question about the current section.</p> placeholder="Frage stellen" :rows="3" auto-grow max-height="none" />
<template v-for="(m, i) in messages" :key="i">
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="chat-msg user">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Thinking…</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
rows="3"
placeholder="Ask a question"
@input="autoGrow"
@keydown.enter.exact.prevent="send"
></textarea>
<button
:disabled="!input.trim() && !loading"
:class="{ cancel: loading }"
:title="loading ? 'Cancel' : 'Send'"
@click="send"
>{{ loading ? '✕' : '➤' }}</button>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -466,8 +444,8 @@ function extractContext() {
font-weight: 600; font-weight: 600;
padding: 0.15rem 0.6rem; padding: 0.15rem 0.6rem;
border-radius: 999px; border-radius: 999px;
background: color-mix(in srgb, #d4af37 20%, var(--panel)); background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
border: 1px solid #d4af37; border: 1px solid var(--level-master);
color: #8a6d12; color: #8a6d12;
} }
@@ -530,26 +508,26 @@ function extractContext() {
/* Understood blocks (10/10): purple */ /* Understood blocks (10/10): purple */
.block-done.understood { .block-done.understood {
background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); background: color-mix(in srgb, var(--level-expert) 16%, var(--panel));
border-color: #8b5cf6; border-color: var(--level-expert);
color: #6d28d9; color: #6d28d9;
} }
.guide-content .section-card.understood { .guide-content .section-card.understood {
border-color: #8b5cf6; border-color: var(--level-expert);
border-top: 3px solid #8b5cf6; border-top: 3px solid var(--level-expert);
background: color-mix(in srgb, #8b5cf6 7%, var(--panel)); background: color-mix(in srgb, var(--level-expert) 7%, var(--panel));
} }
/* Mastered blocks (master path 25/25): gold */ /* Mastered blocks (master path 25/25): gold */
.block-done.mastered { .block-done.mastered {
background: color-mix(in srgb, #d4af37 20%, var(--panel)); background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
border-color: #d4af37; border-color: var(--level-master);
color: #8a6d12; color: #8a6d12;
} }
.guide-content .section-card.mastered { .guide-content .section-card.mastered {
border-color: #d4af37; border-color: var(--level-master);
border-top: 3px solid #d4af37; border-top: 3px solid var(--level-master);
background: color-mix(in srgb, #d4af37 8%, var(--panel)); background: color-mix(in srgb, var(--level-master) 8%, var(--panel));
} }
/* Guides: cards carry the chapter accent color */ /* Guides: cards carry the chapter accent color */
@@ -672,101 +650,7 @@ function extractContext() {
padding: 0 4px; padding: 0 4px;
} }
.chat-messages { /* Chat-Transkript + Eingabe leben jetzt in ChatTranscript.vue (geteilt mit BlockPanel). */
flex: 1;
overflow-y: auto;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-hint {
color: var(--text-faint);
font-size: 0.82rem;
text-align: center;
margin-top: 1rem;
}
.chat-msg {
max-width: 85%;
padding: 7px 11px;
border-radius: 12px;
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
color: var(--on-accent);
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--panel-soft);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-msg.markdown {
white-space: normal;
}
.chat-typing {
color: var(--text-faint);
font-style: italic;
}
.chat-input {
display: flex;
align-items: stretch;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
min-height: 72px;
max-height: 200px;
overflow-y: auto;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
line-height: 1.4;
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
flex-shrink: 0;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chat-input button.cancel {
background: var(--danger);
}
/* .sub-neu/.sub-stufe: global in assets/markdown.css — scoped greift nicht auf v-html-Inhalt. */ /* .sub-neu/.sub-stufe: global in assets/markdown.css — scoped greift nicht auf v-html-Inhalt. */
</style> </style>

View File

@@ -1,7 +1,8 @@
<script setup> <script setup>
import { ref, reactive, computed } from 'vue' import { ref, computed } from 'vue'
import { useConfirm } from '../composables/useConfirm.js' import { useConfirm } from '../composables/useConfirm.js'
import { fetchSource } from '../api.js' import { fetchSource } from '../api.js'
import SourceForm from './SourceForm.vue'
const props = defineProps({ const props = defineProps({
topics: { type: Array, required: true }, topics: { type: Array, required: true },
@@ -179,10 +180,15 @@ async function toggleTopicPanel(t) {
} }
} }
function setEditType(t) { // v-model-Brücken für SourceForm ({type, ort}) auf die beiden State-Objekte
editForm.value.type = t const createSource = computed({
editForm.value.ort = '' get: () => ({ type: form.value.sourceType, ort: form.value.sourceOrt }),
} set: (v) => { form.value.sourceType = v.type; form.value.sourceOrt = v.ort },
})
const editSource = computed({
get: () => ({ type: editForm.value.type, ort: editForm.value.ort }),
set: (v) => { editForm.value.type = v.type; editForm.value.ort = v.ort },
})
function saveSource() { function saveSource() {
if (!canSave.value || !editTopic.value) return if (!canSave.value || !editTopic.value) return
@@ -241,29 +247,12 @@ function saveSource() {
<!-- Create: inline expandable (no modal) --> <!-- Create: inline expandable (no modal) -->
<div v-if="dlg" class="thema-panel"> <div v-if="dlg" class="thema-panel">
<input class="dlg-input" v-model="form.name" placeholder="Topic name…" @keyup.enter="createTopic" autofocus /> <input class="dlg-input" v-model="form.name" placeholder="Themenname…" @keyup.enter="createTopic" autofocus />
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="More info (optional)…"></textarea> <textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
<div class="dlg-sources"> <SourceForm v-model="createSource" :folders="folders" @submit="createTopic" />
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Topic</button>
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Project</button>
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
</div>
<input
v-if="form.sourceType === 'link'"
class="dlg-input" v-model="form.sourceOrt"
placeholder="https://…" @keyup.enter="createTopic"
/>
<select
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
class="dlg-input" v-model="form.sourceOrt"
>
<option value="" disabled>Choose folder</option>
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select>
<div class="dlg-actions"> <div class="dlg-actions">
<button class="dlg-cancel" @click="dlg = false">Cancel</button> <button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button> <button class="dlg-create" :disabled="!canCreate" @click="createTopic">Anlegen</button>
</div> </div>
</div> </div>
<div class="provider-toggle" v-if="providers.length"> <div class="provider-toggle" v-if="providers.length">
@@ -279,7 +268,7 @@ function saveSource() {
<div class="format-section" v-if="selectedTopic"> <div class="format-section" v-if="selectedTopic">
<div class="format-error ui-error" v-if="uiError"> <div class="format-error ui-error" v-if="uiError">
<span class="format-error-text">{{ uiError }}</span> <span class="format-error-text">{{ uiError }}</span>
<button class="format-error-x" title="Hide" @click="emit('dismissUiError')">×</button> <button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button>
</div> </div>
<div class="progress-info" v-if="activeGenerations.length"> <div class="progress-info" v-if="activeGenerations.length">
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div> <div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
@@ -329,7 +318,7 @@ function saveSource() {
>{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div> >{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
<div v-if="errorMsg(f.key)" class="format-error"> <div v-if="errorMsg(f.key)" class="format-error">
<span class="format-error-text">{{ errorMsg(f.key) }}</span> <span class="format-error-text">{{ errorMsg(f.key) }}</span>
<button class="format-error-x" title="Hide" @click="dismissError(f.key)">×</button> <button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
</div> </div>
</div> </div>
<div class="format-row ord-exam"> <div class="format-row ord-exam">
@@ -352,37 +341,20 @@ function saveSource() {
> >
<div class="topic-row"> <div class="topic-row">
<span class="topic-name" @click="emit('select', t)">{{ t }}</span> <span class="topic-name" @click="emit('select', t)">{{ t }}</span>
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Options" @click.stop="toggleTopicPanel(t)"></button> <button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Optionen" @click.stop="toggleTopicPanel(t)"></button>
</div> </div>
<div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop> <div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop>
<p v-if="editLoading" class="dlg-hint">Loading</p> <p v-if="editLoading" class="dlg-hint">Lädt</p>
<template v-else> <template v-else>
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="More info (optional)…"></textarea> <textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
<div class="dlg-sources"> <SourceForm v-model="editSource" :folders="folders" @submit="saveSource" />
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Topic</button>
<button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</button>
<button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Project</button>
<button :class="{ active: editForm.type === 'uni' }" @click="setEditType('uni')">Uni</button>
</div>
<input
v-if="editForm.type === 'link'"
class="dlg-input" v-model="editForm.ort"
placeholder="https://…"
/>
<select
v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'"
class="dlg-input" v-model="editForm.ort"
>
<option value="" disabled>Choose folder</option>
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select>
<div class="dlg-actions"> <div class="dlg-actions">
<button <button
class="dlg-delete" class="dlg-delete"
:class="{ armed: pendingConfirm === 'topic-' + t }" :class="{ armed: pendingConfirm === 'topic-' + t }"
@click="confirmDeleteTopic(t)" @click="confirmDeleteTopic(t)"
>{{ pendingConfirm === 'topic-' + t ? 'Sure?' : 'Delete' }}</button> >{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button> <button class="dlg-create" :disabled="!canSave" @click="saveSource">Aktualisieren</button>
</div> </div>
</template> </template>
</div> </div>
@@ -626,21 +598,6 @@ function saveSource() {
} }
/* Coarse phases as numbered pills (15) — display + clickable for re-run from here. */
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.format-section { .format-section {
flex-shrink: 0; flex-shrink: 0;
@@ -658,9 +615,8 @@ function saveSource() {
height: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
animation: gen-side-pulse 1.2s ease-in-out infinite; animation: pulse-soft 1.2s ease-in-out infinite;
} }
@keyframes gen-side-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.ord-blocks { .ord-blocks {
order: 2; order: 2;
@@ -771,28 +727,6 @@ function saveSource() {
.panel-btn.danger:hover { border-color: var(--danger); } .panel-btn.danger:hover { border-color: var(--danger); }
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); } .panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
.format-x {
display: none;
color: var(--danger);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.format-name:hover .format-x {
display: inline;
}
/* Running/paused: always show × — there is no hover on touch */
.fmt-generating .format-x,
.fmt-queued .format-x,
.fmt-paused .format-x,
.blocks-row.is-active .format-x {
display: inline;
}
.format-x.armed,
.format-error-x.armed, .format-error-x.armed,
.delete-topic.armed { .delete-topic.armed {
display: inline-block; display: inline-block;
@@ -855,45 +789,6 @@ function saveSource() {
opacity: 1; opacity: 1;
} }
.format-actions {
display: flex;
gap: 2px;
margin-left: 6px;
}
.action-btn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.15s;
}
.action-btn.play {
color: var(--success);
}
.action-btn.play:hover {
background: var(--success-soft);
border-color: var(--success-border);
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.action-btn:disabled:hover {
background: none;
border-color: transparent;
}
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.65; } 50% { opacity: 0.65; }
@@ -938,18 +833,6 @@ function saveSource() {
} }
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); } .dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
.dlg-textarea { resize: vertical; min-height: 2rem; } .dlg-textarea { resize: vertical; min-height: 2rem; }
.dlg-sources { display: flex; gap: 0.3rem; }
.dlg-sources button {
flex: 1;
padding: 0.35rem 0.2rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
}
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); } .dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); }
.dlg-actions { display: flex; justify-content: flex-end; gap: 0.4rem; margin-top: 0.1rem; } .dlg-actions { display: flex; justify-content: flex-end; gap: 0.4rem; margin-top: 0.1rem; }
.dlg-actions button { .dlg-actions button {

19
frontend/src/format.js Normal file
View File

@@ -0,0 +1,19 @@
// Anzeige-Formatierer, geteilt über Board-Ansichten.
// Sekunden → „m:ss" / „h:mm:ss" (Laufzeit eines Laufs oder Agenten).
export function fmtRuntime(s) {
s = Math.max(0, Math.round(s || 0))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${m}:${String(sec).padStart(2, '0')}`
}
// Token-Zahl kompakt (48.234 → „48,2k", 1.2M → „1,2M").
export function fmtTokens(n) {
n = n || 0
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace('.', ',')}M`
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace('.', ',')}k`
return String(n)
}

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './assets/markdown.css' import './assets/markdown.css'
import './assets/shared.css'
createApp(App).mount('#app') createApp(App).mount('#app')