refactor
This commit is contained in:
5
Makefile
5
Makefile
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.35–1.5), damit der
|
||||||
|
# Fix VOR der QA-Grenze greift. Die QA-Messlatte selbst bleibt bewusst in guide_qa.py.
|
||||||
|
FIX_LAENGE_BAND = (0.5, 1.2)
|
||||||
WRITER_SPLIT_SUBS = 30 # guide writer splits sections above this sub count
|
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 110–135 s). 0 = aus.
|
# Fix-/Gate-Call (die laufen normal 110–135 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 6–72 s; a stalled call burns the whole
|
# Judge caps tightened 2026-07-04: judge p50 is 6–72 s; a stalled call burns the whole
|
||||||
# cap and its retry heals in seconds — the old 300 s base tripled the stall cost.
|
# 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.
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
2–3× neu geholt (writer, pruefer, re-pruefer). Karten haben disjunkte block_norm-Keys und
|
||||||
|
laufen ihre Stages seriell → kein Race."""
|
||||||
|
d = env.__dict__.get(attr)
|
||||||
|
if d is None:
|
||||||
|
d = env.__dict__[attr] = {}
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def _ziele(env: _Env, block_norm: str) -> list[dict]:
|
||||||
|
cache = _memo(env, "_ziele_cache")
|
||||||
|
if block_norm not in cache:
|
||||||
|
cache[block_norm] = await db.list_lernziele(env.topic, block_norm)
|
||||||
|
return cache[block_norm]
|
||||||
|
|
||||||
|
|
||||||
|
def _ziele_text(ziele: list[dict]) -> str:
|
||||||
|
return "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
|
||||||
|
|
||||||
|
|
||||||
def _card_facts(env: _Env, block_title: str) -> str:
|
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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 110–135 s laufen.
|
# hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110–135 s laufen.
|
||||||
hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0
|
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
|
||||||
|
|||||||
100
backend/qa.py
100
backend/qa.py
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
7
frontend/src/assets/shared.css
Normal file
7
frontend/src/assets/shared.css
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 topic — comes later in the 'Rest'">Edge</span>
|
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Randthema — kommt 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; } }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
88
frontend/src/components/ChatTranscript.vue
Normal file
88
frontend/src/components/ChatTranscript.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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 }"
|
||||||
|
|||||||
26
frontend/src/components/ProgressBar.vue
Normal file
26
frontend/src/components/ProgressBar.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup>
|
||||||
|
// Schmaler Fortschrittsbalken (0–1) 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>
|
||||||
72
frontend/src/components/SourceForm.vue
Normal file
72
frontend/src/components/SourceForm.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (1–5) — 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
19
frontend/src/format.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user