Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2178c6faf4 | ||
|
|
f9d77a113b | ||
|
|
9a6ab0937b | ||
|
|
224ba1ed4f | ||
|
|
27364d0789 | ||
|
|
f2bc6bcedb | ||
|
|
41f96330fd | ||
|
|
f0c1bdacfa | ||
|
|
f2feb1dbd0 | ||
|
|
1441c01f2c | ||
|
|
9c55b05373 | ||
|
|
72a82ed9dd | ||
|
|
73ac3e532f | ||
|
|
cc27e53b9e | ||
|
|
f4c5116abb | ||
|
|
ed5cf146c3 | ||
|
|
250ea0b764 | ||
|
|
07e14fb82e | ||
|
|
fb3e967fdb | ||
|
|
6a765b7d89 | ||
|
|
9d5940e21f | ||
|
|
54cb6f10e7 | ||
|
|
9b6bfa45c6 | ||
|
|
db58d52567 | ||
|
|
219993ffd5 | ||
|
|
d25824229e | ||
|
|
4105146c59 | ||
|
|
2d9ca00b47 | ||
|
|
c05421a8c1 | ||
|
|
92c69c1561 |
No files matched your search
@@ -1,3 +1,6 @@
|
||||
# Standard-Provider für alle Agenten (Pflicht — es gibt keinen Code-Default)
|
||||
DEFAULT_PROVIDER=minimax
|
||||
|
||||
# Datei nach .env kopieren (wird nicht committet).
|
||||
|
||||
# Claude-Provider: lokal einmal 'claude setup-token' ausführen, Token eintragen.
|
||||
@@ -12,3 +15,14 @@ MINIMAX_API_KEY=
|
||||
#ROLE_JUDGE=
|
||||
#ROLE_GUIDE=
|
||||
#ROLE_FAST=
|
||||
|
||||
# Parallelität (Defaults: siehe backend/config.py). Prozess-Tier = opencode-Prozesse
|
||||
# (~310 MB RSS pro Agent), API-Tier = direkte MiniMax-Calls für tool-lose Agenten (~0 RAM).
|
||||
# Auf 8-GB-Maschinen Prozess-Limit klein halten; das API-Tier darf hoch.
|
||||
#MAX_CONCURRENT_AGENTS=8
|
||||
#MAX_CONCURRENT_AGENTS_PER_TOPIC=24
|
||||
#MAX_CONCURRENT_API_AGENTS=28
|
||||
# opencode-Starts warten, wenn weniger als dieser Anteil RAM frei ist (0 = aus).
|
||||
#RAM_MIN_FREE_PCT=20
|
||||
# Kill-Switch: 0 = Text-Calls wieder über opencode-Prozesse statt direkter API.
|
||||
#CREATOR_TEXT_API=0
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,12 @@
|
||||
# Ziele
|
||||
- Auswahl der Bausteine / Subbausteine optimieren: Also Entfernen würde eine Lücke erzeugen und Hinzufügen würde eine Dopplung erzeugen
|
||||
- Performance maximieren: Der Makespan soll so gering wie möglich sein
|
||||
- Tokenverbrauch minimieren: Gesamtverbraucht der Tokens soll so gering wie möglich sein
|
||||
- Korrektheit: Faschinformationen so gerin wie möglich halten
|
||||
|
||||
# Soll folgende Probleme lösen
|
||||
- Perfektionismus: Ich brauche die Info was 100% vom Thema ist
|
||||
- Kontrolle: Ich muss wissen wie weit ich bin, um es für mich zu planen und tracken
|
||||
- Optimale Auswahl: Zu große Auswahl verlängert die Bearbeitungszeit und die Motivation bricht weg, wenn ich zu wenig Fortschritt sehe; Zu gerine Auswahl triggert den Perfektionismus
|
||||
- Tokenverbrauch: Je weniger Token verbaucht werden, desto stärkere Modelle kann ich nehmen
|
||||
- Performance: Bei zu langer Generierungen erschöpft meine Geduld
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all sync-all-reverse projects searxng ollama qa test test-e2e train train-init
|
||||
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all sync-all-reverse projects searxng ollama qa test test-e2e train train-init train-server
|
||||
|
||||
COMPOSE = docker compose
|
||||
|
||||
@@ -47,10 +47,11 @@ stop:
|
||||
logs:
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
remove: stop
|
||||
remove:
|
||||
@read -p "storage/ (DB + alle Nutzdaten) wirklich löschen? [y/N] " a && [ "$$a" = "y" ]
|
||||
@echo "Lösche Datenbank und generierte Dateien..."
|
||||
rm -rf storage/*
|
||||
@echo "Fertig."
|
||||
@echo "Fertig. (Server ggf. separat stoppen: make stop)"
|
||||
|
||||
searxng:
|
||||
docker run -d --name searxng --restart unless-stopped -p 8888:8080 searxng/searxng
|
||||
@@ -129,6 +130,11 @@ train:
|
||||
@set -a; [ -f .env ] && . ./.env; set +a; \
|
||||
cd backend && python3 train.py --trials $(or $(TRIALS),40) --stunden $(or $(STUNDEN),12) --ameisen $(or $(AMEISEN),3)
|
||||
|
||||
# Training im Container starten (auf dem Server ausführen; detached, überlebt ssh-Abbruch).
|
||||
# Fortschritt: storage/train/<sitzung>/trials.jsonl
|
||||
train-server:
|
||||
docker exec -d creator python3 train.py --trials $(or $(TRIALS),40) --stunden $(or $(STUNDEN),12) --ameisen $(or $(AMEISEN),3)
|
||||
|
||||
# Frozen-Inventar-Vorlage für das Training bauen (einmalig, echter Mini-Lauf)
|
||||
train-init:
|
||||
@set -a; [ -f .env ] && . ./.env; set +a; \
|
||||
|
||||
+176
-16
@@ -22,13 +22,16 @@ import urllib.request
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_INTERACTIVE,
|
||||
resolve_role)
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_API_AGENTS,
|
||||
MAX_CONCURRENT_INTERACTIVE, RAM_MIN_FREE_PCT, resolve_role)
|
||||
|
||||
log = logging.getLogger("creator.agents")
|
||||
|
||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||
_active_api: set[str] = set() # agent_keys of running direct-API calls (no process to kill)
|
||||
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
|
||||
_active_labels: dict[str, str] = {} # agent_key → human-readable label (for display + events)
|
||||
|
||||
@@ -39,9 +42,20 @@ def active_agents(scope_prefix: str | None = None) -> list[dict]:
|
||||
now = time.time()
|
||||
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
|
||||
for k, t in list(_active_started.items())
|
||||
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
|
||||
if (k in _active_processes or k in _active_api)
|
||||
and (not scope_prefix or k.startswith(scope_prefix))]
|
||||
return sorted(out, key=lambda a: -a["runtime"])
|
||||
|
||||
|
||||
# Board-2-Calls tragen diese Marker im agent_key; alles andere unter blocks-{topic}- ist Board 1.
|
||||
_ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix",
|
||||
"-outline")
|
||||
|
||||
|
||||
def agent_ebene(key: str) -> str:
|
||||
"""Generierungs-Ebene eines Blocks-Agenten (für Board-Anzeige/Accounting): inventory|artefacts."""
|
||||
return "artefacts" if any(m in key for m in _ARTEFAKT_MARKER) else "inventory"
|
||||
|
||||
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
||||
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
|
||||
# in the semaphore queue are also stopped immediately on abort instead of still starting.
|
||||
@@ -95,7 +109,8 @@ class _PrioritySemaphore:
|
||||
self._value += 1
|
||||
|
||||
|
||||
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS)
|
||||
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS) # process tier (~310 MB RSS each)
|
||||
_batch_sem_api = _PrioritySemaphore(MAX_CONCURRENT_API_AGENTS) # direct-API tier (~0 RAM)
|
||||
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
||||
|
||||
# Per-topic caps (lazily created): each topic gets its own priority semaphore of size
|
||||
@@ -119,17 +134,19 @@ def _agent_priority(key: str) -> int:
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _batch_gate(scope: str | None, priority: int):
|
||||
async def _batch_gate(scope: str | None, priority: int, api: bool = False):
|
||||
"""Per-topic slot FIRST (fair), then the GLOBAL slot by priority (earlier columns win when
|
||||
agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally."""
|
||||
agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally.
|
||||
The per-topic cap is shared across both tiers; only the global cap is tiered (process vs API)."""
|
||||
topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
|
||||
global_sem = _batch_sem_api if api else _batch_sem
|
||||
if topic_sem is not None:
|
||||
await topic_sem.acquire(priority)
|
||||
await _batch_sem.acquire(priority)
|
||||
await global_sem.acquire(priority)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_batch_sem.release()
|
||||
global_sem.release()
|
||||
if topic_sem is not None:
|
||||
topic_sem.release()
|
||||
|
||||
@@ -151,6 +168,58 @@ async def _opencode_slot() -> None:
|
||||
_opencode_next_start = start_at + _OPENCODE_START_DELAY
|
||||
await asyncio.sleep(max(0.0, start_at - now))
|
||||
|
||||
# RAM-adaptive admission for opencode spawns (~310 MB RSS each): below RAM_MIN_FREE_PCT
|
||||
# free memory new processes wait instead of starting. Gates admission only — running
|
||||
# processes are never touched.
|
||||
_RAM_GATE_FLOOR = 2 # below this many running: always admit (deadlock guard)
|
||||
_RAM_PER_PROC_KB = 350 * 1024 # commit estimate for a batch agent (opencode/claude, no MCP)
|
||||
_RAM_PER_PROC_FULL_KB = 1250 * 1024 # `full` agent: opencode + 3 MCP servers (~300 MB each)
|
||||
_RAM_COMMIT_WINDOW_S = 10.0 # fresh admissions count as already-spent RAM
|
||||
_RAM_POLL_S = 2.0
|
||||
_cli_running = 0 # CLI spawns (opencode AND claude) — the deadlock-floor counter
|
||||
_cli_recent_starts: list[tuple[float, int]] = [] # (monotonic ts, est_kb) of recent admissions
|
||||
|
||||
|
||||
def _meminfo() -> tuple[int, int] | None:
|
||||
"""(MemAvailable_kB, MemTotal_kB) from /proc/meminfo; None → gate fails open (non-Linux)."""
|
||||
try:
|
||||
text = Path("/proc/meminfo").read_text()
|
||||
except OSError:
|
||||
return None
|
||||
m = {k: v for k, v in re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE)}
|
||||
if "MemTotal" not in m or "MemAvailable" not in m:
|
||||
return None
|
||||
return int(m["MemAvailable"]), int(m["MemTotal"])
|
||||
|
||||
|
||||
async def _ram_gate(agent_key: str, est_kb: int) -> bool:
|
||||
"""True = start admitted (commit registered), False = scope cancelled while waiting.
|
||||
Gates ALL CLI spawns (opencode + claude). `est_kb` = this spawn's RAM estimate (a `full`
|
||||
agent drags 3 MCP servers). Check and commit-append happen in the same synchronous block
|
||||
(no await between) — concurrent waiters cannot double-admit on the same free RAM."""
|
||||
if RAM_MIN_FREE_PCT <= 0:
|
||||
return True
|
||||
waited = False
|
||||
while True:
|
||||
if _scope_cancelled(agent_key):
|
||||
return False
|
||||
mem = _meminfo()
|
||||
if mem is None or _cli_running < _RAM_GATE_FLOOR:
|
||||
break # fail open / floor
|
||||
avail_kb, total_kb = mem
|
||||
now = time.monotonic()
|
||||
_cli_recent_starts[:] = [(t, kb) for t, kb in _cli_recent_starts
|
||||
if now - t < _RAM_COMMIT_WINDOW_S]
|
||||
committed_kb = sum(kb for _, kb in _cli_recent_starts)
|
||||
if avail_kb - committed_kb >= total_kb * RAM_MIN_FREE_PCT / 100:
|
||||
break
|
||||
if not waited:
|
||||
log.info("agent %s: RAM gate waiting (%.0f%% free)", agent_key, avail_kb * 100 / total_kb)
|
||||
waited = True
|
||||
await asyncio.sleep(_RAM_POLL_S)
|
||||
_cli_recent_starts.append((time.monotonic(), est_kb))
|
||||
return True
|
||||
|
||||
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
|
||||
|
||||
# Capability → Claude --allowedTools
|
||||
@@ -170,6 +239,17 @@ _OPENCODE_AGENTS = {
|
||||
}
|
||||
|
||||
|
||||
def _use_text_api(provider: str, model: str, capabilities: str, on_line) -> bool:
|
||||
"""Direct API path only for tool-less, non-streaming MiniMax calls. Model-prefix check
|
||||
instead of cli check: "lokal" (ollama) also runs via opencode. Missing key or
|
||||
CREATOR_TEXT_API=0 (kill switch) falls back to the opencode process path."""
|
||||
return (capabilities == "none" and on_line is None
|
||||
and PROVIDERS[provider]["cli"] == "opencode"
|
||||
and model.split("/", 1)[0] in ("minimax", "minimax-kalt")
|
||||
and bool(os.environ.get("MINIMAX_API_KEY"))
|
||||
and os.getenv("CREATOR_TEXT_API", "1") != "0")
|
||||
|
||||
|
||||
def provider_available(provider: str) -> bool:
|
||||
cfg = PROVIDERS.get(provider)
|
||||
if not cfg:
|
||||
@@ -258,10 +338,11 @@ async def run_agent(
|
||||
provider, model = run_provider, PROVIDERS[run_provider].get(role, "")
|
||||
if not model:
|
||||
return 1, "", f"No model for role '{role}' (provider: {provider})"
|
||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||
use_api = _use_text_api(provider, model, capabilities, on_line)
|
||||
if not use_api and shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
|
||||
queued = time.monotonic()
|
||||
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
|
||||
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key), api=use_api)
|
||||
async with gate:
|
||||
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
||||
return 1, "", "cancelled"
|
||||
@@ -270,9 +351,13 @@ async def run_agent(
|
||||
status = "error"
|
||||
rc = None
|
||||
err_tail = ""
|
||||
api_tokens = None
|
||||
try:
|
||||
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
if use_api:
|
||||
rc_, out_, err_, api_tokens = await _run_text_api(agent_key, prompt, timeout, model, label=label)
|
||||
res = (rc_, out_, err_)
|
||||
elif PROVIDERS[provider]["cli"] == "opencode":
|
||||
res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label)
|
||||
else:
|
||||
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
|
||||
@@ -291,9 +376,13 @@ async def run_agent(
|
||||
if on_event is not None and scope is not None: # batch pipeline only, never fatal
|
||||
try:
|
||||
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
|
||||
if agent_key.startswith("blocks-"): # Ebene fürs Per-Board-Accounting
|
||||
meta["board"] = agent_ebene(agent_key)
|
||||
if err_tail:
|
||||
meta["stderr"] = err_tail
|
||||
if PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent
|
||||
if api_tokens: # direct-API path: usage from the response, even on rc!=0
|
||||
meta["tokens"] = api_tokens
|
||||
elif PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent
|
||||
if (tok := await asyncio.to_thread(_session_tokens, agent_key)):
|
||||
meta["tokens"] = tok
|
||||
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
|
||||
@@ -303,7 +392,7 @@ async def run_agent(
|
||||
log.debug("on_event failed", exc_info=True)
|
||||
|
||||
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None) -> tuple[int, str, str]:
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None, est_kb: int = _RAM_PER_PROC_KB) -> tuple[int, str, str]:
|
||||
start = time.monotonic()
|
||||
|
||||
async def spawn():
|
||||
@@ -316,9 +405,14 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
env=env,
|
||||
)
|
||||
|
||||
global _cli_running
|
||||
if stagger:
|
||||
await _opencode_slot() # spaced start slot; spawn itself is not serialized
|
||||
if not await _ram_gate(agent_key, est_kb):
|
||||
return 1, "", "cancelled" # like the cancelled path in run_agent
|
||||
await _opencode_slot() # gate BEFORE the start slot: an admission wave still gets spaced
|
||||
process = await spawn()
|
||||
if stagger:
|
||||
_cli_running += 1
|
||||
# Collision-safe tracking: identical keys (e.g. same chunk label from parallel cards)
|
||||
# get a ~n suffix — prefix-based kill/cancel still matches, nothing becomes an orphan.
|
||||
track_key = agent_key
|
||||
@@ -366,6 +460,8 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
)
|
||||
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
if stagger:
|
||||
_cli_running -= 1
|
||||
# Pop only on identity: a slot restart under the same key must not evict
|
||||
# the NEW process from tracking.
|
||||
if _active_processes.get(track_key) is process:
|
||||
@@ -381,7 +477,8 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str,
|
||||
if tools:
|
||||
cmd += ["--allowedTools", tools]
|
||||
cmd += ["--dangerously-skip-permissions"]
|
||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, label=label)
|
||||
# stagger=True: claude-CLI (~310 MB) also passes the RAM gate — previously ungated.
|
||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, stagger=True, label=label)
|
||||
|
||||
|
||||
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
|
||||
@@ -435,12 +532,75 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
||||
# at the mcp-free config copy; only `full` (research/supplement) keeps the servers.
|
||||
env = {**os.environ, "OPENCODE_CONFIG": str(_SLIM_CONFIG)}
|
||||
try:
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env)
|
||||
# `full` keeps the 3 MCP servers (~300 MB each) → charge the gate accordingly.
|
||||
est_kb = _RAM_PER_PROC_FULL_KB if capabilities == "full" else _RAM_PER_PROC_KB
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env, est_kb=est_kb)
|
||||
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
|
||||
finally:
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# Direct text-API path (MiniMax, Anthropic Messages format): saves the ~310 MB RSS opencode
|
||||
# process for tool-less calls. The native "minimax" and "minimax-kalt" opencode providers both
|
||||
# resolve to this base URL — only the per-model options differ. Options source of truth:
|
||||
# dev-ops/opencode.json (+ -slim); keep in sync on changes there.
|
||||
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
|
||||
_API_VERSION = "2023-06-01"
|
||||
_API_MAX_TOKENS = 32_000 # required Messages field; generate calls are long
|
||||
_API_MODEL_OPTS = { # prefix "minimax" (native) → endpoint defaults (no entry)
|
||||
"minimax-kalt/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}},
|
||||
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3},
|
||||
}
|
||||
|
||||
|
||||
async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
|
||||
label: str = "") -> tuple[int, str, str, dict | None]:
|
||||
"""→ (rc, text, err, tokens). Tokens come straight from the response usage (same key set
|
||||
as _session_tokens) and are returned even on rc!=0 — waste stays visible."""
|
||||
body = {
|
||||
"model": model.split("/", 1)[1], # without the opencode provider prefix
|
||||
"max_tokens": _API_MAX_TOKENS,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
**_API_MODEL_OPTS.get(model, {}),
|
||||
}
|
||||
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION}
|
||||
track_key = agent_key
|
||||
n = 2
|
||||
while track_key in _active_api:
|
||||
track_key = f"{agent_key}~{n}"
|
||||
n += 1
|
||||
_active_api.add(track_key)
|
||||
_active_started[track_key] = time.time()
|
||||
_active_labels[track_key] = label
|
||||
start = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
|
||||
resp = await asyncio.wait_for( # belt: hard wall-clock cap like the process path
|
||||
client.post(_API_URL, json=body, headers=headers), timeout=timeout)
|
||||
except httpx.TimeoutException:
|
||||
raise asyncio.TimeoutError # contract: run_agent/_race handle TimeoutError
|
||||
except httpx.HTTPError as e:
|
||||
return 1, "", f"{type(e).__name__}: {e}", None
|
||||
finally:
|
||||
_active_api.discard(track_key)
|
||||
_active_started.pop(track_key, None)
|
||||
_active_labels.pop(track_key, None)
|
||||
log.info("agent %s: api done after %.1fs", agent_key, time.monotonic() - start)
|
||||
if resp.status_code != 200:
|
||||
return 1, "", f"HTTP {resp.status_code}: {resp.text[:300]}", None
|
||||
data = resp.json()
|
||||
# Only text blocks count — thinking blocks (native route) are skipped.
|
||||
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
|
||||
u = data.get("usage") or {}
|
||||
tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0),
|
||||
"reasoning": 0, "cache_read": int(u.get("cache_read_input_tokens") or 0),
|
||||
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
|
||||
if not text.strip():
|
||||
return 1, "", f"empty response (stop_reason={data.get('stop_reason')})", tokens
|
||||
err = "stop_reason=max_tokens (truncated)" if data.get("stop_reason") == "max_tokens" else ""
|
||||
return 0, text, err, tokens
|
||||
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Auto-Repair-Loop einer Ebene: QA → Befunde beheben, bis 100 % (10.0/10),
|
||||
Stillstand (Repair verbessert die Note nicht mehr) oder max_iter erreicht ist.
|
||||
|
||||
Reine Primitive — keine Kanban-/DB-Kopplung. Der Aufrufer übergibt zwei Coroutinen:
|
||||
`messen()` liefert die aktuelle QA-Note (0–10), `reparieren()` behebt die Befunde.
|
||||
So bleibt der Loop pro Ebene (Inventar/Artefakte/Guide) identisch testbar.
|
||||
"""
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("creator.auto_loop")
|
||||
|
||||
VOLL = 10.0 # 100 % = 10.0/10
|
||||
MAX_ITER = 10 # Sicherheitslimit: mehr Runden ⇒ etwas im System stimmt nicht
|
||||
|
||||
|
||||
async def auto_repair_loop(ebene: str, erst_note: float, reparieren, max_iter: int = MAX_ITER) -> dict:
|
||||
"""Loop QA→Repair für eine Ebene.
|
||||
|
||||
erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (0–10).
|
||||
reparieren() -> (float, bool): behebt die Befunde UND gibt die neu gemessene Note
|
||||
zurück (die Repair-Bausteine messen ohnehin am Ende — keine Doppel-
|
||||
messung) plus „bewegt": ob die Runde etwas getan hat (Fixes, Merges,
|
||||
Freisprüche, Nach-Recherche). Eine bewegte Runde darf die Note auch
|
||||
mal transient senken (neue Blöcke → neue Verdachte) — Stillstand ist
|
||||
erst, wenn NICHTS mehr passiert UND die Note nicht steigt.
|
||||
|
||||
Stopp bei: Note == VOLL (fertig), unbewegt ohne Verbesserung (stillstand),
|
||||
oder max_iter Runden (limit — begrenzt „bewegt ohne Notengewinn"-Livelocks).
|
||||
Returns {ebene, note, runden, grund} mit grund ∈ {"fertig","stillstand","limit"}.
|
||||
"""
|
||||
if erst_note >= VOLL:
|
||||
return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"}
|
||||
note = erst_note
|
||||
for runde in range(1, max_iter + 1):
|
||||
neu, bewegt = await reparieren()
|
||||
log.info("[%s] Auto-Loop Runde %d: %.1f → %.1f (%s)",
|
||||
ebene, runde, note, neu, "bewegt" if bewegt else "unbewegt")
|
||||
if neu >= VOLL:
|
||||
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"}
|
||||
if not bewegt and neu <= note: # nichts getan UND keine Verbesserung ⇒ kein Repair-Pfad
|
||||
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "stillstand"}
|
||||
note = max(note, neu)
|
||||
return {"ebene": ebene, "note": note, "runden": max_iter, "grund": "limit"}
|
||||
@@ -0,0 +1,668 @@
|
||||
"""Board 2, verschmolzene Call-Struktur: 3 Bausteine pro Block statt ~20 serieller Segmente.
|
||||
|
||||
Die alte Stage-Treppe (Finder-Runden → Facts find/erg/check → Konsolidierung → Lücken →
|
||||
Nachfass → Levels → Relevanz → Fragen → Kritik → Flashcards → Beispiele → Check) kostete
|
||||
pro Block 35–55 Calls und ~14 min Wandzeit — bei p50 20–50 s pro Call zählt NUR die Zahl
|
||||
der seriellen Segmente. Hier: Generate(∥2) → Verify(∥2, + Fix-Tail) → Artefakte(Gen+Check)
|
||||
= 4–5 Segmente, 6–9 Calls. Unabhängigkeit bleibt: Generatoren und Prüfer sind getrennte
|
||||
Agenten, Konsens (≥2 unabhängige Nennungen) und Einstimmigkeits-Faltung wie zuvor.
|
||||
|
||||
Output-Kontrakt unverändert (finalize/QA/Guide/Übungssystem lesen dieselben Strukturen):
|
||||
raw {block: [sub]}, facts {block: {sub_norm: 5-Felder}}, sidecar {block: [{title, level,
|
||||
relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashcard/example}."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import database as db
|
||||
import embedding
|
||||
from blocks import (
|
||||
_SOURCE_TEMPLATE, _FACTS_FIELDS, _agreed_cliques, _cited_evidence, _dedup_subblocks,
|
||||
_evidence_pack, _facts_lines, _facts_union, _luecken_schnitt, _neg_set, _pairs_of,
|
||||
_sink_json, _sub_tokens, _subs_hash, _variant_clusters, load_source, material_folder,
|
||||
source_folder,
|
||||
)
|
||||
from config import (ART_SPLIT_SUBS, EMBEDDING_AKTIV, GEN_PANEL, SEED_COVER_COS,
|
||||
VERIFY_PANEL)
|
||||
from jsonio import read_json_file as _json_file
|
||||
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _race, _timeout, run_single_slot
|
||||
from textkit import _norm_title, clean_title
|
||||
|
||||
log = logging.getLogger("creator.block_calls")
|
||||
|
||||
_STUFEN = ("beginner", "advanced", "expert")
|
||||
_RELEVANZ = ("relevant", "peripheral")
|
||||
|
||||
|
||||
def _h8(*parts: str) -> str:
|
||||
return hashlib.md5("|".join(parts).encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
# ── Schemas ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _gen_schema(data) -> list[dict] | None:
|
||||
"""{"subs": [{title, level, relevance, …facts}]} → normalisierte Liste · sonst None.
|
||||
Feld-Normalisierung wie _facts_schema; ungültiges level/relevance fällt auf ""
|
||||
(die Stimme entfällt im Vote, der Sub bleibt)."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("subs"), list):
|
||||
return None
|
||||
out = []
|
||||
for e in data["subs"]:
|
||||
if not isinstance(e, dict) or not str(e.get("title", "")).strip():
|
||||
continue
|
||||
bf = [{"text": t, "source": str(f.get("source", "")).strip()}
|
||||
for f in (e.get("cited_facts") or []) if isinstance(f, dict) and (t := str(f.get("text", "")).strip())]
|
||||
lv = str(e.get("level", "")).strip().casefold()
|
||||
rv = str(e.get("relevance", "")).strip().casefold()
|
||||
out.append({
|
||||
"title": clean_title(str(e["title"]).strip()),
|
||||
"level": lv if lv in _STUFEN else "",
|
||||
"relevance": rv if rv in _RELEVANZ else "",
|
||||
"key_points": [k for x in (e.get("key_points") or []) if (k := str(x).strip())],
|
||||
"prerequisites": str(e.get("prerequisites", "")).strip(),
|
||||
"hurdles": str(e.get("hurdles", "")).strip(),
|
||||
"cited_facts": bf,
|
||||
"example_idea": str(e.get("example_idea", "")).strip(),
|
||||
})
|
||||
return out or None
|
||||
|
||||
|
||||
def _vid(x, n: int) -> int | None:
|
||||
"""Prüfer-Nummer → int in 1..n, else None (bools sind keine ids)."""
|
||||
if isinstance(x, bool):
|
||||
return None
|
||||
if isinstance(x, str) and x.isdigit():
|
||||
x = int(x)
|
||||
return x if isinstance(x, int) and 1 <= x <= n else None
|
||||
|
||||
|
||||
def _verify_schema(data, n: int) -> dict | None:
|
||||
"""Prüfer-Output → normalisiertes Verdikt · None wenn kaputt. Alle Felder optional
|
||||
außer der Grundform (dict) — ein leeres Verdikt {"gruppen": []} heißt „alles ok"."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
pflicht = ("gruppen", "kataloge", "fremd", "luecken", "uebernehmen", "facts_probleme",
|
||||
"levels", "relevanz")
|
||||
if not any(k in data for k in pflicht):
|
||||
return None
|
||||
|
||||
def _ids(lst):
|
||||
return sorted({i for x in (lst or []) if (i := _vid(x, n)) is not None})
|
||||
|
||||
gruppen = []
|
||||
for g in data.get("gruppen") or []:
|
||||
if not isinstance(g, dict):
|
||||
continue
|
||||
haupt = _vid(g.get("haupt"), n)
|
||||
ids = _ids(([haupt] if haupt else []) + list(g.get("weitere") or []))
|
||||
if len(ids) >= 2:
|
||||
gruppen.append({"haupt": haupt if haupt in ids else None, "ids": ids})
|
||||
kataloge = []
|
||||
for k in data.get("kataloge") or []:
|
||||
if not isinstance(k, dict):
|
||||
continue
|
||||
ids = _ids(k.get("mitglieder"))
|
||||
titel = str(k.get("titel") or "").strip()
|
||||
if len(ids) >= 2 and titel:
|
||||
kataloge.append({"titel": titel, "ids": ids})
|
||||
uebernehmen = {}
|
||||
for k, v in (data.get("uebernehmen") or {}).items() if isinstance(data.get("uebernehmen"), dict) else []:
|
||||
if (i := _vid(k, n)) is not None:
|
||||
uebernehmen[i] = str(v).strip().casefold()
|
||||
probleme = []
|
||||
for p in data.get("facts_probleme") or []:
|
||||
if isinstance(p, dict) and (i := _vid(p.get("nr"), n)) is not None:
|
||||
probleme.append({"nr": i, "discard": bool(p.get("discard")),
|
||||
"hinweis": str(p.get("hinweis", "")).strip()})
|
||||
def _enum_map(key, allowed):
|
||||
out = {}
|
||||
raw = data.get(key)
|
||||
for k, v in (raw.items() if isinstance(raw, dict) else []):
|
||||
if (i := _vid(k, n)) is not None and str(v).strip().casefold() in allowed:
|
||||
out[i] = str(v).strip().casefold()
|
||||
return out
|
||||
return {"gruppen": gruppen, "kataloge": kataloge, "fremd": set(_ids(data.get("fremd"))),
|
||||
"luecken": [s.strip() for s in data.get("luecken") or [] if isinstance(s, str) and s.strip()],
|
||||
"uebernehmen": uebernehmen, "facts_probleme": probleme,
|
||||
"levels": _enum_map("levels", _STUFEN), "relevanz": _enum_map("relevanz", _RELEVANZ)}
|
||||
|
||||
|
||||
def _pattern_liste(lst) -> list[dict]:
|
||||
out = []
|
||||
for e in lst or []:
|
||||
if isinstance(e, dict):
|
||||
blk, sub, q = (str(e.get(k, "")).strip() for k in ("block", "subblock", "question"))
|
||||
if blk and sub and q:
|
||||
out.append({"block": blk, "subblock": sub, "question": q})
|
||||
return out
|
||||
|
||||
|
||||
def _art_gen_schema(data) -> dict | None:
|
||||
"""{"pattern": […], "cards": […], "examples": […]} → normalisiert · None wenn kaputt.
|
||||
pattern ist Pflicht (Leitner hängt an Fragen), cards/examples best-effort."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
pattern = _pattern_liste(data.get("pattern"))
|
||||
if not pattern:
|
||||
return None
|
||||
cards = []
|
||||
for e in data.get("cards") or []:
|
||||
if isinstance(e, dict):
|
||||
blk, sub, q, a = (str(e.get(k, "")).strip() for k in ("block", "subblock", "question", "answer"))
|
||||
if blk and sub and q and a:
|
||||
cards.append({"block": blk, "subblock": sub, "question": q, "answer": a})
|
||||
examples = []
|
||||
for e in data.get("examples") or []:
|
||||
if isinstance(e, dict):
|
||||
blk, sub, pr, res = (str(e.get(k, "")).strip() for k in ("block", "subblock", "problem", "result"))
|
||||
steps = [s for x in (e.get("steps") or []) if (s := str(x).strip())]
|
||||
if blk and sub and pr and steps:
|
||||
examples.append({"block": blk, "subblock": sub, "problem": pr, "steps": steps, "result": res})
|
||||
return {"pattern": pattern, "cards": cards, "examples": examples}
|
||||
|
||||
|
||||
def _art_check_schema(data) -> dict | None:
|
||||
"""{"ok": true} → leeres Verdikt · sonst pattern (bereinigt) + pattern_ergaenzt +
|
||||
examples_probleme (1-basierte Indizes)."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
|
||||
if not any(k in data for k in ("pattern", "pattern_ergaenzt", "examples_probleme")):
|
||||
return None
|
||||
probleme = set()
|
||||
for p in data.get("examples_probleme") or []:
|
||||
i = p.get("index") if isinstance(p, dict) else p
|
||||
if isinstance(i, str) and i.isdigit():
|
||||
i = int(i)
|
||||
if isinstance(i, int) and not isinstance(i, bool) and i >= 1:
|
||||
probleme.add(i)
|
||||
return {"pattern": _pattern_liste(data.get("pattern")),
|
||||
"pattern_ergaenzt": _pattern_liste(data.get("pattern_ergaenzt")),
|
||||
"examples_probleme": probleme}
|
||||
|
||||
|
||||
# ── Gemeinsames ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _inline_source(topic: str, sources: list[str] | None, queries: list[str]) -> tuple[str, str]:
|
||||
"""→ (source-Slot, capabilities). Korpus-Auszüge inline (uni/projekt/link oder
|
||||
thema-Research-Material); ohne Treffer fail-open auf die alte Selbst-Recherche."""
|
||||
mat = material_folder(topic)
|
||||
ev = _evidence_pack(mat, sources, queries) if mat else ""
|
||||
if ev:
|
||||
return _prompt("Blocks-Source-Inline", excerpts=ev), "none"
|
||||
_type = load_source(topic).get("type", "thema")
|
||||
folder = source_folder(topic)
|
||||
if _type in _SOURCE_TEMPLATE:
|
||||
return _prompt(_SOURCE_TEMPLATE[_type], project=folder), ("files" if folder else "full")
|
||||
return _prompt("Blocks-Source-Thema", topic=topic), "full"
|
||||
|
||||
|
||||
async def _sims_of(titles: list[str]):
|
||||
"""Ähnlichkeitsmatrix fürs Variant-Clustering; ohne Modell exakte Norm-Gleichheit."""
|
||||
if EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available):
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, titles)
|
||||
if sims is not None:
|
||||
return sims
|
||||
norms = [_norm_title(t) for t in titles]
|
||||
return [[1.0 if norms[i] == norms[j] else 0.0 for j in range(len(titles))]
|
||||
for i in range(len(titles))]
|
||||
|
||||
|
||||
def _fk_of(e: dict) -> dict:
|
||||
return {k: e.get(k) for k in _FACTS_FIELDS}
|
||||
|
||||
|
||||
# ── Generate ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _generate_block(ctx: GenContext, files: dict, title: str, description: str,
|
||||
instructions: str = "", ns: str = "", lbl: str = "",
|
||||
sources: list[str] | None = None,
|
||||
seeds: list[str] | None = None, melde=None) -> dict | None:
|
||||
"""Bottom-up-Anreicherung: die Subs stehen FEST — Board 1 hat die Atome geclustert und
|
||||
als Subs persistiert. EIN Call liefert pro vorgegebenem Sub die Lern-Facts + Level +
|
||||
Relevanz aus dem Material. Er entdeckt und entfernt KEINE Subs (Discovery/Konsens/Seeds
|
||||
entfallen; die Vollständigkeit kommt aus Board 1). → {raw, facts, unsicher, votes} | None."""
|
||||
topic = ctx.topic
|
||||
work_dir = files["arbeit"]
|
||||
bnorm = _norm_title(title)
|
||||
# feste Subs aus Board 1 (Reihenfolge erhalten, dublettenfrei)
|
||||
subs: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for r in await db.list_subblocks(topic, bnorm):
|
||||
st = str(r.get("sub_title") or "").strip()
|
||||
sn = _norm_title(st)
|
||||
if st and sn and sn not in seen and r.get("status") in ("consensus", "candidate"):
|
||||
seen.add(sn)
|
||||
subs.append(st)
|
||||
if not subs:
|
||||
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
|
||||
source, caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + subs)
|
||||
if melde:
|
||||
melde("Anreichern")
|
||||
h = _h8(title, description, "enrich")
|
||||
pfad = work_dir / f"enrich-{h}.json"
|
||||
sub_liste = "\n".join(f"{i}. {t}" for i, t in enumerate(subs, 1))
|
||||
prompt = _prompt("Subblock-Anreichern", topic=topic,
|
||||
block=f"{title} — {description}" if description else title,
|
||||
subs=sub_liste, source=source, extra=_extra(instructions))
|
||||
if _gen_schema(_json_file(pfad)) is None:
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"{lbl}Anreichern", key=f"blocks-{topic}-{ns}sb-enrich-{h}",
|
||||
prompt=prompt, role="quick", capabilities=caps,
|
||||
payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema),
|
||||
timeout=_timeout("generate", len(subs)))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Anreichern {title} ohne Ergebnis — Facts bleiben leer, Prüfer misst")
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
out = _gen_schema(_json_file(pfad)) or []
|
||||
by_norm = {_norm_title(e["title"]): e for e in out}
|
||||
facts: dict[str, dict] = {}
|
||||
votes: dict[str, dict] = {}
|
||||
for st in subs:
|
||||
sn = _norm_title(st)
|
||||
e = by_norm.get(sn)
|
||||
if e:
|
||||
fk = _fk_of(e)
|
||||
votes[sn] = {"level": [e["level"]] if e.get("level") else [],
|
||||
"relevance": [e["relevance"]] if e.get("relevance") else []}
|
||||
else: # der Call ließ diesen Sub aus — leere Facts, der Prüfer/QA sieht die Lücke
|
||||
fk = {k: ([] if k in ("key_points", "cited_facts") else "") for k in _FACTS_FIELDS}
|
||||
votes[sn] = {"level": [], "relevance": []}
|
||||
facts[sn] = fk
|
||||
await db.put_subblock(topic, bnorm, sn, title, st, status="consensus")
|
||||
return {"raw": {title: subs}, "facts": {title: facts}, "unsicher": [], "votes": votes}
|
||||
|
||||
|
||||
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _default_vote(stimmen: list[str], default: str) -> str:
|
||||
"""Mehrheit über die Stimmen (Gen-Vorschläge + implizite/explizite Prüfer-Stimmen);
|
||||
Patt oder leer → default."""
|
||||
counter: dict[str, int] = {}
|
||||
for s in stimmen:
|
||||
if s:
|
||||
counter[s] = counter.get(s, 0) + 1
|
||||
best = max(counter.values(), default=0)
|
||||
winners = [s for s, v in counter.items() if v == best]
|
||||
return winners[0] if len(winners) == 1 and best else default
|
||||
|
||||
|
||||
async def _verify_block(ctx: GenContext, files: dict, title: str, gen: dict, q: dict,
|
||||
instructions: str = "", ns: str = "", lbl: str = "",
|
||||
sources: list[str] | None = None, melde=None,
|
||||
keep_all: bool = False) -> dict | None:
|
||||
"""VERIFY_PANEL unabhängige Prüfer auditieren den Block in EINEM Call (MECE-Faltung,
|
||||
Fremd, Lücken, Unsicher-Übernahme, Facts-Korrektheit, Level/Relevanz). Auswertung mit
|
||||
Schnittmengen-Semantik pro Befundklasse (Faltung/Fremd/Übernahme einstimmig, Discard
|
||||
2/2, Korrektur ≥1 Stimme); Fix-Tail ist EIN Call für Korrekturen + belegte Lücken.
|
||||
keep_all=True (Bottom-up): kein Atom wird entfernt/gefaltet/als neu erfunden — nur
|
||||
Facts-Korrektur + Level/Relevanz bleiben (Board 1 hat schon MECE geclustert).
|
||||
→ {raw, facts, sidecar} | None (nur bei Cancel)."""
|
||||
topic = ctx.topic
|
||||
work_dir = files["arbeit"]
|
||||
bnorm = _norm_title(title)
|
||||
subs = list(gen["raw"].get(title) or [])
|
||||
bfacts: dict[str, dict] = dict(gen["facts"].get(title) or {})
|
||||
unsicher: list[dict] = list(gen.get("unsicher") or [])
|
||||
votes: dict[str, dict] = gen.get("votes") or {}
|
||||
nummern = subs + [u["title"] for u in unsicher] # 1-basiert: consensus, dann unsicher
|
||||
n = len(nummern)
|
||||
|
||||
def _kp(t: str) -> list:
|
||||
fk = bfacts.get(_norm_title(t)) or next(
|
||||
(u for u in unsicher if u["title"] == t), {})
|
||||
return (fk.get("key_points") or [])[:3]
|
||||
|
||||
if melde:
|
||||
melde("Verify")
|
||||
verdicts: list[dict] = []
|
||||
if n:
|
||||
zeilen = "\n".join(f"{k}. {t}" + "".join(f"\n - {p}" for p in _kp(t))
|
||||
for k, t in enumerate(nummern, 1))
|
||||
u_txt = ""
|
||||
if unsicher:
|
||||
erste = len(subs) + 1
|
||||
u_txt = (f"\nUNSICHER — entries {erste}–{n} were named by only ONE generator "
|
||||
"(or are seed candidates). Judge their adoption under `uebernehmen`.\n")
|
||||
cites = [bf.get("source", "") for fk in bfacts.values() for bf in fk.get("cited_facts", [])]
|
||||
mat = material_folder(topic)
|
||||
ev = _cited_evidence(mat, sources, cites, [title] + nummern) if mat else ""
|
||||
if ev:
|
||||
source = _prompt("Blocks-Source-Inline", excerpts=ev)
|
||||
else:
|
||||
source, _caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + nummern)
|
||||
sh = _subs_hash({title: nummern})
|
||||
pfade = {j: work_dir / f"verify-{sh}-j{j}.json" for j in (*range(1, VERIFY_PANEL + 1), "E")}
|
||||
|
||||
async def _judge(j):
|
||||
if _verify_schema(_json_file(pfade[j]), n) is not None:
|
||||
return # resume
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"{lbl}Verify j{j}", key=f"blocks-{topic}-{ns}sb-verify-{sh}-j{j}",
|
||||
prompt=_prompt("Subblock-Verify", topic=topic, block=title, subs=zeilen,
|
||||
unsicher=u_txt, source=source, extra=_extra(instructions)),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result, p=pfade[j]: _sink_json(result, p, lambda d: _verify_schema(d, n)),
|
||||
timeout=_timeout("verify", n))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Verify {title} j{j} ohne Ergebnis — fail-open")
|
||||
|
||||
await asyncio.gather(*[_judge(j) for j in range(1, VERIFY_PANEL + 1)])
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
verdicts = [v for j in range(1, VERIFY_PANEL + 1)
|
||||
if (v := _verify_schema(_json_file(pfade[j]), n)) is not None]
|
||||
if len(verdicts) == 1 and VERIFY_PANEL >= 2: # Ersatz-Richter statt fail-open
|
||||
await _judge("E")
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
verdicts = [v for j in (*range(1, VERIFY_PANEL + 1), "E")
|
||||
if (v := _verify_schema(_json_file(pfade[j]), n)) is not None][:2]
|
||||
|
||||
einstimmig = len(verdicts) >= 2
|
||||
if n and not einstimmig:
|
||||
_log(topic, f"Verify {title}: nur {len(verdicts)}/2 Prüfer — fail-open, unsicher verworfen")
|
||||
negs = [_neg_set(t) for t in nummern]
|
||||
gone: set[int] = set()
|
||||
keep = list(subs)
|
||||
korrekturen: list[dict] = [] # {titel, hinweis}
|
||||
luecken: list[str] = []
|
||||
|
||||
def _titel(k: int) -> str:
|
||||
return nummern[k - 1]
|
||||
|
||||
async def _fold(k: int, wf: dict | None):
|
||||
t = _titel(k)
|
||||
lf = bfacts.pop(_norm_title(t), None) or {}
|
||||
if wf is not None:
|
||||
_facts_union(wf, lf)
|
||||
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="variant")
|
||||
if t in keep:
|
||||
keep.remove(t)
|
||||
gone.add(k)
|
||||
|
||||
if einstimmig:
|
||||
v1, v2 = verdicts[0], verdicts[1]
|
||||
if keep_all:
|
||||
# Bottom-up: jedes Board-1-Atom bleibt. Destruktive/erzeugende Befunde
|
||||
# neutralisieren — die Loops unten laufen dann leer. Nur Facts-Korrektur
|
||||
# (ohne discard) + Level/Relevanz überleben.
|
||||
for v in (v1, v2):
|
||||
v["fremd"] = set()
|
||||
v["gruppen"] = []
|
||||
v["kataloge"] = []
|
||||
v["uebernehmen"] = {}
|
||||
v["luecken"] = []
|
||||
# discard abschalten (kein Atom entfernen), aber den Korrektur-Hinweis behalten
|
||||
v["facts_probleme"] = [{**p, "discard": False} for p in v["facts_probleme"]]
|
||||
# 1. Fremd (einstimmig): fürs THEMA fremde Aussagen → discarded
|
||||
for k in sorted(v1["fremd"] & v2["fremd"]):
|
||||
t = _titel(k)
|
||||
bfacts.pop(_norm_title(t), None)
|
||||
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded")
|
||||
if t in keep:
|
||||
keep.remove(t)
|
||||
gone.add(k)
|
||||
# 2. Unsicher-Übernahme (2/2 „ja"): wird consensus samt Generator-Facts
|
||||
for k in range(len(subs) + 1, n + 1):
|
||||
if k in gone:
|
||||
continue
|
||||
u = unsicher[k - len(subs) - 1]
|
||||
if v1["uebernehmen"].get(k) == "ja" and v2["uebernehmen"].get(k) == "ja":
|
||||
sn = _norm_title(u["title"])
|
||||
bfacts[sn] = _fk_of(u)
|
||||
keep.append(u["title"])
|
||||
await db.set_subblock_fields(topic, bnorm, sn, status="consensus")
|
||||
else:
|
||||
await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded")
|
||||
gone.add(k)
|
||||
# 3. Gruppen (einstimmige Paare): haupt-Votum, sonst key_points/Länge
|
||||
haupt_votes: dict[int, int] = {}
|
||||
for v in (v1, v2):
|
||||
for g in v["gruppen"]:
|
||||
if g["haupt"]:
|
||||
haupt_votes[g["haupt"]] = haupt_votes.get(g["haupt"], 0) + 1
|
||||
for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["gruppen"]]) for v in (v1, v2)], negs, n):
|
||||
g = [k for k in g if k not in gone]
|
||||
if len(g) < 2:
|
||||
continue
|
||||
win = max(g, key=lambda k: (haupt_votes.get(k, 0), len(_kp(_titel(k))), len(_titel(k)), -k))
|
||||
wf = bfacts.setdefault(_norm_title(_titel(win)), {})
|
||||
for k in g:
|
||||
if k != win:
|
||||
await _fold(k, wf)
|
||||
# 4. Kataloge: Aufzählungszeilen → EIN neuer benannter Sub (Facts-Union)
|
||||
for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["kataloge"]]) for v in (v1, v2)], negs, n):
|
||||
g = [k for k in g if k not in gone]
|
||||
if len(g) < 2:
|
||||
continue
|
||||
titel = next((clean_title(x["titel"]) for x in v1["kataloge"] + v2["kataloge"]
|
||||
if set(x["ids"]) & set(g) and clean_title(x["titel"])), "")
|
||||
kn = _norm_title(titel)
|
||||
if not kn or kn in {_norm_title(s) for s in keep}:
|
||||
continue
|
||||
kf: dict = {}
|
||||
for k in g:
|
||||
await _fold(k, kf)
|
||||
bfacts[kn] = kf
|
||||
keep.append(titel)
|
||||
await db.put_subblock(topic, bnorm, kn, title, titel, status="consensus")
|
||||
# 5. Facts-Probleme: discard nur 2/2 (irreversibel), Korrektur ab 1 Stimme
|
||||
d1 = {p["nr"] for p in v1["facts_probleme"] if p["discard"]}
|
||||
d2 = {p["nr"] for p in v2["facts_probleme"] if p["discard"]}
|
||||
for k in sorted(d1 & d2):
|
||||
if k in gone:
|
||||
continue
|
||||
t = _titel(k)
|
||||
bfacts.pop(_norm_title(t), None)
|
||||
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded")
|
||||
if t in keep:
|
||||
keep.remove(t)
|
||||
gone.add(k)
|
||||
for p in v1["facts_probleme"] + v2["facts_probleme"]:
|
||||
k = p["nr"]
|
||||
if k in gone or not p["hinweis"]:
|
||||
continue
|
||||
t = _titel(k)
|
||||
if t in keep and all(x["titel"] != t for x in korrekturen):
|
||||
korrekturen.append({"titel": t, "hinweis": p["hinweis"]})
|
||||
# 6. Lücken (Schnitt beider Prüfer, Cap)
|
||||
luecken = _luecken_schnitt(v1["luecken"], v2["luecken"])
|
||||
else:
|
||||
# fail-open: consensus bleibt, unsicher wird verworfen (wie heutiges Clarify-Aus)
|
||||
for u in unsicher:
|
||||
await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded")
|
||||
|
||||
# 7. Level/Relevanz: Stimmen = Generatoren + Prüfer (explizite Korrektur schlägt
|
||||
# die implizite Zustimmung); Patt → advanced/relevant (heutige Defaults)
|
||||
sidecar_subs = []
|
||||
for t in keep:
|
||||
sn = _norm_title(t)
|
||||
try:
|
||||
k = nummern.index(t) + 1
|
||||
except ValueError:
|
||||
k = 0 # Katalog-/Fix-Neuzugänge haben keine Nummer
|
||||
stimmen_l = list((votes.get(sn) or {}).get("level") or [])
|
||||
stimmen_r = list((votes.get(sn) or {}).get("relevance") or [])
|
||||
for v in verdicts[:2]:
|
||||
if k and k in v["levels"]:
|
||||
stimmen_l += [v["levels"][k]] * 2 # explizite Korrektur wiegt doppelt
|
||||
if k and k in v["relevanz"]:
|
||||
stimmen_r += [v["relevanz"][k]] * 2
|
||||
fk = bfacts.get(sn) or {}
|
||||
sidecar_subs.append({"title": t, "level": _default_vote(stimmen_l, "advanced"),
|
||||
"relevance": _default_vote(stimmen_r, "relevant"), "facts": fk})
|
||||
|
||||
# 8. Fix-Tail (0–1 Call): Korrekturen + belegte Lücken
|
||||
if (korrekturen or luecken) and not ctx.is_cancelled():
|
||||
if melde:
|
||||
melde("Fix")
|
||||
neu = await _fix_befunde(ctx, files, title, korrekturen, luecken,
|
||||
[s["title"] for s in sidecar_subs], instructions, ns, lbl, sources)
|
||||
for e in neu or []:
|
||||
sn = _norm_title(e["title"])
|
||||
vorhanden = next((s for s in sidecar_subs if _norm_title(s["title"]) == sn), None)
|
||||
if vorhanden is not None: # Korrektur: Facts ersetzen, Einstufung bleibt
|
||||
vorhanden["facts"] = _fk_of(e)
|
||||
bfacts[sn] = vorhanden["facts"]
|
||||
elif not keep_all: # Lücken-Fund: neuer consensus-Sub
|
||||
# (Bottom-up unterdrückt das: die Subs stehen fest, der Fix-Call darf keine
|
||||
# neuen einschleusen — sonst wächst die Sub-Zahl über die Board-1-Atome hinaus)
|
||||
bfacts[sn] = _fk_of(e)
|
||||
keep.append(e["title"])
|
||||
sidecar_subs.append({"title": e["title"],
|
||||
"level": e["level"] or "advanced",
|
||||
"relevance": e["relevance"] or "relevant",
|
||||
"facts": bfacts[sn]})
|
||||
await db.put_subblock(topic, bnorm, sn, title, e["title"], status="consensus")
|
||||
|
||||
if len(keep) != len(subs):
|
||||
_log(topic, f"Verify {title}: {len(subs)} consensus + {len(unsicher)} unsicher → {len(keep)}")
|
||||
return {"raw": {title: [s["title"] for s in sidecar_subs]},
|
||||
"facts": {title: bfacts},
|
||||
"sidecar": {title: sidecar_subs}}
|
||||
|
||||
|
||||
async def _fix_befunde(ctx: GenContext, files: dict, title: str, korrekturen: list[dict],
|
||||
luecken: list[str], vorhanden: list[str], instructions: str,
|
||||
ns: str, lbl: str, sources: list[str] | None) -> list[dict]:
|
||||
"""EIN Call korrigiert beanstandete Facts und füllt gemeldete Lücken. Hartes Beleg-Gate
|
||||
für Neuzugänge (key_points/cited_facts nicht leer) + Dedup gegen den Bestand — ein
|
||||
unbelegter Lücken-„Fund" flutet sonst das Fakten-Gate des Guides."""
|
||||
topic = ctx.topic
|
||||
work_dir = files["arbeit"]
|
||||
auftraege = [f"- KORRIGIEREN: „{k['titel']}“ — {k['hinweis']}" for k in korrekturen]
|
||||
auftraege += [f"- LÜCKE (neuer Subbaustein, nur wenn belegbar): {l}" for l in luecken]
|
||||
source, caps = await asyncio.to_thread(
|
||||
_inline_source, topic, sources,
|
||||
[title] + [k["titel"] for k in korrekturen] + list(luecken))
|
||||
sh = _h8(title, *sorted(a for a in auftraege))
|
||||
pfad = work_dir / f"fix-{sh}.json"
|
||||
if _gen_schema(_json_file(pfad)) is None:
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"{lbl}Fix", key=f"blocks-{topic}-{ns}sb-fix-{sh}",
|
||||
prompt=_prompt("Subblock-Fix", topic=topic, block=title, source=source,
|
||||
auftraege="\n".join(auftraege), extra=_extra(instructions)),
|
||||
role="quick", capabilities=caps,
|
||||
payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema),
|
||||
timeout=_timeout("fix", len(auftraege)))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Fix {title} ohne Ergebnis — Befunde bleiben offen")
|
||||
return []
|
||||
out = _gen_schema(_json_file(pfad)) or []
|
||||
korrektur_norms = {_norm_title(k["titel"]) for k in korrekturen}
|
||||
have_norms = {_norm_title(t) for t in vorhanden}
|
||||
angenommen = []
|
||||
for e in out:
|
||||
sn = _norm_title(e["title"])
|
||||
if sn in korrektur_norms:
|
||||
angenommen.append(e)
|
||||
continue
|
||||
if sn in have_norms or not (e["key_points"] or e["cited_facts"]):
|
||||
continue # unbelegt oder Dublette → verfällt
|
||||
st = _sub_tokens(e["title"])
|
||||
if any(st <= _sub_tokens(t) or _sub_tokens(t) <= st for t in vorhanden):
|
||||
continue
|
||||
angenommen.append(e)
|
||||
have_norms.add(sn)
|
||||
return angenommen
|
||||
|
||||
|
||||
# ── Artefakte ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _subs_text(title: str, sidecar_subs: list[dict]) -> str:
|
||||
return f"BLOCK: {title}\n" + "\n".join(
|
||||
f"- {s['title']}\n" + "\n".join(f" {z}" for z in _facts_lines(s.get("facts") or {}).splitlines())
|
||||
for s in sidecar_subs)
|
||||
|
||||
|
||||
async def _artefakte_block(ctx: GenContext, files: dict, title: str,
|
||||
sidecar_subs: list[dict], instructions: str = "",
|
||||
ns: str = "", lbl: str = "", melde=None) -> dict | None:
|
||||
"""EIN Generator-Call liefert Fragen + Flashcards + Beispiele (Split in 2 parallele
|
||||
Calls bei > ART_SPLIT_SUBS Subs), EIN Prüfer-Call verifiziert Beispiele, bereinigt
|
||||
die Fragen und ergänzt fehlende. → {pattern, artefacts} | None (nur Cancel)."""
|
||||
topic = ctx.topic
|
||||
work_dir = files["arbeit"]
|
||||
if not sidecar_subs:
|
||||
return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
|
||||
if melde:
|
||||
melde("Artefakte gen")
|
||||
sh = _subs_hash({title: sidecar_subs})
|
||||
haelften = ([sidecar_subs] if len(sidecar_subs) <= ART_SPLIT_SUBS
|
||||
else [sidecar_subs[:len(sidecar_subs) // 2], sidecar_subs[len(sidecar_subs) // 2:]])
|
||||
|
||||
async def _gen(gi: int, teil: list[dict]):
|
||||
pfad = work_dir / f"art-{sh}-t{gi}.json"
|
||||
if _art_gen_schema(_json_file(pfad)) is not None:
|
||||
return
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"{lbl}Artefakte {gi}", key=f"blocks-{topic}-{ns}art-gen-{sh}-t{gi}",
|
||||
prompt=_prompt("Artefakt-Generate", topic=topic, blocks=_subs_text(title, teil),
|
||||
extra=_extra(instructions)),
|
||||
role="quick", capabilities="none",
|
||||
payload=lambda result, p=pfad: _sink_json(result, p, _art_gen_schema),
|
||||
timeout=_timeout("artefakt", len(teil)))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Artefakte {title} Teil {gi} ohne Ergebnis")
|
||||
|
||||
await asyncio.gather(*[_gen(gi, teil) for gi, teil in enumerate(haelften, 1)])
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
pattern: list[dict] = []
|
||||
cards: list[dict] = []
|
||||
examples: list[dict] = []
|
||||
for gi in range(1, len(haelften) + 1):
|
||||
o = _art_gen_schema(_json_file(work_dir / f"art-{sh}-t{gi}.json"))
|
||||
if o:
|
||||
pattern += o["pattern"]
|
||||
cards += o["cards"]
|
||||
examples += o["examples"]
|
||||
|
||||
# Prüfer: Beispiele verifizieren, Fragen bereinigen + fehlende ergänzen
|
||||
sub_titles = [s["title"] for s in sidecar_subs]
|
||||
fehlend = [t for t in sub_titles
|
||||
if _norm_title(t) not in {_norm_title(p["subblock"]) for p in pattern}]
|
||||
if pattern or examples:
|
||||
if melde:
|
||||
melde("Artefakte check")
|
||||
tabelle = "\n".join(f"({p['subblock']}) {p['question']}" for p in pattern) or "(keine)"
|
||||
beisp = "\n\n".join(
|
||||
f"{k}. PROBLEM: {e['problem']}\n SCHRITTE: " + " | ".join(e["steps"])
|
||||
+ (f"\n ERGEBNIS: {e['result']}" if e.get("result") else "")
|
||||
for k, e in enumerate(examples, 1)) or "(keine)"
|
||||
fehlend_txt = ("\nSUBBLOCKS STILL MISSING A QUESTION:\n"
|
||||
+ "\n".join(f"- {t}" for t in fehlend) + "\n") if fehlend else "\n"
|
||||
pfad = work_dir / f"artcheck-{sh}.json"
|
||||
if _art_check_schema(_json_file(pfad)) is None:
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"{lbl}Artefakt-Check", key=f"blocks-{topic}-{ns}art-check-{sh}",
|
||||
prompt=_prompt("Artefakt-Check", topic=topic, facts=_subs_text(title, sidecar_subs),
|
||||
table=tabelle, fehlend=fehlend_txt, examples=beisp,
|
||||
extra=_extra(instructions)),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result, p=pfad: _sink_json(result, p, _art_check_schema),
|
||||
timeout=_timeout("artefakt_check", len(sidecar_subs)))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Artefakt-Check {title} ohne Ergebnis — Rohfassung übernommen")
|
||||
check = _art_check_schema(_json_file(pfad))
|
||||
if check:
|
||||
if check["examples_probleme"]:
|
||||
examples = [e for k, e in enumerate(examples, 1)
|
||||
if k not in check["examples_probleme"]]
|
||||
_log(topic, f"Artefakt-Check {title}: {len(check['examples_probleme'])} Beispiel(e) verworfen")
|
||||
if check["pattern"]: # bereinigte Fassung ersetzt die Rohfassung
|
||||
pattern = check["pattern"]
|
||||
pattern += check["pattern_ergaenzt"]
|
||||
|
||||
pattern_map = {title: [{"subblock": p["subblock"], "question": p["question"]}
|
||||
for p in pattern]}
|
||||
# block-Feld auf den Karten-Block normieren (Ein-Block-Call — Agent-Echos abfangen)
|
||||
for e in cards + examples:
|
||||
e["block"] = title
|
||||
return {"pattern": pattern_map,
|
||||
"artefacts": {"flashcard": [{k: e[k] for k in ("block", "subblock", "question", "answer")} for e in cards],
|
||||
"example": [{k: e[k] for k in ("block", "subblock", "problem", "steps", "result")} for e in examples]}}
|
||||
+214
-1839
File diff suppressed because it is too large.
Load diff
+108
-348
@@ -1,33 +1,25 @@
|
||||
"""Board 2 „Artefakte": per finished block, prepare the learning artefacts the guide presents.
|
||||
|
||||
A card is spawned by board 1's `done` column per mirrored block and runs through:
|
||||
subblocks → facts → levels → relevance → question_pattern → artefacts → finalize
|
||||
generate → verify (inkl. Fix-Tail) → artefakte (Gen + Prüfer) → finalize
|
||||
(die verschmolzenen Calls liegen in block_calls.py — 4–5 serielle Segmente statt ~20).
|
||||
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
|
||||
files + the DB tables. `outline` is a topic-wide BARRIER singleton at the very end
|
||||
(prerequisite graph → chapter order), re-run once per generation run.
|
||||
|
||||
The heavy lifting is the existing per-block functions in blocks.py — each card gets its own
|
||||
work subdirectory + facts/artefakte paths, so their slot files never collide across blocks."""
|
||||
files + the DB tables. Danach eine topic-weite BARRIERE: `outline` (prerequisite graph →
|
||||
chapter order), re-run once per generation run."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import database as db
|
||||
import blocks
|
||||
import embedding
|
||||
from blocks import (
|
||||
ARTEFACT_TYPES, _artefacts_block, _facts_block, _facts_nachfass, _konsolidiere_subblocks,
|
||||
_levels_block, _luecken_runde, _match_sub, _neg_set, _question_pattern_block, _relevance_block,
|
||||
_sink_json, _subblocks_block, _outline_block,
|
||||
)
|
||||
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
|
||||
from block_calls import _artefakte_block, _generate_block, _verify_block
|
||||
from blocks import ARTEFACT_TYPES, _match_sub, _outline_block
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from kanban import Flow, Stage
|
||||
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot
|
||||
from pipeline import GenContext, _log
|
||||
from textkit import _norm_title, _title
|
||||
|
||||
log = logging.getLogger("creator.board_artefacts")
|
||||
@@ -99,7 +91,7 @@ def make_spawner(topic: str, files: dict):
|
||||
norm = payload.get("mirrored_norm")
|
||||
if not norm:
|
||||
return
|
||||
await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "subblocks", {
|
||||
await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "generate", {
|
||||
"title": payload.get("title", ""),
|
||||
"description": payload.get("description", ""),
|
||||
"n_size": payload.get("n_size", 0), # LPT estimate until subs_n exists
|
||||
@@ -107,10 +99,27 @@ def make_spawner(topic: str, files: dict):
|
||||
return spawn
|
||||
|
||||
|
||||
async def seed_artefact_cards(topic: str, files: dict) -> int:
|
||||
"""Board-2-only (Inventar läuft NICHT mit): für jeden fertigen Block eine ablock-Karte
|
||||
erzeugen, falls noch keine existiert — sonst liefe ohne den Board-1-Spawn-Hook nichts
|
||||
durch Board 2. Vorhandene Karten bleiben (sie resümieren in ihrer Spalte). → neue Karten."""
|
||||
spawn = make_spawner(topic, files)
|
||||
existing = {c["card_id"] for c in await db.kanban_cards(topic, board=BOARD, kind="ablock")}
|
||||
n = 0
|
||||
for c in await db.kanban_cards(topic, board="inventory", kind="block", stage="done_block"):
|
||||
norm = c["payload"].get("mirrored_norm")
|
||||
if norm and norm not in existing:
|
||||
await spawn(c["card_id"], c["payload"])
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
async def _gather_cards(ctx: GenContext, flow: Flow, cards, one):
|
||||
results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
|
||||
errs = [r for r in results if isinstance(r, Exception)]
|
||||
if errs:
|
||||
for e in errs[1:]: # nur errs[0] wird re-raised — der Rest darf nicht stumm verschwinden
|
||||
log.error("weitere Karten-Exception im Batch: %r", e)
|
||||
raise errs[0]
|
||||
flow.wake.set()
|
||||
|
||||
@@ -171,353 +180,90 @@ async def _seed_map(topic: str) -> dict[str, list[str]]:
|
||||
return seeds
|
||||
|
||||
|
||||
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
def _melder(flow: Flow, norm: str):
|
||||
"""Live-Stepper der Karte: Phasenname → card_info (ging bei der Call-Verschmelzung
|
||||
verloren — Karten liefen ohne Badge/Stepper durch das Board)."""
|
||||
set_p = _card_set_p(flow, norm)
|
||||
|
||||
def melde(schritt: str) -> None:
|
||||
try:
|
||||
set_p(f"{schritt}…", step=blocks._step_idx(flow.topic, schritt))
|
||||
except ValueError:
|
||||
set_p(f"{schritt}…")
|
||||
return melde
|
||||
|
||||
|
||||
async def _proc_generate(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
"""Verschmolzener Erzeuger: 2 unabhängige Generatoren liefern Subs+Facts+Einstufung
|
||||
in EINEM Call, Konsens im Code (block_calls._generate_block)."""
|
||||
topic = flow.topic
|
||||
# Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks.
|
||||
# fragments demoted to a parent become seed candidates of the parent's subblocks
|
||||
seeds = await _seed_map(topic)
|
||||
|
||||
async def one(c):
|
||||
p = c["payload"]
|
||||
norm = c["card_id"]
|
||||
instr = instructions
|
||||
sd = [s for s in seeds.get(norm, []) if s]
|
||||
if sd:
|
||||
instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
|
||||
"Blocks (prüfen; wenn belegt UND noch nicht durch einen anderen Eintrag "
|
||||
"abgedeckt, aufnehmen — nicht wörtlich übernehmen, sondern als eigenständige "
|
||||
"Aussage formulieren):\n"
|
||||
+ "\n".join(f"- {s}" for s in sd))
|
||||
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-",
|
||||
seeds=sd or None, lbl=f"{p.get('title', norm)} · ",
|
||||
sources=p.get("sources"))
|
||||
if raw is None:
|
||||
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
|
||||
p["raw"] = raw
|
||||
p["subs_n"] = sum(len(v) for v in raw.values()) # LPT: bigger blocks pull first
|
||||
gen = await _generate_block(ctx, _pfiles(files, norm), p.get("title", ""),
|
||||
p.get("description", ""), instructions,
|
||||
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ",
|
||||
sources=p.get("sources"),
|
||||
seeds=[s for s in seeds.get(norm, []) if s] or None,
|
||||
melde=_melder(flow, norm))
|
||||
if gen is None:
|
||||
return _fail_or_cancel(ctx, f"Generate {p.get('title', norm)}")
|
||||
p["raw"], p["facts"] = gen["raw"], gen["facts"]
|
||||
p["unsicher"], p["votes"] = gen["unsicher"], gen["votes"]
|
||||
p["subs_n"] = sum(len(v) for v in gen["raw"].values()) + len(gen["unsicher"])
|
||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||
await db.kanban_advance(topic, BOARD, norm, "facts")
|
||||
await db.kanban_advance(topic, BOARD, norm, "verify")
|
||||
|
||||
await _gather_cards(ctx, flow, cards, one)
|
||||
|
||||
|
||||
async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
||||
instructions: str, cards):
|
||||
async def _proc_verify(ctx: GenContext, flow: Flow, files: dict, q: dict, instructions: str, cards):
|
||||
"""Verschmolzener Prüfer: MECE + Facts + Einstufung in einem Panel-Call, Fix-Tail
|
||||
inklusive (block_calls._verify_block) — ersetzt facts-check/konsolidierung/levels/
|
||||
relevance als eigene Stages."""
|
||||
topic = flow.topic
|
||||
|
||||
async def one(c):
|
||||
p = c["payload"]
|
||||
norm = c["card_id"]
|
||||
raw = p.get("raw") or {}
|
||||
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q,
|
||||
folder, instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
|
||||
title = p.get("title", "")
|
||||
gen = {"raw": p.get("raw") or {}, "facts": p.get("facts") or {},
|
||||
"unsicher": p.get("unsicher") or [], "votes": p.get("votes") or {}}
|
||||
res = await _verify_block(ctx, _pfiles(files, norm), title, gen, q, instructions,
|
||||
ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
|
||||
sources=p.get("sources"), melde=_melder(flow, norm),
|
||||
keep_all=True)
|
||||
if res is None:
|
||||
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
|
||||
facts_map, discarded = res
|
||||
if discarded: # unsupportable subs vanish from raw too (guide never sees them)
|
||||
for bt, sns in discarded.items():
|
||||
if bt in raw:
|
||||
raw[bt] = [s for s in raw[bt] if _norm_title(s) not in sns]
|
||||
raw = {bt: subs for bt, subs in raw.items() if subs}
|
||||
# In-block consolidation: two-judge panel folds same-statement/subset subs, bundles
|
||||
# catalogs, drops off-topic ones — the facts are in hand (key points as evidence),
|
||||
# questions/artefacts not yet built. Reported gaps get ONE follow-up finder round.
|
||||
luecken = await _konsolidiere_subblocks(ctx, _pfiles(files, norm), raw, facts_map,
|
||||
instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ")
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
nachgefasst = 0
|
||||
for bt, lk in (luecken or {}).items():
|
||||
nachgefasst += await _luecken_runde(ctx, _pfiles(files, norm), bt, lk, raw, facts_map,
|
||||
q, folder, instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
if nachgefasst: # close the loop: follow-up finds get the SAME duplicate test as the
|
||||
# rest (new subs-hash → fresh judge files); their gap report is deliberately ignored
|
||||
await _konsolidiere_subblocks(ctx, _pfiles(files, norm), raw, facts_map,
|
||||
instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ")
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
raw = {bt: subs for bt, subs in raw.items() if subs}
|
||||
# consolidation renames/catalogs can leave consensus subs without grounding — the
|
||||
# guide fact gate then flags their correct statements wholesale (measured: 74/210)
|
||||
await _facts_nachfass(ctx, _pfiles(files, norm), raw, facts_map, q, folder,
|
||||
instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
p["raw"], p["facts"] = raw, facts_map
|
||||
return None # nur Cancel — Karte bleibt liegen
|
||||
p["raw"], p["facts"], p["sidecar"] = res["raw"], res["facts"], res["sidecar"]
|
||||
p.pop("unsicher", None)
|
||||
p.pop("votes", None)
|
||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||
await db.kanban_advance(topic, BOARD, norm, "levels")
|
||||
await db.kanban_advance(topic, BOARD, norm, "artefakte")
|
||||
|
||||
await _gather_cards(ctx, flow, cards, one)
|
||||
|
||||
|
||||
def _cross_schema(data) -> dict[int, str] | None:
|
||||
"""{"pairs": {"1": "a"|"b"|"nein"}} → {pair_nr: verdict} · otherwise None."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("pairs"), dict):
|
||||
return None
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data["pairs"].items():
|
||||
try:
|
||||
nr = int(k)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
s = str(v).strip().casefold()
|
||||
if s in ("a", "b", "nein"):
|
||||
out[nr] = s
|
||||
return out or None
|
||||
|
||||
|
||||
async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
"""BARRIER/drain — cross-block sub dedup: the SAME statement carried by two blocks
|
||||
(measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes — the
|
||||
in-block paths never see these). Embedding candidates (block≠block, cos ≥
|
||||
SUB_DUP_KANDIDAT_COS) go to a two-judge panel; UNANIMITY decides which block keeps the
|
||||
statement. The loser leaves its card's raw/facts and turns DB `variant` — before
|
||||
questions/artefacts exist, so no orphans. Fail-open on judge failure/dissent."""
|
||||
topic = flow.topic
|
||||
work_dir = flow.work_dir
|
||||
package_norms = {c["card_id"] for c in cards}
|
||||
entries: list[tuple[int, str, str]] = [] # (card idx, block title, sub title); idx -1 = context
|
||||
for ci, c in enumerate(cards):
|
||||
for bt, subs in (c["payload"].get("raw") or {}).items():
|
||||
for s in subs:
|
||||
entries.append((ci, bt, s))
|
||||
n_pkg = len(entries)
|
||||
# Context: consensus subs of blocks already PAST this barrier (late spawns via the
|
||||
# gap-check feedback would otherwise never be compared). Context never folds —
|
||||
# its card payload lives downstream (board-1 rule: confirmed context always wins).
|
||||
for r in await db.list_subblocks(topic):
|
||||
if r["status"] == "consensus" and r["block_norm"] not in package_norms:
|
||||
entries.append((-1, r["block"], r["sub_title"]))
|
||||
ctx_facts: dict[str, dict] = {} # facts of downstream cards (DB rows carry none yet)
|
||||
for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
|
||||
if bc["card_id"] not in package_norms:
|
||||
for bt, fm in (bc["payload"].get("facts") or {}).items():
|
||||
ctx_facts[_norm_title(bt)] = fm
|
||||
|
||||
async def _advance_all():
|
||||
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], "question_pattern") for c in cards])
|
||||
flow.wake.set()
|
||||
|
||||
if n_pkg < 1 or len(entries) < 2 or not EMBEDDING_AKTIV or not await asyncio.to_thread(embedding.available):
|
||||
await _advance_all()
|
||||
return
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, [s for _, _, s in entries])
|
||||
if sims is None:
|
||||
await _advance_all()
|
||||
return
|
||||
negs = [_neg_set(s) for _, _, s in entries]
|
||||
pairs = [(i, j) for i in range(len(entries)) for j in range(i + 1, len(entries))
|
||||
if entries[i][0] != entries[j][0] and negs[i] == negs[j]
|
||||
and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS]
|
||||
if not pairs:
|
||||
await _advance_all()
|
||||
return
|
||||
|
||||
def _kp(ci: int, bt: str, s: str) -> list:
|
||||
if ci < 0:
|
||||
f = ctx_facts.get(_norm_title(bt)) or {}
|
||||
else:
|
||||
f = (cards[ci]["payload"].get("facts") or {}).get(bt) or {}
|
||||
return (f.get(_norm_title(s)) or {}).get("key_points") or []
|
||||
|
||||
def _side(tag: str, ci: int, bt: str, s: str) -> str:
|
||||
return f"{tag}: [Block: {bt}] {s}" + "".join(f"\n - {p}" for p in _kp(ci, bt, s))
|
||||
|
||||
# chunked judging: ONE call over all pairs scaled its timeout past 50 min, and a hung
|
||||
# call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min)
|
||||
chunks = [pairs[lo:lo + CROSS_CHUNK_PAARE] for lo in range(0, len(pairs), CROSS_CHUNK_PAARE)]
|
||||
|
||||
async def _urteile_chunk(chunk: list[tuple[int, int]]) -> dict[int, str]:
|
||||
"""Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict};
|
||||
empty dict = fail-open (pairs stay)."""
|
||||
lines = "\n\n".join(
|
||||
f"{k}.\n{_side('A', *entries[i])}\n{_side('B', *entries[j])}"
|
||||
for k, (i, j) in enumerate(chunk, 1))
|
||||
h = hashlib.md5(lines.encode()).hexdigest()[:8]
|
||||
paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)]
|
||||
|
||||
async def _judge(j, path, plines, n):
|
||||
if _cross_schema(_json_file(path)) is not None:
|
||||
return # resume
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"Sub-Crossblock j{j}", key=f"blocks-{topic}-sub-crossblock-{h}-j{j}",
|
||||
prompt=_prompt("Subblock-Crossblock", topic=topic, pairs=plines, extra=_extra(instructions)),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result, p=path: _sink_json(result, p, _cross_schema),
|
||||
timeout=_timeout("subblock_check", n))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Sub-Crossblock j{j} ohne Ergebnis — fail-open")
|
||||
|
||||
await asyncio.gather(*[_judge(j, p, lines, len(chunk)) for j, p in zip((1, 2), paths)])
|
||||
if ctx.is_cancelled():
|
||||
return {}
|
||||
outs = [o for p in paths if (o := _cross_schema(_json_file(p))) is not None]
|
||||
if len(outs) == 1: # Ersatz-Richter statt fail-open bei EINEM Ausfall
|
||||
ersatz = work_dir / f"sub-crossblock-{h}-jE.json"
|
||||
await _judge("E", ersatz, lines, len(chunk))
|
||||
if ctx.is_cancelled():
|
||||
return {}
|
||||
outs = [o for p in [*paths, ersatz] if (o := _cross_schema(_json_file(p))) is not None]
|
||||
if len(outs) != 2:
|
||||
if outs:
|
||||
_log(topic, "Sub-Crossblock: nur 1/2 Richter — fail-open")
|
||||
return {}
|
||||
final = {k: (outs[0].get(k, "nein") if outs[0].get(k, "nein") == outs[1].get(k, "nein")
|
||||
else "uneinig") for k in range(1, len(chunk) + 1)}
|
||||
disputed = [k for k, v in final.items() if v == "uneinig"]
|
||||
if disputed: # tie-breaker: a third judge sees ONLY the disputed pairs, majority 2/3
|
||||
d_lines = "\n\n".join(
|
||||
f"{x}.\n{_side('A', *entries[chunk[k - 1][0]])}\n{_side('B', *entries[chunk[k - 1][1]])}"
|
||||
for x, k in enumerate(disputed, 1))
|
||||
p3 = work_dir / f"sub-crossblock-{h}-j3.json"
|
||||
await _judge(3, p3, d_lines, len(disputed))
|
||||
if ctx.is_cancelled():
|
||||
return {}
|
||||
v3 = _cross_schema(_json_file(p3)) or {}
|
||||
if not v3:
|
||||
_log(topic, "Sub-Crossblock j3 ohne Ergebnis — strittige Paare bleiben")
|
||||
for x, k in enumerate(disputed, 1):
|
||||
t = v3.get(x, "nein")
|
||||
if t in (outs[0].get(k, "nein"), outs[1].get(k, "nein")):
|
||||
final[k] = t # majority 2/3; anything else stays disputed → no fold
|
||||
return final
|
||||
|
||||
chunk_finals = await asyncio.gather(*[_urteile_chunk(c) for c in chunks])
|
||||
if ctx.is_cancelled():
|
||||
return
|
||||
final_all: dict[int, str] = {} # global pair index (1-based over `pairs`) → verdict
|
||||
for cnr, fin in enumerate(chunk_finals):
|
||||
for k, v in fin.items():
|
||||
final_all[cnr * CROSS_CHUNK_PAARE + k] = v
|
||||
journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []}
|
||||
gone: set[int] = set()
|
||||
touched: set[int] = set()
|
||||
for k, (i, j) in enumerate(pairs, 1):
|
||||
verdict = final_all.get(k, "nein")
|
||||
journal["verdicts"].append({"a": f"{entries[i][1]} · {entries[i][2]}",
|
||||
"b": f"{entries[j][1]} · {entries[j][2]}",
|
||||
"verdict": verdict})
|
||||
if verdict not in ("a", "b"):
|
||||
continue
|
||||
lose = j if verdict == "a" else i
|
||||
if entries[lose][0] < 0: # context never folds — the package side goes instead
|
||||
lose = i if lose == j else j
|
||||
keep = i if lose == j else j
|
||||
if lose in gone or keep in gone: # keeper already folded → don't chain away the content
|
||||
continue
|
||||
ci, bt, s = entries[lose]
|
||||
p = cards[ci]["payload"]
|
||||
if s in (p.get("raw") or {}).get(bt, []):
|
||||
p["raw"][bt].remove(s)
|
||||
(p.get("facts") or {}).get(bt, {}).pop(_norm_title(s), None)
|
||||
sc = (p.get("sidecar") or {}).get(bt)
|
||||
if isinstance(sc, list): # questions/artefacts consume the sidecar downstream
|
||||
p["sidecar"][bt] = [e for e in sc
|
||||
if _norm_title(str((e or {}).get("title", ""))) != _norm_title(s)]
|
||||
await db.set_subblock_fields(topic, _norm_title(bt), _norm_title(s), status="variant")
|
||||
gone.add(lose)
|
||||
touched.add(ci)
|
||||
journal["gefaltet"].append({"weg": f"{bt} · {s}",
|
||||
"bleibt": f"{entries[keep][1]} · {entries[keep][2]}"})
|
||||
for ci in touched:
|
||||
p = cards[ci]["payload"]
|
||||
p["raw"] = {bt: subs for bt, subs in (p.get("raw") or {}).items() if subs}
|
||||
await db.kanban_set_payload(topic, BOARD, cards[ci]["card_id"], p)
|
||||
if journal["gefaltet"]:
|
||||
_log(topic, f"Sub-Crossblock: {len(journal['gefaltet'])} blockübergreifende Dublette(n) gefaltet")
|
||||
hg = hashlib.md5("\n".join(f"{i}:{j}" for i, j in pairs).encode()).hexdigest()[:8]
|
||||
atomic_write_json(work_dir / f"sub-crossblock-{hg}.json", journal, indent=1)
|
||||
await _advance_all()
|
||||
|
||||
|
||||
async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
"""Fragen + Flashcards + Beispiele in einem Generator-Call, ein Prüfer-Call dahinter
|
||||
(block_calls._artefakte_block)."""
|
||||
topic = flow.topic
|
||||
|
||||
async def one(c):
|
||||
p = c["payload"]
|
||||
norm = c["card_id"]
|
||||
sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ")
|
||||
if sidecar is None:
|
||||
return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}")
|
||||
facts_map = p.get("facts") or {}
|
||||
for btitle, subs in sidecar.items(): # merge facts into the subs (mirror + guide)
|
||||
fm = facts_map.get(btitle, {})
|
||||
for sub in subs:
|
||||
# level agents paraphrase titles — exact miss falls back to the unique
|
||||
# prefix/containment match, else the sub silently loses its grounding
|
||||
sn = _sub_key(set(fm), _norm_title(sub["title"]))
|
||||
if (fk := fm.get(sn)):
|
||||
sub["facts"] = fk
|
||||
p["sidecar"] = sidecar
|
||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||
await db.kanban_advance(topic, BOARD, norm, "relevance")
|
||||
|
||||
await _gather_cards(ctx, flow, cards, one)
|
||||
|
||||
|
||||
async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
topic = flow.topic
|
||||
|
||||
async def one(c):
|
||||
p = c["payload"]
|
||||
norm = c["card_id"]
|
||||
sidecar = p.get("sidecar") or {}
|
||||
rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
sidecar, instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ")
|
||||
if rel is None:
|
||||
return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}")
|
||||
gid = 0
|
||||
for subs in sidecar.values():
|
||||
for sub in subs:
|
||||
gid += 1
|
||||
sub["relevance"] = rel.get(gid, "relevant")
|
||||
p["sidecar"] = sidecar
|
||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||
await db.kanban_advance(topic, BOARD, norm, "konsolidierung")
|
||||
|
||||
await _gather_cards(ctx, flow, cards, one)
|
||||
|
||||
|
||||
async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
topic = flow.topic
|
||||
|
||||
async def one(c):
|
||||
p = c["payload"]
|
||||
norm = c["card_id"]
|
||||
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
p.get("sidecar") or {}, instructions,
|
||||
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
|
||||
if pattern is None:
|
||||
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
|
||||
p["pattern"] = pattern
|
||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||
await db.kanban_advance(topic, BOARD, norm, "artefacts")
|
||||
|
||||
await _gather_cards(ctx, flow, cards, one)
|
||||
|
||||
|
||||
async def _proc_artefacts(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
topic = flow.topic
|
||||
|
||||
async def one(c):
|
||||
p = c["payload"]
|
||||
norm = c["card_id"]
|
||||
artefacts = await _artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
p.get("sidecar") or {}, instructions,
|
||||
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
|
||||
if artefacts is None and ctx.is_cancelled():
|
||||
return None
|
||||
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
|
||||
title = p.get("title", "")
|
||||
res = await _artefakte_block(ctx, _pfiles(files, norm), title,
|
||||
(p.get("sidecar") or {}).get(title) or [],
|
||||
instructions, ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
|
||||
melde=_melder(flow, norm))
|
||||
if res is None:
|
||||
return None # nur Cancel
|
||||
p["pattern"] = res["pattern"]
|
||||
p["artefacts"] = res["artefacts"]
|
||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||
await db.kanban_advance(topic, BOARD, norm, "finalize")
|
||||
|
||||
@@ -639,23 +385,37 @@ async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions:
|
||||
|
||||
|
||||
# ── Stage list (appended after board 1 in chain order) ─────────────────────────────
|
||||
_ALT_STAGES = ("subblocks", "facts", "levels", "relevance", "question_pattern", "artefacts",
|
||||
"konsolidierung") # abgeschaltete Stage: verwaiste Karten auf generate zurück
|
||||
|
||||
|
||||
async def migriere_alt_karten(topic: str) -> int:
|
||||
"""Harter Schnitt: Karten der alten Stage-Treppe beim Flow-Start auf `generate`
|
||||
zurücksetzen (Payload auf die Spawn-Felder reduziert — Zwischenstände der alten
|
||||
Struktur sind für die verschmolzenen Calls wertlos). → Anzahl migrierter Karten."""
|
||||
moves = []
|
||||
for c in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
|
||||
if c["stage"] in _ALT_STAGES:
|
||||
p = c["payload"]
|
||||
await db.kanban_set_payload(topic, BOARD, c["card_id"], {
|
||||
"title": p.get("title", ""), "description": p.get("description", ""),
|
||||
"n_size": p.get("n_size", 0), "sources": p.get("sources")})
|
||||
moves.append((c["card_id"], "generate"))
|
||||
if moves:
|
||||
await db.kanban_advance_many(topic, BOARD, moves)
|
||||
return len(moves)
|
||||
|
||||
|
||||
def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
||||
instructions: str) -> list[Stage]:
|
||||
research_done = lambda: flow.research_done # noqa: E731
|
||||
return [
|
||||
Stage(BOARD, "subblocks", lambda cs: _proc_subblocks(ctx, flow, files, instructions, cs)),
|
||||
Stage(BOARD, "facts", lambda cs: _proc_facts(ctx, flow, files, q, folder, instructions, cs)),
|
||||
Stage(BOARD, "levels", lambda cs: _proc_levels(ctx, flow, files, instructions, cs)),
|
||||
Stage(BOARD, "relevance", lambda cs: _proc_relevance(ctx, flow, files, instructions, cs)),
|
||||
# Barrier sits AFTER the sub-local stages: cards used to idle here median 36 min
|
||||
# while levels/relevance work was still ahead of them
|
||||
Stage(BOARD, "konsolidierung",
|
||||
lambda cs: _proc_konsolidierung(ctx, flow, files, instructions, cs),
|
||||
barrier=True, drain=True),
|
||||
Stage(BOARD, "question_pattern",
|
||||
lambda cs: _proc_question_pattern(ctx, flow, files, instructions, cs)),
|
||||
Stage(BOARD, "artefacts", lambda cs: _proc_artefacts(ctx, flow, files, instructions, cs)),
|
||||
Stage(BOARD, "generate", lambda cs: _proc_generate(ctx, flow, files, instructions, cs)),
|
||||
Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)),
|
||||
Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)),
|
||||
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
|
||||
# Cross-Block-Dedup (konsolidierung) entfällt im Bottom-up: Board 1 hat die Atome
|
||||
# global geclustert/dedupliziert, es gibt keine block-übergreifenden Sub-Dubletten mehr.
|
||||
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
|
||||
barrier=True, drain=True, gate=research_done),
|
||||
]
|
||||
+483
-117
@@ -39,27 +39,27 @@ import kanban
|
||||
from kanban import Flow, Stage, chain_stages
|
||||
import blocks
|
||||
from blocks import (
|
||||
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
|
||||
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
|
||||
RESEARCH_THEMA_AGENTS, _FILTER_NOTATION, _GROUP_STANDALONE,
|
||||
_FILTER_NOTATION,
|
||||
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
|
||||
_completion_schema, _containment_parent, _crawl_index, _file_payload,
|
||||
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement,
|
||||
_is_parentless_noise, _is_reference, _pairs_schema, _read,
|
||||
_relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
|
||||
_is_parentless_noise, _is_reference, _reference_strip, _pairs_schema, _read,
|
||||
_direction_conflict, _relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
|
||||
_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, LUECKEN_RESEARCH_MAX,
|
||||
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
|
||||
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
|
||||
RESEARCH_THEMA_AGENTS,
|
||||
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
||||
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
|
||||
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
|
||||
GROUP_RECONCILE_FLOOR, GROUP_THEMES_PER_SQRT,
|
||||
)
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_file
|
||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||
_runde_schema, _timeout, _yesno_schema, run_single_slot)
|
||||
from textkit import _norm_title, _parse_selection, _title, clean_title
|
||||
from textkit import _norm_title, _parse_selection, _title, _unclosed, clean_title
|
||||
|
||||
log = logging.getLogger("creator.board_inventory")
|
||||
|
||||
@@ -104,6 +104,20 @@ def _naming_schema(data, count: int) -> tuple[int | None, str | None, bool] | No
|
||||
return n, name, False
|
||||
|
||||
|
||||
def _sanierung_schema(data) -> tuple[str, str] | None:
|
||||
"""{"title": …, "description": …} → (title, description), beides getrimmt; Titel über
|
||||
80 Zeichen verfällt (leerer String = kein Vorschlag, Feld bleibt wie es ist)."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
t = str(data.get("title") or "").strip()
|
||||
d = str(data.get("description") or "").strip()
|
||||
if not t and not d:
|
||||
return None
|
||||
if len(t) > 80:
|
||||
t = ""
|
||||
return t, d
|
||||
|
||||
|
||||
def _name_verankert(name: str, rows: list[dict], ctoks: set[str] | None) -> bool:
|
||||
"""Abstraction guard: a free-formed title must be anchored — in the corpus (uni/projekt)
|
||||
or in the members' own words (thema). Unanchored names drift to textbook canon
|
||||
@@ -195,6 +209,10 @@ async def _research_once(ctx: GenContext, flow: Flow, q: dict, folder, instructi
|
||||
work_dir = flow.work_dir
|
||||
caps = "files" if folder else "full"
|
||||
p = work_dir / f"research-{tag}.md"
|
||||
mp = None
|
||||
if folder is None: # thema: Fundstellen als Material für die Inline-Folgeschritte sichern
|
||||
(work_dir / "material").mkdir(parents=True, exist_ok=True)
|
||||
mp = work_dir / "material" / f"research-{tag}.txt"
|
||||
stop = asyncio.Event()
|
||||
buf: list[str] = [] # assistant text streamed live from the JSON events
|
||||
|
||||
@@ -223,7 +241,7 @@ async def _research_once(ctx: GenContext, flow: Flow, q: dict, folder, instructi
|
||||
await run_single_slot(
|
||||
ctx, f"Research {tag}", key=f"blocks-{ctx.topic}-research-{tag}",
|
||||
prompt=_build_research_prompt(ctx.topic, p, instructions, q["type"], folder,
|
||||
fokus=fokus, section=section),
|
||||
fokus=fokus, section=section, material_path=mp),
|
||||
role="quick", capabilities=caps,
|
||||
payload=(lambda result, p=p: _file_payload(p)),
|
||||
timeout=RESEARCH_RUNTIME, on_line=_on_line,
|
||||
@@ -405,7 +423,8 @@ async def _pair_one(ctx: GenContext, flow: Flow, c):
|
||||
for ci, chunk in enumerate(chunks):
|
||||
verdict = _pairs_schema(_json_file(flow.work_dir / f"pair-{cid}-{h}-c{ci}.json")) or {}
|
||||
for j, (a, b) in enumerate(chunk):
|
||||
if verdict.get(j + 1) and not _relation_conflict(rows[a]["title"], rows[b]["title"]):
|
||||
# Judge entscheidet Operanden-Identität selbst; nur getauschte Richtung bleibt geblockt
|
||||
if verdict.get(j + 1) and not _direction_conflict(rows[a]["title"], rows[b]["title"]):
|
||||
edges.append((a, b))
|
||||
if sims_title is not None: # auto recall net: near-identical titles merge without the judge
|
||||
for a, b in ordered:
|
||||
@@ -470,6 +489,20 @@ def _hat_anker(title: str, ctoks: set[str]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _sanierung_noetig(p: dict, ctoks: set[str] | None) -> bool:
|
||||
"""QA-messbare Befund-Formen am Entstehungsort: Titel ohne Korpus-Anker (QA: fremd —
|
||||
misst Token-Anker, nicht Semantik) oder leere Beschreibung (QA: hygiene). Zusätzlich ein
|
||||
Referenz-Titel (reine Katalog-Nummer), eine unbalancierte Klammer oder ein überlanger Titel
|
||||
(>80). Gilt auch für Singleton-Cluster, die das Naming sonst überspringen — Reader-Rohtitel
|
||||
gingen wörtlich bis done_block durch (gemessen: 'k-Coloring' statt Korpus-Form 'k-Color')."""
|
||||
title = p.get("title", "")
|
||||
if not (p.get("description") or "").strip():
|
||||
return True
|
||||
if _is_reference(title) or _unclosed(title) or len(title) > 80:
|
||||
return True
|
||||
return bool(ctoks) and not _hat_anker(title, ctoks)
|
||||
|
||||
|
||||
async def _anker_beleg(ctx: GenContext, flow: Flow, kandidaten: list[tuple[str, dict]]) -> set[str]:
|
||||
"""Evidence judge for quorum titles WITHOUT any corpus anchor — two readers naming the
|
||||
same famous canon independently beat the quorum although the material never mentions it
|
||||
@@ -545,6 +578,9 @@ async def _proc_consensus_gate(ctx: GenContext, flow: Flow, cards):
|
||||
else:
|
||||
moves.append((cid, "naming"))
|
||||
else:
|
||||
# single-reader find: majority of the Klärung panel suffices (was unanimity — a single
|
||||
# "nein" discarded a real once-mentioned concept as failed-quorum, e.g. Cook-Levin)
|
||||
p["quorum"] = "majority"
|
||||
moves.append((cid, "clarify"))
|
||||
await db.kanban_set_payload(topic, BOARD, cid, p)
|
||||
if anker_kandidaten:
|
||||
@@ -564,8 +600,9 @@ async def _proc_consensus_gate(ctx: GenContext, flow: Flow, cards):
|
||||
|
||||
async def _proc_clarify(ctx: GenContext, flow: Flow, cards):
|
||||
"""Single-reader finds: deterministic pre-reject, then a 3-judge panel (Blocks-Klaerung).
|
||||
Quorum: UNANIMITY for single-reader clusters (origin-split C1), majority for reference-titled
|
||||
consensus clusters. Kept reference titles get the panel's rename."""
|
||||
Quorum: MAJORITY of the panel for single-reader clusters and for reference-titled consensus
|
||||
clusters (unanimity dropped real once-mentioned concepts on a single dissent). Kept reference
|
||||
titles get the panel's rename."""
|
||||
topic = flow.topic
|
||||
moves: list[tuple[str, str]] = []
|
||||
pending = []
|
||||
@@ -703,9 +740,59 @@ async def _name_one(ctx: GenContext, flow: Flow, c):
|
||||
p["title"] = custom or w["title"]
|
||||
p["description"] = w.get("description") or p.get("description", "")
|
||||
await db.kanban_set_payload(topic, BOARD, cid, p)
|
||||
await _saniere_one(ctx, flow, cid, p)
|
||||
await db.kanban_advance(topic, BOARD, cid, "naming_check")
|
||||
|
||||
|
||||
async def _saniere_one(ctx: GenContext, flow: Flow, cid: str, p: dict) -> None:
|
||||
"""Fremd-/Hygiene-Sanierung am Entstehungsort (nur Korpus-Quellen): Titel ohne
|
||||
Korpus-Anker auf die Oberflächenform des Materials umschreiben, leere Beschreibung
|
||||
belegt nachfassen. Läuft NACH der Member-Wahl auf dem finalen Payload — greift damit
|
||||
auch für Singleton-Cluster und unverankerte Naming-Gewinner. Vorschläge werden
|
||||
deterministisch validiert (_hat_anker, dieselbe Messlatte wie QA-fremd); fail-open:
|
||||
unverankerter Vorschlag oder Judge-Ausfall lässt die Karte unverändert weiter."""
|
||||
from qa import _distinctive
|
||||
topic = flow.topic
|
||||
folder = source_folder(topic)
|
||||
if folder is None:
|
||||
return # thema: kein Korpus, keine Oberflächenform zum Verankern
|
||||
ctoks = flow.state.get("korpus_tokens")
|
||||
if ctoks is None: # Resume: das Konsens-Gate dieses Flows lief nie
|
||||
ctoks = flow.state["korpus_tokens"] = await asyncio.to_thread(_korpus_tokens, folder)
|
||||
if not _sanierung_noetig(p, ctoks):
|
||||
return
|
||||
titel, beschr = p.get("title", ""), (p.get("description") or "").strip()
|
||||
toks = _distinctive(titel) | _distinctive(beschr)
|
||||
ev = _evidence_pack(folder, p.get("sources") or None, [" ".join(sorted(toks))],
|
||||
budget=4000) if toks else ""
|
||||
if not ev:
|
||||
return # ohne Auszüge kein Anker-Rewrite — Anker-Beleg hat Echtheit schon entschieden
|
||||
h = _h(titel, beschr, "sanierung")
|
||||
path = flow.work_dir / f"sanierung-{cid}-{h}.json"
|
||||
verdict = _sanierung_schema(_json_file(path))
|
||||
if verdict is None:
|
||||
status, verdict = await run_single_slot(
|
||||
ctx, f"Sanierung {cid}", key=f"blocks-{topic}-sanierung-{cid}-{h}",
|
||||
prompt=_prompt("Blocks-Sanierung", topic=topic, title=titel,
|
||||
description=beschr or "(leer)", excerpts=ev),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result, p2=path: _sink_json(result, p2, _sanierung_schema),
|
||||
timeout=_timeout("selection_mapping", 1))
|
||||
if status != OK or verdict is None:
|
||||
return
|
||||
neu_titel, neu_beschr = verdict
|
||||
changed = False
|
||||
if (neu_titel and _norm_title(neu_titel) != _norm_title(titel)
|
||||
and _hat_anker(neu_titel, ctoks)):
|
||||
p["title"] = clean_title(neu_titel)
|
||||
changed = True
|
||||
if not beschr and neu_beschr:
|
||||
p["description"] = neu_beschr
|
||||
changed = True
|
||||
if changed:
|
||||
await db.kanban_set_payload(topic, BOARD, cid, p)
|
||||
|
||||
|
||||
async def _proc_naming_check(ctx: GenContext, flow: Flow, cards):
|
||||
results = await asyncio.gather(*[_namecheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
errs = [r for r in results if isinstance(r, Exception)]
|
||||
@@ -731,6 +818,12 @@ async def _namecheck_one(ctx: GenContext, flow: Flow, c):
|
||||
p["description"] = w.get("description") or p.get("description", "")
|
||||
readers = sorted(set().union(*[set(r.get("readers") or []) for r in rows])) if rows else []
|
||||
sources = sorted(set().union(*[set(r.get("sources") or []) for r in rows])) if rows else []
|
||||
# deterministic catalogue-number strip (final title): a source numbering is never canonical —
|
||||
# "Satz 7.13 (Christofides)" → "Christofides", "Satz 7.6: Kriterium …" → "Kriterium …". Runs AFTER
|
||||
# naming_check so a re-picked member title cannot re-introduce the number.
|
||||
stripped = _reference_strip(p.get("title", ""))
|
||||
if stripped and _norm_title(stripped) != _norm_title(p.get("title", "")) and not _is_reference(stripped):
|
||||
p["title"] = clean_title(stripped)
|
||||
await db.kanban_upsert_card(topic, BOARD, f"b-{cid}", "block", "fragment_filter", {
|
||||
"title": p.get("title", ""), "description": p.get("description", ""),
|
||||
"readers": readers, "sources": sources, "n_size": len(readers),
|
||||
@@ -946,13 +1039,21 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
for nr in nums:
|
||||
if nr in fragments or nr in honored:
|
||||
continue
|
||||
votes = len(dem.get(nr, []))
|
||||
if drp.get(nr, 0) >= 2 and (_is_artifact(allrows[nr - 1]["title"])
|
||||
or _is_parentless_noise(allrows[nr - 1]["title"])):
|
||||
honored.add(nr)
|
||||
elif len(dem.get(nr, [])) >= 2:
|
||||
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
||||
elif nr in proposals:
|
||||
# a pass-1 judge already proposed this demote — overturn it ONLY on a
|
||||
# UNANIMOUS recheck keep (zero demote votes). 15/17 majority-rescued cards
|
||||
# were later QA duplicates, so a single recheck demote reconfirms the fragment.
|
||||
if votes >= 1:
|
||||
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
||||
else:
|
||||
overruled.append(nr)
|
||||
elif votes >= 2:
|
||||
# pure ⚠ suspect without a pass-1 proposal: still needs a panel majority
|
||||
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
||||
# embedding backstop: veto confirmed non-containment demotes whose direct title pair is
|
||||
# literally structureless (see FRAGMENT_MIN_COS) — applied BEFORE _root resolution.
|
||||
floor_veto: list[int] = []
|
||||
@@ -1104,7 +1205,10 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
|
||||
detail: dict[tuple[int, int], str] = {}
|
||||
|
||||
def _edge(a, b, kanal):
|
||||
if _relation_conflict(allrows[a]["title"], allrows[b]["title"]):
|
||||
# Auto-Merges (kein Judge): voller String-Guard. Judge-Kanal ("ja"): nur der deterministische
|
||||
# Richtungs-Guard — Operanden-Identität (Synonym vs. echt verschieden) hat der Judge entschieden.
|
||||
guard = _direction_conflict if kanal == "ja" else _relation_conflict
|
||||
if guard(allrows[a]["title"], allrows[b]["title"]):
|
||||
detail[(a, b)] = "guard_veto"
|
||||
else:
|
||||
edges.append((a, b))
|
||||
@@ -1168,6 +1272,80 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _attach_nachzuegler(ctx: GenContext, flow: Flow, rows: list[dict]) -> set[str]:
|
||||
"""Nachzügler-Atome (Supplement-/Lücken-Welle) zuerst an BESTEHENDE fertige Blöcke
|
||||
anschließen: das Atom wird Sub des Blocks (Bottom-up-Persistenz) statt Zwergblock
|
||||
mit einem Sub; die Board-2-Karte des Blocks geht zurück auf generate (Re-Anreicherung,
|
||||
Resume-Hash ändert sich mit der Sub-Menge). Erstwelle hat keine done_block-Blöcke →
|
||||
no-op. Ohne Zuordnung bleibt das Atom standalone (echtes neues Konzept).
|
||||
→ card_ids der angeschlossenen rows."""
|
||||
topic = flow.topic
|
||||
done = [c for c in await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block")
|
||||
if c["payload"].get("mirrored_norm")]
|
||||
if not done or not rows:
|
||||
return set()
|
||||
work_dir = flow.work_dir
|
||||
h = _h(*[r["card_id"] for r in rows], "attach")
|
||||
path = work_dir / f"gruppierung-attach-{h}.json"
|
||||
ids = set(range(1, len(rows) + 1))
|
||||
add = _completion_schema(_json_file(path), len(done), ids)
|
||||
if add is None:
|
||||
anchors = "\n".join(
|
||||
f"UMBRELLA {k}: {c['payload'].get('title', '')} — {(c['payload'].get('description') or '')[:160]}"
|
||||
for k, c in enumerate(done))
|
||||
rest = "\n".join(f"{i}. {_t_text(r)}" for i, r in enumerate(rows, 1))
|
||||
status, add = await run_single_slot(
|
||||
ctx, "Gruppierung Anschluss", key=f"blocks-{topic}-gruppierung-attach-{h}",
|
||||
prompt=_prompt("Blocks-Gruppierung-Completion", topic=topic, umbrellas=anchors,
|
||||
rest=rest, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _completion_schema(_json_file(p), len(done), ids),
|
||||
timeout=_timeout("research_mapping", len(rows)))
|
||||
if status != OK or add is None:
|
||||
return set()
|
||||
attached: set[str] = set()
|
||||
moves = []
|
||||
for k, members in (add or []):
|
||||
c = done[k]
|
||||
bnorm = c["payload"]["mirrored_norm"]
|
||||
btitle = c["payload"].get("title", "")
|
||||
traf = False
|
||||
for m in members:
|
||||
if not (1 <= m <= len(rows)) or rows[m - 1]["card_id"] in attached:
|
||||
continue
|
||||
r = rows[m - 1]
|
||||
sn = _norm_title(r["title"])
|
||||
if not sn:
|
||||
continue
|
||||
await db.put_subblock(topic, bnorm, sn, btitle, r["title"], status="consensus")
|
||||
r["payload"].update(reason="attach", merged_into=btitle)
|
||||
await db.kanban_set_payload(topic, BOARD, r["card_id"], r["payload"])
|
||||
moves.append((r["card_id"], "grouped"))
|
||||
attached.add(r["card_id"])
|
||||
ch = c["payload"].setdefault("children", [])
|
||||
if r["title"] not in ch:
|
||||
ch.append(r["title"])
|
||||
traf = True
|
||||
if traf:
|
||||
await db.kanban_set_payload(topic, BOARD, c["card_id"], c["payload"])
|
||||
b2 = await db.kanban_get_card(topic, "artefacts", bnorm)
|
||||
if b2 and b2["stage"] != "generate": # neuen Sub anreichern lassen
|
||||
await db.kanban_advance(topic, "artefacts", bnorm, "generate")
|
||||
if moves:
|
||||
await db.kanban_advance_many(topic, BOARD, moves)
|
||||
_log(topic, f"Gruppierung: {len(moves)} Nachzügler an bestehende Blöcke angeschlossen")
|
||||
return attached
|
||||
|
||||
|
||||
def _themen_zielband(n: int) -> tuple[int, int]:
|
||||
"""Weiches Prompt-Band der Themenzahl: k = GROUP_THEMES_PER_SQRT·√n, ±30 %, min 2.
|
||||
n ist die Item-Zahl der GROUPING-WELLE — die Supplement-Welle bekommt bewusst ein
|
||||
kleines Band aus ihrem kleinen n (sie sortiert nur Neuzugänge nach)."""
|
||||
k = GROUP_THEMES_PER_SQRT * math.sqrt(max(n, 1))
|
||||
lo = max(2, math.floor(0.7 * k))
|
||||
return lo, max(lo + 1, math.ceil(1.3 * k))
|
||||
|
||||
|
||||
async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
"""BARRIER/drain — umbrella grouping over the filter survivors: embedding sibling clusters
|
||||
(low floor, high recall) + top-down pass, one judge per cluster, type gate + min-cos backstop,
|
||||
@@ -1178,6 +1356,13 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
"title": c["payload"].get("title", ""),
|
||||
"description": c["payload"].get("description") or "",
|
||||
"title_norm": _norm_title(c["payload"].get("title", ""))} for c in cards]
|
||||
# Nachzügler-Wellen: erst an bestehende fertige Blöcke anschließen (Sub statt Zwergblock)
|
||||
attached = await _attach_nachzuegler(ctx, flow, rows)
|
||||
if attached:
|
||||
rows = [r for r in rows if r["card_id"] not in attached]
|
||||
if not rows:
|
||||
flow.wake.set()
|
||||
return
|
||||
n = len(rows)
|
||||
if not (BLOCKS_GRUPPIERUNG_AKTIV and await _emb_ok(flow)) or n < 3:
|
||||
await db.kanban_advance_many(topic, BOARD, [(r["card_id"], "gap_check") for r in rows])
|
||||
@@ -1196,11 +1381,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
all_ids = set(range(1, n + 1))
|
||||
full_list = "\n".join(f"{i}. {texts[i - 1]}" for i in range(1, n + 1))
|
||||
h = _h(*[r["card_id"] for r in rows])
|
||||
|
||||
def _min_cos(idxs):
|
||||
if len(idxs) < 2:
|
||||
return 1.0
|
||||
return round(min(float(sims[i][j]) for a, i in enumerate(idxs) for j in idxs[a + 1:]), 3)
|
||||
theme_lo, theme_hi = _themen_zielband(n)
|
||||
|
||||
async def _assess(tag, cand_text, count):
|
||||
path = work_dir / f"gruppierung-{h}-c{tag}.json"
|
||||
@@ -1208,7 +1389,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"Gruppierung {tag}", key=f"blocks-{topic}-gruppierung-{h}-c{tag}",
|
||||
prompt=_prompt("Blocks-Gruppierung", topic=topic, candidates=cand_text,
|
||||
list=full_list, out_path=path),
|
||||
list=full_list, out_path=path,
|
||||
theme_lo=theme_lo, theme_hi=theme_hi, n_items=n),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _umbrella_schema(_json_file(p), all_ids),
|
||||
timeout=_timeout("research_mapping", count))
|
||||
@@ -1219,8 +1401,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
# ONE wave: TOP + all cluster judges in parallel. The used-ties precedence lives in the
|
||||
# ORDER of `sources` (TOP first), not in execution order — a failed judge simply leaves
|
||||
# no valid file and is skipped in the collection below (legacy semantics).
|
||||
jobs = [("TOP", "Scan the ENTIRE block list below and propose EVERY genuine umbrella "
|
||||
"you find — do not restrict yourself to any subset.", n)]
|
||||
jobs = [("TOP", "Card-sort the ENTIRE list below into themes: assign as MANY items as "
|
||||
"possible to a coherent theme, each item into exactly one.", n)]
|
||||
jobs += [(str(ci), "\n".join(f"{g + 1}. {texts[g]}" for g in cluster), len(cluster))
|
||||
for ci, cluster in enumerate(multi)]
|
||||
await asyncio.gather(*[_assess(tag, cand, count) for tag, cand, count in jobs],
|
||||
@@ -1242,16 +1424,9 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
members = [m for m in members if m not in used]
|
||||
if len(members) < 2:
|
||||
continue
|
||||
# Bottom-up card-sorting: keine type-gate/min-cos-Vetos mehr — jedes Item soll in
|
||||
# ein Thema; ein „Fehl-Merge" ist billig (Item bleibt als Sub erhalten, keine Lücke).
|
||||
mrows = [rows[m - 1] for m in members]
|
||||
if any(_GROUP_STANDALONE.search(r["title"]) for r in mrows):
|
||||
skipped.append({"umbrella": title, "grund": "type-gate",
|
||||
"mitglieder": [r["title"] for r in mrows]})
|
||||
continue
|
||||
mc = _min_cos([m - 1 for m in members])
|
||||
if mc < GROUP_MIN_COS_FLOOR:
|
||||
skipped.append({"umbrella": title, "grund": "min-cos", "min_cos": mc,
|
||||
"mitglieder": [r["title"] for r in mrows]})
|
||||
continue
|
||||
unorm = _norm_title(title)
|
||||
member_norms = {r["title_norm"] for r in mrows}
|
||||
if unorm not in member_norms and unorm in seen_norm:
|
||||
@@ -1260,7 +1435,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
continue
|
||||
used.update(members)
|
||||
seen_norm.add(unorm)
|
||||
chosen.append({"umbrella": title, "description": desc, "min_cos": mc, "members": members})
|
||||
chosen.append({"umbrella": title, "description": desc, "members": members})
|
||||
# reconcile: same parent proposed twice under different titles → union
|
||||
if len(chosen) >= 2:
|
||||
uv = await _vec_rows(flow, [f"{c['umbrella']} — {c['description']}" for c in chosen])
|
||||
@@ -1308,7 +1483,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
add = []
|
||||
for k, new_members in (add or []):
|
||||
for m in new_members:
|
||||
if m in used or not (1 <= m <= n) or _GROUP_STANDALONE.search(rows[m - 1]["title"]):
|
||||
if m in used or not (1 <= m <= n):
|
||||
continue
|
||||
used.add(m)
|
||||
chosen[k]["members"].append(m)
|
||||
@@ -1364,24 +1539,25 @@ async def _proc_gap_check(ctx: GenContext, flow: Flow, cards):
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list) -> list:
|
||||
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list, tag: str = "") -> list:
|
||||
"""Evidence gate for supplement proposals: keyword excerpts per proposal, ONE no-tool
|
||||
judge marks material coverage (ja/nein). Proposals without any matching excerpt drop
|
||||
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round)."""
|
||||
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round).
|
||||
`tag` trennt Resume-Datei/Key je Aufrufer (Lücken-Recherche läuft mehrere Runden)."""
|
||||
topic = flow.topic
|
||||
folder = source_folder(topic)
|
||||
packs = [(t, d, _evidence_pack(folder, None, [t], budget=6000)) for t, d in supplements]
|
||||
cands = [(t, d, ev) for t, d, ev in packs if ev]
|
||||
kept: list = []
|
||||
if cands:
|
||||
path = flow.work_dir / "supplement-beleg.json"
|
||||
path = flow.work_dir / f"supplement-beleg{tag}.json"
|
||||
ids = set(range(1, len(cands) + 1))
|
||||
verdict = _yesno_schema(_json_file(path), ids)
|
||||
if verdict is None:
|
||||
lines = "\n\n".join(f"{k}. {t} — {d}\nAUSZÜGE:\n{ev}"
|
||||
for k, (t, d, ev) in enumerate(cands, 1))
|
||||
status, verdict = await run_single_slot(
|
||||
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg",
|
||||
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg{tag}",
|
||||
prompt=_prompt("Blocks-Supplement-Beleg", topic=topic, proposals=lines,
|
||||
extra=_extra(flow.state.get("instructions", ""))),
|
||||
role="judge", capabilities="none",
|
||||
@@ -1427,11 +1603,21 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
# treats). Only proposals the material itself covers may enter the inventory.
|
||||
if supplements and source_folder(topic):
|
||||
supplements = await _supplement_beleg(ctx, flow, supplements)
|
||||
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
|
||||
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
|
||||
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
|
||||
# in the dedup: those were rejected as non-blocks, not lost as content.
|
||||
new, reopened = await _ingest_proposals(topic, supplements or [])
|
||||
if new or reopened:
|
||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _ingest_proposals(topic: str, supplements: list) -> tuple[int, int]:
|
||||
"""Vorschläge (title, description) als Titel-Karten einspeisen — gemeinsamer Ingest-
|
||||
Schwanz von Supplement- und Lücken-Recherche. → (neu, wiedereröffnet).
|
||||
|
||||
Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||
must NOT dedup a proposal — their content is gone. A hit on a dead title REOPENS the
|
||||
lineage instead: the title card rejoins its cluster (live re-cluster) and the respawned
|
||||
block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay in the dedup:
|
||||
those were rejected as non-blocks, not lost as content."""
|
||||
dead_reasons = {"fragment", "drop-collateral", "drop"}
|
||||
cards = await db.kanban_cards(topic, board=BOARD)
|
||||
dead_clusters = {c["payload"].get("cluster") for c in cards
|
||||
@@ -1465,7 +1651,7 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
if (k := _canonical_key(tt)):
|
||||
known_keys.add(k)
|
||||
new = reopened = 0
|
||||
for t, d in (supplements or []):
|
||||
for t, d in supplements:
|
||||
t, d = clean_title(t), clean_title(d)
|
||||
norm = _norm_title(t)
|
||||
key = _canonical_key(t)
|
||||
@@ -1489,9 +1675,57 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
card["payload"]["supplement"] = True
|
||||
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
|
||||
new += 1
|
||||
return new, reopened
|
||||
|
||||
|
||||
async def _luecken_producer(ctx: GenContext, flow: Flow, report: dict) -> int:
|
||||
"""Gezielte Nach-Recherche für offene QA-Lücken (Auto-Loop): EIN Agent sieht Bestand
|
||||
plus Lücken-Fundstellen (Abschnittstexte deterministisch re-extrahiert, gleicher
|
||||
Splitter wie die QA) und schlägt NUR dafür Blöcke vor — leere Liste erlaubt.
|
||||
Danach Evidence-Gate + Ingest wie beim Supplement; neue Titel durchlaufen alle
|
||||
Inventar-Gates bis done_block. → Zahl neuer/wiedereröffneter Karten."""
|
||||
import qa as _qa
|
||||
topic = flow.topic
|
||||
lk, kl = _qa.offene_luecken(report)
|
||||
if not (lk or kl):
|
||||
return 0
|
||||
flow.add_producer() # Engine am Leben halten, solange der Research-Call läuft
|
||||
try:
|
||||
texts = _qa._corpus_texts(topic)
|
||||
sections = {f: _qa._sections(t) for f, t in texts.items()}
|
||||
gaps = []
|
||||
for x in lk[:20]:
|
||||
sec = sections.get(x.get("datei"), [])
|
||||
k = int(x.get("abschnitt") or 0)
|
||||
volltext = sec[k - 1][:1500] if 0 < k <= len(sec) else x.get("vorschau", "")
|
||||
gaps.append(f"[{x.get('datei')} #{k}]\n{volltext}")
|
||||
gaps += [f"NAMED RESULT (no covering block): {n}" for n in kl[:20]]
|
||||
done = await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block")
|
||||
runde = flow.state.get("luecken_runde", 0)
|
||||
path = flow.work_dir / f"luecken-research-r{runde}.json"
|
||||
supplements = _supplement_schema(_json_file(path))
|
||||
if supplements is None:
|
||||
status, supplements = await run_single_slot(
|
||||
ctx, f"Lücken-Recherche r{runde}", key=f"blocks-{topic}-luecken-nachfass-r{runde}",
|
||||
prompt=_prompt("Blocks-Luecken-Research", topic=topic,
|
||||
blocks="\n".join(f"- {c['payload'].get('title', '')}" for c in done),
|
||||
gaps="\n\n".join(gaps), out_path=path,
|
||||
extra=_extra(flow.state.get("instructions", ""))),
|
||||
role="quick", capabilities="files",
|
||||
payload=lambda result, p=path: _supplement_schema(_json_file(p)),
|
||||
timeout=_timeout("ergaenzung"))
|
||||
if status != OK or supplements is None:
|
||||
_log(topic, "Lücken-Recherche ohne Ergebnis — übersprungen")
|
||||
return 0
|
||||
if supplements and source_folder(topic):
|
||||
supplements = await _supplement_beleg(ctx, flow, supplements, tag=f"-l{runde}")
|
||||
new, reopened = await _ingest_proposals(topic, supplements or [])
|
||||
if new or reopened:
|
||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
_log(topic, f"Lücken-Recherche: {new} Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
return new + reopened
|
||||
finally:
|
||||
flow.done_producer()
|
||||
|
||||
|
||||
async def _proc_done(ctx: GenContext, flow: Flow, cards):
|
||||
@@ -1518,6 +1752,19 @@ async def _proc_done(ctx: GenContext, flow: Flow, cards):
|
||||
await db.upsert_block(topic, norm, title, p.get("description", ""), p.get("sources") or [])
|
||||
await db.set_block_status(topic, norm, "consensus",
|
||||
title=title, description=p.get("description", ""))
|
||||
# Bottom-up-Persistenz: die geclusterten Atome bleiben als Subs (jedes Atom genau
|
||||
# ein Sub). Ein childless Standalone-Block ist sein eigener einziger Sub. Board 2
|
||||
# reichert diese an, entdeckt sie nie neu — so überlebt jedes Board-1-Atom.
|
||||
sub_titles = [(ch.get("title") if isinstance(ch, dict) else ch)
|
||||
for ch in (p.get("children") or [])]
|
||||
sub_titles = [s for s in sub_titles if s] or [title]
|
||||
seen_sub: set[str] = set()
|
||||
for st in sub_titles:
|
||||
sn = _norm_title(st)
|
||||
if not sn or sn in seen_sub:
|
||||
continue
|
||||
seen_sub.add(sn)
|
||||
await db.put_subblock(topic, norm, sn, title, st, status="consensus")
|
||||
mirrored[norm] = c["card_id"]
|
||||
p.update(mirrored_norm=norm, title=title)
|
||||
await db.kanban_set_payload(topic, BOARD, c["card_id"], p)
|
||||
@@ -1569,14 +1816,24 @@ async def _preload_state(flow: Flow):
|
||||
|
||||
|
||||
async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str,
|
||||
research: bool = True, artefacts: bool = True, qa_force: bool = False) -> bool:
|
||||
research: bool = True, artefacts: bool = True, qa_force: bool = False,
|
||||
auto_inventory: bool = True, auto_artefacts: bool = True,
|
||||
inventory: bool = True, status_out: dict | None = None) -> bool:
|
||||
"""Run the inventory board (plus board 2 „Artefakte") until quiescence.
|
||||
inventory=False = artefacts-only: board 1 is NOT run; board-2 cards are seeded from the
|
||||
finished blocks and the QA gate stays open (the inventory is already done).
|
||||
research=False = Continue: drain the existing queue, search nothing new.
|
||||
qa_force=True overrides a failed QA gate (user clicked „Trotzdem fortsetzen")."""
|
||||
qa_force=True overrides a failed QA gate (user clicked „Trotzdem fortsetzen").
|
||||
auto_inventory: after the inventory QA, loop repair until 100 %/stall/10×, then open the
|
||||
board-2 gate; off = pause after the QA. auto_artefacts: same loop after board 2.
|
||||
status_out (optional): filled with {"weiter": bool, "note_artefakte": float|None} — the
|
||||
orchestrator uses „weiter" to decide whether the guide level may start next."""
|
||||
topic = ctx.topic
|
||||
flow = Flow(topic, files["arbeit"])
|
||||
flow.state["instructions"] = instructions
|
||||
flow.state["qa_force"] = qa_force
|
||||
flow.state["auto_inventory"] = auto_inventory
|
||||
flow.state["auto_artefacts"] = auto_artefacts
|
||||
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-{uuid.uuid4().hex[:4]}"
|
||||
flow.state["run_id"] = run_id
|
||||
flow.state["run_started"] = datetime.now(timezone.utc).isoformat()
|
||||
@@ -1585,28 +1842,37 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
pages = await db.list_content(topic)
|
||||
flow.state["pages"] = pages or sorted(set(_crawl_index(folder).values()))
|
||||
await _preload_state(flow)
|
||||
stages = inventory_stages(ctx, flow)
|
||||
stages = inventory_stages(ctx, flow) if inventory else []
|
||||
inv_names = [st.stage for st in stages]
|
||||
if artefacts:
|
||||
import board_artefacts # lazy — board_artefacts imports blocks too
|
||||
flow.state["spawn_artefact"] = board_artefacts.make_spawner(topic, files)
|
||||
await board_artefacts.ensure_outline_card(topic)
|
||||
if not inventory: # board 1 does not run → seed the board-2 cards from the finished blocks
|
||||
geseedet = await board_artefacts.seed_artefact_cards(topic, files)
|
||||
if geseedet:
|
||||
_log(topic, f"Board 2 (nur Artefakte): {geseedet} Karte(n) aus fertigen Blocks erzeugt")
|
||||
migriert = await board_artefacts.migriere_alt_karten(topic)
|
||||
if migriert:
|
||||
_log(topic, f"Board 2: {migriert} Karte(n) der alten Stage-Struktur → generate")
|
||||
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
|
||||
if QA_GATE_NOTE > 0:
|
||||
if QA_GATE_NOTE > 0 and inventory:
|
||||
# QA gate: board 2 waits until the inventory QA passed (or the user forces).
|
||||
# Costs pipelining (board 2 no longer starts per finished block) but saves
|
||||
# tokens on a bad foundation — the watcher below runs the QA and decides.
|
||||
sub = next(st for st in stages if st.stage == "subblocks")
|
||||
sub = next(st for st in stages if st.stage == "generate")
|
||||
sub.gate = lambda: bool(flow.state.get("qa_ok") or flow.state.get("qa_force"))
|
||||
elif not inventory:
|
||||
flow.state["qa_ok"] = True # artefacts-only: no inventory gate, board 2 runs immediately
|
||||
stages = chain_stages(stages)
|
||||
if artefacts:
|
||||
# Outline needs every block's TITLE + FACTS, nothing later: cut the post-facts
|
||||
# artefact stages from its barrier so it runs parallel to levels…finalize of the
|
||||
# slowest block (makespan tail). Inventory stages all stay — no late blocks.
|
||||
# Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the
|
||||
# post-generate stages from its barrier so it runs parallel to verify…finalize
|
||||
# des langsamsten Blocks (makespan tail).
|
||||
outline = next(s for s in stages if s.stage == "outline")
|
||||
outline.upstream = [u for u in outline.upstream if u not in
|
||||
("levels", "relevance", "question_pattern", "artefacts", "finalize")]
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
|
||||
("verify", "artefakte", "finalize")]
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if (research and inventory) else []
|
||||
|
||||
async def _as_producer(coro):
|
||||
try:
|
||||
@@ -1623,37 +1889,63 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
# cancel hook: blocks.cancel_blocks flips is_cancelled; stop the flow with it
|
||||
stopper = asyncio.create_task(_stop_on_cancel(ctx, flow))
|
||||
watcher = (asyncio.create_task(_qa_gate_watch(ctx, flow, inv_names, set_p))
|
||||
if artefacts and QA_GATE_NOTE > 0 else None)
|
||||
if artefacts and QA_GATE_NOTE > 0 and inventory else None)
|
||||
try:
|
||||
try:
|
||||
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
|
||||
finally:
|
||||
stopper.cancel()
|
||||
if watcher:
|
||||
watcher.cancel()
|
||||
db.set_current_run(topic, None)
|
||||
if ctx.is_cancelled():
|
||||
return False
|
||||
if flow.state.get("infra_paused"):
|
||||
from blocks import _blocks_errors # lazy: Import-Zyklus vermeiden
|
||||
_blocks_errors[topic] = flow.state.get("infra_error") \
|
||||
or "Pausiert: API-Ratelimit (429) — «Fortsetzen», sobald Kontingent zurück"
|
||||
if status_out is not None:
|
||||
status_out["weiter"] = False
|
||||
return True # kein Fehler: Karten warten resümierbar in ihrer Spalte
|
||||
if flow.state.get("qa_paused"):
|
||||
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in subblocks
|
||||
if status_out is not None:
|
||||
status_out["weiter"] = False # Inventar pausiert → Board 2 lief nicht
|
||||
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in generate
|
||||
# Board 2 lief durch → Artefakt-Ebene: QA + (Auto) „Befunde beheben"-Loop.
|
||||
if artefacts and not ctx.is_cancelled():
|
||||
await _artefakt_auto_loop(ctx, flow, set_p)
|
||||
await _write_final(topic, files)
|
||||
await _write_run_summary(topic, flow)
|
||||
if status_out is not None:
|
||||
status_out["weiter"] = not (ctx.is_cancelled() or flow.state.get("artefakt_stopp"))
|
||||
status_out["note_artefakte"] = flow.state.get("note_artefakte")
|
||||
return True
|
||||
finally:
|
||||
# erst NACH der Abschluss-QA leeren: deren Judge-Events gehören zum Lauf —
|
||||
# vorher fielen sie ohne run_id aus jeder Run-Aggregation (Lauf 20260704-1452-b223)
|
||||
db.set_current_run(topic, None)
|
||||
|
||||
|
||||
_QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters
|
||||
|
||||
|
||||
async def _warte_inventar_ruhe(flow: Flow, inv_names: list[str]) -> None:
|
||||
"""Bis das Inventar quiescent ist (Research fertig, keine aktiven Karten in den
|
||||
Inventar-Stages) oder der Flow stoppt — Poll-Kopf des QA-Wächters, auch nach
|
||||
Lücken-Recherche-Runden gebraucht (neue Karten laufen erst durch alle Gates)."""
|
||||
while not flow.stop:
|
||||
if (flow.research_done and not flow.active_in(inv_names)
|
||||
and await db.kanban_count(flow.topic, inv_names) == 0):
|
||||
return
|
||||
await asyncio.sleep(_QA_GATE_POLL)
|
||||
|
||||
|
||||
async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_p):
|
||||
"""Companion task: once the inventory is quiescent, run the QA once and decide —
|
||||
open the board-2 gate or pause the flow. Fail-OPEN on errors (QA is a helper,
|
||||
not a jailer); qa_force short-circuits to open."""
|
||||
topic = flow.topic
|
||||
try:
|
||||
while not flow.stop:
|
||||
if (flow.research_done and not flow.active_in(inv_names)
|
||||
and await db.kanban_count(topic, inv_names) == 0):
|
||||
break
|
||||
await asyncio.sleep(_QA_GATE_POLL)
|
||||
await _warte_inventar_ruhe(flow, inv_names)
|
||||
if flow.stop or ctx.is_cancelled():
|
||||
return
|
||||
if flow.state.get("qa_force"):
|
||||
@@ -1665,17 +1957,66 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
|
||||
report = await qa.qa_report(topic, llm=QA_GATE_LLM)
|
||||
note = float(report["note"]) if report else 10.0
|
||||
flow.state["qa_note"] = note
|
||||
if report:
|
||||
if not report:
|
||||
flow.state["qa_ok"] = True # nichts messbar → fail-open (QA ist Helfer, nicht Jailer)
|
||||
flow.wake.set()
|
||||
return
|
||||
try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen
|
||||
await asyncio.to_thread(qa._write_report, report)
|
||||
await qa.write_report(report)
|
||||
except Exception:
|
||||
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
|
||||
if note >= QA_GATE_NOTE:
|
||||
_log(topic, f"QA-Gate: Note {note} ≥ {QA_GATE_NOTE} — Board 2 startet")
|
||||
|
||||
if not flow.state.get("auto_inventory", True):
|
||||
# Auto aus: nach der QA anhalten (Kontrollpunkt), egal welche Note.
|
||||
_log(topic, f"Inventar-QA {round(note * 10)} % — Auto aus, pausiert")
|
||||
set_p(f"Inventar-QA {round(note * 10)} % — Auto aus, «Fortsetzen» startet Board 2")
|
||||
flow.state["qa_paused"] = True
|
||||
flow.stop = True
|
||||
flow.wake.set()
|
||||
return
|
||||
|
||||
# Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×. Offene Lücken
|
||||
# bekommen erst bis zu LUECKEN_RESEARCH_MAX gezielte Nach-Recherche-Runden;
|
||||
# das Lücken-Urteil (Freispruch/bestätigt) fällt erst, wenn die Recherche
|
||||
# nichts mehr bewegt — sonst würde eine füllbare Lücke vorschnell verurteilt.
|
||||
from auto_loop import auto_repair_loop
|
||||
import repair as _repair
|
||||
|
||||
async def _reparieren():
|
||||
rep = qa.latest_report(topic) or {}
|
||||
research_bewegt = False
|
||||
runde = flow.state.get("luecken_runde", 0)
|
||||
lk, kl = qa.offene_luecken(rep)
|
||||
if (lk or kl) and runde < LUECKEN_RESEARCH_MAX:
|
||||
flow.state["luecken_runde"] = runde + 1
|
||||
set_p(f"Lücken-Recherche {runde + 1}/{LUECKEN_RESEARCH_MAX}…")
|
||||
neue = await _luecken_producer(ctx, flow, rep)
|
||||
research_bewegt = neue > 0
|
||||
if neue: # neue Titel durchlaufen alle Gates — erst Ruhe, dann messen
|
||||
await _warte_inventar_ruhe(flow, inv_names)
|
||||
if flow.stop or ctx.is_cancelled():
|
||||
return float(flow.state.get("qa_note") or 0.0), False
|
||||
set_p("Befunde beheben (Inventar)…")
|
||||
r = await _repair.repair_befunde(topic, "inventory",
|
||||
luecken_urteil=not research_bewegt)
|
||||
n = float(r.get("note", 10.0))
|
||||
flow.state["qa_note"] = n
|
||||
return n, bool(r.get("aktionen", 0)) or research_bewegt
|
||||
|
||||
res = await auto_repair_loop("Inventar", note, _reparieren)
|
||||
end_note = res["note"]
|
||||
if res["grund"] == "fertig":
|
||||
_log(topic, f"Inventar: 100 % nach {res['runden']} Runde(n) — Board 2 startet")
|
||||
flow.state["qa_ok"] = True
|
||||
else:
|
||||
_log(topic, f"QA-Gate: Note {note} < {QA_GATE_NOTE} — pausiert (Continue erzwingt)")
|
||||
set_p(f"QA-Note {note} < {QA_GATE_NOTE} — pausiert")
|
||||
# Stillstand/Limit ohne 100 % → Abbruch: Repair hat keinen Pfad für die Restbefunde.
|
||||
msg = (f"Inventar bleibt bei {round(end_note * 10)} % ({res['grund']}, "
|
||||
f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad für die "
|
||||
f"Restbefunde (Systemfehler)")
|
||||
_log(topic, msg)
|
||||
set_p(msg)
|
||||
from blocks import _blocks_errors
|
||||
_blocks_errors[topic] = msg
|
||||
flow.state["qa_paused"] = True
|
||||
flow.stop = True
|
||||
flow.wake.set()
|
||||
@@ -1687,6 +2028,55 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _artefakt_auto_loop(ctx: GenContext, flow: Flow, set_p):
|
||||
"""Nach Board 2: Artefakt-QA → (Auto) „Befunde beheben"-Loop bis 100 %/Stillstand/10×.
|
||||
Auto aus = nur messen (Kontrollpunkt). Setzt flow.state[„note_artefakte"] und bei
|
||||
Stopp flow.state[„artefakt_stopp"] (der Guide startet dann nicht automatisch).
|
||||
Fail-open: ein QA-Fehler überspringt die Ebene (QA ist Helfer, nicht Jailer)."""
|
||||
topic = ctx.topic
|
||||
try:
|
||||
import qa
|
||||
rep = await qa.qa_report(topic, llm=QA_GATE_LLM)
|
||||
na = rep.get("note_artefakte") if rep else None
|
||||
if na is None: # Board 2 hat (noch) nichts geliefert → nichts zu messen
|
||||
return
|
||||
na = float(na)
|
||||
flow.state["note_artefakte"] = na
|
||||
try:
|
||||
await qa.write_report(rep)
|
||||
except Exception:
|
||||
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
|
||||
if na >= 10.0:
|
||||
return
|
||||
if not flow.state.get("auto_artefacts", True):
|
||||
_log(topic, f"Artefakt-QA {round(na * 10)} % — Auto aus, Guide startet nicht automatisch")
|
||||
set_p(f"Artefakt-QA {round(na * 10)} % — Auto aus")
|
||||
flow.state["artefakt_stopp"] = True
|
||||
return
|
||||
from auto_loop import auto_repair_loop
|
||||
import repair as _repair
|
||||
|
||||
async def _reparieren():
|
||||
set_p("Befunde beheben (Artefakte)…")
|
||||
r = await _repair.repair_befunde(topic, "artefacts")
|
||||
return float(r.get("note_artefakte") or 10.0), bool(r.get("aktionen", 0))
|
||||
|
||||
res = await auto_repair_loop("Artefakte", na, _reparieren)
|
||||
flow.state["note_artefakte"] = res["note"]
|
||||
if res["grund"] != "fertig":
|
||||
msg = (f"Artefakte bleiben bei {round(res['note'] * 10)} % ({res['grund']}, "
|
||||
f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad (Systemfehler)")
|
||||
_log(topic, msg)
|
||||
set_p(msg)
|
||||
from blocks import _blocks_errors
|
||||
_blocks_errors[topic] = msg
|
||||
flow.state["artefakt_stopp"] = True
|
||||
else:
|
||||
_log(topic, f"Artefakte: 100 % nach {res['runden']} Runde(n)")
|
||||
except Exception:
|
||||
log.exception("[%s] Artefakt-Auto-Loop fehlgeschlagen — übersprungen (fail-open)", topic)
|
||||
|
||||
|
||||
async def _write_run_summary(topic: str, flow: Flow):
|
||||
"""lauf-summary.json: the per-run numbers block QA diffs against. Never fatal."""
|
||||
try:
|
||||
@@ -1707,7 +2097,7 @@ async def _write_run_summary(topic: str, flow: Flow):
|
||||
summary["note"] = report["note"]
|
||||
summary["note_artefakte"] = report.get("note_artefakte")
|
||||
summary["artefakte"] = report.get("artefakte", {})
|
||||
await asyncio.to_thread(qa._write_report, report)
|
||||
await qa.write_report(report)
|
||||
except Exception:
|
||||
log.exception("[%s] Abschluss-QA fehlgeschlagen", topic)
|
||||
atomic_write_json(flow.work_dir / "lauf-summary.json", summary, indent=1)
|
||||
@@ -1752,13 +2142,9 @@ COLUMNS = [
|
||||
("inventory", "done_block", "Fertig", "block"),
|
||||
("inventory", "rejected", "Verworfen", None),
|
||||
("inventory", "grouped", "Zusammengelegt", "block"),
|
||||
("artefacts", "subblocks", "Subbausteine", "ablock"),
|
||||
("artefacts", "facts", "Fakten", "ablock"),
|
||||
("artefacts", "levels", "Stufen", "ablock"),
|
||||
("artefacts", "relevance", "Relevanz", "ablock"),
|
||||
("artefacts", "konsolidierung", "Konsolidierung", "ablock"),
|
||||
("artefacts", "question_pattern", "Fragen", "ablock"),
|
||||
("artefacts", "artefacts", "Lernkarten", "ablock"),
|
||||
("artefacts", "generate", "Erzeugen", "ablock"),
|
||||
("artefacts", "verify", "Prüfen", "ablock"),
|
||||
("artefacts", "artefakte", "Lernmittel", "ablock"),
|
||||
("artefacts", "finalize", "Zusammenführen", "ablock"),
|
||||
("artefacts", "outline", "Gliederung", "outline"),
|
||||
("artefacts", "done_artefact", "Fertig", "ablock"),
|
||||
@@ -1767,18 +2153,16 @@ COLUMNS = [
|
||||
_TITLE_STAGES = ["ingest", "cluster"]
|
||||
_CLUSTER_STAGES = ["pair_check", "consensus_gate", "clarify", "naming", "naming_check"]
|
||||
_BLOCK_STAGES = ["fragment_filter", "dedup", "grouping", "gap_check", "done"]
|
||||
_ART_STAGES = ["subblocks", "facts", "levels", "relevance", "konsolidierung", "question_pattern",
|
||||
"artefacts", "finalize", "outline"]
|
||||
_ART_STAGES = ["generate", "verify", "artefakte", "finalize", "outline"]
|
||||
# where a requeued dead card restarts, by kind
|
||||
_DEAD_RESTART = {"title": "cluster", "cluster": "pair_check", "block": "fragment_filter",
|
||||
"ablock": "subblocks", "outline": "outline"}
|
||||
"ablock": "generate", "outline": "outline"}
|
||||
_VERDICT_KEYS = ("reason", "votes", "judges", "merged_into", "parent_norm", "mirrored_norm")
|
||||
DONE_ART = "done_artefact"
|
||||
|
||||
|
||||
# Board-2 stage → its fine-step group in blocks.PHASEN (drives the per-card stepper).
|
||||
_STAGE_PHASE = {"subblocks": "Subblocks", "facts": "Facts", "levels": "Levels",
|
||||
"relevance": "Relevance", "question_pattern": "Questions", "artefacts": "Artefacts"}
|
||||
_STAGE_PHASE = {"generate": "Generate", "verify": "Verify", "artefakte": "Artefakte"}
|
||||
_PHASE_STEPS = {name: steps for name, steps in blocks.PHASEN}
|
||||
|
||||
|
||||
@@ -1828,28 +2212,25 @@ async def board_snapshot(topic: str, limit: int = 20) -> dict:
|
||||
|
||||
def _qa_view(topic: str, counts: dict, flow) -> dict | None:
|
||||
"""Latest QA report digest for the board header. `pausiert` = the gate stopped the
|
||||
flow (score below threshold, board-2 cards waiting, no flow running)."""
|
||||
flow (score below 100 %, board-2 cards waiting, no flow running) — das Auto-Gate
|
||||
öffnet nur bei VOLL; eine 9.6 pausierte den Loop, galt hier aber nicht als pausiert."""
|
||||
# no inventory (deleted/never built) → no badge; the report files stay on purpose,
|
||||
# so the first run after a rebuild diffs against the old state
|
||||
if not counts.get("inventory", {}).get("done_block", 0) and not (
|
||||
flow and flow.state.get("qa_note") is not None):
|
||||
return None
|
||||
import qa
|
||||
tdir = qa.QA_DIR / topic
|
||||
# by mtime: a re-run overwrites the run-id-named file, which sorts before timestamp names.
|
||||
# guide-* reports are the guide_qa series — they must not shadow the inventory badge.
|
||||
reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
if not reports:
|
||||
from auto_loop import VOLL
|
||||
r = qa.latest_report(topic)
|
||||
if r is None:
|
||||
return None
|
||||
r = _json_file(reports[-1]) or {}
|
||||
note = r.get("note")
|
||||
if note is None:
|
||||
return None
|
||||
wartend = counts.get("artefacts", {}).get("subblocks", 0)
|
||||
pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None)
|
||||
wartend = counts.get("artefacts", {}).get("generate", 0)
|
||||
pausiert = bool(note < VOLL and wartend and flow is None)
|
||||
return {"note": note, "note_artefakte": r.get("note_artefakte"),
|
||||
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
|
||||
"schwelle": VOLL, "pausiert": pausiert,
|
||||
"quoten": r.get("quoten", {}),
|
||||
"befunde": (r.get("fremd", []) + r.get("unecht", []))[:6]}
|
||||
|
||||
@@ -1885,9 +2266,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
await _requeue(r, stage)
|
||||
await db.kanban_delete_cards(topic, "inventory", "cluster")
|
||||
await db.kanban_delete_cards(topic, "inventory", "block")
|
||||
dbc = await db.get_db()
|
||||
await dbc.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,))
|
||||
await dbc.commit()
|
||||
await db.kanban_delete_members(topic)
|
||||
await db.delete_blocks(topic)
|
||||
await _clean_artefact_state(topic, files)
|
||||
elif board == "inventory" and stage in _CLUSTER_STAGES:
|
||||
@@ -1915,13 +2294,17 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
await _requeue(r, "outline")
|
||||
elif r["stage"] in later:
|
||||
await _requeue(r, stage)
|
||||
if stage == "subblocks": # full artefact re-derive → also the DB mirrors
|
||||
await db.delete_subblocks(topic)
|
||||
if stage == "generate": # re-derive enrichment + artefacts; the Board-1 subs STAY
|
||||
# Bottom-up: subblocks belong to Board 1 (the clustered atoms). Deleting them would
|
||||
# leave Board 2 nothing to enrich → cards fall straight through to done. Only the
|
||||
# DERIVED artefacts/questions + sidecar files are wiped; the enrichment (facts/level)
|
||||
# gets overwritten by the fresh Board-2 run.
|
||||
await db.delete_question_pattern(topic)
|
||||
await db.delete_sub_artefakte(topic)
|
||||
await db.delete_outline(topic) # the outline is a blocks artifact too (set in board_artefacts)
|
||||
# global sidecar files and per-block resume slots: leftovers of the previous
|
||||
# derive would re-merge/resume into the fresh run — nothing here is reused
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"):
|
||||
files[k].unlink(missing_ok=True)
|
||||
for d in files["arbeit"].glob("ab-*"):
|
||||
if d.is_dir():
|
||||
@@ -1930,23 +2313,6 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
return moved
|
||||
|
||||
|
||||
async def restart_artefact_card(topic: str, card_id: str) -> bool:
|
||||
"""Restart ONE artefacts card from `subblocks` — wipes only ITS derived DB rows
|
||||
(per-block work-dir slots overwrite themselves; finalize re-upserts later).
|
||||
Only call while nothing is generating (the route guards). → False if unknown."""
|
||||
card = await db.kanban_get_card(topic, "artefacts", card_id)
|
||||
if card is None or card["kind"] != "ablock":
|
||||
return False
|
||||
await db.delete_subblocks(topic, card_id)
|
||||
await db.delete_question_pattern(topic, card_id)
|
||||
await db.delete_sub_artefakte(topic, card_id)
|
||||
p = {k: v for k, v in card["payload"].items() if k in ("title", "description")}
|
||||
await db.kanban_set_payload(topic, "artefacts", card_id, p)
|
||||
await db.kanban_advance(topic, "artefacts", card_id, "subblocks")
|
||||
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
|
||||
return True
|
||||
|
||||
|
||||
async def requeue_dead(topic: str) -> int:
|
||||
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
|
||||
n = 0
|
||||
|
||||
+58
-75
@@ -75,9 +75,6 @@ SEED_COVER_COS = 0.80
|
||||
# (Markdown: 50 pairs in the band, 4 above) — below every auto-merge threshold, so an LLM
|
||||
# judge decides. Candidates only; a merge still needs judge unanimity.
|
||||
SUB_DUP_KANDIDAT_COS = 0.75
|
||||
# Cross-block judge pairs per call: ONE call over all pairs scaled its timeout to 54 min
|
||||
# and a hung call blocked the barrier that long (aak: 196 pairs) — chunks cap it at ~15 min.
|
||||
CROSS_CHUNK_PAARE = 40
|
||||
|
||||
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
|
||||
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
|
||||
@@ -94,16 +91,13 @@ EMBEDDING_SIBLING_CAP = 18 # a rich model (TM) can have many constituent p
|
||||
# titles (e.g. two „Turingmaschine"-umbrellas). Merge umbrella pairs whose title+description cosine is
|
||||
# ≥ this (conservative → only true same-parent duplicates, never two distinct umbrellas).
|
||||
GROUP_RECONCILE_FLOOR = 0.75
|
||||
# Over-merge backstop ONLY (no-structure floor). Research (meronymy ≠ similarity): parts of ONE model are
|
||||
# legitimately DISSIMILAR (TM: Alphabet/Konfiguration/δ ~0.22), while distinct same-type concepts (P/NP/…)
|
||||
# are SIMILAR (~0.85) — so member-vs-member cosine is the WRONG instrument for over-merge (empirically
|
||||
# inverted: TM 0.218 < the P/NP bundle 0.227). The real precision floor is the ATOMICITY type-guard
|
||||
# (_GROUP_STANDALONE: a member that is a named algorithm/problem/theorem/complexity-class dissolves the
|
||||
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless
|
||||
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
|
||||
GROUP_MIN_COS_FLOOR = 0.15
|
||||
# Fragment-demote backstop, same logic as GROUP_MIN_COS_FLOOR: fragment↔parent cosine is a BAD
|
||||
# fragment detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
|
||||
# Zielband der Themen-Blockzahl im Gruppierungs-Prompt: k = GROUP_THEMES_PER_SQRT·√n
|
||||
# (n = Items der Grouping-Welle), Band [0.7k, 1.3k] — weiche Vorgabe, kein Cap. √n hält
|
||||
# Blockzahl UND mittlere Blockgröße sublinear (n=30 → ~4–8, n=285 → ~12–22; das alte
|
||||
# statische „15–25" passte implizit nur zu n≈225–625).
|
||||
GROUP_THEMES_PER_SQRT = 1.0
|
||||
# Fragment-demote backstop (no-structure floor): fragment↔parent cosine is a BAD fragment
|
||||
# detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
|
||||
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
|
||||
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
|
||||
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
|
||||
@@ -114,9 +108,17 @@ FRAGMENT_MIN_COS = 0.15
|
||||
# dominates). Locally raise the global cap to actually parallelize across topics (per-topic stays 10).
|
||||
# Own lane for interactive calls (chat, elements) so they don't hang behind running writers.
|
||||
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16")) # global, all topics
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "12")) # per topic
|
||||
# Per-topic higher than the process cap: it is SHARED between process and API tier — at 12 a
|
||||
# single-topic run (the normal case) would never benefit from the cheap API tier.
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "24")) # per topic
|
||||
# Direct-API text calls (agents._run_text_api): ~0 RAM, only network — own, higher global cap.
|
||||
MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "28"))
|
||||
MAX_CONCURRENT_INTERACTIVE = 8
|
||||
|
||||
# RAM guard for opencode spawns: below this free share (MemAvailable/MemTotal in %) new
|
||||
# processes wait instead of starting (agents._ram_gate). 0 = off.
|
||||
RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20"))
|
||||
|
||||
# Grace window of the consensus races (blocks, guide, OnePager): after the first
|
||||
# valid result the remaining agents may still become done for this many seconds
|
||||
# (kill only once the minimum is already in).
|
||||
@@ -127,30 +129,15 @@ CONSENSUS_GRACE = 300
|
||||
# check loops leave any remaining objections standing after that.
|
||||
CONSENSUS_MAX_ROUNDS = 3
|
||||
|
||||
# Crawler triage (content/noise) — deterministic rule filter instead of an LLM.
|
||||
# Match: substring (lowercase) against URL AND file name. Order: keep > noise > min_chars > keep.
|
||||
# Just add special rules here.
|
||||
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # always content
|
||||
CRAWL_NOISE_PATTERNS = [ # clearly off-topic → out
|
||||
"clubs", "events", "podcasts", "resources", "-u-",
|
||||
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login",
|
||||
]
|
||||
CRAWL_MIN_CHARS = 400 # too little text → out
|
||||
|
||||
# LLM topic relevance gate (after the rule filter): per content page yes/no against the spec.
|
||||
# Separates the subject area (e.g. backend vs frontend), which the global CRAWL_* rules can't.
|
||||
QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page)
|
||||
QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL is the primary signal)
|
||||
|
||||
# QA gate: after the inventory phase an automatic QA run scores the blocks; below the
|
||||
# threshold the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
|
||||
QA_GATE_NOTE = 9.5 # 0 = gate off; quota-based, so the tolerated finding count scales with topic size
|
||||
# QA gate: after the inventory phase an automatic QA run scores the blocks; below 100 %
|
||||
# the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
|
||||
# QA_GATE_NOTE ist nur noch der An/Aus-Schalter (> 0 = Gate aktiv) — das Gate selbst
|
||||
# öffnet ausschließlich bei 100 % (auto_loop.VOLL); Repair/Recherche arbeiten dahin.
|
||||
QA_GATE_NOTE = 9.5 # 0 = gate off
|
||||
QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run
|
||||
|
||||
# Guide section length per relevant sub (ausführlich part) — QA detector AND the
|
||||
# deterministic readability-stage trigger share these bounds (writers overshot 2.7–4.1×).
|
||||
GUIDE_LAENGE_MIN = 150
|
||||
GUIDE_LAENGE_MAX = 1200
|
||||
# Lücken-Recherche im Auto-Loop: max. gezielte Nach-Recherche-Runden pro Lauf, bevor die
|
||||
# Rest-Lücken dem Stichentscheid vorgelegt werden (Repair-Budget, keine Detektor-Konstante).
|
||||
LUECKEN_RESEARCH_MAX = 2
|
||||
|
||||
# Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting
|
||||
# every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the
|
||||
@@ -161,79 +148,72 @@ EVIDENCE_CTX_LINES = 15 # context lines around a cited source position
|
||||
# ── Pipeline tuning (zentral, tunebar via CREATOR_PARAMS — siehe Override-Hook am Datei-Ende;
|
||||
# Registry mit Suchraum: backend/train_params.py). QA-/Detektor-Konstanten bleiben bewusst in
|
||||
# qa.py/guide_qa.py — die Messlatte darf nie Teil des Suchraums sein. ─────────────────────────
|
||||
SUBBLOCK_CHUNK = 10 # subblock finder: 1 agent per ~10 blocks, capped
|
||||
SUBBLOCK_MAX = 40 # chunk cap
|
||||
LEVEL_CHUNK = 100 # classifying is cheap → large packages
|
||||
RESEARCH_BATCH = 20 # crawl pages per batch
|
||||
RESEARCH_BATCH = 1 # eine Seite pro Reader — er kann nichts übersehen (Vollständigkeit)
|
||||
RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2)
|
||||
RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema")
|
||||
RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard)
|
||||
RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live)
|
||||
SUBBLOCK_CAP = 900 # subblock find loop per chunk (seconds)
|
||||
SUBBLOCK_MIN = 5 # below this consensus count → focused catch-up rounds
|
||||
SUBBLOCK_EXTRA_ROUNDS = 2 # max catch-up rounds
|
||||
SUBBLOCK_MAX_ROUNDS = 3 # hard round cap (rounds 4–5 burned 29 % of finders for ~0 gain)
|
||||
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (fallback path)
|
||||
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair
|
||||
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package
|
||||
DEDUP_TITLE_AUTO = 0.95 # near-identical TITLE cosine → merge without judge
|
||||
DEDUP_GLOBAL_FLOOR = 0.65 # global post-naming dedup candidate floor
|
||||
FILTER_CHUNK = 35 # blocks per judge in the degrade pass
|
||||
QUESTION_CHUNK_SUBS = 25 # target relevant subs per question chunk (LPT)
|
||||
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern
|
||||
FACTS_CHUNK_SUBS = 10 # facts extraction chunk (chunk count = parallelism)
|
||||
ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples bulk chunk
|
||||
FACTS_CHECK_PANEL = 3 # judges per facts-check chunk (majority)
|
||||
CONSOLIDATION_PANEL = 3 # mapping judges per chunk
|
||||
SUBBLOCK_PANEL = 3 # judges in the subblock clarification
|
||||
# Board 2, verschmolzene Calls (block_calls.py): Panel-Größen der neuen Struktur.
|
||||
# Konsens braucht ≥2 unabhängige Nennungen bzw. Einstimmigkeit — 2 ist das Minimum,
|
||||
# 3 kauft Robustheit für +50 % Tokens auf dem jeweiligen Segment.
|
||||
GEN_PANEL = 2 # unabhängige Generator-Calls pro Block
|
||||
VERIFY_PANEL = 2 # unabhängige Prüfer-Calls pro Block (+ Ersatz bei 1 Ausfall)
|
||||
ART_SPLIT_SUBS = 20 # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls
|
||||
FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck
|
||||
MAX_WRITER_ROUNDS = 2 # guide coverage→writer loop cap
|
||||
GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below this → 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
|
||||
KANBAN_BATCH = 5 # cards a worker pulls per micro-batch
|
||||
MAX_CARD_RETRIES = 3 # failures per card → dead-letter
|
||||
RETRY_BACKOFF = 30.0 # base seconds; backoff = base · 2^(retries-1)
|
||||
MAX_RESTARTS = 2 # agent restart cap per race slot
|
||||
INFRA_MAX_RETRIES = 3 # 429/Timeout/Netz: Retries pro Slot, dann Lauf-Pause (kein fail-open)
|
||||
INFRA_BACKOFF_BASE = 8.0 # Pause = base · 2^(n-1) → 8/16/32 s
|
||||
# Stall-Hedge: läuft ein Race-Slot so lange ohne Ergebnis, startet parallel ein Zwilling
|
||||
# (key -h), der erste valide gewinnt. Gemessen (kanban-smoke): 4 Panel-Stalls à 160–230 s
|
||||
# verlängerten den kritischen Pfad um ~5 min. UNTERGRENZE: effektiv gilt
|
||||
# max(HEDGE_NACH_S, halbes Call-Timeout) — pauschale 90 s hedgten jeden gesunden
|
||||
# Fix-/Gate-Call (die laufen normal 110–135 s). 0 = aus.
|
||||
HEDGE_NACH_S = 90
|
||||
JUDGE_CHUNK = 40 # repair: findings per judge call
|
||||
EVENTS_RETENTION_TAGE = 60 # events älter als das werden beim Start gelöscht (Tabelle wuchs unbegrenzt)
|
||||
EVIDENCE_PER_BLOCK = 6000 # repair: excerpt chars per fremd candidate
|
||||
ABSCHLUSS_QA_LLM = 1 # 0 = Abschluss-QA ohne LLM-Judges (Training misst selbst; spart Minuten)
|
||||
|
||||
# Timeouts per agent step: (base seconds, seconds per block/section).
|
||||
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
|
||||
TIMEOUTS = {
|
||||
"research": (900, 0), # p95 measured 125 s (web mode); uni/link sections need headroom
|
||||
"research_mapping": (600, 3), # n = pre-merged entries
|
||||
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
|
||||
"ergaenzung": (600, 0), # subject-field extension for projects (web research)
|
||||
"plan": (300, 5),
|
||||
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
|
||||
"content": (450, 30), # facts find/erg/fix — p95 measured 241 s (was 600+90n)
|
||||
# Judge caps tightened 2026-07-04: judge p50 is 6–72 s; a stalled call burns the whole
|
||||
# cap and its retry heals in seconds — the old 300 s base tripled the stall cost.
|
||||
"content_check": (150, 8), # content exam per block in the package
|
||||
"subblock": (400, 15), # finder round — p95 measured 124 s (was 900+45n)
|
||||
"subblock_check": (150, 10), # judge decides contested subblocks in the chunk
|
||||
"konsolidierung": (300, 20), # consolidation judge sees ALL subs with key points
|
||||
"level": (300, 10), # classify subblocks per chunk
|
||||
"level_check": (150, 8), # judge decides contested levels in the chunk
|
||||
"relevance": (300, 10), # subblocks relevant/peripheral per chunk
|
||||
"relevance_check": (150, 8), # judge decides contested relevance in the chunk
|
||||
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
||||
"question_pattern_check": (150, 8), # critic cleans up the pattern table per block
|
||||
# Board 2, verschmolzene Calls: größere Outputs pro Call, dafür wenige Segmente
|
||||
"generate": (450, 0), # Subs+Facts+Level in einem (Sub-Zahl vorab unbekannt)
|
||||
"verify": (300, 10), # Audit über alle Subs (n = Subs), key points gekappt
|
||||
"fix": (300, 15), # Korrekturen + Lücken (n = Aufträge)
|
||||
"artefakt": (450, 15), # Fragen+Karten+Beispiele (n = Subs)
|
||||
"artefakt_check": (200, 8), # Beispiel-Verifikation + Fragen-Kritik (n = Subs)
|
||||
"writer": (450, 60), # per section — split keeps sections ≤30 subs
|
||||
"lese_check": (300, 10), # per section in the package
|
||||
# guide board (per card = one block)
|
||||
"lernziele": (300, 5), # backward-design objectives per block
|
||||
"fakten_gate": (600, 5), # CoVe claim check per block
|
||||
"coverage": (300, 5), # objective↔section mapping per block
|
||||
}
|
||||
|
||||
# Purpose per format — flows into the outline judge (what the guide should achieve).
|
||||
# German strings: these are inserted verbatim into the judge prompt → kept German on purpose.
|
||||
FORMAT_PURPOSE = {
|
||||
"Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen",
|
||||
"FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen",
|
||||
"Rest": "einen Ergänzungs-Guide — nur die Randthemen",
|
||||
"pruefer": (600, 5), # verschmolzener Qualitäts-Pass (Gate+Coverage+Lese) per block
|
||||
# QA/Repair-Judge-Wellen (qa.judge_wave) — außerhalb der Boards, keine n-Skalierung
|
||||
"qa_judge": (600, 0),
|
||||
}
|
||||
|
||||
# Provider stacks: completely independent, any one can be removed at any time.
|
||||
@@ -242,7 +222,10 @@ FORMAT_PURPOSE = {
|
||||
# "judge" = mapping/judge/check agents — cold (low temperature,
|
||||
# no thinking) for stable verdicts; Claude/local map to "fast",
|
||||
# "guide" = large generation (proposals, writer).
|
||||
DEFAULT_PROVIDER = "claude"
|
||||
# Kein Provider-Default im Code (Betreiber-Vorgabe): die .env entscheidet.
|
||||
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "")
|
||||
if not DEFAULT_PROVIDER:
|
||||
raise RuntimeError("DEFAULT_PROVIDER fehlt in der .env (z. B. DEFAULT_PROVIDER=minimax)")
|
||||
PROVIDERS = {
|
||||
"claude": {
|
||||
"cli": "claude",
|
||||
|
||||
+23
-51
@@ -1,9 +1,9 @@
|
||||
"""Bounded domain crawler for link sources — renders JS via Playwright (Chromium).
|
||||
"""Page loader for link sources — renders JS via Playwright (Chromium).
|
||||
|
||||
Loads pages + PDFs starting from a start URL — ONLY the same domain, limited depth
|
||||
and page count. HTML pages are rendered in a headless browser (needed for SPAs), then
|
||||
links + text are pulled from the finished DOM. PDFs are loaded directly as bytes.
|
||||
Deterministic, bounded; runs via asyncio.to_thread (sync API, no event loop).
|
||||
Loads a FIXED list of URLs the user supplied — one page each, NO link following.
|
||||
HTML pages are rendered in a headless browser (needed for SPAs), then the main text
|
||||
is pulled from the finished DOM. PDFs are loaded directly as bytes. Deterministic,
|
||||
bounded; runs via asyncio.to_thread (sync API, no event loop).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -17,8 +17,6 @@ from fsutil import atomic_write_text
|
||||
|
||||
log = logging.getLogger("creator.crawl")
|
||||
|
||||
MAX_DEPTH = 3
|
||||
MAX_PAGES = 500
|
||||
PAGE_TIMEOUT = 30 # seconds per page (render or PDF download)
|
||||
CRAWL_SETTLE_MS = 3000 # capped settle after domcontentloaded (SPA render); no 30s networkidle hang
|
||||
MAX_BYTES = 10_000_000 # 10 MB cap per PDF
|
||||
@@ -33,7 +31,7 @@ def _fetch_bytes(url: str) -> bytes | None:
|
||||
data = resp.read(MAX_BYTES + 1)
|
||||
return None if len(data) > MAX_BYTES else data
|
||||
except Exception as e:
|
||||
log.debug("crawl: PDF fetch failed %s: %s", url, e)
|
||||
log.debug("load: PDF fetch failed %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -47,21 +45,6 @@ def _is_pdf(url: str) -> bool:
|
||||
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
|
||||
|
||||
|
||||
def _scope_prefix(start_url: str) -> str:
|
||||
"""First non-empty path segment of the start URL as the crawl scope, e.g.
|
||||
`/learn/path/x` → `/learn`. No path segment → `""` (whole domain, no narrowing)."""
|
||||
seg = [s for s in urlparse(start_url).path.split("/") if s]
|
||||
return f"/{seg[0]}" if seg else ""
|
||||
|
||||
|
||||
def _in_scope(url: str, prefix: str) -> bool:
|
||||
"""Segment-exact prefix match (no `/learn` ⊃ `/learning-x`). Empty prefix → everything allowed."""
|
||||
if not prefix:
|
||||
return True
|
||||
p = urlparse(url).path
|
||||
return p == prefix or p.startswith(prefix + "/")
|
||||
|
||||
|
||||
def _page_text(page) -> str:
|
||||
"""Main text of the rendered page — nav/footer/boilerplate removed via trafilatura.
|
||||
Falls back to the raw body text when extraction is empty/too short (non-article pages)."""
|
||||
@@ -78,34 +61,34 @@ def _page_text(page) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages: int = MAX_PAGES, cancelled=None) -> int:
|
||||
"""Crawl from start_url (same domain only), render JS and store pages/PDFs in `target`.
|
||||
def load_pages(urls: list[str], target: Path, *, cancelled=None) -> int:
|
||||
"""Load each URL in `urls` (one page each, NO link following), render JS and store
|
||||
pages/PDFs in `target`.
|
||||
|
||||
BFS up to `max_depth` / `max_pages`. Errors on individual pages are skipped.
|
||||
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it,
|
||||
so a restart crawls again. Returns the number of saved sources.
|
||||
Duplicate/fragment-only URLs collapse to one. Errors on individual pages are skipped.
|
||||
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it, so a
|
||||
restart loads again. Returns the number of saved sources.
|
||||
"""
|
||||
# Lazy: this way the backend starts even without Playwright installed; only crawling then fails.
|
||||
# Lazy: this way the backend starts even without Playwright installed; only loading then fails.
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
domain = urlparse(start_url).netloc
|
||||
prefix = _scope_prefix(start_url) # only follow links under this path segment
|
||||
seen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
todo: list[str] = []
|
||||
for u in urls:
|
||||
nu = urldefrag(u)[0].strip()
|
||||
if nu and nu not in seen:
|
||||
seen.add(nu)
|
||||
todo.append(nu)
|
||||
saved = 0
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
|
||||
page = browser.new_page(user_agent=_UA)
|
||||
try:
|
||||
while queue and saved < max_pages:
|
||||
for url in todo:
|
||||
if cancelled and cancelled():
|
||||
return saved # abort → NO .done marker → restart crawls again
|
||||
url, depth = queue.pop(0)
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
return saved # abort → NO .done marker → restart loads again
|
||||
|
||||
# PDFs need no rendering — load directly.
|
||||
if _is_pdf(url):
|
||||
@@ -120,7 +103,7 @@ def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto incomplete %s: %s", url, e) # still try to read the content
|
||||
log.debug("load: goto incomplete %s: %s", url, e) # still try to read the content
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
|
||||
except Exception:
|
||||
@@ -129,20 +112,9 @@ def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages
|
||||
if text:
|
||||
atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
saved += 1
|
||||
if depth < max_depth:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
hrefs = []
|
||||
for href in hrefs:
|
||||
nxt = urldefrag(href)[0]
|
||||
if (nxt.startswith(("http://", "https://"))
|
||||
and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
|
||||
and nxt not in seen):
|
||||
queue.append((nxt, depth + 1))
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
(target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
|
||||
log.info("crawl %s → %d sources in %s", start_url, saved, target)
|
||||
log.info("load_pages → %d sources in %s", saved, target)
|
||||
return saved
|
||||
+211
-115
@@ -1,4 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import aiosqlite
|
||||
from config import DB_PATH
|
||||
@@ -222,6 +224,11 @@ CREATE_EVENTS_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_events ON events(topic, ts)
|
||||
"""
|
||||
|
||||
# run-Summaries filtern topic + run_id — ohne den Index wird jeder Aufruf ein Topic-Scan
|
||||
CREATE_EVENTS_RUN_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_events_run ON events(topic, run_id)
|
||||
"""
|
||||
|
||||
CREATE_KANBAN_PULL_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at)
|
||||
"""
|
||||
@@ -280,6 +287,39 @@ async def get_db() -> aiosqlite.Connection:
|
||||
return _db
|
||||
|
||||
|
||||
_write_lock: asyncio.Lock | None = None
|
||||
_write_lock_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def _get_write_lock() -> asyncio.Lock:
|
||||
"""Lock lazily bound to the CURRENT loop: an asyncio.Lock is loop-bound, and a
|
||||
module-global one breaks when tests run each case in a fresh loop."""
|
||||
global _write_lock, _write_lock_loop
|
||||
loop = asyncio.get_running_loop()
|
||||
if _write_lock is None or _write_lock_loop is not loop:
|
||||
_write_lock = asyncio.Lock()
|
||||
_write_lock_loop = loop
|
||||
return _write_lock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _tx():
|
||||
"""Atomarer Schreibblock: Lock + commit. Die eine geteilte Connection interleavt sonst
|
||||
fremde Commits zwischen execute und commit eines Batches. Lock NUR in Blatt-Funktionen,
|
||||
die selbst committen — nie in Funktionen, die andere schreibende DB-Funktionen rufen."""
|
||||
async with _get_write_lock():
|
||||
db = await get_db()
|
||||
try:
|
||||
yield db
|
||||
await db.commit()
|
||||
except BaseException:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
async def init_db():
|
||||
db = await get_db()
|
||||
# WAL survives crashes much better; busy_timeout absorbs short locks.
|
||||
@@ -359,6 +399,10 @@ async def init_db():
|
||||
await db.execute("ALTER TABLE events ADD COLUMN run_id TEXT NOT NULL DEFAULT ''")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
await db.execute(CREATE_EVENTS_RUN_INDEX) # nach der run_id-Migration — Spalte muss existieren
|
||||
# Retention: events wachsen sonst unbegrenzt (gelöscht wurde nur per Topic-Delete)
|
||||
from config import EVENTS_RETENTION_TAGE
|
||||
await db.execute("DELETE FROM events WHERE ts < datetime('now', ?)", (f"-{EVENTS_RETENTION_TAGE} days",))
|
||||
await db.execute(
|
||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
|
||||
"WHERE status IN ('queued', 'generating')"
|
||||
@@ -379,13 +423,12 @@ def _row_to_dict(row, cursor):
|
||||
|
||||
|
||||
async def create_guide(guide: dict) -> dict:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO guides (id, topic, format, instructions, status, progress, created_at, updated_at)
|
||||
VALUES (:id, :topic, :format, :instructions, :status, :progress, :created_at, :updated_at)""",
|
||||
guide,
|
||||
)
|
||||
await db.commit()
|
||||
return guide
|
||||
|
||||
|
||||
@@ -411,9 +454,8 @@ async def _update(table: str, fields: dict, where: dict) -> None:
|
||||
rename (SET new norm WHERE old norm)."""
|
||||
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
||||
cond = " AND ".join(f"{k} = :w_{k}" for k in where)
|
||||
db = await get_db()
|
||||
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.commit()
|
||||
|
||||
|
||||
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:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
cursor = await db.execute("DELETE FROM guides WHERE id = ?", (guide_id,))
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
@@ -431,12 +472,11 @@ async def delete_guide(guide_id: str) -> bool:
|
||||
|
||||
async def create_topic(name: str) -> None:
|
||||
from datetime import datetime, timezone
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO topics (name, created_at) VALUES (?, ?)",
|
||||
(name, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_topics() -> list[str]:
|
||||
@@ -447,9 +487,8 @@ async def list_topics() -> list[str]:
|
||||
|
||||
|
||||
async def delete_topic(name: str) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute("DELETE FROM topics WHERE name = ?", (name,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- 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:
|
||||
"""Freeze base + streak BEFORE the now-open question (anchor for idempotent re-rating)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
now = _now()
|
||||
await db.execute(
|
||||
"""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""",
|
||||
(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]:
|
||||
"""Set score + streak atomically (clamped by the caller). Returns (score, streak)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO block_progress (topic, block, good_answers, streak, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
@@ -511,16 +549,14 @@ async def set_block_score_and_streak(topic: str, block: str, score: int, streak:
|
||||
updated_at = excluded.updated_at""",
|
||||
(topic, block, score, streak, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
return score, streak
|
||||
|
||||
|
||||
async def delete_block_progress(topic: str, block: str) -> None:
|
||||
"""Reset the progress of ONE block: delete the row (score/streak/flags/open question).
|
||||
If the row is missing, get_block_progress returns defaults (0) — i.e. a full reset."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
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):
|
||||
@@ -602,9 +638,8 @@ async def list_block_scores_all() -> list[tuple[str, str, int]]:
|
||||
|
||||
|
||||
async def delete_block_data(topic: str) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute("DELETE FROM block_progress WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- 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
|
||||
read-modify-write across `await` would lose members. `mentions` stays in sync
|
||||
with `len(reader)`. `reader=None` (e.g. from `_set_inventar`) leaves the set unchanged."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
rid = reader if isinstance(reader, str) and reader else None
|
||||
await db.execute(
|
||||
"""INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at)
|
||||
@@ -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(),
|
||||
rid, rid, rid, rid),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_blocks(topic: str, status: str | None = None) -> list[dict]:
|
||||
@@ -668,10 +702,25 @@ async def set_block_status(topic: str, title_norm: str, status: str, title: str
|
||||
await _update("blocks", fields, {"topic": topic, "title_norm": title_norm})
|
||||
|
||||
|
||||
async def rename_block_norm(topic: str, alt_norm: str, neu_norm: str, neu_titel: str) -> None:
|
||||
"""Block-Norm vollständig um-keyen (Repair: `(n)`-Kollisionssuffix ablegen): blocks-
|
||||
Spiegel PLUS alle angehängten Tabellen in einer Transaktion — anders als
|
||||
set_block_status(neu_norm=…) auch mit vorhandenen Subblocks/Fragen/Artefakten sicher."""
|
||||
async with _tx() as db:
|
||||
for table in ("subblocks", "question_pattern", "sub_artefakte"):
|
||||
await db.execute(
|
||||
f"UPDATE {table} SET block_norm = ?, block = ?, updated_at = ? "
|
||||
"WHERE topic = ? AND block_norm = ?",
|
||||
(neu_norm, neu_titel, _now(), topic, alt_norm))
|
||||
await db.execute(
|
||||
"UPDATE blocks SET title_norm = ?, title = ?, updated_at = ? "
|
||||
"WHERE topic = ? AND title_norm = ?",
|
||||
(neu_norm, neu_titel, _now(), topic, alt_norm))
|
||||
|
||||
|
||||
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.commit()
|
||||
|
||||
|
||||
# ── Kanban dataflow (generic card layer, boards 'inventory' + 'artefacts') ────────
|
||||
@@ -743,12 +792,11 @@ def set_current_run(topic: str, run_id: str | None) -> None:
|
||||
async def add_event(topic: str, kind: str, key: str = "", label: str = "", status: str = "",
|
||||
dur_ms: int | None = None, wait_ms: int | None = None, meta: dict | None = None) -> None:
|
||||
"""One pipeline-history row, own commit. Callers treat this as fire-and-forget."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta, run_id) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(topic, _now(), kind, key, label, status, dur_ms, wait_ms,
|
||||
json.dumps(meta or {}, ensure_ascii=False), _current_run.get(topic, "")))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _add_events_many(db, topic: str, rows: list[tuple]) -> None:
|
||||
@@ -766,21 +814,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)."""
|
||||
if not moves:
|
||||
return
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
now = _now()
|
||||
await db.executemany(
|
||||
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
||||
WHERE topic = ? AND board = ? AND card_id = ?""",
|
||||
[(stage, now, topic, board, cid) for cid, stage in moves])
|
||||
await _add_events_many(db, topic, [("stage", f"{board}:{cid}", "", stage) for cid, stage in moves])
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_upsert_card(topic: str, board: str, card_id: str, kind: str, stage: str,
|
||||
payload: dict | None = None) -> None:
|
||||
"""Insert or overwrite a card (stable ids → growing clusters upsert, never duplicate).
|
||||
payload=None keeps the existing payload on conflict."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, '{}'), ?)
|
||||
@@ -791,15 +838,13 @@ async def kanban_upsert_card(topic: str, board: str, card_id: str, kind: str, st
|
||||
(topic, board, card_id, kind, stage,
|
||||
json.dumps(payload, ensure_ascii=False) if payload is not None else None, _now(),
|
||||
json.dumps(payload, ensure_ascii=False) if payload is not None else None))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async 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(
|
||||
"UPDATE kanban_cards SET payload = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?",
|
||||
(json.dumps(payload, ensure_ascii=False), _now(), topic, board, card_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_get_card(topic: str, board: str, card_id: str) -> dict | None:
|
||||
@@ -826,7 +871,7 @@ async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
|
||||
max_retries: int, backoff_base: float = 30.0) -> bool:
|
||||
"""Register a processing failure: retries++, exponential backoff (not_before), and after
|
||||
`max_retries` → stage 'dead' (dead-letter, requeue-able). → True if the card went dead."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT retries FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?",
|
||||
(topic, board, card_id))
|
||||
@@ -850,22 +895,30 @@ 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 (?,?,?,?,?,?,?,?,?,?)",
|
||||
(topic, _now(), "fail", f"{board}:{card_id}", "", "dead" if dead else f"retry{retries}",
|
||||
None, None, json.dumps({"error": error[:200]}, ensure_ascii=False), _current_run.get(topic, "")))
|
||||
await db.commit()
|
||||
return dead
|
||||
|
||||
|
||||
async def events_run_summary(topic: str, run_id: str) -> dict:
|
||||
"""Agent/token aggregate of ONE run — the numbers block of lauf-summary.json."""
|
||||
async def events_run_summary(topic: str, run_id: str, board: str | None = None) -> dict:
|
||||
"""Agent/token aggregate of ONE run — the numbers block of lauf-summary.json.
|
||||
board="inventory"|"artefacts" scopes to that level (via meta.board); None = whole run.
|
||||
Returns start/ende (MIN/MAX ts of the scoped events) so the caller can show a per-board span."""
|
||||
db = await get_db()
|
||||
where = "topic = ? AND run_id = ? AND kind = 'agent'"
|
||||
params = [topic, run_id]
|
||||
if board:
|
||||
where += " AND json_extract(meta,'$.board') = ?"
|
||||
params.append(board)
|
||||
cursor = await db.execute(
|
||||
"""SELECT status, COUNT(*), SUM(dur_ms),
|
||||
f"""SELECT status, COUNT(*), SUM(dur_ms),
|
||||
SUM(json_extract(meta,'$.tokens.input')), SUM(json_extract(meta,'$.tokens.output')),
|
||||
SUM(json_extract(meta,'$.tokens.cache_read')), SUM(json_extract(meta,'$.tokens.cache_write'))
|
||||
FROM events WHERE topic = ? AND run_id = ? AND kind = 'agent' GROUP BY status""",
|
||||
(topic, run_id))
|
||||
SUM(json_extract(meta,'$.tokens.cache_read')), SUM(json_extract(meta,'$.tokens.cache_write')),
|
||||
MIN(ts), MAX(ts)
|
||||
FROM events WHERE {where} GROUP BY status""",
|
||||
params)
|
||||
agents = {"gesamt": 0, "ok": 0, "timeout": 0, "cancelled": 0, "sonstige": 0, "verlorene_min": 0}
|
||||
tokens = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0}
|
||||
for status, n, dur, ti, to, cr, cw in await cursor.fetchall():
|
||||
start = ende = None
|
||||
for status, n, dur, ti, to, cr, cw, mn, mx in await cursor.fetchall():
|
||||
agents["gesamt"] += n
|
||||
if status in ("ok", "timeout", "cancelled"):
|
||||
agents[status] += n
|
||||
@@ -877,7 +930,64 @@ async def events_run_summary(topic: str, run_id: str) -> dict:
|
||||
tokens["output"] += to or 0
|
||||
tokens["cache_read"] += cr or 0
|
||||
tokens["cache_write"] += cw or 0
|
||||
return {"agents": agents, "tokens": tokens}
|
||||
if mn and (start is None or mn < start):
|
||||
start = mn
|
||||
if mx and (ende is None or mx > ende):
|
||||
ende = mx
|
||||
return {"agents": agents, "tokens": tokens, "start": start, "ende": ende}
|
||||
|
||||
|
||||
async def list_runs(topic: str, limit: int = 10) -> list[dict]:
|
||||
"""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)
|
||||
# Per-Board-Aufschlüsselung (Zeit/Tokens getrennt für Inventar vs. Artefakte)
|
||||
boards = {b: await events_run_summary(topic, run_id, board=b)
|
||||
for b in ("inventory", "artefacts")}
|
||||
cur = await db.execute(
|
||||
"SELECT key, status, meta, ts FROM events WHERE topic = ? AND run_id = ? AND kind = 'fail' "
|
||||
"ORDER BY ts DESC LIMIT 10", (topic, run_id))
|
||||
fails = [{"key": k, "status": s, "error": json.loads(m or "{}").get("error", ""), "ts": ts}
|
||||
for k, s, m, ts in await cur.fetchall()]
|
||||
out.append({"run_id": run_id, "aktiv": _current_run.get(topic) == run_id,
|
||||
"start": start, "ende": ende, **summary, "boards": boards, "fails": fails})
|
||||
return out
|
||||
|
||||
|
||||
async def latest_board_runs(topic: str) -> dict:
|
||||
"""Jüngster Lauf MIT Daten je Ebene — Anzeige-Quelle der Board-Kopfzeilen im Frontend.
|
||||
„Nur Artefakte"-/Guide-Läufe bekommen eine frische run_id und lassen die anderen Ebenen
|
||||
leer; der jüngste Lauf allein zeigt dann nichts. Hier zählt je Ebene der letzte Lauf,
|
||||
der sie wirklich enthielt. inventory/artefacts via meta.$.board (agents.agent_ebene);
|
||||
guide via run_id-Suffix „-g"+4hex (guide_board.run_guide_board setzt den Marker,
|
||||
Blocks-Suffixe sind reines Hex — enthalten nie „g").
|
||||
→ {ebene: {run_id, aktiv, agents, tokens, start, ende} | None}"""
|
||||
db = await get_db()
|
||||
out: dict = {}
|
||||
for b in ("inventory", "artefacts"):
|
||||
cur = await db.execute(
|
||||
"SELECT run_id FROM events WHERE topic = ? AND kind = 'agent' AND run_id != '' "
|
||||
"AND json_extract(meta,'$.board') = ? ORDER BY ts DESC LIMIT 1", (topic, b))
|
||||
row = await cur.fetchone()
|
||||
out[b] = None if row is None else {
|
||||
"run_id": row[0], "aktiv": _current_run.get(topic) == row[0],
|
||||
**await events_run_summary(topic, row[0], board=b)}
|
||||
cur = await db.execute(
|
||||
"SELECT run_id FROM events WHERE topic = ? AND kind = 'agent' AND run_id != '' "
|
||||
"AND run_id GLOB '*-g[0-9a-f][0-9a-f][0-9a-f][0-9a-f]' ORDER BY ts DESC LIMIT 1",
|
||||
(topic,))
|
||||
row = await cur.fetchone()
|
||||
out["guide"] = None if row is None else {
|
||||
"run_id": row[0], "aktiv": _current_run.get(topic) == row[0],
|
||||
**await events_run_summary(topic, row[0])} # Guide: ganzer Lauf (Events sind untagged)
|
||||
return out
|
||||
|
||||
|
||||
async def kanban_dead(topic: str) -> list[dict]:
|
||||
@@ -887,13 +997,12 @@ async def kanban_dead(topic: str) -> list[dict]:
|
||||
|
||||
async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int:
|
||||
"""dead → `stage` (fresh retries). → number of requeued cards."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
cursor = await db.execute(
|
||||
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
||||
WHERE topic = ? AND board = ? AND stage = 'dead'""",
|
||||
(stage, _now(), topic, board))
|
||||
await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)])
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
@@ -920,25 +1029,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:
|
||||
"""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 = ?",
|
||||
(topic, board, card_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_delete_cards(topic: str, board: str, kind: str | None = None) -> None:
|
||||
"""Delete derived cards (board reset) — kind=None wipes the whole board."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
if kind:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND kind = ?",
|
||||
(topic, board, kind))
|
||||
else:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_reset(topic: str, board: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
if board:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board))
|
||||
if board == "inventory":
|
||||
@@ -946,18 +1053,21 @@ async def kanban_reset(topic: str, board: str | None = None) -> None:
|
||||
else:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (topic,))
|
||||
await db.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_set_members(topic: str, group_id: str, members: list[str]) -> None:
|
||||
"""Replace the member set of a cluster (one member belongs to exactly one cluster)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute("DELETE FROM kanban_members WHERE topic = ? AND group_id = ?", (topic, group_id))
|
||||
await db.executemany(
|
||||
"""INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""",
|
||||
[(topic, m, group_id) for m in members])
|
||||
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]:
|
||||
@@ -971,14 +1081,32 @@ async def kanban_members_of(topic: str, group_id: str) -> list[str]:
|
||||
async def upsert_guide_card(topic: str, format: str, block_norm: str, block: str,
|
||||
stage: str = "lernziele") -> None:
|
||||
"""Insert a card; an existing one keeps its stage/progress (resume)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO guide_cards (topic, format, block_norm, block, stage, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, format, block_norm) DO UPDATE SET
|
||||
block = excluded.block, updated_at = excluded.updated_at""",
|
||||
(topic, format, block_norm, block, stage, _now()))
|
||||
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]:
|
||||
@@ -997,7 +1125,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:
|
||||
if not fields:
|
||||
return
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
cols = ", ".join(f"{k} = ?" for k in fields)
|
||||
await db.execute(
|
||||
f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?",
|
||||
@@ -1007,7 +1135,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
|
||||
await _add_events_many(db, topic, [("fail", f"guide:{format}:{block_norm}", "",
|
||||
str(fields.get("gate_info", ""))[:200])])
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def guide_stage_counts(topic: str, format: str) -> dict[str, int]:
|
||||
@@ -1023,7 +1150,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)."""
|
||||
if not stages:
|
||||
return 0
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
ph = ",".join("?" * len(stages))
|
||||
md = ", md = ''" if clear_md else ""
|
||||
cursor = await db.execute(
|
||||
@@ -1031,12 +1158,11 @@ async def reset_guide_cards_from_stage(topic: str, format: str, stages: list[str
|
||||
gate_info = ''{md}, updated_at = ?
|
||||
WHERE topic = ? AND format = ? AND stage IN ({ph})""",
|
||||
(to_stage, _now(), topic, format, *stages))
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
async def delete_guide_board(topic: str, format: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
if format:
|
||||
await db.execute("DELETE FROM guide_cards WHERE topic = ? AND format = ?", (topic, format))
|
||||
cursor = await db.execute("SELECT count(*) FROM guide_cards WHERE topic = ?", (topic,))
|
||||
@@ -1045,18 +1171,16 @@ async def delete_guide_board(topic: str, format: str | None = None) -> None:
|
||||
else:
|
||||
await db.execute("DELETE FROM guide_cards WHERE topic = ?", (topic,))
|
||||
await db.execute("DELETE FROM guide_lernziele WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def put_lernziel(topic: str, block_norm: str, ziel_id: str, text: str, sub_norm: str = "") -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO guide_lernziele (topic, block_norm, ziel_id, text, sub_norm, covered, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||
ON CONFLICT(topic, block_norm, ziel_id) DO UPDATE SET
|
||||
text = excluded.text, sub_norm = excluded.sub_norm, updated_at = excluded.updated_at""",
|
||||
(topic, block_norm, ziel_id, text, sub_norm, _now()))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_lernziele(topic: str, block_norm: str | None = None) -> list[dict]:
|
||||
@@ -1072,17 +1196,15 @@ async def list_lernziele(topic: str, block_norm: str | None = None) -> list[dict
|
||||
|
||||
|
||||
async def set_ziel_covered(topic: str, block_norm: str, ziel_id: str, covered: bool) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"UPDATE guide_lernziele SET covered = ?, updated_at = ? WHERE topic = ? AND block_norm = ? AND ziel_id = ?",
|
||||
(1 if covered else 0, _now(), topic, block_norm, ziel_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
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.commit()
|
||||
|
||||
|
||||
async def kanban_membership(topic: str) -> dict[str, str]:
|
||||
@@ -1093,12 +1215,11 @@ async def kanban_membership(topic: str) -> dict[str, str]:
|
||||
|
||||
|
||||
async def kanban_set_member(topic: str, member_id: str, group_id: str) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""",
|
||||
(topic, member_id, group_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_add_title(topic: str, board: str, card_id: str, title: str,
|
||||
@@ -1111,12 +1232,11 @@ async def kanban_add_title(topic: str, board: str, card_id: str, title: str,
|
||||
if row is None:
|
||||
payload = {"title": title, "description": description,
|
||||
"sources": [source] if source else [], "readers": [reader]}
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at)
|
||||
VALUES (?, ?, ?, 'title', 'ingest', ?, ?)""",
|
||||
(topic, board, card_id, json.dumps(payload, ensure_ascii=False), _now()))
|
||||
await db.commit()
|
||||
return True
|
||||
p = row["payload"]
|
||||
p["readers"] = list(dict.fromkeys((p.get("readers") or []) + [reader]))
|
||||
@@ -1128,7 +1248,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:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, status, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, 'candidate', ?)
|
||||
@@ -1136,7 +1256,6 @@ async def upsert_subblock(topic: str, block_norm: str, sub_norm: str, block: str
|
||||
mentions = mentions + 1, updated_at = excluded.updated_at""",
|
||||
(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,
|
||||
@@ -1144,7 +1263,7 @@ async def put_subblock(topic: str, block_norm: str, sub_norm: str, block: str, s
|
||||
facts: str | None = None, status: str = "consensus") -> None:
|
||||
"""Insert/update WITHOUT a mention counter (mirror from the sidecar). Overwrite
|
||||
level/relevance/facts only when a new value is passed (COALESCE protects existing data)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO subblocks (topic, block_norm, sub_norm, block, sub_title, mentions, level, relevance, facts, status, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, COALESCE(?, ''), ?, ?)
|
||||
@@ -1156,7 +1275,6 @@ async def put_subblock(topic: str, block_norm: str, sub_norm: str, block: str, s
|
||||
status = excluded.status, updated_at = excluded.updated_at""",
|
||||
(topic, block_norm, sub_norm, block, sub_title, level, relevance, facts, status, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_subblocks(topic: str, block_norm: str | None = None) -> list[dict]:
|
||||
@@ -1174,7 +1292,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:
|
||||
"""Classify stragglers after finalize: consensus rows without a valid level fall out of
|
||||
the guide/practice/level queries (re-run resume left 25 such rows — invisible content)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""UPDATE subblocks SET level = 'advanced' WHERE topic = ? AND block_norm = ?
|
||||
AND status = 'consensus' AND (level IS NULL OR level NOT IN ('beginner', 'advanced', 'expert'))""",
|
||||
@@ -1183,14 +1301,13 @@ async def default_subblock_levels(topic: str, block_norm: str) -> None:
|
||||
"""UPDATE subblocks SET relevance = 'relevant' WHERE topic = ? AND block_norm = ?
|
||||
AND status = 'consensus' AND relevance IS NULL""",
|
||||
(topic, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def copy_topic(quelle: str, ziel: str) -> None:
|
||||
"""Trainings-Helfer: Kanban-Karten + Block-Rows der Quelle unter neuem Topic duplizieren
|
||||
(Frozen-Inventar-Trials — Board 2 läuft auf identischem Board-1-Stand neu). Nur DB;
|
||||
Dateien (source.json/blocks.md) kopiert der Runner."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (ziel,))
|
||||
await db.execute("DELETE FROM blocks WHERE topic = ?", (ziel,))
|
||||
await db.execute(
|
||||
@@ -1201,14 +1318,13 @@ async def copy_topic(quelle: str, ziel: str) -> None:
|
||||
"""INSERT INTO blocks (topic, title_norm, title, description, mentions, status, sources, reader, updated_at)
|
||||
SELECT ?, title_norm, title, description, mentions, status, sources, reader, updated_at
|
||||
FROM blocks WHERE topic = ?""", (ziel, quelle))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_stale_consensus(topic: str, block_norm: str, keep: set[str]) -> None:
|
||||
"""Drop consensus rows of a block that are NOT in this run's sidecar (`keep`): finalize
|
||||
only upserts, so re-runs piled up orphan rows (measured: 25 subs without any board-2
|
||||
output). variant/discarded rows stay — QA reads those statuses."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT sub_norm FROM subblocks WHERE topic = ? AND block_norm = ? AND status = 'consensus'",
|
||||
(topic, block_norm))
|
||||
@@ -1217,8 +1333,6 @@ async def delete_stale_consensus(topic: str, block_norm: str, keep: set[str]) ->
|
||||
for sn in stale:
|
||||
await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ? AND sub_norm = ?",
|
||||
(topic, block_norm, sn))
|
||||
if stale:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_subblock_fields(topic: str, block_norm: str, sub_norm: str, **fields) -> None:
|
||||
@@ -1228,16 +1342,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:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
if block_norm is None:
|
||||
await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
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(
|
||||
"""INSERT INTO question_pattern (topic, block_norm, sub_norm, block, sub_title, question, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -1245,7 +1358,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""",
|
||||
(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]:
|
||||
@@ -1285,31 +1397,29 @@ async def event_span(topic: str) -> int:
|
||||
|
||||
|
||||
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:
|
||||
await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def mark_sources_read_done(topic: str, sources: list[str]) -> None:
|
||||
"""Mark the cited crawl pages as read_done (research-loop coverage)."""
|
||||
if not sources:
|
||||
return
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
now = _now()
|
||||
await db.executemany(
|
||||
"""INSERT INTO research_coverage (topic, source, read_done, updated_at) VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(topic, source) DO UPDATE SET read_done = 1, updated_at = excluded.updated_at""",
|
||||
[(topic, q, now) for q in sources],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def mark_content(topic: str, content: list[str], noise: list[str]) -> None:
|
||||
"""Store the triage result per crawl page: content=1 (content) or 0 (noise)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
now = _now()
|
||||
rows = [(topic, q, 1, now) for q in content] + [(topic, q, 0, now) for q in noise]
|
||||
if not rows:
|
||||
@@ -1319,7 +1429,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""",
|
||||
rows,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_content(topic: str) -> list[str]:
|
||||
@@ -1332,19 +1441,17 @@ async def list_content(topic: str) -> list[str]:
|
||||
|
||||
|
||||
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.commit()
|
||||
|
||||
|
||||
async def set_step_status(topic: str, step: str, status: str) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO pipeline_state (topic, step, status, updated_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(topic, step) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at""",
|
||||
(topic, step, status, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_step_status(topic: str, step: str) -> str:
|
||||
@@ -1363,20 +1470,18 @@ async def get_step_status(topic: str, step: str) -> str:
|
||||
|
||||
|
||||
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.commit()
|
||||
|
||||
|
||||
async def set_guide_content(topic: str, format: str, content_json: str) -> None:
|
||||
"""Store finished guide content (JSON blob) per topic+format."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO guide_content (topic, format, json, updated_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(topic, format) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""",
|
||||
(topic, format, content_json, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_guide_content(topic: str, format: str) -> str | None:
|
||||
@@ -1387,23 +1492,21 @@ async def get_guide_content(topic: str, format: str) -> str | None:
|
||||
|
||||
|
||||
async def delete_guide_content(topic: str, format: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
if format is None:
|
||||
await db.execute("DELETE FROM guide_content WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM guide_content WHERE topic = ? AND format = ?", (topic, format))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_outline(topic: str, outline_json: str) -> None:
|
||||
"""Store the outline (chapter→numbers, JSON) per topic — blocks artifact for the guide."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO guide_outline (topic, json, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(topic) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at""",
|
||||
(topic, outline_json, _now()),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_outline(topic: str) -> str | None:
|
||||
@@ -1414,15 +1517,14 @@ async def get_outline(topic: str) -> 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.commit()
|
||||
|
||||
|
||||
async def put_sub_artifact(topic: str, block_norm: str, sub_norm: str, type: str,
|
||||
data: str, block: str = "", sub_title: str = "") -> None:
|
||||
"""Store one learning artifact (flashcard/example) as JSON in `data`."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO sub_artefakte (topic, block_norm, sub_norm, type, block, sub_title, data, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -1431,7 +1533,6 @@ async def put_sub_artifact(topic: str, block_norm: str, sub_norm: str, type: str
|
||||
data = excluded.data, updated_at = excluded.updated_at""",
|
||||
(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,
|
||||
@@ -1460,7 +1561,7 @@ async def get_practice_progress(topic: str) -> list[dict]:
|
||||
|
||||
async def upsert_practice_progress(topic: str, block_norm: str, sub_norm: str,
|
||||
box: int, due_at: str) -> None:
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO practice_progress (topic, block_norm, sub_norm, box, due_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
@@ -1468,7 +1569,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,
|
||||
updated_at = excluded.updated_at""",
|
||||
(topic, block_norm, sub_norm, box, due_at, _now()))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def sub_levels_norm(topic: str) -> dict[tuple[str, str], int]:
|
||||
@@ -1494,27 +1594,24 @@ async def subs_per_level_norm(topic: str) -> dict[str, dict[int, int]]:
|
||||
|
||||
async def delete_artefakt_row(topic: str, block_norm: str, sub_norm: str, type: str) -> None:
|
||||
"""Remove ONE artefact row (repair: dead target — sub discarded or gone)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ? AND sub_norm = ? AND type = ?",
|
||||
(topic, block_norm, sub_norm, type))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_frage_row(topic: str, block_norm: str, sub_norm: str) -> None:
|
||||
"""Remove ONE question_pattern row (repair: dead target)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ? AND sub_norm = ?",
|
||||
(topic, block_norm, sub_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
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:
|
||||
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_block_hurdles(topic: str, block_norm: str) -> list[str]:
|
||||
@@ -1542,8 +1639,7 @@ async def get_block_hurdles(topic: str, block_norm: str) -> list[str]:
|
||||
async def delete_topic_pipeline(topic: str) -> None:
|
||||
"""Discard the blocks area of a topic (inventory/subs/pattern/coverage/state/artifacts).
|
||||
NOT the topic config `source` — that is managed separately (delete_source)."""
|
||||
db = await get_db()
|
||||
async with _tx() as db:
|
||||
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage",
|
||||
"pipeline_state", "guide_outline", "sub_artefakte", "practice_progress", "events"):
|
||||
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
+97
-82
@@ -21,7 +21,6 @@ _PATH_RE = re.compile(r"(/\S+\.(?:json|md))")
|
||||
_NUM_RE = re.compile(r"^\s*(\d+)[.)]\s+(.*\S)", re.MULTILINE)
|
||||
_SUBLIST_RE = re.compile(r"^- (?:\[(\w+)\] )?(.+\S)\s*$", re.MULTILINE)
|
||||
_ZIEL_RE = re.compile(r"\(([a-z]\d+)\)")
|
||||
_PAIR_RE = re.compile(r"^(\d+)\.\s*\nA: \[Block: (.*?)\] (.*?)\n", re.MULTILINE)
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
@@ -30,7 +29,7 @@ def _norm(s: str) -> str:
|
||||
|
||||
class Welt:
|
||||
"""Deterministisches Themen-Modell. bloecke: {titel: {"beschreibung": str,
|
||||
"subs": [titel]}}; optionale Regeln steuern Konsolidierung/Cross-Block."""
|
||||
"subs": [titel]}}; optionale Regeln steuern das Störfall-Verhalten."""
|
||||
|
||||
def __init__(self, bloecke: dict | None = None, *, gruppen: list | None = None,
|
||||
kataloge: list | None = None, stoerungen: list | None = None):
|
||||
@@ -101,10 +100,13 @@ class Welt:
|
||||
j = json.dumps
|
||||
# Board 1 / Inventar
|
||||
if "-research-" in key:
|
||||
# Bottom-up: Research liefert die ATOME flach; Board 1 gruppiert sie zu den
|
||||
# Cluster-Bausteinen. Jedes Atom gehört zu genau einem Cluster.
|
||||
zeilen = []
|
||||
n = 1
|
||||
for t, b in self.bloecke.items():
|
||||
zeilen.append(f"{n}. {t} — {b['beschreibung']}")
|
||||
for cl, b in self.bloecke.items():
|
||||
for atom in b["subs"]:
|
||||
zeilen.append(f"{n}. {atom} — Atom aus {cl}")
|
||||
n += 1
|
||||
return "\n".join(zeilen)
|
||||
if "-pair-" in key:
|
||||
@@ -118,31 +120,61 @@ class Welt:
|
||||
return j({"keep": keep, "rest": []})
|
||||
if "-naming-" in key: # deckt auch naming_check (gleicher Key)
|
||||
return j({"best": 1})
|
||||
if "-sanierung-" in key: # Titel/Beschreibung unverändert zurück (Fake-Welt ist sauber)
|
||||
t = re.search(r"^Title: (.+)$", prompt, re.M)
|
||||
d = re.search(r"^Description: (.+)$", prompt, re.M)
|
||||
beschr = (d.group(1).strip() if d else "")
|
||||
if beschr == "(leer)":
|
||||
beschr = "Beschreibung aus dem Material."
|
||||
return j({"title": t.group(1).strip() if t else "", "description": beschr})
|
||||
if "-filter-" in key: # auch filter-recheck
|
||||
return j({"fragments": {}, "drop": []})
|
||||
if "-gruppierung-completion-" in key:
|
||||
return j({"additions": []})
|
||||
if "-gruppierung-completion-" in key or "-gruppierung-attach-" in key:
|
||||
return j({"additions": []}) # Anschluss-Judge: Fake-Nachzügler bleiben standalone
|
||||
if "-gruppierung-" in key:
|
||||
return j({"umbrellas": []})
|
||||
# jedes Atom seinem Cluster zuordnen (nur die FULL ITEM LIST, 1..n eindeutig)
|
||||
seg = prompt.split("FULL ITEM LIST", 1)[-1]
|
||||
atom_cluster = {_norm(a): cl for cl, b in self.bloecke.items() for a in b["subs"]}
|
||||
umb: dict[str, list[int]] = {}
|
||||
for m in _NUM_RE.finditer(seg):
|
||||
nr, zeile = int(m.group(1)), _norm(m.group(2))
|
||||
for an, cl in atom_cluster.items():
|
||||
if zeile.startswith(an):
|
||||
umb.setdefault(cl, []).append(nr)
|
||||
break
|
||||
umbrellas = [{"title": cl, "description": self.bloecke[cl]["beschreibung"],
|
||||
"members": ms} for cl, ms in umb.items() if ms]
|
||||
return j({"umbrellas": umbrellas})
|
||||
if "-supplement-beleg" in key or "-anker-beleg-" in key:
|
||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
|
||||
if "-supplement" in key:
|
||||
return j({"blocks": []})
|
||||
if "-supplement" in key or "-luecken-nachfass-" in key:
|
||||
return j({"blocks": []}) # Fake-Welt ist vollständig — Lücken-Research findet nichts
|
||||
if "-source-relevance-" in key:
|
||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
|
||||
|
||||
# Board 2 / Artefakte
|
||||
if "-luecken-" in key:
|
||||
return "<!-- block: leer -->\n" # Lücken-Nachfass findet nichts Neues
|
||||
if "-subblock-final-" in key or "-subblock-" in key: # Finder + Judge, gleiches Format
|
||||
teile = []
|
||||
# Board 2 / Artefakte (verschmolzene Calls, block_calls.py)
|
||||
if "-sb-enrich-" in key: # Bottom-up: feste Subs (aus dem Prompt) mit Facts anreichern
|
||||
seg = prompt.split("FIXED SUBBLOCKS", 1)[-1].split("\n\n", 1)[0]
|
||||
subs = []
|
||||
for m in re.finditer(r"^\s*\d+\.\s+(.+)$", seg, re.M):
|
||||
s = m.group(1).strip()
|
||||
f = self._fakt("", s)
|
||||
subs.append({"title": s, "level": "beginner", "relevance": "relevant",
|
||||
**{k: f[k] for k in ("key_points", "prerequisites", "hurdles",
|
||||
"cited_facts", "example_idea")}})
|
||||
return j({"subs": subs})
|
||||
if "-sb-gen-" in key:
|
||||
subs = []
|
||||
for t in self._bloecke_im_prompt(prompt):
|
||||
subs = "\n".join(f"- {s}" for s in self.bloecke[t]["subs"])
|
||||
teile.append(f"<!-- block: {t} -->\n{subs}")
|
||||
return "\n".join(teile) or "<!-- block: leer -->\n"
|
||||
if "-sub-konsolidierung-" in key:
|
||||
for s in self.bloecke[t]["subs"]:
|
||||
f = self._fakt(t, s)
|
||||
subs.append({"title": s, "level": "beginner", "relevance": "relevant",
|
||||
**{k: f[k] for k in ("key_points", "prerequisites", "hurdles",
|
||||
"cited_facts", "example_idea")}})
|
||||
return j({"subs": subs})
|
||||
if "-sb-verify-" in key:
|
||||
nummern = {_norm(m.group(2)): m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
gruppen = []
|
||||
for haupt, weitere in self.gruppen:
|
||||
@@ -155,50 +187,24 @@ class Welt:
|
||||
m = [int(nummern[_norm(x)]) for x in mitglieder if _norm(x) in nummern]
|
||||
if len(m) >= 2:
|
||||
kataloge.append({"titel": kt, "mitglieder": m})
|
||||
return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": []})
|
||||
if "-sub-crossblock-" in key:
|
||||
urteile = {}
|
||||
for m in _PAIR_RE.finditer(prompt):
|
||||
urteile[m.group(1)] = "a" # identischer Text (nur so wird gepaart) → A behält
|
||||
return j({"pairs": urteile or {"1": "nein"}})
|
||||
if "-facts-check-" in key:
|
||||
unsicher = {}
|
||||
if (u := prompt.find("UNSICHER")) != -1: # alle Unsicher-Nummern übernehmen
|
||||
import re as _re
|
||||
for m in _re.finditer(r"entries (\d+)–(\d+)", prompt[u:u + 200]):
|
||||
unsicher = {str(k): "ja" for k in range(int(m.group(1)), int(m.group(2)) + 1)}
|
||||
return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": [],
|
||||
"uebernehmen": unsicher, "facts_probleme": [], "levels": {}, "relevanz": {}})
|
||||
if "-sb-fix-" in key:
|
||||
return j({"subs": []})
|
||||
if "-art-gen-" in key:
|
||||
subs = self._subs_im_prompt(prompt)
|
||||
t = (self._bloecke_im_prompt(prompt) or ["?"])[0]
|
||||
return j({"pattern": [{"block": t, "subblock": s, "question": f"Was ist {s}?"} for s in subs],
|
||||
"cards": [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"} for s in subs],
|
||||
"examples": [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}",
|
||||
"steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"} for s in subs]})
|
||||
if "-art-check-" in key:
|
||||
return j({"ok": True})
|
||||
if "-facts-" in key: # facts / facts-fix / facts-erg: gleiches Format
|
||||
eintraege = []
|
||||
for t in self._bloecke_im_prompt(prompt):
|
||||
for s in self.bloecke[t]["subs"]:
|
||||
if s in prompt:
|
||||
eintraege.append(self._fakt(t, s))
|
||||
for kt, _m in self.kataloge:
|
||||
if kt in prompt:
|
||||
eintraege.append(self._fakt(t, kt))
|
||||
if not eintraege: # Nachfass-Fälle: Subs ohne Blockkontext im Prompt
|
||||
eintraege = [self._fakt(bt, s) for bt, b in self.bloecke.items()
|
||||
for s in b["subs"] if s in prompt]
|
||||
return j({"facts": eintraege})
|
||||
if "-level-" in key: # Rater + final: alle geforderten Nummern
|
||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
return j({"levels": {k: "beginner" for k in sorted(nums, key=int)} or {"1": "beginner"}})
|
||||
if "-relevance-" in key:
|
||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
return j({"relevance": {k: "relevant" for k in sorted(nums, key=int)} or {"1": "relevant"}})
|
||||
if "-question-pattern-" in key:
|
||||
eintraege = []
|
||||
for t in self._bloecke_im_prompt(prompt):
|
||||
for s in self._subs_im_prompt(prompt):
|
||||
eintraege.append({"block": t, "subblock": s, "question": f"Was ist {s}?"})
|
||||
return j({"pattern": eintraege})
|
||||
if "-artifact-example-check-" in key:
|
||||
return j({"ok": True})
|
||||
if "-artifact-flashcard-" in key:
|
||||
karten = [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"}
|
||||
for t in self._bloecke_im_prompt(prompt) for s in self._subs_im_prompt(prompt)]
|
||||
return j({"cards": karten})
|
||||
if "-artifact-example-" in key:
|
||||
bsp = [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}",
|
||||
"steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"}
|
||||
for t in self._bloecke_im_prompt(prompt) for s in self._subs_im_prompt(prompt)]
|
||||
return j({"examples": bsp})
|
||||
if "-outline-prereqs" in key:
|
||||
return j({"prereqs": {}})
|
||||
if "-outline-review" in key:
|
||||
@@ -212,15 +218,12 @@ class Welt:
|
||||
ziele = [{"id": f"z{i}", "text": f"Verstehen von {s}", "sub": s}
|
||||
for i, s in enumerate(self._subs_im_prompt(prompt), 1)][:8]
|
||||
return j({"ziele": ziele or [{"id": "z1", "text": "Grundlagen verstehen", "sub": ""}]})
|
||||
if "-gatefix-" in key or "-lesefix-" in key:
|
||||
if "-gfix-" in key:
|
||||
return self._section_aus_prompt(prompt) or "<!-- section: X -->\nRepariert."
|
||||
if "-gate-" in key:
|
||||
return j({"ok": True})
|
||||
if "-cov-" in key:
|
||||
if "-pruef-" in key: # verschmolzener Prüfer: Fakten + Coverage + Lesbarkeit
|
||||
ids = sorted(set(_ZIEL_RE.findall(prompt)))
|
||||
return j({"ziele": {z: True for z in ids}, "luecken": [], "ballast": []})
|
||||
if "-lese-" in key:
|
||||
return j({"ok": True})
|
||||
return j({"claims": [], "ziele": {z: True for z in ids}, "luecken": [],
|
||||
"ballast": [], "lese_probleme": []})
|
||||
if "-w-" in key:
|
||||
return self._writer_md(prompt)
|
||||
|
||||
@@ -267,13 +270,15 @@ class Welt:
|
||||
|
||||
|
||||
def standard_bloecke() -> dict:
|
||||
"""3 Blöcke; „Gemeinsamer Grundbegriff" liegt in Alpha UND Beta (Cross-Block-Fall)."""
|
||||
"""3 Themen-Cluster; die subs sind die ATOME, die Board 1 bottom-up zu genau diesem
|
||||
Cluster gruppiert. Jedes Atom liegt in genau einem Cluster (kein geteiltes Atom mehr —
|
||||
das globale Clustern in Board 1 macht Cross-Block-Dubletten unmöglich)."""
|
||||
return {
|
||||
"Alpha-Konzept": {"beschreibung": "Das erste Grundkonzept",
|
||||
"subs": ["Definition Alpha", "Alpha Eigenschaften",
|
||||
"Gemeinsamer Grundbegriff"]},
|
||||
"Beta-Verfahren": {"beschreibung": "Das zentrale Verfahren",
|
||||
"subs": ["Beta Ablauf", "Beta Grenzen", "Gemeinsamer Grundbegriff"]},
|
||||
"subs": ["Beta Ablauf", "Beta Grenzen"]},
|
||||
"Gamma-Anwendung": {"beschreibung": "Praktische Anwendung",
|
||||
"subs": ["Gamma Praxisfall", "Gamma Werkzeuge"]},
|
||||
}
|
||||
@@ -297,20 +302,18 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
|
||||
|
||||
import agents
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
import board_inventory as bi
|
||||
import guide
|
||||
import guide_board
|
||||
import kanban
|
||||
import pipeline
|
||||
import qa
|
||||
import repair
|
||||
|
||||
async def fake_run_agent(agent_key, prompt, timeout, provider="claude", role="fast",
|
||||
async def fake_run_agent(agent_key, prompt, timeout, provider="", role="fast",
|
||||
capabilities="none", lane="batch", scope=None, on_line=None, label=""):
|
||||
return welt.respond(agent_key, prompt, capabilities)
|
||||
|
||||
for mod in (agents, pipeline, blocks, guide, repair):
|
||||
for mod in (agents, pipeline, guide):
|
||||
setattr_fn(mod, "run_agent", fake_run_agent)
|
||||
setattr_fn(blocks, "CONSENSUS_GRACE", 0)
|
||||
setattr_fn(bi, "_QA_GATE_POLL", 0.05)
|
||||
@@ -325,11 +328,18 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
|
||||
|
||||
@staticmethod
|
||||
def embed(texts):
|
||||
import hashlib
|
||||
|
||||
import numpy as np
|
||||
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
|
||||
arr = np.zeros((len(texts), max(len(uniq), 1)))
|
||||
# FESTE Dimension (hash → One-hot): identischer Text = gleiche Spalte = cos 1.0,
|
||||
# verschiedener Text = andere Spalte = cos 0.0. Anders als eine pro-Aufruf
|
||||
# variable Breite lassen sich so Vektoren aus verschiedenen embed()-Aufrufen
|
||||
# concatenieren (Board-1-Cluster-Cache) ohne Dimensions-Mismatch.
|
||||
D = 4096
|
||||
arr = np.zeros((len(texts), D))
|
||||
for r, t in enumerate(texts):
|
||||
arr[r, uniq[t]] = 1.0
|
||||
h = int(hashlib.blake2b(t.encode("utf-8"), digest_size=8).hexdigest(), 16) % D
|
||||
arr[r, h] = 1.0
|
||||
return arr
|
||||
|
||||
@staticmethod
|
||||
@@ -337,9 +347,14 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
|
||||
arr = _FakeEmb.embed(texts)
|
||||
return arr @ arr.T
|
||||
|
||||
for mod in (blocks, ba, qa):
|
||||
setattr_fn(mod, "embedding", _FakeEmb)
|
||||
# reine Matrix-/Union-Find-Helfer (modell-unabhängig) ans echte Modul delegieren
|
||||
import embedding as _real_emb
|
||||
capped_blocks = staticmethod(_real_emb.capped_blocks)
|
||||
_find = staticmethod(_real_emb._find)
|
||||
_union = staticmethod(_real_emb._union)
|
||||
|
||||
async def emb_ok(flow): # Board-1-Vektorpfade aus — Judge-Wellen reichen
|
||||
return False
|
||||
setattr_fn(bi, "_emb_ok", emb_ok)
|
||||
for mod in (blocks, qa, bi):
|
||||
setattr_fn(mod, "embedding", _FakeEmb)
|
||||
# _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage.
|
||||
# _FakeEmb bildet nur bei identischem Text Nachbarn — verschiedene Atome erzeugen keine
|
||||
# falschen Vorab-Cluster; das eigentliche Clustern macht der TOP-Judge (Card-sort).
|
||||
+19
-32
@@ -11,7 +11,6 @@ Step files are kept → an abort preserves progress, ▶ resumes at the open ste
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,26 +18,14 @@ import uuid
|
||||
|
||||
from agents import run_agent
|
||||
from blocks import _convert_pdfs, source_folder
|
||||
from config import (
|
||||
DEFAULT_PROVIDER, FORMAT_PURPOSE, CONSENSUS_GRACE,
|
||||
READABILITY_ACTIVE, TEMPLATES_DIR,
|
||||
)
|
||||
import readability
|
||||
from config import DEFAULT_PROVIDER, TEMPLATES_DIR
|
||||
from database import (list_guides, update_guide, list_blocks, list_subblocks, set_guide_content,
|
||||
get_guide_content, get_outline, guide_stage_counts, delete_guide_board)
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_file, parse_json_text as _parse_json_text
|
||||
from paths import blocks_path, guide_content_path, project_dir, subblocks_path
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, GenContext, _claude_error, _extra,
|
||||
_fail, _gather_error, _gather_progress, _log, _prompt, _race,
|
||||
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
|
||||
is_guide_cancelled, run_single_slot,
|
||||
)
|
||||
from textkit import (
|
||||
_unique_title, _load_blocks, _norm_title, _parse_fragment, _split_chunks,
|
||||
_title, _resolve_title, _title_index,
|
||||
)
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from paths import blocks_path, guide_content_path, subblocks_path
|
||||
from pipeline import _fail, _prompt, _semaphore, clear_guide_cancelled, is_guide_cancelled
|
||||
from textkit import _unique_title, _load_blocks, _norm_title, _title, parse_facts
|
||||
|
||||
log = logging.getLogger("creator.guide")
|
||||
|
||||
@@ -66,10 +53,7 @@ async def _load_subblocks(topic: str) -> dict[str, list[dict]]:
|
||||
out: dict[str, list[dict]] = {}
|
||||
for r in await list_subblocks(topic):
|
||||
if r["status"] == "consensus" and r["sub_title"]:
|
||||
try:
|
||||
facts = json.loads(r["facts"]) if r.get("facts") else {}
|
||||
except (ValueError, TypeError):
|
||||
facts = {}
|
||||
facts = parse_facts(r.get("facts"))
|
||||
level = r["level"] if r["level"] in _LEVELS_OK else "advanced"
|
||||
out.setdefault(r["block"], []).append(
|
||||
{"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts})
|
||||
@@ -234,13 +218,19 @@ async def reconcile_guides() -> None:
|
||||
file write and status update.
|
||||
"""
|
||||
for g in await list_guides():
|
||||
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
|
||||
log.warning("[%s] Guide %s: done without content file — set to error", g["topic"], g["id"])
|
||||
if g["status"] != "done":
|
||||
continue
|
||||
# DB-first wie die Content-Route — die Datei ist nur Legacy-Fallback
|
||||
if await get_guide_content(g["topic"], g["format"]) is not None:
|
||||
continue
|
||||
if guide_content_path(g["topic"], g["format"]).exists():
|
||||
continue
|
||||
log.warning("[%s] Guide %s: done without content — set to error", g["topic"], g["id"])
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now)
|
||||
|
||||
|
||||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None:
|
||||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||
async with _semaphore:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="generating", progress="Starting…", updated_at=now)
|
||||
@@ -257,12 +247,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
||||
await asyncio.to_thread(_convert_pdfs, project)
|
||||
|
||||
import guide_board # lazy — guide_board imports helpers from this module
|
||||
# Re-run from stage: cards from `ab_step` onward back to that column.
|
||||
# A FINISHED guide without ab_step → complete fresh start (board + slots wiped).
|
||||
# Otherwise cards are leftovers of an abort/error → resume at their stored stage.
|
||||
if ab_step is not None:
|
||||
await guide_board.reset_from_stage(topic, format_name, ab_step)
|
||||
elif content_path.exists():
|
||||
# A FINISHED guide → complete fresh start (board + slots wiped). Otherwise the cards
|
||||
# are leftovers of an abort/error/repair → resume at their stored stage.
|
||||
if content_path.exists():
|
||||
counts = await guide_stage_counts(topic, format_name)
|
||||
if not counts or set(counts) == {"done"}:
|
||||
await delete_guide_board(topic, format_name)
|
||||
|
||||
+291
-238
@@ -3,9 +3,10 @@
|
||||
lernziele judge-Rolle Backward Design — objectives BEFORE writing
|
||||
zuweisung code chapter/order from the outline artefact + facts grounding
|
||||
writer guide-Rolle ONE coherent per-block text, only from VERIFIED FACTS
|
||||
fakten_gate judge-Rolle CoVe: atomic claims, each binary against the facts → minimal fix
|
||||
coverage judge-Rolle objective↔section mapping; gap → back to writer (max 2 rounds)
|
||||
lesbarkeit judge-Rolle Lese-Check + deterministic readability gate → fix → done
|
||||
pruefer judge-Rolle EIN Call: CoVe-Fakten + Coverage + Lesbarkeit (lasen vorher
|
||||
denselben Text in 3 seriellen Calls) + deterministische Gates
|
||||
fix guide-Rolle EIN Rewrite unter allen Auflagen; bei falsch/Lücken danach
|
||||
genau ein Re-Prüfer-Pass (der alte Lese-Fix blieb ungeprüft)
|
||||
|
||||
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
|
||||
needed); stage transitions are persisted in guide_cards, so the board is live and
|
||||
@@ -21,9 +22,9 @@ import re
|
||||
import database as db
|
||||
import readability
|
||||
from blocks import _sink_json
|
||||
from config import (FORMAT_PURPOSE, GUIDE_LAENGE_MAX, GUIDE_LAENGE_MIN, READABILITY_ACTIVE,
|
||||
TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC)
|
||||
from fsutil import atomic_write_json
|
||||
from config import (FIX_LAENGE_BAND, READABILITY_ACTIVE, TEMPLATES_DIR,
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC, ZIELE_MAX)
|
||||
from guide_qa import block_budget
|
||||
from jsonio import read_json_file as _json_file
|
||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||
_timeout, is_guide_cancelled, run_single_slot)
|
||||
@@ -31,11 +32,10 @@ from textkit import _norm_title, _parse_fragment, _title
|
||||
|
||||
log = logging.getLogger("creator.guide_board")
|
||||
|
||||
GUIDE_STAGES = ("lernziele", "zuweisung", "writer", "fakten_gate", "coverage", "lesbarkeit")
|
||||
GUIDE_STAGES = ("lernziele", "zuweisung", "writer", "pruefer", "fix")
|
||||
STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "Writer",
|
||||
"fakten_gate": "Fakten-Gate", "coverage": "Coverage",
|
||||
"lesbarkeit": "Lesbarkeit", "done": "Fertig"}
|
||||
from config import GATE_FIX_MIN, MAX_WRITER_ROUNDS, WRITER_SPLIT_SUBS # zentral tunebar
|
||||
"pruefer": "Prüfen", "fix": "Fix", "done": "Fertig"}
|
||||
from config import GATE_FIX_MIN, WRITER_SPLIT_SUBS # zentral tunebar
|
||||
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
|
||||
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
|
||||
# from the old 10-slot era while the .env already allowed 24).
|
||||
@@ -56,7 +56,7 @@ def _ziele_schema(data):
|
||||
return None
|
||||
zid = str(z.get("id", "")).strip()
|
||||
text = str(z.get("text", "")).strip()
|
||||
if not zid or not text or zid in seen or len(out) >= 12:
|
||||
if not zid or not text or zid in seen or len(out) >= ZIELE_MAX:
|
||||
continue
|
||||
seen.add(zid)
|
||||
out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()})
|
||||
@@ -87,33 +87,31 @@ def _gate_schema(data):
|
||||
return out
|
||||
|
||||
|
||||
def _coverage_schema(data, ziel_ids: set[str]):
|
||||
"""{"ziele":{id:bool}, "luecken":[{ziel,fehlt}], "ballast":[str]} — ziele must cover all ids."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("ziele"), dict):
|
||||
def _pruefer_schema(data, ziel_ids: set[str]):
|
||||
"""Verschmolzenes Prüfer-Verdikt: Claims (Fakten-Gate-Semantik via _gate_schema) +
|
||||
Coverage (ziele/luecken/ballast) + Lesbarkeit (lese_probleme). {"ok":true} = leeres
|
||||
Verdikt. `ziele` muss alle ids abdecken, wenn Ziele existieren — sonst optional."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
|
||||
if not any(k in data for k in ("claims", "ziele", "luecken", "ballast", "lese_probleme")):
|
||||
return None
|
||||
claims = _gate_schema({"claims": data["claims"]}) if data.get("claims") else []
|
||||
if claims is None:
|
||||
return None
|
||||
ziele = {}
|
||||
for k, v in data["ziele"].items():
|
||||
for k, v in (data.get("ziele") or {}).items() if isinstance(data.get("ziele"), dict) else []:
|
||||
ziele[str(k)] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
|
||||
if not ziel_ids <= set(ziele):
|
||||
if ziel_ids and not ziel_ids <= set(ziele):
|
||||
return None
|
||||
luecken = [{"ziel": str(l.get("ziel", "")), "fehlt": str(l.get("fehlt", ""))}
|
||||
for l in data.get("luecken", []) if isinstance(l, dict) and str(l.get("fehlt", "")).strip()]
|
||||
ballast = [str(b).strip() for b in data.get("ballast", []) if str(b).strip()]
|
||||
return {"ziele": ziele, "luecken": luecken, "ballast": ballast}
|
||||
|
||||
|
||||
def _problems_schema(data):
|
||||
"""Lese-Check: {"ok":true} → [] · {"problems":[{section,problem}]} → [problem…]."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
probs = data.get("problems")
|
||||
if not isinstance(probs, list) or not probs:
|
||||
return None
|
||||
out = [str(p.get("problem", "")).strip() for p in probs
|
||||
lese = [str(p.get("problem", "")).strip() for p in data.get("lese_probleme", [])
|
||||
if isinstance(p, dict) and str(p.get("problem", "")).strip()]
|
||||
return out or None
|
||||
return {"claims": claims, "ziele": ziele, "luecken": luecken, "ballast": ballast,
|
||||
"lese_probleme": lese}
|
||||
|
||||
|
||||
def _first_section(md: str) -> dict | None:
|
||||
@@ -141,10 +139,35 @@ class _Env:
|
||||
return self.content_path.parent / f"{self.content_path.stem}.{name}"
|
||||
|
||||
|
||||
def _memo(env, attr: str) -> dict:
|
||||
"""Lazy per-Karte-Cache auf dem env-Objekt (funktioniert auch für Test-Mocks). Lernziele,
|
||||
Beispiel-Rows und Facts-Grounding sind während EINES Laufs immutabel, wurden aber je Karte
|
||||
2–3× neu geholt (writer, pruefer, re-pruefer). Karten haben disjunkte block_norm-Keys und
|
||||
laufen ihre Stages seriell → kein Race."""
|
||||
d = env.__dict__.get(attr)
|
||||
if d is None:
|
||||
d = env.__dict__[attr] = {}
|
||||
return d
|
||||
|
||||
|
||||
async def _ziele(env: _Env, block_norm: str) -> list[dict]:
|
||||
cache = _memo(env, "_ziele_cache")
|
||||
if block_norm not in cache:
|
||||
cache[block_norm] = await db.list_lernziele(env.topic, block_norm)
|
||||
return cache[block_norm]
|
||||
|
||||
|
||||
def _ziele_text(ziele: list[dict]) -> str:
|
||||
return "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
|
||||
|
||||
|
||||
def _card_facts(env: _Env, block_title: str) -> str:
|
||||
cache = _memo(env, "_facts_cache")
|
||||
if block_title not in cache:
|
||||
from guide import _facts_grounding # lazy: guide imports this module
|
||||
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
|
||||
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],
|
||||
@@ -152,7 +175,10 @@ async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
|
||||
"""Verified worked examples of the block as writer input, matched to `subs` via
|
||||
sub_norm (a split half gets only its own). Rows whose sub does not match (generation
|
||||
mismatch) go to the full writer / split part 1 so they never vanish silently."""
|
||||
rows = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
|
||||
cache = _memo(env, "_example_rows")
|
||||
if block_norm not in cache:
|
||||
cache[block_norm] = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
|
||||
rows = cache[block_norm]
|
||||
if not rows:
|
||||
return ""
|
||||
wanted = {_norm_title(s["title"]) for s in subs}
|
||||
@@ -276,12 +302,6 @@ def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _writer_budget(n_subs: int, sockel: int = 800) -> int:
|
||||
"""Length guideline (chars) for the detailed version — unguided sections measured 2–4×
|
||||
too long (23k) or, after the readability fix, far too thin (180 chars/sub)."""
|
||||
return sockel + 400 * max(n_subs, 1)
|
||||
|
||||
|
||||
async def _write_split(env: _Env, card: dict, ziele_text: str):
|
||||
"""First draft in two halves (parallel), merged into one section.
|
||||
→ merged text | None (failed) | False (cancelled)."""
|
||||
@@ -318,7 +338,7 @@ async def _write_split(env: _Env, card: dict, ziele_text: str):
|
||||
examples=await _card_examples(env, norm, parts[i],
|
||||
include_unmatched=(i == 0)),
|
||||
gaps="\n" + hints[i] + "\n",
|
||||
budget=_writer_budget(len(parts[i]), sockel=400),
|
||||
budget=block_budget(parts[i]),
|
||||
spec=env.spec, out_path=path, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_payload,
|
||||
timeout=_timeout("writer", 1))
|
||||
@@ -333,13 +353,7 @@ async def _write_split(env: _Env, card: dict, ziele_text: str):
|
||||
|
||||
async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
ziele = await db.list_lernziele(env.topic, norm)
|
||||
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
|
||||
gaps = ""
|
||||
if card["writer_rounds"] > 0 and card.get("gate_info"):
|
||||
gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
|
||||
"below, cut the listed ballast, keep everything else as-is.\n"
|
||||
f"PREVIOUS VERSION:\n{card.get('md', '')}\n\nGAPS/BALLAST:\n{card['gate_info']}\n")
|
||||
ziele_text = _ziele_text(await _ziele(env, norm))
|
||||
# oversized first drafts: two halves, merged into one canonical section
|
||||
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
|
||||
text = await _write_split(env, card, ziele_text)
|
||||
@@ -348,7 +362,7 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
if text is None:
|
||||
await _set(env, card, status="error", gate_info="Writer (Split) ohne Ergebnis")
|
||||
return False
|
||||
await _set(env, card, md=text, stage="fakten_gate", status="open")
|
||||
await _set(env, card, md=text, stage="pruefer", status="open")
|
||||
return True
|
||||
|
||||
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||
@@ -366,8 +380,8 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
assignment=_card_assignment(env, card), ziele=ziele_text,
|
||||
facts=_card_facts(env, card["block"]),
|
||||
examples=await _card_examples(env, norm, env.subs_by_title.get(card["block"], [])),
|
||||
gaps=gaps, spec=env.spec,
|
||||
budget=_writer_budget(len(env.subs_by_title.get(card["block"], []))),
|
||||
gaps="", spec=env.spec,
|
||||
budget=block_budget(env.subs_by_title.get(card["block"], [])),
|
||||
out_path=path, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_payload,
|
||||
timeout=_timeout("writer", 1))
|
||||
@@ -376,162 +390,137 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
if status == FAILED:
|
||||
await _set(env, card, status="error", gate_info="Writer ohne Ergebnis")
|
||||
return False
|
||||
await _set(env, card, md=text, stage="fakten_gate", status="open")
|
||||
await _set(env, card, md=text, stage="pruefer", status="open")
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
|
||||
def _n_rel(env: _Env, card: dict) -> int:
|
||||
return sum(1 for s in env.subs_by_title.get(card["block"], [])
|
||||
if s.get("relevance") != "peripheral")
|
||||
|
||||
|
||||
def _det_hinweise(env: _Env, card: dict, sec: dict) -> list[str]:
|
||||
"""Deterministische Befunde (extern geerdet, kein LLM): Readability-Modell +
|
||||
Längenbudget — dieselbe Formel wie der QA-Detektor (guide_qa.block_budget), nur mit
|
||||
engerem Band, damit der Fix VOR der QA-Grenze greift. Gehen direkt in den Fix
|
||||
und als „nicht wiederholen"-Notiz in den Prüfer-Prompt."""
|
||||
out: list[str] = []
|
||||
subs_all = env.subs_by_title.get(card["block"], [])
|
||||
if not any(s.get("relevance") != "peripheral" for s in subs_all):
|
||||
return out # kein Inventar als Budget-Basis → kein Längen-Urteil (wie der QA-Detektor)
|
||||
budget = block_budget(subs_all)
|
||||
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
|
||||
zeichen = len(aus[1] if len(aus) == 2 else sec["md"])
|
||||
lo, hi = FIX_LAENGE_BAND
|
||||
if not (lo * budget <= zeichen <= hi * budget):
|
||||
out.append(
|
||||
f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(lo * budget)}–{round(hi * budget)}): "
|
||||
f"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und "
|
||||
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
|
||||
return out
|
||||
|
||||
|
||||
async def _det_readability(sec: dict) -> list[str]:
|
||||
if not READABILITY_ACTIVE:
|
||||
return []
|
||||
hints = await asyncio.to_thread(readability.rate_sections, {1: sec["md"]})
|
||||
return [hints[1]] if hints.get(1) else []
|
||||
|
||||
|
||||
def _auftraege(verdict: dict, det: list[str], n_rel: int = 0) -> tuple[list[str], bool]:
|
||||
"""Prüfer-Verdikt → Fix-Auftragszeilen. kritisch = falsch-Claims oder Lücken
|
||||
(nur die rechtfertigen den Re-Prüfer-Pass — Fakten/Coverage sind der Qualitätskern).
|
||||
Claims-Schwelle: wenige nur-„unbelegt" lohnen keinen Fix-Pass — bei kleinen Sektionen
|
||||
sinkt sie auf die Sub-Zahl (2 unbelegte Claims in 2 Subs sind viel, nicht wenig)."""
|
||||
claims = verdict["claims"]
|
||||
falsch = [c for c in claims if c["urteil"] == "falsch"]
|
||||
schwelle = min(GATE_FIX_MIN, n_rel) if n_rel else GATE_FIX_MIN
|
||||
if claims and not falsch and len(claims) < schwelle:
|
||||
claims = []
|
||||
zeilen = [f"- CLAIM ({c['urteil']}): {c['text']}" + (f" — {c['grund']}" if c['grund'] else "")
|
||||
for c in claims]
|
||||
zeilen += [f"- LÜCKE ({l['ziel']}): {l['fehlt']}" for l in verdict["luecken"]]
|
||||
zeilen += [f"- BALLAST (kürzen): {b}" for b in verdict["ballast"]]
|
||||
zeilen += [f"- LESBARKEIT: {p}" for p in verdict["lese_probleme"]]
|
||||
zeilen += [f"- LESBARKEIT: {p}" for p in det]
|
||||
return zeilen, bool(falsch or verdict["luecken"])
|
||||
|
||||
|
||||
async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[str]) -> dict | None:
|
||||
"""EIN Judge-Call prüft Fakten + Coverage + Lesbarkeit (die drei lasen vorher denselben
|
||||
Section-Text in drei seriellen Calls). Text-Antwort + Engine-Sink (Datei-schreibende
|
||||
Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED)."""
|
||||
norm = card["block_norm"]
|
||||
ziele = await _ziele(env, norm)
|
||||
ziele_text = _ziele_text(ziele)
|
||||
ids = {z["ziel_id"] for z in ziele}
|
||||
facts = _card_facts(env, card["block"])
|
||||
ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
|
||||
if ex: # der Fix sieht dieselben Facts — Beispiele überleben den Fix-Pass
|
||||
facts += "\n\nVERIFIED WORKED EXAMPLES (count as verified facts for this check):\n" + ex
|
||||
hinweise = ("\nALREADY NOTED deterministically (do NOT repeat, they go to the fix anyway):\n"
|
||||
+ "\n".join(f"- {d}" for d in det) + "\n") if det else "\n"
|
||||
path = env.slot(f"pruefer-{_safe(norm)}-{tag}.json")
|
||||
status, verdict = await run_single_slot(
|
||||
env.ctx, f"Prüfer {card['block']}", key=f"{env.guide_id}-pruef-{_safe(norm)}-{tag}",
|
||||
prompt=_prompt("Guide-Pruefer", topic=env.topic, block=card["block"],
|
||||
section=sec["md"], facts=facts, ziele=ziele_text, spec=env.spec,
|
||||
hinweise=hinweise, extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result: _sink_json(result, path, lambda d: _pruefer_schema(d, ids)),
|
||||
timeout=_timeout("pruefer", 1))
|
||||
if status != OK or verdict is None:
|
||||
return None
|
||||
for zid, ok in verdict["ziele"].items():
|
||||
if zid in ids:
|
||||
await db.set_ziel_covered(env.topic, norm, zid, ok)
|
||||
return verdict
|
||||
|
||||
|
||||
async def _stage_pruefer(env: _Env, card: dict) -> bool:
|
||||
"""Verschmolzener Qualitäts-Pass: Fakten-Gate + Coverage + Lese-Check in EINEM Call
|
||||
(vorher 3 serielle Judges + bis zu 3 Edit-Pässe, die einander überschrieben und deren
|
||||
letzter ungeprüft blieb). Befunde → Fix-Stage; ohne Befund → done."""
|
||||
sec = _first_section(card["md"])
|
||||
if sec is None:
|
||||
await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar")
|
||||
return False
|
||||
facts = _card_facts(env, card["block"])
|
||||
ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
|
||||
if ex: # the fix agent sees the same facts variable — examples survive the fix pass
|
||||
facts += "\n\nVERIFIED WORKED EXAMPLES (count as verified facts for this check):\n" + ex
|
||||
path = env.slot(f"gate-{_safe(norm)}-r{card['writer_rounds']}.json")
|
||||
status, claims = await run_single_slot(
|
||||
env.ctx, f"Fakten-Gate {card['block']}", key=f"{env.guide_id}-gate-{_safe(norm)}-r{card['writer_rounds']}",
|
||||
prompt=_prompt("Guide-Fakten-Gate", topic=env.topic, block=card["block"],
|
||||
section=sec["md"], facts=facts, out_path=path,
|
||||
extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _gate_schema(_json_file(path)),
|
||||
timeout=_timeout("fakten_gate", 1))
|
||||
if status == CANCELLED:
|
||||
det = (await _det_readability(sec)) + _det_hinweise(env, card, sec)
|
||||
verdict = await _pruefer_call(env, card, sec, f"r{card['writer_rounds']}", det)
|
||||
if verdict is None:
|
||||
if is_guide_cancelled(env.guide_id):
|
||||
return False
|
||||
if status == FAILED:
|
||||
claims = [] # gate failure must not block the card — logged, text stands
|
||||
_log(env.topic, f"Fakten-Gate {card['block']}: kein Ergebnis — Text bleibt ungeprüft")
|
||||
falsch = [c for c in claims if c.get("urteil") == "falsch"] if claims else []
|
||||
if claims and not falsch and len(claims) < GATE_FIX_MIN:
|
||||
# 1–2 merely UNSUPPORTED claims don't justify a fix pass (it ran for 19/20 blocks,
|
||||
# 40 agent-minutes) — but a WRONG claim always does: one slipped through this
|
||||
# threshold and cost the guide 1.5 QA points
|
||||
_log(env.topic, f"Fakten-Gate {card['block']}: {len(claims)} Claim(s) unter Schwelle — kein Fix")
|
||||
claims = []
|
||||
if claims:
|
||||
_log(env.topic, f"Fakten-Gate {card['block']}: {len(claims)} Claims ({len(falsch)} falsch) → Fix")
|
||||
fixp = env.slot(f"gatefix-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||
fixp.unlink(missing_ok=True)
|
||||
claims_text = "\n".join(f"- {c['text']}" + (f" ({c['grund']})" if c["grund"] else "")
|
||||
for c in claims)
|
||||
|
||||
def _fixload(result):
|
||||
text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
|
||||
return text if _first_section(text) else None
|
||||
|
||||
fstatus, fixed = await run_single_slot(
|
||||
env.ctx, f"Fakten-Fix {card['block']}", key=f"{env.guide_id}-gatefix-{_safe(norm)}-r{card['writer_rounds']}",
|
||||
prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
|
||||
section=card["md"], claims=claims_text, facts=facts,
|
||||
out_path=fixp, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_fixload, # Fix auf der Schreib-Rolle, Gate auf der Judge-Rolle
|
||||
timeout=_timeout("fakten_gate", 1))
|
||||
if fstatus == CANCELLED:
|
||||
return False
|
||||
if fstatus == OK and fixed:
|
||||
new_sec = _first_section(fixed)
|
||||
# marker invariant: a fix that loses the sub markers kills the level filter → discard
|
||||
if sec.get("subs") and not (new_sec and new_sec.get("subs")):
|
||||
_log(env.topic, f"Fakten-Fix {card['block']} ohne Sub-Marker — verworfen")
|
||||
else:
|
||||
card["md"] = fixed
|
||||
await _set(env, card, md=card["md"], stage="coverage", status="open")
|
||||
# fail-open wie das alte Gate: Karte nie blockieren — deterministische Befunde
|
||||
# gehen trotzdem in den Fix
|
||||
_log(env.topic, f"Prüfer {card['block']}: kein Ergebnis — nur deterministische Checks")
|
||||
verdict = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
|
||||
zeilen, kritisch = _auftraege(verdict, det, _n_rel(env, card))
|
||||
if not zeilen:
|
||||
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
|
||||
return True
|
||||
_log(env.topic, f"Prüfer {card['block']}: {len(zeilen)} Befund(e){' (kritisch)' if kritisch else ''} → Fix")
|
||||
await _set(env, card, gate_info=("KRITISCH\n" if kritisch else "") + "\n".join(zeilen),
|
||||
stage="fix", status="open")
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_coverage(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
ziele = await db.list_lernziele(env.topic, norm)
|
||||
if not ziele:
|
||||
await _set(env, card, stage="lesbarkeit")
|
||||
return True
|
||||
sec = _first_section(card["md"])
|
||||
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele)
|
||||
path = env.slot(f"coverage-{_safe(norm)}-r{card['writer_rounds']}.json")
|
||||
ids = {z["ziel_id"] for z in ziele}
|
||||
status, res = await run_single_slot(
|
||||
env.ctx, f"Coverage {card['block']}", key=f"{env.guide_id}-cov-{_safe(norm)}-r{card['writer_rounds']}",
|
||||
prompt=_prompt("Guide-Coverage", topic=env.topic, block=card["block"],
|
||||
ziele=ziele_text, section=sec["md"] if sec else card["md"],
|
||||
out_path=path, extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _coverage_schema(_json_file(path), ids),
|
||||
timeout=_timeout("coverage", len(ziele)))
|
||||
if status == CANCELLED:
|
||||
return False
|
||||
if status == FAILED:
|
||||
_log(env.topic, f"Coverage {card['block']}: kein Ergebnis — weiter ohne Gate")
|
||||
await _set(env, card, stage="lesbarkeit")
|
||||
return True
|
||||
for zid, ok in res["ziele"].items():
|
||||
if zid in ids:
|
||||
await db.set_ziel_covered(env.topic, norm, zid, ok)
|
||||
if res["luecken"] and card["writer_rounds"] < MAX_WRITER_ROUNDS:
|
||||
info = "\n".join(f"- Lücke ({l['ziel']}): {l['fehlt']}" for l in res["luecken"])
|
||||
if res["ballast"]:
|
||||
info += "\n" + "\n".join(f"- Ballast (kürzen): {b}" for b in res["ballast"])
|
||||
_log(env.topic, f"Coverage {card['block']}: {len(res['luecken'])} Lücke(n) → Writer-Runde "
|
||||
f"{card['writer_rounds'] + 1}")
|
||||
await _set(env, card, writer_rounds=card["writer_rounds"] + 1, gate_info=info,
|
||||
stage="writer", status="open")
|
||||
return True
|
||||
if res["luecken"]:
|
||||
_log(env.topic, f"Coverage {card['block']}: Lücken bleiben nach {MAX_WRITER_ROUNDS} Runden")
|
||||
await _set(env, card, gate_info="", stage="lesbarkeit")
|
||||
return True
|
||||
|
||||
|
||||
async def _stage_lesbarkeit(env: _Env, card: dict) -> bool:
|
||||
async def _stage_fix(env: _Env, card: dict) -> bool:
|
||||
"""EIN kompletter Section-Rewrite unter allen Auflagen (ersetzt Fakten-Fix +
|
||||
Writer-Revision + Lese-Fix). Danach GENAU EIN Re-Prüfer-Pass, wenn der Fix wegen
|
||||
falsch-Claims/Lücken lief — der alte Lese-Fix blieb ungeprüft. Rest-Befunde bleiben
|
||||
sichtbar (gate_info), keine weitere Fix-Runde."""
|
||||
from guide import _level_label
|
||||
norm = card["block_norm"]
|
||||
sec = _first_section(card["md"])
|
||||
if sec is None:
|
||||
await _set(env, card, status="error", gate_info="Fragment unlesbar")
|
||||
return False
|
||||
problems: list[str] = []
|
||||
path = env.slot(f"lese-{_safe(norm)}-r{card['writer_rounds']}.json")
|
||||
# Text-Antwort + Engine-Sink: Datei-schreibende Judges lieferten invalides JSON
|
||||
# (3 kaputte Check-Dateien im Messlauf) — der Sink validiert vor dem Persistieren
|
||||
status, res = await run_single_slot(
|
||||
env.ctx, f"Lese-Check {card['block']}", key=f"{env.guide_id}-lese-{_safe(norm)}",
|
||||
prompt=_prompt("Guide-Lese-Check", topic=env.topic, format_name=env.format,
|
||||
spec=env.spec, sections=f"SECTION: {card['block']}\n{sec['md']}",
|
||||
extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result: _sink_json(result, path, _problems_schema),
|
||||
timeout=_timeout("lese_check", 1))
|
||||
if status == CANCELLED:
|
||||
return False
|
||||
if status == OK and res:
|
||||
problems += res
|
||||
if READABILITY_ACTIVE: # deterministic gate, external grounding
|
||||
hints = await asyncio.to_thread(readability.rate_sections, {1: sec["md"]})
|
||||
if hints.get(1):
|
||||
problems.append(hints[1])
|
||||
# deterministic length trigger, same formula as the QA detector: prompt guidelines
|
||||
# alone left writers 2.7–4.1× over target — a measured overshoot forces the fix pass
|
||||
subs_all = env.subs_by_title.get(card["block"], [])
|
||||
n_rel = max(sum(1 for s in subs_all if s.get("relevance") != "peripheral"), 1)
|
||||
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
|
||||
pro_sub = len(aus[1] if len(aus) == 2 else sec["md"]) / n_rel
|
||||
if not (GUIDE_LAENGE_MIN <= pro_sub <= GUIDE_LAENGE_MAX * 0.9):
|
||||
ziel = _writer_budget(len(subs_all))
|
||||
problems.append(
|
||||
f"Länge {round(pro_sub)} Zeichen/Sub (Rahmen {GUIDE_LAENGE_MIN}–{round(GUIDE_LAENGE_MAX * 0.9)}): "
|
||||
f"schreibe den ausführlich-Teil auf etwa {ziel} Zeichen GESAMT um — Sockel-Prosa und "
|
||||
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
|
||||
if problems:
|
||||
from guide import _level_label
|
||||
info = card.get("gate_info") or ""
|
||||
kritisch = info.startswith("KRITISCH\n")
|
||||
auftraege = info.removeprefix("KRITISCH\n")
|
||||
subs = env.subs_by_title.get(card["block"], [])
|
||||
sub_list = "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
|
||||
tasks = (f"SECTION: {card['block']}\n"
|
||||
f"SUBBLOCKS (set one `<!-- sub: LABEL | title -->` marker each, label/order as here):\n{sub_list}\n"
|
||||
f"LENGTH TARGET: about {_writer_budget(len(subs))} characters for the detailed "
|
||||
f"version (guideline — covering every subblock beats brevity).\n"
|
||||
f"PROBLEM: {' · '.join(problems)}\nCURRENT CONTENT:\n{sec['md']}")
|
||||
fixp = env.slot(f"lesefix-{_safe(norm)}.md")
|
||||
fixp = env.slot(f"fix-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||
fixp.unlink(missing_ok=True)
|
||||
|
||||
def _fixload(result):
|
||||
@@ -539,27 +528,48 @@ async def _stage_lesbarkeit(env: _Env, card: dict) -> bool:
|
||||
return text if _first_section(text) else None
|
||||
|
||||
fstatus, fixed = await run_single_slot(
|
||||
env.ctx, f"Lese-Fix {card['block']}", key=f"{env.guide_id}-lesefix-{_safe(norm)}",
|
||||
prompt=_prompt("Guide-Sections-Fix", topic=env.topic, format_name=env.format,
|
||||
facts=_card_facts(env, card["block"]), spec=env.spec, tasks=tasks,
|
||||
out_path=fixp, extra=_extra(env.instructions)),
|
||||
env.ctx, f"Fix {card['block']}", key=f"{env.guide_id}-gfix-{_safe(norm)}-r{card['writer_rounds']}",
|
||||
prompt=_prompt("Guide-Fix", topic=env.topic, format_name=env.format, block=card["block"],
|
||||
section=card["md"], facts=_card_facts(env, card["block"]), spec=env.spec,
|
||||
auftraege=auftraege, sub_list=sub_list, out_path=fixp,
|
||||
extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_fixload,
|
||||
timeout=_timeout("writer", 1))
|
||||
if fstatus == CANCELLED:
|
||||
return False
|
||||
angewandt = False
|
||||
if fstatus == OK and fixed:
|
||||
new_sec = _first_section(fixed)
|
||||
# marker invariant: a fix that loses the sub markers kills the level filter → discard
|
||||
if sec.get("subs") and not (new_sec and new_sec.get("subs")):
|
||||
_log(env.topic, f"Lese-Fix {card['block']} ohne Sub-Marker — verworfen")
|
||||
_log(env.topic, f"Fix {card['block']} ohne Sub-Marker — verworfen")
|
||||
else:
|
||||
card["md"] = fixed
|
||||
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
|
||||
angewandt = True
|
||||
rest = ""
|
||||
if not angewandt:
|
||||
# Fix ohne Ergebnis: Befunde nicht stumm löschen — sie bleiben im gate_info sichtbar
|
||||
rest = "Fix ohne Ergebnis — offene Befunde:\n" + auftraege
|
||||
_log(env.topic, f"Fix {card['block']}: nicht angewandt — Befunde bleiben sichtbar")
|
||||
if kritisch and angewandt:
|
||||
sec2 = _first_section(card["md"])
|
||||
verdict = await _pruefer_call(env, card, sec2, "re", [])
|
||||
if verdict is None and is_guide_cancelled(env.guide_id):
|
||||
return False
|
||||
if verdict is None:
|
||||
rest = "Re-Prüfer ohne Ergebnis — Fix ungeprüft übernommen"
|
||||
_log(env.topic, f"Re-Prüfer {card['block']}: kein Ergebnis — Fix ungeprüft übernommen")
|
||||
else:
|
||||
zeilen, _k = _auftraege(verdict, [], _n_rel(env, card))
|
||||
if zeilen:
|
||||
rest = "Rest-Befunde nach Fix:\n" + "\n".join(zeilen)
|
||||
_log(env.topic, f"Re-Prüfer {card['block']}: {len(zeilen)} Rest-Befund(e) bleiben")
|
||||
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info=rest)
|
||||
return True
|
||||
|
||||
|
||||
_STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
|
||||
"writer": _stage_writer, "fakten_gate": _stage_fakten_gate,
|
||||
"coverage": _stage_coverage, "lesbarkeit": _stage_lesbarkeit}
|
||||
"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
|
||||
|
||||
|
||||
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
|
||||
@@ -592,6 +602,22 @@ async def _run_card_inner(env: _Env, card: dict) -> None:
|
||||
|
||||
|
||||
# ── Orchestration ──────────────────────────────────────────────────────────────────
|
||||
async def _progress_reporter(guide_id: str, topic: str, format_name: str, takt: float = 2.0) -> None:
|
||||
"""Live-Fortschritt fürs Frontend; ein DB-Fehler darf den Reporter nie beenden
|
||||
(der Fortschritt fror sonst still ein), unveränderter Stand wird nicht geschrieben."""
|
||||
zuletzt = None
|
||||
while True:
|
||||
try:
|
||||
counts = await db.guide_stage_counts(topic, format_name)
|
||||
stand = (counts.get("done", 0), sum(counts.values()))
|
||||
if stand != zuletzt:
|
||||
zuletzt = stand
|
||||
await db.update_guide(guide_id, progress=f"Board: {stand[0]}/{stand[1]} Karten fertig")
|
||||
except Exception:
|
||||
log.exception("[%s] guide progress reporter", topic)
|
||||
await asyncio.sleep(takt)
|
||||
|
||||
|
||||
async def _chapter_map(topic: str, entries: dict[int, str]) -> dict[str, tuple[str, int]]:
|
||||
"""block_norm → (chapter title, global order) from the outline artefact."""
|
||||
from guide import _outline_from_db, _fallback_outline, _with_remainder
|
||||
@@ -617,7 +643,10 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
|
||||
is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
# „-g"+hex4-Suffix markiert Guide-Läufe (Blocks-Suffixe sind reines Hex) —
|
||||
# database.latest_board_runs erkennt Guide-run_ids an genau diesem Muster.
|
||||
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
|
||||
try:
|
||||
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||||
subs_raw = await _load_subblocks(topic)
|
||||
project = source_folder(topic)
|
||||
@@ -625,30 +654,22 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
|
||||
else _prompt("Guide-Facts-Thema"))
|
||||
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
|
||||
subs_raw, await _chapter_map(topic, entries), fallback, spec)
|
||||
for num, line in entries.items():
|
||||
title = _title(line)
|
||||
await db.upsert_guide_card(topic, format_name, _norm_title(title), title)
|
||||
await db.upsert_guide_cards_many(
|
||||
topic, format_name,
|
||||
[(_norm_title(_title(line)), _title(line)) for line in entries.values()])
|
||||
cards = await db.list_guide_cards(topic, format_name)
|
||||
open_cards = [c for c in cards if c["stage"] != "done"]
|
||||
if open_cards:
|
||||
sem = asyncio.Semaphore(CARD_CONCURRENCY)
|
||||
|
||||
async def _progress():
|
||||
while True:
|
||||
counts = await db.guide_stage_counts(topic, format_name)
|
||||
done = counts.get("done", 0)
|
||||
total = sum(counts.values())
|
||||
await db.update_guide(guide_id, progress=f"Board: {done}/{total} Karten fertig")
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
reporter = asyncio.create_task(_progress())
|
||||
reporter = asyncio.create_task(_progress_reporter(guide_id, topic, format_name))
|
||||
try:
|
||||
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
|
||||
ergebnisse = await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards],
|
||||
return_exceptions=True)
|
||||
for c, r in zip(open_cards, ergebnisse):
|
||||
if isinstance(r, BaseException):
|
||||
log.error("[%s] guide card task %s: %r", topic, c["block"], r)
|
||||
finally:
|
||||
reporter.cancel()
|
||||
db.set_current_run(topic, None)
|
||||
else:
|
||||
db.set_current_run(topic, None)
|
||||
if is_guide_cancelled(guide_id):
|
||||
return None
|
||||
# assembly — identical shape to the legacy pipeline
|
||||
@@ -685,6 +706,10 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
|
||||
except Exception:
|
||||
log.exception("[%s] Abschluss-Guide-QA fehlgeschlagen", topic)
|
||||
return chapters or None
|
||||
finally:
|
||||
# erst NACH der Abschluss-Guide-QA leeren: deren Judge-Events gehören zum
|
||||
# Lauf — vorher fielen sie ohne run_id aus jeder Run-Aggregation
|
||||
db.set_current_run(topic, None)
|
||||
|
||||
|
||||
async def done_step(topic: str, format_name: str) -> int:
|
||||
@@ -723,36 +748,64 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
|
||||
columns.append({"key": stage, "label": STAGE_LABELS[stage],
|
||||
"total": len(in_stage), "cards": views})
|
||||
import qa as qa_mod # lazy wie in board_inventory
|
||||
tdir = qa_mod.QA_DIR / topic
|
||||
greports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
note_guide = (_json_file(greports[-1]) or {}).get("note_guide") if greports else None
|
||||
note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide")
|
||||
return {"columns": columns, "qa_guide": note_guide}
|
||||
|
||||
|
||||
async def reset_card(topic: str, format_name: str, block_norm: str, ab_stage: int) -> bool:
|
||||
"""Reset ONE guide card to a stage (single-card variant of reset_from_stage):
|
||||
fields re-zeroed, md only wiped for writer(2) and earlier, lernziele only for 0."""
|
||||
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
|
||||
async def repair_karten(topic: str, format_name: str) -> list[str]:
|
||||
"""QA-Befund-getriebenes Guide-Repair (Pendant zum Blocks-Repair: „Score unter 10
|
||||
muss einen Fix-Pfad haben"). Kritische Befunde (marker_fehlend/ziel_ohne_anker/
|
||||
fachlich_falsch) → zurück auf `pruefer` (brauchen Facts-/Ziele-Kontext + Re-Prüfer).
|
||||
Reine Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit expliziten
|
||||
Auftragszeilen: der Prüfer sieht pro Karte nur die EIGENE Section und kann
|
||||
Cross-Karten-Redundanz nie wiederfinden — der Fix bekommt den Kontext hier hinein
|
||||
(kein KRITISCH-Präfix → genau EIN Rewrite ohne Re-Prüfer). generate_guide resumt
|
||||
die offenen Karten und misst am Ende neu. → betroffene Blocktitel."""
|
||||
import qa as qa_mod
|
||||
from guide_qa import block_budget, LAENGE_BAND
|
||||
reports = qa_mod.report_paths(topic, guide=True)
|
||||
rep = _json_file(reports[-1]) if reports else None
|
||||
if not rep:
|
||||
return []
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
|
||||
if block_norm not in cards:
|
||||
return False
|
||||
fields = dict(stage=GUIDE_STAGES[ab_stage], status="open", writer_rounds=0, gate_info="")
|
||||
if ab_stage <= 2:
|
||||
fields["md"] = ""
|
||||
if ab_stage == 0:
|
||||
await db.delete_lernziele(topic, block_norm)
|
||||
await db.set_guide_card(topic, format_name, block_norm, **fields)
|
||||
return True
|
||||
kritisch: set[str] = set()
|
||||
for e in rep.get("marker_fehlend", []): # "Block · sub"
|
||||
kritisch.add(_norm_title(str(e).split(" · ")[0]))
|
||||
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
|
||||
kritisch.add(str(e).split(" · ")[0])
|
||||
for t in rep.get("fachlich_falsch", []) or []:
|
||||
kritisch.add(_norm_title(str(t)))
|
||||
stil: dict[str, list[str]] = {}
|
||||
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
|
||||
a, b = str(e.get("a", "")), str(e.get("b", ""))
|
||||
bn = _norm_title(b.split(":")[0])
|
||||
auszug = b.split(":", 1)[1].strip() if ":" in b else b
|
||||
stil.setdefault(bn, []).append(
|
||||
f"- BALLAST (kürzen): Absatz doppelt zu Block «{a.split(':')[0]}» („{auszug}…\") — "
|
||||
"hier straffen, der Inhalt bleibt im anderen Block")
|
||||
for e in rep.get("laengen_ausreisser", []): # {"block", "zeichen", "budget"}
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
bn = _norm_title(e.get("block", ""))
|
||||
budget = e.get("budget") or block_budget([])
|
||||
lo, hi = LAENGE_BAND
|
||||
stil.setdefault(bn, []).append(
|
||||
f"- LÄNGE: {e.get('zeichen')} Zeichen (Budget {budget}, erlaubt "
|
||||
f"{round(lo * budget)}–{round(hi * budget)}): schreibe den ausführlich-Teil auf etwa "
|
||||
f"{budget} Zeichen GESAMT um — Sockel-Prosa und Wiederholungen streichen, "
|
||||
"alle Sub-Marker und Beispiele behalten")
|
||||
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
|
||||
bn = _norm_title(str(e).split(":")[0])
|
||||
hint = str(e).split(":", 1)[1].strip() if ":" in str(e) else str(e)
|
||||
stil.setdefault(bn, []).append(f"- LESBARKEIT: {hint}")
|
||||
betroffen = []
|
||||
for n in sorted((kritisch | set(stil)) & set(cards)):
|
||||
if n in kritisch:
|
||||
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
|
||||
else:
|
||||
await db.set_guide_card(topic, format_name, n, stage="fix", status="open",
|
||||
gate_info="\n".join(stil[n]))
|
||||
betroffen.append(cards[n]["block"])
|
||||
return betroffen
|
||||
|
||||
|
||||
async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int:
|
||||
"""Cards in stages ≥ ab_stage (incl. done) back to GUIDE_STAGES[ab_stage]."""
|
||||
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
|
||||
target = GUIDE_STAGES[ab_stage]
|
||||
stages = list(GUIDE_STAGES[ab_stage:]) + ["done"]
|
||||
if ab_stage == 0:
|
||||
for c in await db.list_guide_cards(topic, format_name):
|
||||
await db.delete_lernziele(topic, c["block_norm"])
|
||||
moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target,
|
||||
clear_md=ab_stage <= 2)
|
||||
return moved
|
||||
+46
-24
@@ -10,6 +10,7 @@ Report: storage/qa/<topic>/guide-<ts>.json + Konsolen-Digest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
@@ -18,9 +19,9 @@ import database as db
|
||||
import qa
|
||||
import readability
|
||||
from fsutil import atomic_write_json
|
||||
from textkit import _norm_title
|
||||
from textkit import _norm_title, parse_facts
|
||||
|
||||
from config import GUIDE_LAENGE_MAX as LAENGE_MAX, GUIDE_LAENGE_MIN as LAENGE_MIN
|
||||
log = logging.getLogger("creator.guide_qa")
|
||||
|
||||
JACCARD_ABSATZ = 0.6 # Wort-Jaccard, ab dem zwei Absätze als Doppel gelten
|
||||
ABSATZ_MIN_CHARS = 200 # kürzere Absätze sind Übergänge — kein Dubletten-Signal
|
||||
@@ -71,13 +72,40 @@ def ziel_ohne_anker(cards: list[dict], ziele: list[dict]) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def laengen_ausreisser(cards: list[dict], subs_rel: dict[str, set]) -> list[dict]:
|
||||
# Längenbudget je Sub aus der Inventar-Substanz — ersetzt den festen Rahmen 150–1200/Sub:
|
||||
# ein dichter Sub (viele key_points, Fakten, Beispiel) trägt mehr Text als ein Einzeiler.
|
||||
# Die Pipeline (Writer-Vorgabe, Prüfer-Trigger, Fix-Ziel) nutzt DIESELBE Formel mit engerem
|
||||
# Band — Messlatte und Fix-Auftrag müssen übereinstimmen, sonst sind Befunde unfixbar.
|
||||
BUDGET_BASIS = 140 # Einstieg/Übergang je Sub
|
||||
BUDGET_KEY_POINT = 110 # ~1 Satz Erklärung je key_point
|
||||
BUDGET_FAKT = 45 # zitierter Fakt, in den Text eingewoben
|
||||
BUDGET_BEISPIEL = 170 # knappes Beispiel
|
||||
LAENGE_BAND = (0.35, 1.5) # QA-Toleranz um das Blockbudget
|
||||
|
||||
|
||||
def sub_budget(facts: dict) -> int:
|
||||
"""Zeichenbudget für den ausführlich-Teil EINES Subs (facts = Inventar-JSON des Subs)."""
|
||||
kp = len(facts.get("key_points") or [])
|
||||
cf = len(facts.get("cited_facts") or [])
|
||||
ex = 1 if str(facts.get("example_idea") or "").strip() else 0
|
||||
return BUDGET_BASIS + BUDGET_KEY_POINT * kp + BUDGET_FAKT * cf + BUDGET_BEISPIEL * ex
|
||||
|
||||
|
||||
def block_budget(subs: list[dict]) -> int:
|
||||
"""Budget einer Section: Summe über die relevanten Subs ({relevance, facts}-Dicts)."""
|
||||
return max(BUDGET_BASIS, sum(sub_budget(s.get("facts") or {}) for s in subs
|
||||
if s.get("relevance") != "peripheral"))
|
||||
|
||||
|
||||
def laengen_ausreisser(cards: list[dict], budget_by_norm: dict[str, int]) -> list[dict]:
|
||||
out = []
|
||||
for c in cards:
|
||||
n = max(len(subs_rel.get(c["block_norm"], set())), 1)
|
||||
pro_sub = len(_ausfuehrlich(c["md"])) / n
|
||||
if not (LAENGE_MIN <= pro_sub <= LAENGE_MAX):
|
||||
out.append({"block": c["block"], "zeichen_pro_sub": round(pro_sub)})
|
||||
budget = budget_by_norm.get(c["block_norm"])
|
||||
if not budget:
|
||||
continue
|
||||
zeichen = len(_ausfuehrlich(c["md"]))
|
||||
if not (LAENGE_BAND[0] * budget <= zeichen <= LAENGE_BAND[1] * budget):
|
||||
out.append({"block": c["block"], "zeichen": zeichen, "budget": budget})
|
||||
return out
|
||||
|
||||
|
||||
@@ -111,22 +139,11 @@ async def _fachlich_falsch(topic: str, cards: list[dict]) -> list[str]:
|
||||
"""LLM-Stichprobe: Section enthält eine fachlich falsche Aussage? Zwei unabhängige
|
||||
Durchgänge, nur DOPPELT bestätigte zählen — ein Einzel-Judge schwankte zwischen
|
||||
0 und 5 Befunden am selben Guide und kippte die Note (Gewicht 3.0) auf 0."""
|
||||
from agents import run_agent
|
||||
from jsonio import parse_json_text
|
||||
from pipeline import _yesno_schema
|
||||
|
||||
async def _pass(kandidaten: list[dict], tag: str) -> list[str]:
|
||||
out = []
|
||||
for lo in range(0, len(kandidaten), 5):
|
||||
chunk = kandidaten[lo:lo + 5]
|
||||
listing = "\n\n".join(f"{k}. SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}"
|
||||
for k, c in enumerate(chunk, 1))
|
||||
rc, txt, _err = await run_agent(
|
||||
f"qa-guide-{topic}-fakten{tag}-{lo}", qa._qa_prompt("QA-Guide-Fakten", topic=topic, extra="", sections=listing),
|
||||
600, role="judge", capabilities="none", scope=topic, label=f"Guide-QA Fakten{tag} {lo}")
|
||||
v = (_yesno_schema(parse_json_text(txt)) or {}) if rc == 0 else {}
|
||||
out += [c["block"] for k, c in enumerate(chunk, 1) if v.get(k) == "ja"]
|
||||
return out
|
||||
items = [f"SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}" for c in kandidaten]
|
||||
v = await qa.judge_wave("QA-Guide-Fakten", topic, f"fakten{tag}", "sections", items,
|
||||
chunk=5, prefix="qa-guide", label="Guide-QA")
|
||||
return [c["block"] for k, c in enumerate(kandidaten, 1) if v.get(k) == "ja"]
|
||||
|
||||
verdacht = await _pass(cards, "")
|
||||
if not verdacht:
|
||||
@@ -148,14 +165,19 @@ async def guide_qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
print(f"Keine Guide-Karten für '{topic}' — Guide noch nicht gebaut?")
|
||||
return None
|
||||
subs_rel: dict[str, set] = {}
|
||||
subs_by_norm: dict[str, list[dict]] = {}
|
||||
for r in await db.list_subblocks(topic):
|
||||
if r["status"] == "consensus" and r["relevance"] != "peripheral":
|
||||
if r["status"] != "consensus":
|
||||
continue
|
||||
subs_by_norm.setdefault(r["block_norm"], []).append(
|
||||
{"relevance": r["relevance"], "facts": parse_facts(r["facts"])})
|
||||
if r["relevance"] != "peripheral":
|
||||
subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"])
|
||||
ziele = [dict(r) for r in await db.list_lernziele(topic)]
|
||||
|
||||
mf = marker_fehlend(cards, subs_rel)
|
||||
za = ziel_ohne_anker(cards, ziele)
|
||||
la = laengen_ausreisser(cards, subs_rel)
|
||||
la = laengen_ausreisser(cards, {n: block_budget(s) for n, s in subs_by_norm.items()})
|
||||
rd = redundanz(cards)
|
||||
lb = lesbarkeit(cards)
|
||||
falsch = await _fachlich_falsch(topic, cards) if llm else None
|
||||
|
||||
File renamed without changes.
@@ -19,6 +19,7 @@ import logging
|
||||
|
||||
import database as db
|
||||
from config import KANBAN_BATCH, MAX_CARD_RETRIES, MAX_CONCURRENT_AGENTS_PER_TOPIC, RETRY_BACKOFF
|
||||
from pipeline import AgentInfraError
|
||||
|
||||
log = logging.getLogger("creator.kanban")
|
||||
|
||||
@@ -165,6 +166,12 @@ async def _worker(flow: Flow, spec: Stage, inflight: int, all_stages: list[str])
|
||||
flow.active_cards.update(f"{spec.board}:{i}" for i in ids)
|
||||
try:
|
||||
await spec.process(cards)
|
||||
except AgentInfraError as e: # 429/Timeout erschöpft → Lauf pausieren, NICHT dead-letter
|
||||
log.warning("kanban %s/%s: infra-pause: %s", topic, spec.stage, e)
|
||||
flow.state["infra_paused"] = True
|
||||
flow.state["infra_error"] = str(e)
|
||||
flow.stop = True
|
||||
flow.wake.set()
|
||||
except Exception as e: # one bad package must not kill the worker → backoff/dead-letter
|
||||
log.info("kanban %s/%s: %s: %s", topic, spec.stage, type(e).__name__, e)
|
||||
try:
|
||||
|
||||
+22
-7
@@ -269,7 +269,13 @@ def _rating_text(rating: dict) -> str:
|
||||
|
||||
# Deterministic guard against double questions — the AI critic misses "…, and which…".
|
||||
_QUESTION_WORD = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)"
|
||||
_DOUBLE_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _QUESTION_WORD + r"\b", re.IGNORECASE)
|
||||
# Optional preposition between the conjunction and the question word — the second question
|
||||
# hides behind it: "und IN welcher Datei", "und UNTER welcher Bedingung".
|
||||
_PREP = r"(?:in|unter|auf|für|mit|bei|zu[rm]?|von|vom|über|durch|aus|nach|an|am|um|gegen|ohne|wobei)\s+"
|
||||
_DOUBLE_RE = re.compile(
|
||||
r"[,;]?\s+(?:und|sowie|außerdem|bzw\.?)\s+(?:" + _PREP + r")?" + _QUESTION_WORD + r"\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _double_question_flaw(question: str) -> str | None:
|
||||
@@ -368,9 +374,9 @@ def _avoid_block(avoid: list[str] | None) -> str:
|
||||
# the model takes the easy path (mere recall) — the cues lift higher tiers to apply/analyze/transfer.
|
||||
TIER_ROLE = {
|
||||
"beginner": "The learner is a BEGINNER. Cognitive: REMEMBER/UNDERSTAND. Ask about the basic understanding — the core concept, simple and direct.",
|
||||
"advanced": "The learner is ADVANCED. Cognitive: APPLY. Pose a small concrete situation and have the concept applied to it — don't just ask for the definition.",
|
||||
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Have them distinguish/compare, classify a special case or uncover a typical pitfall (hurdle) — don't quiz textbook knowledge.",
|
||||
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have the concept transferred to a NEW problem, justify a decision or weigh a trade-off.",
|
||||
"advanced": "The learner is ADVANCED. Cognitive: APPLY. Have the concept applied to ONE minimal case — name the case in a half-sentence (no story, no roles, no backstory), then ask ONE single thing.",
|
||||
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Ask for ONE distinction, comparison, or typical pitfall — a single question, no scenario prose.",
|
||||
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have them justify ONE decision or weigh ONE trade-off — one question, no build-up, no second sub-question.",
|
||||
}
|
||||
|
||||
|
||||
@@ -409,18 +415,27 @@ async def exam_question_variant(
|
||||
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Action 'question' with a pattern: from a predefined pattern, phrase a concrete question in
|
||||
the addressee role of the tier. No critic (the pattern is build-checked).
|
||||
The style guard stays as a cheap protection against double questions · None on error."""
|
||||
the addressee role of the tier. No AI critic (the pattern is build-checked), but the
|
||||
deterministic double-question guard runs and forces a regenerate on a flaw · None on error."""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
kritik_block = "(none)"
|
||||
question = None
|
||||
for _ in range(CRITIC_MAX_ROUNDS):
|
||||
data = await _gen_call(
|
||||
"Block-Question-Variante", "guide", _question_schema, provider, lane="batch",
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
|
||||
kritik_block=kritik_block,
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
return data["question"]
|
||||
question = data["question"]
|
||||
flaw = _double_question_flaw(question)
|
||||
if not flaw:
|
||||
return question
|
||||
kritik_block = _critique_block(question, [flaw])
|
||||
return question # best-effort after the last round
|
||||
except Exception:
|
||||
log.warning("[%s] Question variant failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
+19
-45
@@ -7,6 +7,8 @@ FormatType = Literal[
|
||||
"Rest",
|
||||
]
|
||||
|
||||
from config import DEFAULT_PROVIDER
|
||||
|
||||
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||
|
||||
SourceType = Literal["thema", "projekt", "uni", "link"]
|
||||
@@ -16,14 +18,7 @@ class GuideCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
format: FormatType
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
ab_step: int | None = Field(default=None, ge=0, le=5) # re-run from board stage (0 lernziele … 5 lesbarkeit); None = full/resume
|
||||
|
||||
|
||||
class GuideBoardResetRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
format: FormatType = "Guide"
|
||||
ab_stage: int = Field(ge=0, le=5) # reset cards back to this board stage (no generation)
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
@@ -33,25 +28,28 @@ class TopicCreateRequest(BaseModel):
|
||||
class QaRunRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
llm: bool = True # wie das Gate: Echtheits-/Dubletten-Stichprobe inklusive
|
||||
ebene: str | None = None # None = alle Ebenen; "inventory"/"artefacts" = ohne Guide-QA-Overhead
|
||||
|
||||
|
||||
class RepairRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
ebene: str | None = None # None = alle Fixes, "inventory" oder "artefacts" filtert je Ebene
|
||||
|
||||
|
||||
class BlocksCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
source_type: SourceType = "thema"
|
||||
source_location: str = Field(default="", max_length=2000)
|
||||
source_location: str = Field(default="", max_length=20000) # link mode: one URL per line
|
||||
research: bool = True # False = Continue: drain the existing kanban queue, no new search
|
||||
qa_force: bool = False # True = übersteuert ein pausierendes QA-Gate („Trotzdem fortsetzen")
|
||||
|
||||
|
||||
class BlocksCardRestartRequest(BaseModel):
|
||||
topic: str = Field(min_length=1)
|
||||
card_id: str = Field(min_length=1, max_length=200)
|
||||
# „Auto" pro Ebene: nach der QA im Loop „Befunde beheben", bis 100 % / Stillstand / 10×.
|
||||
# Aus = nach der QA stoppen (Kontrollpunkt). Default an.
|
||||
auto_inventory: bool = True
|
||||
auto_artefacts: bool = True
|
||||
auto_guide: bool = True
|
||||
only_artefacts: bool = False # True = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate)
|
||||
|
||||
|
||||
class GuideFormatRequest(BaseModel):
|
||||
@@ -66,28 +64,6 @@ class PracticeAnswerRequest(BaseModel):
|
||||
correct: bool
|
||||
|
||||
|
||||
class GuideCardResetRequest(BaseModel):
|
||||
topic: str = Field(min_length=1)
|
||||
format: str = Field(min_length=1)
|
||||
block_norm: str = Field(min_length=1, max_length=200)
|
||||
ab_stage: int = Field(ge=0, le=5)
|
||||
|
||||
|
||||
class BlocksResetStageRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
board: Literal["inventory", "artefacts"]
|
||||
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):
|
||||
@@ -96,8 +72,6 @@ class BlocksStatusResponse(BaseModel):
|
||||
progress: str | None = None
|
||||
error: str | None = None
|
||||
partial: bool = False
|
||||
steps: list[BlocksStep] = []
|
||||
feine_steps: list[BlocksFineStep] = []
|
||||
|
||||
|
||||
class FolderResponse(BaseModel):
|
||||
@@ -108,7 +82,7 @@ class FolderResponse(BaseModel):
|
||||
class BlocksSourceUpdate(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
type: SourceType = "thema"
|
||||
location: str = Field(default="", max_length=2000)
|
||||
location: str = Field(default="", max_length=20000) # link mode: one URL per line
|
||||
spec: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
@@ -157,7 +131,7 @@ class GuideChatRequest(BaseModel):
|
||||
section: str = Field(default="", max_length=20000)
|
||||
outline: str = Field(default="", max_length=8000)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
|
||||
|
||||
class GuideChatResponse(BaseModel):
|
||||
@@ -172,7 +146,7 @@ class BlockChatRequest(BaseModel):
|
||||
section: str = Field(default="", max_length=20000) # detailed version
|
||||
section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
|
||||
|
||||
class BlockChatResponse(BaseModel):
|
||||
@@ -203,7 +177,7 @@ class BlockExamRequest(BaseModel):
|
||||
# Base + cap are kept server-side (anchor / subs×25) — the client cap is only a hint.
|
||||
cap: int = Field(default=10, ge=1, le=10000) # score cap = unlocked subblocks × 25
|
||||
messages: list[ChatMessage] = [] # dialog so far; empty = first question
|
||||
provider: ProviderType = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
thorough: bool = False # "thorough check": rating with a strong model (role guide)
|
||||
|
||||
|
||||
@@ -246,7 +220,7 @@ class BlockPruefenRequest(BaseModel):
|
||||
spot: str = "ausführlich" # "compact" | "ausführlich" (displayed field)
|
||||
snippet: str = Field(min_length=1, max_length=20000) # raw markdown block
|
||||
hint: str = Field(default="", max_length=2000) # optional addition (✏️)
|
||||
provider: ProviderType = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
|
||||
|
||||
class BlockPruefenResponse(BaseModel):
|
||||
@@ -258,7 +232,7 @@ class BlockUebernehmenRequest(BaseModel):
|
||||
spot: str = "ausführlich"
|
||||
alt: str = Field(min_length=1, max_length=20000)
|
||||
revised: str = Field(default="", max_length=20000)
|
||||
provider: ProviderType = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
|
||||
|
||||
class BlockUebernehmenResponse(BaseModel):
|
||||
|
||||
+78
-37
@@ -9,14 +9,11 @@ import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agents import run_agent, kill_process, cancel_scope, clear_scope
|
||||
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
|
||||
from database import update_guide
|
||||
from jsonio import read_json_file as _json_file
|
||||
from textkit import _STUFEN
|
||||
|
||||
log = logging.getLogger("creator.pipeline")
|
||||
|
||||
@@ -80,6 +77,15 @@ def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
|
||||
return f"{label} (exit {returncode}, no output)"
|
||||
|
||||
|
||||
_INFRA_MARKERS = ("HTTP 429", "HTTP 5", "rate_limit", "Timeout after",
|
||||
"ConnectError", "ConnectTimeout", "ReadError", "RemoteProtocolError")
|
||||
|
||||
|
||||
def _is_infra(err: str) -> bool:
|
||||
"""Transport-/Infra-Fehler (retry + pause) statt inhaltlichem Fehlschlag."""
|
||||
return any(m in (err or "") for m in _INFRA_MARKERS)
|
||||
|
||||
|
||||
def _gather_error(label: str, results: list) -> str:
|
||||
for r in results:
|
||||
if isinstance(r, BaseException):
|
||||
@@ -132,7 +138,6 @@ def _runde_schema(data, final: bool = False):
|
||||
return include, rest
|
||||
|
||||
|
||||
_RELEVANCE = ("relevant", "peripheral")
|
||||
_YESNO = ("ja", "nein")
|
||||
|
||||
|
||||
@@ -159,15 +164,14 @@ def _enum_map_schema(key: str, allowed):
|
||||
return parse
|
||||
|
||||
|
||||
_levels_schema = _enum_map_schema("levels", _STUFEN) # level ∈ beginner/advanced/expert
|
||||
_relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈ relevant/peripheral
|
||||
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
|
||||
|
||||
|
||||
from config import MAX_RESTARTS as _MAX_RESTARTS # noqa: E402 — zentral tunebar
|
||||
from config import (MAX_RESTARTS as _MAX_RESTARTS, HEDGE_NACH_S as _HEDGE_NACH_S, # noqa: E402 — zentral tunebar
|
||||
INFRA_MAX_RETRIES as _INFRA_MAX_RETRIES, INFRA_BACKOFF_BASE as _INFRA_BACKOFF)
|
||||
|
||||
|
||||
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) -> list | None:
|
||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, cancelled=None, *, grace: int | None = None) -> list | None:
|
||||
"""Starts all slots in parallel and collects `quorum` valid results.
|
||||
|
||||
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||
@@ -180,29 +184,33 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
a timer of `grace` seconds. After it expires, running agents are only
|
||||
killed if the minimum stands — otherwise the race, including restarts,
|
||||
keeps running until it stands. Returns: `quorum` to `len(slots)` results.
|
||||
|
||||
`min_runtime` (wall-clock from start): the race does not return before it
|
||||
elapses while agents are still running — gives them time to search thoroughly.
|
||||
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
|
||||
(or None if nothing), killing the rest. Both default off; only Research sets them.
|
||||
"""
|
||||
attempts = {i: 0 for i in range(len(slots))}
|
||||
infra_attempts = {i: 0 for i in range(len(slots))}
|
||||
infra_erschoepft: set[int] = set()
|
||||
tasks: dict[asyncio.Task, int] = {}
|
||||
keys: dict[asyncio.Task, str] = {}
|
||||
born: dict[asyncio.Task, float] = {}
|
||||
hedged: set[int] = set() # slot got its one twin — no hedge cascades
|
||||
fertig: set[int] = set() # slot delivered a valid result (late twins are ignored)
|
||||
# Hedge-Schwelle relativ zum Call-Timeout (HEDGE_NACH_S = Untergrenze): pauschale 90 s
|
||||
# hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110–135 s laufen.
|
||||
hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
min_deadline = start + min_runtime if min_runtime else None
|
||||
max_deadline = start + max_runtime if max_runtime else None
|
||||
deadline: float | None = None
|
||||
|
||||
def spawn(i: int) -> None:
|
||||
def spawn(i: int, suffix: str = "") -> None:
|
||||
slot = slots[i]
|
||||
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
|
||||
key = slot["key"] + suffix
|
||||
task = asyncio.create_task(run_agent(
|
||||
slot["key"], slot["prompt"], timeout,
|
||||
key, slot["prompt"], timeout,
|
||||
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
||||
scope=topic, on_line=slot.get("on_line"), label=lbl,
|
||||
))
|
||||
tasks[task] = i
|
||||
keys[task] = key
|
||||
born[task] = loop.time()
|
||||
|
||||
for i in range(len(slots)):
|
||||
spawn(i)
|
||||
@@ -212,27 +220,38 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
while tasks:
|
||||
if cancelled and cancelled():
|
||||
return None
|
||||
# Hard wall-clock cap: return whatever we have (None if empty), kill the rest.
|
||||
if max_deadline is not None and loop.time() >= max_deadline:
|
||||
_log(topic, f"{label}: max runtime {max_runtime}s reached ({len(results)} valid)")
|
||||
return results or None
|
||||
min_ok = min_deadline is None or loop.time() >= min_deadline
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
|
||||
return results
|
||||
# Wake up for the earliest relevant deadline (grace, min, or max).
|
||||
# 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
|
||||
# timeout cap before the restart even began (measured: 160–230 s per stall).
|
||||
if hedge_s:
|
||||
now = loop.time()
|
||||
for t in [t for t in list(tasks) if tasks[t] not in hedged | fertig
|
||||
and now - born[t] >= hedge_s]:
|
||||
i = tasks[t]
|
||||
hedged.add(i)
|
||||
spawn(i, suffix="-h")
|
||||
_log(topic, f"{label} {i + 1}: {round(hedge_s)}s ohne Ergebnis — Hedge-Zwilling gestartet")
|
||||
# Wake up for the earliest relevant deadline (grace or next hedge).
|
||||
waits = []
|
||||
if deadline is not None and len(results) >= quorum:
|
||||
waits.append(deadline - loop.time())
|
||||
if min_deadline is not None:
|
||||
waits.append(min_deadline - loop.time())
|
||||
if max_deadline is not None:
|
||||
waits.append(max_deadline - loop.time())
|
||||
if hedge_s:
|
||||
naechste = [born[t] + hedge_s - loop.time() for t in tasks
|
||||
if tasks[t] not in hedged | fertig]
|
||||
if naechste:
|
||||
waits.append(max(0.0, min(naechste)))
|
||||
wait_timeout = max(0.0, min(waits)) if waits else None
|
||||
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
|
||||
if not done:
|
||||
continue
|
||||
for task in done:
|
||||
i = tasks.pop(task)
|
||||
keys.pop(task, None)
|
||||
born.pop(task, None)
|
||||
if i in fertig:
|
||||
continue # späte Zwillinge eines bereits gewerteten Slots
|
||||
payload, err = None, None
|
||||
try:
|
||||
result = task.result()
|
||||
@@ -249,30 +268,47 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
|
||||
if payload is not None:
|
||||
results.append(payload)
|
||||
fertig.add(i)
|
||||
for t2 in [t2 for t2, i2 in tasks.items() if i2 == i]: # Zwilling killen
|
||||
kill_process(keys.get(t2, slots[i]["key"]))
|
||||
t2.cancel()
|
||||
if grace is not None and deadline is None:
|
||||
deadline = loop.time() + grace
|
||||
_log(topic, f"{label}: first result — grace {grace}s running")
|
||||
if on_update:
|
||||
on_update(len(results))
|
||||
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
|
||||
and (min_deadline is None or loop.time() >= min_deadline)):
|
||||
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
|
||||
return results
|
||||
continue
|
||||
|
||||
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {err}")
|
||||
infra = _is_infra(err)
|
||||
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}{' infra' if infra else ''}): {err}")
|
||||
attempts[i] += 1
|
||||
# If the minimum already stands, restarts are pointless — the restart
|
||||
# would be killed at the grace end anyway.
|
||||
# would be killed at the grace end anyway. A still-running twin IS the retry.
|
||||
enough = grace is not None and len(results) >= quorum
|
||||
if attempts[i] <= _MAX_RESTARTS and not enough and not (cancelled and cancelled()):
|
||||
zwilling = any(i2 == i for i2 in tasks.values())
|
||||
if enough or zwilling or (cancelled and cancelled()):
|
||||
continue
|
||||
if infra:
|
||||
# 429/Timeout/Netz: eigener Zähler + wachsende Pause, dann Eskalation
|
||||
infra_attempts[i] += 1
|
||||
if infra_attempts[i] > _INFRA_MAX_RETRIES:
|
||||
infra_erschoepft.add(i) # kein Respawn → raise am Quorum-Miss
|
||||
continue
|
||||
await asyncio.sleep(_INFRA_BACKOFF * 2 ** (infra_attempts[i] - 1))
|
||||
spawn(i)
|
||||
elif attempts[i] <= _MAX_RESTARTS:
|
||||
spawn(i)
|
||||
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
|
||||
return results
|
||||
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
|
||||
if infra_erschoepft:
|
||||
raise AgentInfraError(
|
||||
f"{label}: {len(infra_erschoepft)} Slot(s) nach {_INFRA_MAX_RETRIES} "
|
||||
"Infra-Retries erschöpft (429/Timeout)")
|
||||
return None
|
||||
finally:
|
||||
for task, i in tasks.items():
|
||||
kill_process(slots[i]["key"])
|
||||
kill_process(keys.get(task, slots[i]["key"]))
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks.keys(), return_exceptions=True)
|
||||
@@ -291,6 +327,11 @@ class GenContext:
|
||||
OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
|
||||
|
||||
|
||||
class AgentInfraError(Exception):
|
||||
"""Slot nach Infra-Retries erschöpft (429/Timeout). KEIN inhaltliches Urteil —
|
||||
der Aufrufer pausiert den Lauf, nie fail-open."""
|
||||
|
||||
|
||||
async def run_single_slot(
|
||||
ctx: GenContext, label: str, *,
|
||||
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, on_line=None,
|
||||
|
||||
+236
-44
@@ -11,7 +11,8 @@ previous report of the same topic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
@@ -19,23 +20,28 @@ from pathlib import Path
|
||||
|
||||
import database as db
|
||||
import embedding
|
||||
from config import STORAGE_DIR, SUB_DUP_KANDIDAT_COS
|
||||
from config import JUDGE_CHUNK, STORAGE_DIR, SUB_DUP_KANDIDAT_COS
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from paths import arbeit_dir
|
||||
from textkit import _norm_title
|
||||
|
||||
log = logging.getLogger("creator.qa")
|
||||
|
||||
QA_DIR = STORAGE_DIR / "qa"
|
||||
JACCARD_FLOOR = 0.5 # title token overlap that makes a pair suspicious
|
||||
EMB_FLOOR = 0.82 # casefolded title cosine (own threshold, NOT the pipeline's 0.65)
|
||||
SECTION_CHARS = 4000 # own paragraph splitter — independent of _text_sections
|
||||
COVER_MIN_TOKENS = 2 # distinctive block tokens a section must share to count as covered
|
||||
FREMD_MIN_TOKENS = 1 # distinctive title tokens that must appear in the corpus
|
||||
CONCEPT_COVER_RATIO = 0.6 # fraction of a named result's stems a block must share to count as covered
|
||||
LLM_SAMPLE = 12 # pairs/sections per judge call with --llm
|
||||
# Note 0-10, deterministisch aus den Quoten (transparent, diffbar — keine LLM-"Gefühlsnote").
|
||||
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff); Dubletten-VERDACHT enthält
|
||||
# bewusst Rauschen und wiegt daher wenig.
|
||||
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "hygiene": 0.5}
|
||||
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff). konzept_luecken (benannte Kernresultate
|
||||
# ohne Baustein) ist heuristisch → mittleres Gewicht. Dubletten-VERDACHT enthält bewusst Rauschen,
|
||||
# bildet aber das Nutzerproblem (Dopplungen) ab → leichtestes Gewicht, aber nicht null.
|
||||
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "konzept_luecken": 1.5,
|
||||
"dubletten_verdacht": 1.0, "hygiene": 0.5}
|
||||
# subs/artefacts only exist after board 2 — at gate time these quotas would always be 0
|
||||
# and water down the inventory score, hence a separate score.
|
||||
# sub_dubletten counts only with --llm (confirmed pairs); the bare candidate list is
|
||||
@@ -92,9 +98,15 @@ def _corpus_texts(topic: str) -> dict[str, str]:
|
||||
out = {}
|
||||
for f in sorted(folder.glob("*.txt")):
|
||||
try:
|
||||
out[f.name] = f.read_text(encoding="utf-8")
|
||||
text = f.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
# QUELLE:-Header (Metadata, kein Lerninhalt) strippen — sonst bildet er bei großen
|
||||
# Seiten einen isolierten, block-losen Abschnitt = False-Positive-Lücke.
|
||||
lines = text.splitlines()
|
||||
if lines and lines[0].startswith("QUELLE:"):
|
||||
text = "\n".join(lines[1:]).lstrip("\n")
|
||||
out[f.name] = text
|
||||
return out
|
||||
|
||||
|
||||
@@ -179,6 +191,55 @@ def luecken(blocks: list[dict], subs_by_norm: dict[str, list[str]], corpus: dict
|
||||
return out
|
||||
|
||||
|
||||
# A NAMED result carries a concept name, not just a number: "Satz 7.13 (Christofides)",
|
||||
# "Satz 6.24: Cook-Levin". These are the core statements the token-based `luecken` misses — 108
|
||||
# over-granular blocks cover every SECTION, yet the named core result may have no block at all.
|
||||
# Generic + domain-safe: the NUMBER is mandatory (a numbered labeled unit is a formal statement in
|
||||
# a structured document, any field), so prose like "ein Satz von Goethe" (no number) never matches.
|
||||
_NAMED_RESULT_RE = re.compile(
|
||||
r'\b(?:Satz|Lemma|Korollar|Theorem|Proposition|Folgerung|Definition|Algorithmus)\s+\d+(?:\.\d+)*\s*'
|
||||
r'(?:\(\s*([^()\n]{3,60}?)\s*\)|:\s*([^\n.;·]{3,60}?)\s*(?:[.\n;·]|$))', re.M)
|
||||
|
||||
|
||||
def _stem(t: str) -> str:
|
||||
"""Declension-tolerant token stem: drop trailing digits, keep the 5-char prefix
|
||||
('Eulerschen'/'Eulerscher' → 'euler', 'Kreise'/'Kreis' → 'kreis')."""
|
||||
return t.rstrip("0123456789")[:5]
|
||||
|
||||
|
||||
def _named_results(corpus: dict[str, str]) -> dict[str, set[str]]:
|
||||
"""Named/attributed corpus results → {concept name: distinctive stems}. A bare 'Satz 7.18'
|
||||
(number, no name) yields nothing to match. Same catalogue vocabulary as the title strip.
|
||||
Umbruch-Schutz (generisch): endet ein Doppelpunkt-Fang am Zeilenumbruch und läuft die
|
||||
nächste Zeile klein weiter, ist es ein UMBROCHENER SATZ, kein Name — solche Fragmente
|
||||
(„Satz 6.16: Wenn ein … in P⏎liegt …") kann kein Block je ankern (Phantom-Lücke)."""
|
||||
out: dict[str, set[str]] = {}
|
||||
for text in corpus.values():
|
||||
for m in _NAMED_RESULT_RE.finditer(text):
|
||||
name = (m.group(1) or m.group(2) or "").strip()
|
||||
if m.group(2) and m.end() > 0 and text[m.end() - 1] == "\n":
|
||||
rest = text[m.end():].lstrip(" \t")
|
||||
if rest[:1].islower():
|
||||
continue # Satzfragment: Fortsetzung klein auf der nächsten Zeile
|
||||
toks = _distinctive(name)
|
||||
if len(name) >= 3 and toks:
|
||||
out.setdefault(name, set()).update(_stem(t) for t in toks)
|
||||
return out
|
||||
|
||||
|
||||
def konzept_luecken(blocks: list[dict], named: dict[str, set[str]]) -> list[str]:
|
||||
"""Named corpus results that NO block covers — concept gaps the token-based `luecken` cannot see.
|
||||
Covered = a block whose title+description share ≥ CONCEPT_COVER_RATIO of the result's distinctive
|
||||
stems (declension-tolerant). Errs toward 'covered' so the heuristic never invents a false gap."""
|
||||
anchors = [{_stem(t) for t in _distinctive(b["title"]) | _distinctive(b.get("description") or "")}
|
||||
for b in blocks]
|
||||
out = []
|
||||
for name, stems in named.items():
|
||||
if not any(len(stems & a) >= CONCEPT_COVER_RATIO * len(stems) for a in anchors):
|
||||
out.append(name)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def fremd(blocks: list[dict], corpus: dict[str, str]) -> list[str]:
|
||||
"""Blocks whose distinctive title tokens never appear in the corpus (scope creep).
|
||||
Token/stem match, NOT raw substring — 'bergang' ⊂ 'Übergang' had whitewashed the
|
||||
@@ -224,8 +285,10 @@ def hygiene(blocks: list[dict]) -> list[dict]:
|
||||
|
||||
def _zaehlbare_luecken(lk: list[dict], llm: bool) -> list[dict]:
|
||||
"""With --llm only non-refuted gaps count ('?' = unjudged stays, conservative) — refuted
|
||||
ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0)."""
|
||||
return [x for x in lk if x.get("llm") != "nein"] if llm else lk
|
||||
ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0).
|
||||
Persistierte Freisprüche (2:1 im Repair) zählen nie."""
|
||||
offen = [x for x in lk if not x.get("freispruch")]
|
||||
return [x for x in offen if x.get("llm") != "nein"] if llm else offen
|
||||
|
||||
|
||||
def note(quoten: dict, gewichte: dict = NOTE_GEWICHTE) -> float:
|
||||
@@ -281,19 +344,106 @@ def _qa_prompt(name: str, **kwargs) -> str:
|
||||
return (TEMPLATES_DIR / "QA" / f"{name}.md").read_text(encoding="utf-8").format(**kwargs)
|
||||
|
||||
|
||||
async def _llm_verdicts(template: str, topic: str, key: str, items: list[str]) -> dict[int, str]:
|
||||
async def judge_wave(template: str, topic: str, key: str, slot: str, items: list[str],
|
||||
*, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA",
|
||||
extra: str = "") -> 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. `extra` füllt den {extra}-Slot des Templates
|
||||
(z. B. Inventar-Kontext für das Lücken-Urteil im Repair)."""
|
||||
from agents import run_agent
|
||||
from pipeline import _yesno_schema
|
||||
from pipeline import _timeout, _yesno_schema
|
||||
from jsonio import parse_json_text
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(items, 1))
|
||||
slot = {"Dubletten": "pairs", "Luecken": "sections", "Bausteine": "blocks", "Sub": "pairs"}[template.split("-")[1]]
|
||||
rc, out, _err = await run_agent(f"qa-{topic}-{key}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}),
|
||||
600, role="judge", capabilities="none", scope=topic, label=f"QA {key}")
|
||||
return (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {}
|
||||
|
||||
async def _chunk(lo: int) -> dict[int, str]:
|
||||
teil = items[lo:lo + chunk]
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1))
|
||||
try:
|
||||
rc, out, _err = await run_agent(
|
||||
f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra=extra, **{slot: listing}),
|
||||
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}")
|
||||
except Exception:
|
||||
log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo)
|
||||
return {}
|
||||
if rc != 0:
|
||||
log.warning("[%s] %s-Judge %s+%d fehlgeschlagen (rc=%s) — %d Items ohne Urteil",
|
||||
topic, label, key, lo, rc, len(teil))
|
||||
return {}
|
||||
return _yesno_schema(parse_json_text(out)) or {}
|
||||
|
||||
offsets = range(0, len(items), chunk)
|
||||
results = await asyncio.gather(*[_chunk(lo) for lo in offsets])
|
||||
return {lo + k: urteil for lo, v in zip(offsets, results) for k, urteil in v.items()}
|
||||
|
||||
|
||||
# ── Report ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def report_paths(topic: str, guide: bool = False) -> list[Path]:
|
||||
"""QA-Reports eines Topics, mtime-aufsteigend (Run-ID- und Timestamp-Namen sortieren
|
||||
lexikographisch nicht). guide=True → die separate guide-*-Serie (guide_qa.py).
|
||||
freispruch.json teilt den Ordner, ist aber kein Report — immer außen vor."""
|
||||
tdir = QA_DIR / topic
|
||||
if not tdir.is_dir():
|
||||
return []
|
||||
return sorted((p for p in tdir.glob("*.json")
|
||||
if p.name.startswith("guide-") == guide and p.name != "freispruch.json"),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
_latest_cache: dict[tuple[str, bool], tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def latest_report(topic: str, guide: bool = False) -> dict | None:
|
||||
"""Jüngster Report als geparstes dict, mtime-gecacht — die Board-Snapshots lesen das
|
||||
im 1,2-s-Frontend-Takt, ein JSON-Read je Poll war unnötiges Datei-I/O. glob+stat
|
||||
bleiben (billig), der Read passiert nur bei geänderter mtime."""
|
||||
reports = report_paths(topic, guide)
|
||||
if not reports:
|
||||
return None
|
||||
p = reports[-1]
|
||||
mtime = p.stat().st_mtime
|
||||
key = (topic, guide)
|
||||
cached = _latest_cache.get(key)
|
||||
if cached is None or cached[0] != mtime:
|
||||
_latest_cache[key] = (mtime, _json_file(p) or {})
|
||||
return _latest_cache[key][1]
|
||||
|
||||
|
||||
def freispruch_pfad(topic: str) -> Path:
|
||||
return QA_DIR / topic / "freispruch.json"
|
||||
|
||||
|
||||
def _paar_key(a: str, b: str) -> str:
|
||||
return "||".join(sorted((_norm_title(a), _norm_title(b))))
|
||||
|
||||
|
||||
def luecken_key(item: dict) -> str:
|
||||
"""Stabiler Freispruch-Schlüssel einer Lücken-Fundstelle: Datei + Vorschau-Hash.
|
||||
Abschnittsnummern verschieben sich bei Korpusänderung — dann veralten die Keys
|
||||
und werden inert (gewollt: neue Fundstellen brauchen ein neues Urteil)."""
|
||||
v = " ".join(str(item.get("vorschau", "")).casefold().split())
|
||||
return f"{item.get('datei', '')}||{hashlib.sha1(v.encode()).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def offene_luecken(report: dict) -> tuple[list[dict], list[str]]:
|
||||
"""Zählbare offene Lücken eines Reports: (luecken-Items ohne Freispruch/LLM-nein,
|
||||
konzept_luecken-Namen). Grundlage für Lücken-Research und Lücken-Urteil im Repair."""
|
||||
lk = [x for x in report.get("luecken", [])
|
||||
if not x.get("freispruch") and x.get("llm") != "nein"]
|
||||
return lk, list(report.get("konzept_luecken", []))
|
||||
|
||||
|
||||
def lade_freispruch(topic: str) -> dict[str, list[str]]:
|
||||
"""Persistierte 2:1-Freisprüche des Repair-Stichentscheids (repair._mit_stichentscheid):
|
||||
mehrheitlich als „behalten" geurteilte Befunde zählen nicht mehr in die Note — sonst
|
||||
pendelte sie dauerhaft unter 10 ohne Fix-Pfad (gemessen: kanban-smoke 9.4, aak 9.2).
|
||||
Die Detektoren bleiben unverändert; ein Freispruch ist ein persistiertes Urteil,
|
||||
kein Detektor-Tuning. Freigesprochene bleiben im Report sichtbar."""
|
||||
d = _json_file(freispruch_pfad(topic))
|
||||
return d if isinstance(d, dict) else {}
|
||||
|
||||
|
||||
async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
|
||||
if not cards:
|
||||
@@ -313,43 +463,67 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
sd = sub_dubletten(sub_rows)
|
||||
lk = luecken(blocks, subs_by_norm, corpus) if corpus else []
|
||||
fr = fremd(blocks, corpus) if corpus else []
|
||||
named = _named_results(corpus) if corpus else {}
|
||||
kl = konzept_luecken(blocks, named) if corpus else []
|
||||
bl = beleg(blocks, sub_rows)
|
||||
hy = hygiene(blocks)
|
||||
n_sections = sum(len(_sections(t)) for t in corpus.values()) or 1
|
||||
|
||||
if llm and d:
|
||||
v = await _llm_verdicts("QA-Dubletten", topic, "dubletten",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]])
|
||||
for k, p in enumerate(d[:LLM_SAMPLE], 1):
|
||||
frei = lade_freispruch(topic)
|
||||
frei_fremd = set(frei.get("fremd") or [])
|
||||
fremd_frei = [t for t in fr if _norm_title(t) in frei_fremd]
|
||||
fr = [t for t in fr if _norm_title(t) not in frei_fremd]
|
||||
# Persistierte 2:1-Freisprüche: markiert bleibt sichtbar (diffbar), zählt aber nicht mehr
|
||||
# in Quote und Judge-Welle — sonst pendelte der Verdachts-Floor ewig unter 10.
|
||||
frei_dub = set(frei.get("dubletten") or [])
|
||||
for p in d:
|
||||
if _paar_key(p["a"], p["b"]) in frei_dub:
|
||||
p["freispruch"] = True
|
||||
d_offen = [p for p in d if not p.get("freispruch")]
|
||||
frei_lk = set(frei.get("luecken") or [])
|
||||
for x in lk:
|
||||
if luecken_key(x) in frei_lk:
|
||||
x["freispruch"] = True
|
||||
lk_offen = [x for x in lk if not x.get("freispruch")]
|
||||
frei_kl = set(frei.get("konzept_luecken") or [])
|
||||
kl_frei = [nm for nm in kl if _norm_title(nm) in frei_kl]
|
||||
kl = [nm for nm in kl if _norm_title(nm) not in frei_kl]
|
||||
|
||||
if llm and d_offen:
|
||||
v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in d_offen[:LLM_SAMPLE]])
|
||||
for k, p in enumerate(d_offen[:LLM_SAMPLE], 1):
|
||||
p["llm"] = v.get(k, "?")
|
||||
if llm and lk:
|
||||
v = await _llm_verdicts("QA-Luecken", topic, "luecken",
|
||||
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]])
|
||||
for k, x in enumerate(lk[:LLM_SAMPLE], 1):
|
||||
if llm and lk_offen:
|
||||
v = await judge_wave("QA-Luecken", topic, "luecken", "sections",
|
||||
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk_offen[:LLM_SAMPLE]])
|
||||
for k, x in enumerate(lk_offen[:LLM_SAMPLE], 1):
|
||||
x["llm"] = v.get(k, "?")
|
||||
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note
|
||||
for lo in range(0, len(sd), 40):
|
||||
chunk = sd[lo:lo + 40]
|
||||
v = await _llm_verdicts("QA-Sub-Dubletten", topic, f"sub-dubletten-{lo}",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in chunk])
|
||||
for k, p in enumerate(chunk, 1):
|
||||
v = await judge_wave("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in sd])
|
||||
for k, p in enumerate(sd, 1):
|
||||
p["llm"] = v.get(k, "?")
|
||||
frei_sub = set(frei.get("sub_dubletten") or [])
|
||||
for p in sd:
|
||||
if p.get("llm") == "ja" and _paar_key(p["a"], p["b"]) in frei_sub:
|
||||
p["freispruch"] = True # 2:1-Urteil „behalten" — sichtbar, aber notenfrei
|
||||
unecht: list[str] | None = None
|
||||
if llm and blocks:
|
||||
verdacht = []
|
||||
for lo in range(0, len(blocks), 80): # ein Call je 80 Titel
|
||||
chunk = blocks[lo:lo + 80]
|
||||
v = await _llm_verdicts("QA-Bausteine", topic, f"bausteine-{lo}",
|
||||
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in chunk])
|
||||
verdacht += [b for k, b in enumerate(chunk, 1) if v.get(k) == "nein"]
|
||||
v = await judge_wave("QA-Bausteine", topic, "bausteine", "blocks",
|
||||
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in blocks],
|
||||
chunk=80)
|
||||
verdacht = [b for k, b in enumerate(blocks, 1) if v.get(k) == "nein"]
|
||||
# Bestätiger-Pass nur über die Geflaggten: der Einzel-Judge flaggte pro Lauf ANDERE
|
||||
# Blöcke (gemessen aak: Note pendelte 9.3↔10.0 bei identischem Bestand) — nur
|
||||
# doppelt-„nein" zählt; Repair hat als dritte Sicherung die eigene Zweitmeinung
|
||||
unecht = []
|
||||
if verdacht:
|
||||
v2 = await _llm_verdicts("QA-Bausteine", topic, "bausteine-b2",
|
||||
v2 = await judge_wave("QA-Bausteine", topic, "bausteine-b2", "blocks",
|
||||
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in verdacht])
|
||||
unecht = [b["title"] for k, b in enumerate(verdacht, 1) if v2.get(k) == "nein"]
|
||||
frei_unecht = set(frei.get("unecht") or [])
|
||||
unecht = [t for t in unecht if _norm_title(t) not in frei_unecht]
|
||||
|
||||
art_rows = [dict(r) for r in await db.get_sub_artefakte(topic)]
|
||||
fragen = [dict(r) for r in await db.list_question_pattern(topic)]
|
||||
@@ -362,22 +536,28 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
n_cons = sum(1 for r in sub_rows if r["status"] == "consensus")
|
||||
if n_cons:
|
||||
quoten_art["sub_dubletten_verdacht"] = round(len(sd) / n_cons, 3)
|
||||
if llm: # confirmed pairs only — the bare candidate list is suspicion, not damage
|
||||
quoten_art["sub_dubletten"] = round(sum(1 for p in sd if p.get("llm") == "ja") / n_cons, 3)
|
||||
if llm: # confirmed pairs only — the bare candidate list is suspicion, not damage;
|
||||
# freigesprochene (2:1 „behalten") zählen nicht mehr
|
||||
quoten_art["sub_dubletten"] = round(
|
||||
sum(1 for p in sd if p.get("llm") == "ja" and not p.get("freispruch")) / n_cons, 3)
|
||||
summary = _json_file(arbeit_dir(topic) / "lauf-summary.json") or {}
|
||||
report = {
|
||||
"topic": topic, "erstellt": datetime.now(timezone.utc).isoformat(),
|
||||
"run_id": summary.get("run_id", ""), "bloecke": len(blocks),
|
||||
"quoten": {
|
||||
"dubletten_verdacht": round(len(d) / max(len(blocks), 1), 3),
|
||||
"dubletten_verdacht": round(len(d_offen) / max(len(blocks), 1), 3),
|
||||
"luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3),
|
||||
"fremd": round(len(fr) / max(len(blocks), 1), 3),
|
||||
**({"konzept_luecken": round(len(kl) / max(len(named), 1), 3)} if corpus else {}),
|
||||
"hygiene": round(len(hy) / max(len(blocks), 1), 3),
|
||||
**({"unechte_bloecke": round(len(unecht) / max(len(blocks), 1), 3)} if unecht is not None else {}),
|
||||
},
|
||||
"quoten_artefakte": quoten_art,
|
||||
**({"unecht": unecht} if unecht is not None else {}),
|
||||
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "fremd": fr, "beleg": bl, "hygiene": hy,
|
||||
**({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}),
|
||||
**({"konzept_luecken_freigesprochen": kl_frei} if kl_frei else {}),
|
||||
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "konzept_luecken": kl,
|
||||
"fremd": fr, "beleg": bl, "hygiene": hy,
|
||||
"artefakte": art,
|
||||
"lauf": summary,
|
||||
}
|
||||
@@ -400,10 +580,7 @@ def _diff(prev: dict | None, cur: dict) -> dict:
|
||||
def _write_report(report: dict) -> Path:
|
||||
tdir = QA_DIR / report["topic"]
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
# by mtime: run-id names (…-1311-5e5c) and timestamp names don't sort lexicographically.
|
||||
# guide-* reports share the directory but are a SEPARATE series (guide_qa.py).
|
||||
older = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
older = report_paths(report["topic"])
|
||||
prev = _json_file(older[-1]) if older else None
|
||||
report["diff_zum_vorlauf"] = _diff(prev, report)
|
||||
name = report["run_id"] or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
@@ -412,6 +589,21 @@ def _write_report(report: dict) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
async def write_report(report: dict) -> Path:
|
||||
"""_write_report + kompaktes kind='qa'-Event. Die Report-JSONs liegen nur auf der
|
||||
Lauf-Maschine (storage/qa/) — ein DB-Pull reichte nicht, um Note/Quoten eines Runs
|
||||
zu rekonstruieren (Analyse 20260704-1452-b223). Nur die Kennzahlen, kein Volltext;
|
||||
run_id stempelt add_event aus der Registry (gesetzt im Lauf, leer bei manueller QA)."""
|
||||
path = await asyncio.to_thread(_write_report, report)
|
||||
try: # Event ist Komfort — ein DB-Fehler darf den Report nicht kosten (fail-open)
|
||||
await db.add_event(report["topic"], "qa", key=path.stem, meta={
|
||||
"note": report["note"], "note_artefakte": report.get("note_artefakte"),
|
||||
"quoten": report["quoten"], "quoten_artefakte": report.get("quoten_artefakte", {})})
|
||||
except Exception:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def _digest(report: dict, path: Path):
|
||||
na = report.get("note_artefakte")
|
||||
print(f"QA {report['topic']} — {report['bloecke']} Blöcke (run {report['run_id'] or '—'})"
|
||||
@@ -443,7 +635,7 @@ async def main(topic: str, llm: bool):
|
||||
report = await qa_report(topic, llm=llm)
|
||||
if report is None:
|
||||
sys.exit(1)
|
||||
_digest(report, _write_report(report))
|
||||
_digest(report, await write_report(report))
|
||||
finally:
|
||||
await db.close_db()
|
||||
|
||||
|
||||
+334
-91
@@ -2,77 +2,272 @@
|
||||
|
||||
Blindes Re-Filtern reproduziert die blinden Flecken der Pipeline (sie hat die Befunde ja
|
||||
durchgelassen). Hier fließen die QA-BEFUNDE als Input in gezielte Aktionen: Hygiene
|
||||
deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach
|
||||
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
|
||||
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
|
||||
deterministisch (+ Beschreibung/Suffix per Sanierung/Rename), bestätigte Dubletten mergen
|
||||
(Zweitmeinung), Fremd/Unecht nur nach Gegen-Judge entfernen, Beleg-Nachfass je Sub,
|
||||
Lücken per Stichentscheid klären (Freispruch oder bestätigt = braucht Recherche).
|
||||
Fail-open überall: Zweifel/Judge-Fehler → behalten, nichts persistieren."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import database as db
|
||||
import qa
|
||||
from agents import run_agent
|
||||
from blocks import _blocks_files, _evidence_pack, source_folder
|
||||
from blocks import _blocks_files, _evidence_pack, material_folder, source_folder
|
||||
from config import EVIDENCE_PER_BLOCK
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import parse_json_text, read_json_file as _json_file
|
||||
from pipeline import _yesno_schema
|
||||
from textkit import _norm_title, _title, clean_title
|
||||
from jsonio import read_json_file as _json_file
|
||||
from textkit import _norm_title, _title, clean_title, parse_facts
|
||||
|
||||
log = logging.getLogger("creator.repair")
|
||||
|
||||
JUDGE_TIMEOUT = 600
|
||||
from config import EVIDENCE_PER_BLOCK, JUDGE_CHUNK # noqa: E402 — zentral tunebar
|
||||
|
||||
|
||||
async def repair_befunde(topic: str) -> dict:
|
||||
tdir = qa.QA_DIR / topic
|
||||
reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
async def repair_befunde(topic: str, ebene: str | None = None, luecken_urteil: bool = True) -> dict:
|
||||
"""ebene=None → alle Fixes; „inventory" → nur Inventar; „artefacts" → nur Artefakte.
|
||||
luecken_urteil: offene Lücken/Konzept-Lücken dem Stichentscheid vorlegen (Freispruch
|
||||
oder bestätigt) — der QA-Gate-Watch schaltet es aus, solange seine Lücken-Recherche
|
||||
noch Runden hat. Misst am Ende IMMER beide Noten neu. `aktionen` zählt alles, was die
|
||||
Runde getan hat (Fixes+Merges+Freisprüche) — der Auto-Loop stoppt erst bei 0."""
|
||||
reports = qa.report_paths(topic)
|
||||
report = _json_file(reports[-1]) if reports else None
|
||||
if not report:
|
||||
return {"fehler": "kein QA-Report — erst QA laufen lassen"}
|
||||
files = _blocks_files(topic)
|
||||
cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
|
||||
by_norm = {_norm_title(c["payload"].get("title", "")): c for c in cards}
|
||||
inv = ebene in (None, "inventory")
|
||||
art = ebene in (None, "artefacts")
|
||||
|
||||
hygiene = await _fix_hygiene(topic, report, by_norm, files)
|
||||
merges = await _merge_dubletten(topic, report, by_norm, files)
|
||||
sub_merges = await _merge_sub_dubletten(topic, report, files)
|
||||
entfernt = await _entferne_fremd_unecht(topic, report, by_norm, files)
|
||||
aufgeraeumt = await _raeume_waisen(topic)
|
||||
hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else []
|
||||
hygiene += (await _fix_beschreibungen(topic, report, by_norm)) if inv else []
|
||||
hygiene += (await _fix_suffixe(topic, report, by_norm, files)) if inv else []
|
||||
merges, frei_paare = (await _merge_dubletten(topic, report, by_norm, files)) if inv else ([], [])
|
||||
sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], [])
|
||||
entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], [])
|
||||
frei_luecken = (await _pruefe_luecken(topic, report, by_norm)) if inv and luecken_urteil else []
|
||||
beleg_fix = (await _belege_nachfassen(topic, report, files)) if art else []
|
||||
aufgeraeumt = (await _raeume_waisen(topic)) if art else 0
|
||||
|
||||
# llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report
|
||||
# blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
|
||||
neu = await qa.qa_report(topic, llm=True)
|
||||
if neu:
|
||||
await asyncio.to_thread(qa._write_report, neu)
|
||||
await qa.write_report(neu)
|
||||
na = neu.get("note_artefakte") if neu else None
|
||||
freigesprochen = frei_paare + frei_subs + frei_bloecke + frei_luecken
|
||||
lk_rest, kl_rest = qa.offene_luecken(neu or {})
|
||||
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
|
||||
"aufgeraeumt": aufgeraeumt, "braucht_research": len(report.get("luecken", []))}
|
||||
"beleg_fix": beleg_fix, "aufgeraeumt": aufgeraeumt, "freigesprochen": freigesprochen,
|
||||
"aktionen": (len(hygiene) + len(merges) + len(sub_merges) + len(entfernt)
|
||||
+ len(beleg_fix) + aufgeraeumt + len(freigesprochen)),
|
||||
"braucht_research": len(lk_rest) + len(kl_rest), # offene Lücken NACH dieser Runde
|
||||
"note": float(neu["note"]) if neu else 10.0, # neu gemessene Inventar-Note
|
||||
"note_artefakte": float(na) if na is not None else None} # None solange Board 2 leer
|
||||
|
||||
|
||||
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
|
||||
"""No-Tool-Judge-Wellen über alle Items (fail-open: Fehler → leeres Verdikt = behalten)."""
|
||||
verdicts: dict[int, str] = {}
|
||||
for lo in range(0, len(items), JUDGE_CHUNK):
|
||||
chunk = items[lo:lo + JUDGE_CHUNK]
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(chunk, 1))
|
||||
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str],
|
||||
extra: str = "") -> dict[int, str]:
|
||||
"""No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten)."""
|
||||
return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair",
|
||||
extra=extra)
|
||||
|
||||
|
||||
def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
|
||||
d = qa.lade_freispruch(topic)
|
||||
alt = set(d.get(kategorie) or [])
|
||||
d[kategorie] = sorted(alt | set(keys))
|
||||
qa.freispruch_pfad(topic).parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(qa.freispruch_pfad(topic), d, indent=1)
|
||||
|
||||
|
||||
async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
|
||||
lines: list[str], befund: str, kategorie: str = "",
|
||||
ids: list[str] | None = None, extra: str = "") -> tuple[dict[int, str], list[str]]:
|
||||
"""Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei
|
||||
Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über
|
||||
die strittigen Items, Mehrheit 2/3 (Muster Crossblock-Tiebreaker). Ohne ihn pendelte
|
||||
die Note dauerhaft unter 10 ohne Fix-Pfad (gemessen: aak-fremd 9.2, kanban-smoke-
|
||||
Dublette 9.4 — „keine behebbaren Befunde" trotz Befund).
|
||||
Explizites 2:1-„behalten" wird als FREISPRUCH persistiert (kategorie+ids) — die QA
|
||||
zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht
|
||||
(fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen)."""
|
||||
v = await _judge(template, topic, key, slot, lines, extra=extra)
|
||||
strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund]
|
||||
frei: list[str] = []
|
||||
if strittig:
|
||||
v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig], extra=extra)
|
||||
gegen = "nein" if befund == "ja" else "ja"
|
||||
frei_keys: list[str] = []
|
||||
for pos, i in enumerate(strittig, 1):
|
||||
if v3.get(pos) == befund:
|
||||
v[i] = befund # 2:1 für den QA-Befund → handeln
|
||||
elif v3.get(pos) == gegen and kategorie and ids:
|
||||
frei_keys.append(ids[i - 1])
|
||||
frei.append(lines[i - 1].splitlines()[0][:80])
|
||||
if frei_keys:
|
||||
_speichere_freispruch(topic, kategorie, frei_keys)
|
||||
log.info("[%s] Repair %s: %d Befund(e) per 2:1 freigesprochen", topic, kategorie, len(frei_keys))
|
||||
return v, frei
|
||||
|
||||
|
||||
async def _fix_beschreibungen(topic: str, report: dict, by_norm: dict) -> list[str]:
|
||||
"""Hygiene-Resttyp „leere-beschreibung": EINE Beschreibung aus Material-Auszügen
|
||||
generieren (Blocks-Sanierung-Template; der Titel bleibt — Repair ist flow-los und
|
||||
hat keinen Korpus-Anker-Kontext für Renames). Ohne Material/Auszüge bleibt der Befund."""
|
||||
from agents import run_agent
|
||||
from pipeline import _prompt, _timeout
|
||||
from jsonio import parse_json_text
|
||||
from board_inventory import _sanierung_schema
|
||||
items = [h for h in report.get("hygiene", []) if "leere-beschreibung" in (h.get("probleme") or [])]
|
||||
folder = source_folder(topic) or material_folder(topic)
|
||||
if not items or not folder:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for h in items:
|
||||
norm = _norm_title(h.get("titel", ""))
|
||||
card = by_norm.get(norm)
|
||||
if not card or (card["payload"].get("description") or "").strip():
|
||||
continue
|
||||
titel = card["payload"].get("title", "")
|
||||
ev = _evidence_pack(folder, card["payload"].get("sources") or None, [titel], budget=4000)
|
||||
if not ev:
|
||||
continue
|
||||
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 {}
|
||||
rc, txt, _err = await run_agent(
|
||||
f"repair-{topic}-sanierung-{norm[:24]}",
|
||||
_prompt("Blocks-Sanierung", topic=topic, title=titel, description="(leer)", excerpts=ev),
|
||||
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic,
|
||||
label="Repair Beschreibung")
|
||||
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
|
||||
log.exception("[%s] Beschreibungs-Sanierung fehlgeschlagen — Befund bleibt", topic)
|
||||
continue
|
||||
verdict = _sanierung_schema(parse_json_text(txt)) if rc == 0 else None
|
||||
if not verdict or not verdict[1].strip():
|
||||
continue
|
||||
p = dict(card["payload"])
|
||||
p["description"] = verdict[1].strip()
|
||||
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
|
||||
await db.set_block_status(topic, norm, "consensus", description=p["description"])
|
||||
card["payload"] = p
|
||||
out.append(f"beschrieben: {titel}")
|
||||
return out
|
||||
|
||||
|
||||
_SUFFIX_RE = re.compile(r"\s*\(\d+\)\s*$")
|
||||
|
||||
|
||||
async def _fix_suffixe(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
|
||||
"""Hygiene-Resttyp „kollisions-suffix" („Titel (2)", entstanden beim Spiegeln):
|
||||
Basis-Norm frei (Partner inzwischen gemerged/verworfen) → Rename auf den Basistitel
|
||||
inkl. Re-Key aller angehängten Tabellen (db.rename_block_norm) + Board-2-Karte +
|
||||
Sidecars. Basis besetzt → Judge entscheidet Dublette (Merge); sonst bleibt der
|
||||
Befund sichtbar (zwei echte gleichnamige Konzepte sind ein Naming-Problem)."""
|
||||
items = [h for h in report.get("hygiene", []) if "kollisions-suffix" in (h.get("probleme") or [])]
|
||||
out: list[str] = []
|
||||
for h in items:
|
||||
alt = h.get("titel", "")
|
||||
basis = _SUFFIX_RE.sub("", alt).strip()
|
||||
alt_norm, basis_norm = _norm_title(alt), _norm_title(basis)
|
||||
card = by_norm.get(alt_norm)
|
||||
if not card or not basis or basis_norm == alt_norm:
|
||||
continue
|
||||
if basis_norm in by_norm: # Basis lebt noch → Dubletten-Frage statt Rename
|
||||
merged, _frei = await _merge_dubletten(
|
||||
topic, {"dubletten": [{"a": basis, "b": alt, "llm": "?"}]}, by_norm, files)
|
||||
out += [f"suffix-merge: {z}" for z in merged]
|
||||
continue
|
||||
await db.rename_block_norm(topic, alt_norm, basis_norm, basis)
|
||||
b2 = await db.kanban_get_card(topic, "artefacts", alt_norm)
|
||||
if b2:
|
||||
p2 = dict(b2["payload"])
|
||||
p2["title"] = basis
|
||||
await db.kanban_upsert_card(topic, "artefacts", basis_norm, b2["kind"], b2["stage"], p2)
|
||||
await db.kanban_delete_card(topic, "artefacts", alt_norm)
|
||||
p = dict(card["payload"])
|
||||
p["title"] = basis
|
||||
if p.get("mirrored_norm"):
|
||||
p["mirrored_norm"] = basis_norm
|
||||
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
|
||||
card["payload"] = p
|
||||
_rename_in_files(files, alt_norm, basis)
|
||||
by_norm.pop(alt_norm, None)
|
||||
by_norm[basis_norm] = card
|
||||
out.append(f"suffix: {alt} → {basis}")
|
||||
return out
|
||||
|
||||
|
||||
async def _pruefe_luecken(topic: str, report: dict, by_norm: dict) -> list[str]:
|
||||
"""Lücken-Urteil (2:1): offene luecken-Fundstellen und konzept_luecken-Namen dem
|
||||
Stichentscheid vorlegen — 2:1-„keine echte Lücke" wird als Freispruch persistiert,
|
||||
bestätigte bleiben als braucht_research sichtbar (Gate-Watch recherchiert, der Nutzer
|
||||
klickt „+ Recherche"). Das Inventar geht als Kontext in den {extra}-Slot, damit die
|
||||
Judges Abdeckung gegen den Bestand prüfen können."""
|
||||
lk, kl = qa.offene_luecken(report)
|
||||
if not lk and not kl:
|
||||
return []
|
||||
extra = ("\n\nEXISTING INVENTORY BLOCKS (judge coverage against these):\n"
|
||||
+ "\n".join(f"- {c['payload'].get('title', '')}" for c in by_norm.values()))
|
||||
frei: list[str] = []
|
||||
if lk:
|
||||
_v, f = await _mit_stichentscheid(
|
||||
"QA-Luecken", topic, "luecken", "sections",
|
||||
[f"[{x.get('datei')} #{x.get('abschnitt')}] {x.get('vorschau', '')}" for x in lk],
|
||||
"ja", kategorie="luecken", ids=[qa.luecken_key(x) for x in lk], extra=extra)
|
||||
frei += f
|
||||
if kl:
|
||||
_v, f = await _mit_stichentscheid(
|
||||
"QA-Konzept-Luecken", topic, "konzept-luecken", "results", list(kl),
|
||||
"ja", kategorie="konzept_luecken", ids=[_norm_title(n) for n in kl], extra=extra)
|
||||
frei += f
|
||||
return frei
|
||||
|
||||
|
||||
async def _belege_nachfassen(topic: str, report: dict, files: dict) -> list[str]:
|
||||
"""subs_ohne_beleg (mentions=0): Beleg-Nachfass je Sub mit Material-Auszügen.
|
||||
Judge+QA einig „nein" (kein Beleg) → Sub verwerfen (Artefakte/Fragen räumt das
|
||||
nachlaufende _raeume_waisen); 2:1-„ja" → mentions=1 nachtragen — Datenfix am
|
||||
gemessenen Datum selbst, kein Freispruch nötig, Detektor unverändert. Produktiv ist
|
||||
der Detektor derzeit inaktiv (alle Schreiber setzen mentions=1) — Pfad ist Robustheit.
|
||||
Ohne Quellordner kein Urteil möglich → Befund bleibt."""
|
||||
eintraege = (report.get("beleg") or {}).get("subs_ohne_beleg") or []
|
||||
folder = source_folder(topic) or material_folder(topic)
|
||||
if not eintraege or not folder:
|
||||
return []
|
||||
rows = {f"{r['block']} · {r['sub_title']}": r for r in await db.list_subblocks(topic)
|
||||
if r["status"] != "variant"}
|
||||
items = [(e, rows[e]) for e in eintraege if e in rows and not rows[e]["mentions"]]
|
||||
if not items:
|
||||
return []
|
||||
lines = []
|
||||
for e, r in items:
|
||||
ev = _evidence_pack(folder, None, [r["block"], r["sub_title"]], budget=EVIDENCE_PER_BLOCK)
|
||||
lines.append(f"{e}\n{ev or '(keine Treffer im Material)'}")
|
||||
v1 = await _judge("QA-Repair-Beleg", topic, "beleg", "blocks", lines)
|
||||
strittig = [i for i in range(1, len(items) + 1) if v1.get(i) == "ja"]
|
||||
v3 = (await _judge("QA-Repair-Beleg", topic, "beleg-st", "blocks",
|
||||
[lines[i - 1] for i in strittig])) if strittig else {}
|
||||
out: list[str] = []
|
||||
|
||||
async def _verwerfen(e: str, r: dict):
|
||||
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], status="discarded")
|
||||
_entferne_sub_in_files(files, r["block_norm"], r["sub_norm"])
|
||||
out.append(f"entfernt: {e[:60]}")
|
||||
|
||||
for i, (e, r) in enumerate(items, 1):
|
||||
if v1.get(i) == "nein": # QA (mentions=0) + Judge einig → weg
|
||||
await _verwerfen(e, r)
|
||||
elif v1.get(i) == "ja": # Dissens → Stichentscheid
|
||||
pos = strittig.index(i) + 1
|
||||
if v3.get(pos) == "ja":
|
||||
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], mentions=1)
|
||||
out.append(f"belegt: {e[:60]}")
|
||||
elif v3.get(pos) == "nein":
|
||||
await _verwerfen(e, r)
|
||||
return out
|
||||
|
||||
|
||||
async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
|
||||
"""Nur der norm-invariante Teil (`**`/Backticks); `(n)`-Suffix und leere Beschreibung
|
||||
ändern die Norm bzw. brauchen Inhalt — bleiben Befund."""
|
||||
ändern die Norm bzw. brauchen Inhalt — eigene Handler (_fix_suffixe/_fix_beschreibungen)."""
|
||||
fixed = []
|
||||
for h in report.get("hygiene", []):
|
||||
alt = h.get("titel", "")
|
||||
@@ -92,20 +287,12 @@ async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) ->
|
||||
return fixed
|
||||
|
||||
|
||||
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
|
||||
"""Nur QA-bestätigte Paare (llm=ja); eine Zweitmeinung, Merge nur bei erneut ja.
|
||||
Merge spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped."""
|
||||
paare = [p for p in report.get("dubletten", []) if p.get("llm") == "ja"
|
||||
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
|
||||
if not paare:
|
||||
return []
|
||||
v = await _judge("QA-Dubletten", topic, "dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in paare])
|
||||
merged = []
|
||||
for i, p in enumerate(paare, 1):
|
||||
async def _merge_paar(topic: str, p: dict, by_norm: dict, files: dict) -> str | None:
|
||||
"""Ein bestätigtes Dubletten-Paar mergen (spiegelt die dedup-Stage: Union ins
|
||||
Gewinner-Payload, Verlierer → grouped). → Journalzeile oder None."""
|
||||
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
|
||||
if v.get(i) != "ja" or not a or not b or a["card_id"] == b["card_id"]:
|
||||
continue
|
||||
if not a or not b or a["card_id"] == b["card_id"]:
|
||||
return None
|
||||
win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
|
||||
len(c["payload"].get("title") or "")), reverse=True)
|
||||
wp, lp = dict(win["payload"]), dict(lose["payload"])
|
||||
@@ -117,8 +304,51 @@ async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict)
|
||||
await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
|
||||
await _purge_block(topic, lp.get("title", ""), files)
|
||||
by_norm.pop(_norm_title(lp.get("title", "")), None)
|
||||
merged.append(f"{lp.get('title')} → {wp.get('title')}")
|
||||
return merged
|
||||
return f"{lp.get('title')} → {wp.get('title')}"
|
||||
|
||||
|
||||
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
|
||||
"""QA-bestätigte Paare (llm=ja): Zweitmeinung, Merge nur bei erneut ja; 2:1-„behalten"
|
||||
persistiert jetzt als Freispruch (fehlte — die Paare pendelten ewig im Verdacht).
|
||||
Widerlegte/unbeurteilte Paare (llm=nein/?) bekommen den Klärungskanal: Judge-„nein" →
|
||||
Freispruch (mit QA-nein 2:0; beim bloßen Verdacht reicht das klare Gegen-Urteil),
|
||||
Judge-„ja" → Stichentscheid (ja → Merge 2:1, nein → Freispruch 2:1, Ausfall → nichts)."""
|
||||
alle = [p for p in report.get("dubletten", []) if not p.get("freispruch")
|
||||
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
|
||||
ja = [p for p in alle if p.get("llm") == "ja"]
|
||||
offen = [p for p in alle if p.get("llm") != "ja"]
|
||||
merged: list[str] = []
|
||||
frei: list[str] = []
|
||||
if ja:
|
||||
v, f = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in ja], "ja",
|
||||
kategorie="dubletten",
|
||||
ids=[qa._paar_key(p["a"], p["b"]) for p in ja])
|
||||
frei += f
|
||||
for i, p in enumerate(ja, 1):
|
||||
if v.get(i) == "ja" and (zeile := await _merge_paar(topic, p, by_norm, files)):
|
||||
merged.append(zeile)
|
||||
if offen:
|
||||
ids = [qa._paar_key(p["a"], p["b"]) for p in offen]
|
||||
v1 = await _judge("QA-Dubletten", topic, "dubletten-v", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in offen])
|
||||
frei_keys = [ids[i - 1] for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
|
||||
frei += [f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80]
|
||||
for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
|
||||
strittig = [i for i in range(1, len(offen) + 1) if v1.get(i) == "ja"]
|
||||
if strittig:
|
||||
v3 = await _judge("QA-Dubletten", topic, "dubletten-v-st", "pairs",
|
||||
[f"A: {offen[i - 1]['a']}\nB: {offen[i - 1]['b']}" for i in strittig])
|
||||
for pos, i in enumerate(strittig, 1):
|
||||
if v3.get(pos) == "ja" and (zeile := await _merge_paar(topic, offen[i - 1], by_norm, files)):
|
||||
merged.append(zeile)
|
||||
elif v3.get(pos) == "nein":
|
||||
frei_keys.append(ids[i - 1])
|
||||
frei.append(f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80])
|
||||
if frei_keys:
|
||||
_speichere_freispruch(topic, "dubletten", frei_keys)
|
||||
log.info("[%s] Repair dubletten: %d Verdacht(e) freigesprochen", topic, len(frei_keys))
|
||||
return merged, frei
|
||||
|
||||
|
||||
_SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S)
|
||||
@@ -127,43 +357,16 @@ _SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S)
|
||||
def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]:
|
||||
"""Gewinner = mehr key_points im facts-Feld, dann längerer Titel (Muster Konsolidierung)."""
|
||||
def score(r):
|
||||
try:
|
||||
kp = len((json.loads(r.get("facts") or "{}")).get("key_points") or [])
|
||||
except ValueError:
|
||||
kp = 0
|
||||
kp = len(parse_facts(r.get("facts")).get("key_points") or [])
|
||||
return (kp, len(r.get("sub_title") or ""))
|
||||
return (a, b) if score(a) >= score(b) else (b, a)
|
||||
|
||||
|
||||
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[str]:
|
||||
"""QA-bestätigte Sub-Paare (llm=ja) nach Zweitmeinung falten: Verlierer → variant,
|
||||
seine Fragen/Artefakte wandern zum Gewinner (oder fallen weg, wenn er den Typ hat).
|
||||
Repair hatte dafür keinen Handler — die Paare überlebten jeden Repair-Zyklus."""
|
||||
rows = {(r["block_norm"], r["sub_norm"]): r for r in await db.list_subblocks(topic)
|
||||
if r["status"] == "consensus"}
|
||||
|
||||
def _row(eintrag: str):
|
||||
m = _SUB_PAAR.match(eintrag or "")
|
||||
return rows.get((_norm_title(m.group(1)), _norm_title(m.group(2)))) if m else None
|
||||
|
||||
paare = [(a, b) for p in report.get("sub_dubletten", []) if p.get("llm") == "ja"
|
||||
and (a := _row(p.get("a"))) and (b := _row(p.get("b")))
|
||||
and (a["block_norm"], a["sub_norm"]) != (b["block_norm"], b["sub_norm"])]
|
||||
if not paare:
|
||||
return []
|
||||
v = await _judge("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
|
||||
[f"A: [{a['block']}] {a['sub_title']}\nB: [{b['block']}] {b['sub_title']}"
|
||||
for a, b in paare])
|
||||
merged: list[str] = []
|
||||
gone: set[tuple] = set()
|
||||
for i, (a, b) in enumerate(paare, 1):
|
||||
win, lose = _sub_gewinner(a, b)
|
||||
wk, lk = (win["block_norm"], win["sub_norm"]), (lose["block_norm"], lose["sub_norm"])
|
||||
if v.get(i) != "ja" or wk in gone or lk in gone:
|
||||
continue
|
||||
async def falte_sub(topic: str, files: dict, win: dict, lose: dict) -> None:
|
||||
"""Verlierer-Sub falten: Status variant, Fragen/Artefakte zum Gewinner umhängen (oder
|
||||
löschen, wenn der Typ dort existiert), Sidecar-Dateien bereinigen. Gemeinsamer Kern
|
||||
von QA-Repair und Cross-Block-Dedup (Board 2, Run-Ende) — win/lose sind subblocks-Rows."""
|
||||
await db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant")
|
||||
gone.add(lk)
|
||||
# Fragen/Artefakte des Verlierers: umhängen, wenn der Gewinner den Typ nicht hat
|
||||
w_fragen = {r["sub_norm"] for r in await db.list_question_pattern(topic, win["block_norm"])}
|
||||
for r in await db.list_question_pattern(topic, lose["block_norm"]):
|
||||
if r["sub_norm"] != lose["sub_norm"]:
|
||||
@@ -182,8 +385,41 @@ async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[st
|
||||
r["data"], win["block"], win["sub_title"])
|
||||
await db.delete_artefakt_row(topic, lose["block_norm"], lose["sub_norm"], r["type"])
|
||||
_entferne_sub_in_files(files, lose["block_norm"], lose["sub_norm"])
|
||||
|
||||
|
||||
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> tuple[list[str], list[str]]:
|
||||
"""QA-bestätigte Sub-Paare (llm=ja) nach Zweitmeinung falten: Verlierer → variant,
|
||||
seine Fragen/Artefakte wandern zum Gewinner (oder fallen weg, wenn er den Typ hat).
|
||||
Repair hatte dafür keinen Handler — die Paare überlebten jeden Repair-Zyklus."""
|
||||
rows = {(r["block_norm"], r["sub_norm"]): r for r in await db.list_subblocks(topic)
|
||||
if r["status"] == "consensus"}
|
||||
|
||||
def _row(eintrag: str):
|
||||
m = _SUB_PAAR.match(eintrag or "")
|
||||
return rows.get((_norm_title(m.group(1)), _norm_title(m.group(2)))) if m else None
|
||||
|
||||
paare = [(a, b) for p in report.get("sub_dubletten", []) if p.get("llm") == "ja"
|
||||
and (a := _row(p.get("a"))) and (b := _row(p.get("b")))
|
||||
and (a["block_norm"], a["sub_norm"]) != (b["block_norm"], b["sub_norm"])]
|
||||
if not paare:
|
||||
return [], []
|
||||
v, frei = await _mit_stichentscheid(
|
||||
"QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
|
||||
[f"A: [{a['block']}] {a['sub_title']}\nB: [{b['block']}] {b['sub_title']}" for a, b in paare],
|
||||
"ja", kategorie="sub_dubletten",
|
||||
ids=[qa._paar_key(f"[{a['block']}] {a['sub_title']}", f"[{b['block']}] {b['sub_title']}")
|
||||
for a, b in paare])
|
||||
merged: list[str] = []
|
||||
gone: set[tuple] = set()
|
||||
for i, (a, b) in enumerate(paare, 1):
|
||||
win, lose = _sub_gewinner(a, b)
|
||||
wk, lk = (win["block_norm"], win["sub_norm"]), (lose["block_norm"], lose["sub_norm"])
|
||||
if v.get(i) != "ja" or wk in gone or lk in gone:
|
||||
continue
|
||||
await falte_sub(topic, files, win, lose)
|
||||
gone.add(lk)
|
||||
merged.append(f"{lose['sub_title'][:40]} → {win['sub_title'][:40]}")
|
||||
return merged
|
||||
return merged, frei
|
||||
|
||||
|
||||
def _entferne_sub_in_files(files: dict, bnorm: str, sub_norm: str) -> None:
|
||||
@@ -214,8 +450,9 @@ def _entferne_sub_in_files(files: dict, bnorm: str, sub_norm: str) -> None:
|
||||
atomic_write_json(files["artefakte"], neu, indent=1)
|
||||
|
||||
|
||||
async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
|
||||
async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
|
||||
out = []
|
||||
frei_alle: list[str] = []
|
||||
fremd = [t for t in report.get("fremd", []) if _norm_title(t) in by_norm]
|
||||
if fremd:
|
||||
folder = source_folder(topic)
|
||||
@@ -224,7 +461,10 @@ async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files:
|
||||
srcs = by_norm[_norm_title(t)]["payload"].get("sources") or None
|
||||
ev = _evidence_pack(folder, srcs, [t], budget=EVIDENCE_PER_BLOCK) if folder else ""
|
||||
lines.append(f"{t}\n{ev or '(keine Treffer im Material)'}")
|
||||
v = await _judge("QA-Repair-Beleg", topic, "fremd", "blocks", lines)
|
||||
v, frei = await _mit_stichentscheid("QA-Repair-Beleg", topic, "fremd", "blocks", lines,
|
||||
"nein", kategorie="fremd",
|
||||
ids=[_norm_title(t) for t in fremd])
|
||||
frei_alle += frei
|
||||
for i, t in enumerate(fremd, 1):
|
||||
if v.get(i) == "nein":
|
||||
await _reject(topic, t, by_norm, files, "qa-fremd")
|
||||
@@ -233,12 +473,15 @@ async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files:
|
||||
if unecht:
|
||||
lines = [f"{t} — {by_norm[_norm_title(t)]['payload'].get('description') or '(ohne Beschreibung)'}"
|
||||
for t in unecht]
|
||||
v = await _judge("QA-Bausteine", topic, "unecht", "blocks", lines)
|
||||
v, frei = await _mit_stichentscheid("QA-Bausteine", topic, "unecht", "blocks", lines,
|
||||
"nein", kategorie="unecht",
|
||||
ids=[_norm_title(t) for t in unecht])
|
||||
frei_alle += frei
|
||||
for i, t in enumerate(unecht, 1):
|
||||
if v.get(i) == "nein":
|
||||
await _reject(topic, t, by_norm, files, "qa-unecht")
|
||||
out.append(t)
|
||||
return out
|
||||
return out, frei_alle
|
||||
|
||||
|
||||
async def _raeume_waisen(topic: str) -> int:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
aiosqlite
|
||||
httpx
|
||||
playwright
|
||||
trafilatura
|
||||
pymupdf4llm
|
||||
|
||||
+115
-74
@@ -3,15 +3,16 @@ import json
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from agents import active_agents, provider_available
|
||||
from config import PROJECTS_DIR, UNI_DIR, PROVIDERS
|
||||
from agents import active_agents, agent_ebene, provider_available
|
||||
from config import DEFAULT_PROVIDER, PROJECTS_DIR, UNI_DIR, PROVIDERS
|
||||
from database import (
|
||||
create_guide, delete_guide, get_guide, list_guides,
|
||||
create_guide, delete_guide, get_guide, list_guides, update_guide,
|
||||
create_topic, list_topics as db_list_topics, delete_topic,
|
||||
list_block_progress, get_block_progress, set_open_question,
|
||||
set_block_score_and_streak,
|
||||
@@ -19,10 +20,11 @@ from database import (
|
||||
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
|
||||
get_sub_artefakte, kanban_reset, delete_guide_board,
|
||||
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
|
||||
list_runs, latest_board_runs, get_db,
|
||||
)
|
||||
from textkit import _norm_title
|
||||
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
|
||||
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
|
||||
from board_inventory import add_research_agent, board_snapshot, requeue_dead
|
||||
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup, leitner_step, PRACTICE_NEW_PER_SESSION
|
||||
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
|
||||
from pipeline import cancel_guide
|
||||
@@ -30,9 +32,9 @@ from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learns
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
|
||||
GuideCardResetRequest, GuideFormatRequest,
|
||||
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
|
||||
BlocksCreateRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
|
||||
GuideFormatRequest,
|
||||
GuideChatRequest, GuideChatResponse,
|
||||
ProviderInfo,
|
||||
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
||||
BlockChatRequest, BlockChatResponse,
|
||||
@@ -72,6 +74,20 @@ async def get_stats():
|
||||
return {"topics": len(topics), "formats": formats_stats(guides, levels)}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
await (await get_db()).execute("SELECT 1")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def get_runs(topic: str, limit: int = 10):
|
||||
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
|
||||
`latest` = jüngster Lauf MIT Daten je Ebene (inventory/artefacts/guide) — die
|
||||
Board-Kopfzeilen zeigen so auch nach „Nur Artefakte"-/Guide-Läufen ihre Zahlen."""
|
||||
return {"runs": await list_runs(topic, limit), "latest": await latest_board_runs(topic)}
|
||||
|
||||
|
||||
@router.get("/topics/progress")
|
||||
async def topic_progress(topic: str):
|
||||
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
|
||||
@@ -89,14 +105,28 @@ async def add_topic(req: TopicCreateRequest):
|
||||
|
||||
@router.delete("/topics")
|
||||
async def remove_topic(topic: str):
|
||||
guides = [g for g in await list_guides() if g["topic"] == topic]
|
||||
status = await blocks_status(topic)
|
||||
if status["generating"] or any(g["status"] == "generating" for g in guides):
|
||||
raise HTTPException(409, "Generierung läuft — erst abbrechen")
|
||||
await delete_topic(topic)
|
||||
await delete_block_data(topic)
|
||||
await delete_topic_pipeline(topic)
|
||||
await delete_source(topic) # topic config (DB) — removed together with the topic
|
||||
await delete_guide_content(topic)
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
# guides/Board/Kanban mitlöschen — GET /topics leitet Topics aus guides ab,
|
||||
# sonst taucht das gelöschte Topic sofort wieder auf
|
||||
for g in guides:
|
||||
await delete_guide(g["id"])
|
||||
await delete_guide_board(topic)
|
||||
await kanban_reset(topic)
|
||||
import qa
|
||||
# rmtree (potenziell große Topic-Ordner) in den Threadpool — der Event-Loop bedient
|
||||
# parallel laufende Flows/Polls, blockierendes Datei-I/O friert die alle ein
|
||||
def _wipe():
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
|
||||
await asyncio.to_thread(_wipe)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -134,17 +164,13 @@ async def create_blocks(req: BlocksCreateRequest):
|
||||
# Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
|
||||
if not qp.exists():
|
||||
type, location = req.source_type, req.source_location.strip()
|
||||
if type in ("projekt", "uni"):
|
||||
folder = safe_folder(location)
|
||||
if folder is None or not folder.is_dir():
|
||||
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
|
||||
elif type == "link":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
_validate_source(type, location)
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
|
||||
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider,
|
||||
research=req.research, qa_force=req.qa_force))
|
||||
research=req.research, qa_force=req.qa_force,
|
||||
auto_inventory=req.auto_inventory, auto_artefacts=req.auto_artefacts,
|
||||
auto_guide=req.auto_guide, only_artefacts=req.only_artefacts))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -156,7 +182,8 @@ async def get_blocks_board(topic: str):
|
||||
snap["generating"] = status["generating"]
|
||||
snap["progress"] = status["progress"]
|
||||
snap["error"] = status["error"]
|
||||
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
||||
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"),
|
||||
"runtime": a["runtime"], "ebene": agent_ebene(a["key"])}
|
||||
for a in active_agents(f"blocks-{topic}-")]
|
||||
return snap
|
||||
|
||||
@@ -175,12 +202,14 @@ async def run_qa_route(req: QaRunRequest):
|
||||
report = await qa.qa_report(req.topic, llm=req.llm)
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="keine fertigen Bausteine")
|
||||
await asyncio.to_thread(qa._write_report, report)
|
||||
await qa.write_report(report)
|
||||
note_guide = None
|
||||
# ebene-scoped: ein Inventar-/Artefakt-QA-Klick misst NICHT den (teuren) Guide mit.
|
||||
guide_scope = req.ebene in (None, "guide")
|
||||
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
|
||||
import guide_qa
|
||||
from database import list_guide_cards
|
||||
if any(c["stage"] == "done" and (c.get("md") or "").strip()
|
||||
if guide_scope and any(c["stage"] == "done" and (c.get("md") or "").strip()
|
||||
for c in await list_guide_cards(req.topic)):
|
||||
grep = await guide_qa.guide_qa_report(req.topic, llm=req.llm)
|
||||
if grep:
|
||||
@@ -208,7 +237,7 @@ async def run_repair_route(req: RepairRequest):
|
||||
_repair_laeuft.add(topic)
|
||||
try:
|
||||
import repair
|
||||
res = await repair.repair_befunde(topic)
|
||||
res = await repair.repair_befunde(topic, req.ebene)
|
||||
if "fehler" in res:
|
||||
raise HTTPException(status_code=404, detail=res["fehler"])
|
||||
return res
|
||||
@@ -217,7 +246,7 @@ async def run_repair_route(req: RepairRequest):
|
||||
|
||||
|
||||
@router.post("/blocks/research")
|
||||
async def add_blocks_research(topic: str, provider: str = "claude"):
|
||||
async def add_blocks_research(topic: str, provider: str = DEFAULT_PROVIDER):
|
||||
"""Attach one more research agent — to the live flow, or attach-or-start."""
|
||||
if add_research_agent(topic):
|
||||
return {"ok": True, "attached": True}
|
||||
@@ -227,43 +256,11 @@ async def add_blocks_research(topic: str, provider: str = "claude"):
|
||||
return {"ok": True, "attached": False}
|
||||
|
||||
|
||||
@router.post("/blocks/reset-stage")
|
||||
async def reset_blocks_stage(req: BlocksResetStageRequest):
|
||||
"""Reset cards from a column onward back to that column (no regeneration)."""
|
||||
topic = req.topic.strip()
|
||||
if (await blocks_status(topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"} # don't interfere with a running generation
|
||||
moved = await reset_board_from_stage(topic, req.board, req.stage, _blocks_files(topic))
|
||||
return {"ok": True, "moved": moved}
|
||||
|
||||
|
||||
@router.post("/blocks/requeue-dead")
|
||||
async def requeue_blocks_dead(topic: str):
|
||||
return {"ok": True, "requeued": await requeue_dead(topic)}
|
||||
|
||||
|
||||
@router.post("/blocks/card-restart")
|
||||
async def blocks_card_restart(req: BlocksCardRestartRequest):
|
||||
if (await blocks_status(req.topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"}
|
||||
if not await restart_artefact_card(req.topic, req.card_id):
|
||||
raise HTTPException(404, "Card not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/guides/board/card-reset")
|
||||
async def guide_card_reset(req: GuideCardResetRequest):
|
||||
running = [g for g in await list_guides()
|
||||
if g["topic"] == req.topic and g["format"] == req.format
|
||||
and g["status"] in ("queued", "generating")]
|
||||
if running:
|
||||
return {"ok": True, "status": "generating"}
|
||||
from guide_board import reset_card
|
||||
if not await reset_card(req.topic, req.format, req.block_norm, req.ab_stage):
|
||||
raise HTTPException(404, "Card not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/blocks/cancel")
|
||||
async def cancel_blocks_route(topic: str):
|
||||
if not cancel_blocks(topic):
|
||||
@@ -279,6 +276,18 @@ async def remove_blocks(topic: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/blocks/artefacts")
|
||||
async def remove_artefacts(topic: str):
|
||||
"""Artefakte-Ebene leeren: Board-2-Daten (Subs/Facts/Flashcards) weg, Inventar bleibt.
|
||||
Karten gehen zurück auf `generate` — via „Artefakte generieren" (Fortsetzen) neu baubar."""
|
||||
topic = topic.strip()
|
||||
if (await blocks_status(topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"}
|
||||
from board_inventory import reset_board_from_stage
|
||||
moved = await reset_board_from_stage(topic, "artefacts", "generate", _blocks_files(topic))
|
||||
return {"ok": True, "moved": moved}
|
||||
|
||||
|
||||
@router.delete("/blocks/progress")
|
||||
async def reset_block_progress(topic: str, block: str):
|
||||
"""Reset learning progress of ONE block to zero (score/streak/flags/open question)."""
|
||||
@@ -287,14 +296,17 @@ async def reset_block_progress(topic: str, block: str):
|
||||
|
||||
|
||||
def _validate_source(type: str, location: str) -> None:
|
||||
"""Check source input (same rules as on creation)."""
|
||||
"""Check source input (shared by create + edit)."""
|
||||
if type in ("projekt", "uni"):
|
||||
folder = safe_folder(location)
|
||||
if folder is None or not folder.is_dir():
|
||||
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
|
||||
elif type == "link":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
urls = [ln.strip() for ln in location.splitlines() if ln.strip()]
|
||||
if not urls:
|
||||
raise HTTPException(400, "Enter at least one link.")
|
||||
if not all(u.lower().startswith(("http://", "https://")) for u in urls):
|
||||
raise HTTPException(400, "Each link must start with http:// or https:// (one per line).")
|
||||
|
||||
|
||||
@router.get("/blocks/source", response_model=BlocksSourceResponse)
|
||||
@@ -325,9 +337,10 @@ async def blocks_completeness(topic: str):
|
||||
counts = await kanban_stage_counts(topic)
|
||||
inv = counts.get("inventory", {})
|
||||
blocks = await list_blocks(topic, status="consensus")
|
||||
subs = 0
|
||||
for b in blocks:
|
||||
subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus")
|
||||
# ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen
|
||||
consensus_blocks = {b["title_norm"] for b in blocks}
|
||||
subs = sum(1 for s in await list_subblocks(topic)
|
||||
if s["status"] == "consensus" and s["block_norm"] in consensus_blocks)
|
||||
ziele = await list_lernziele(topic)
|
||||
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
|
||||
degradiert = ueberstimmt = 0
|
||||
@@ -466,15 +479,26 @@ async def block_chat_route(req: BlockChatRequest):
|
||||
|
||||
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
|
||||
# overwrite the absolute score with a stale base (race).
|
||||
_check_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {}
|
||||
|
||||
|
||||
def _check_lock(topic: str, block: str) -> asyncio.Lock:
|
||||
@asynccontextmanager
|
||||
async def _check_lock(topic: str, block: str):
|
||||
"""Per-(topic,block)-Lock mit Refcount, das den Eintrag nach dem letzten Nutzer
|
||||
entfernt — die Map wuchs sonst unbegrenzt (ein Lock pro je geprüftem Block)."""
|
||||
key = (topic, block)
|
||||
lock = _check_locks.get(key)
|
||||
if lock is None:
|
||||
lock = _check_locks[key] = asyncio.Lock()
|
||||
return lock
|
||||
entry = _check_locks.get(key)
|
||||
if entry is None:
|
||||
entry = _check_locks[key] = (asyncio.Lock(), [0])
|
||||
lock, ref = entry
|
||||
ref[0] += 1
|
||||
try:
|
||||
async with lock:
|
||||
yield
|
||||
finally:
|
||||
ref[0] -= 1
|
||||
if ref[0] == 0 and _check_locks.get(key) is entry:
|
||||
del _check_locks[key]
|
||||
|
||||
|
||||
def _basis(state: dict, question: str) -> tuple[int, bool]:
|
||||
@@ -639,7 +663,13 @@ async def block_exam_route(req: BlockExamRequest):
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
guides, levels = await load_learnstate()
|
||||
reason = guide_lock(req.topic.strip(), req.format, guides, levels)
|
||||
from database import list_blocks
|
||||
from paths import blocks_path
|
||||
topic = req.topic.strip()
|
||||
# DB is authoritative (generate_guide builds from it); blocks.md is only a legacy fallback
|
||||
# and is absent when the inventory paused at the QA gate — don't lock the guide on the file.
|
||||
has_blocks = bool(await list_blocks(topic, status="consensus")) or blocks_path(topic).exists()
|
||||
reason = guide_lock(topic, req.format, guides, levels, has_blocks)
|
||||
if reason:
|
||||
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
|
||||
await create_topic(req.topic.strip())
|
||||
@@ -655,7 +685,7 @@ async def create(req: GuideCreateRequest):
|
||||
"updated_at": now,
|
||||
}
|
||||
await create_guide(guide)
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider, ab_step=req.ab_step))
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider))
|
||||
return guide
|
||||
|
||||
|
||||
@@ -681,17 +711,25 @@ async def get_guide_board(topic: str, format: str = "Guide"):
|
||||
return snap
|
||||
|
||||
|
||||
@router.post("/guides/board/reset")
|
||||
async def reset_guide_board(req: GuideBoardResetRequest):
|
||||
"""Reset cards from a stage onward — without generation (pendant to blocks reset-stage)."""
|
||||
@router.post("/guides/board/repair")
|
||||
async def repair_guide_board(req: GuideFormatRequest):
|
||||
"""Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`,
|
||||
dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
|
||||
import guide_board
|
||||
topic = req.topic.strip()
|
||||
guide = next((g for g in await list_guides()
|
||||
if g["topic"] == topic and g["format"] == req.format), None)
|
||||
if guide and guide["status"] in ("queued", "generating"):
|
||||
return {"ok": True, "status": "generating"}
|
||||
moved = await guide_board.reset_from_stage(topic, req.format, req.ab_stage)
|
||||
return {"ok": True, "moved": moved}
|
||||
if guide is None:
|
||||
raise HTTPException(404, "Kein Guide für dieses Topic/Format")
|
||||
if guide["status"] in ("queued", "generating"):
|
||||
return {"ok": True, "status": "generating", "betroffen": []}
|
||||
betroffen = await guide_board.repair_karten(topic, req.format)
|
||||
if betroffen:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide["id"], status="queued", progress="Repair…", updated_at=now)
|
||||
asyncio.create_task(generate_guide(guide["id"], topic, req.format,
|
||||
guide.get("instructions") or "", DEFAULT_PROVIDER))
|
||||
return {"ok": True, "betroffen": betroffen}
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}/content")
|
||||
@@ -778,6 +816,9 @@ async def remove_guide_format(req: GuideFormatRequest):
|
||||
for p in guide_slot_files(content):
|
||||
p.unlink(missing_ok=True)
|
||||
content.unlink(missing_ok=True)
|
||||
import qa # Guide-QA-Report mitlöschen — sonst zeigt board_snapshot die alte Note weiter
|
||||
for p in qa.report_paths(req.topic, guide=True):
|
||||
p.unlink(missing_ok=True)
|
||||
return {"ok": True, "removed": len(doomed)}
|
||||
|
||||
|
||||
|
||||
+22
-7
@@ -14,7 +14,7 @@ import json
|
||||
from database import list_block_scores_all, subs_per_level_all, list_guides
|
||||
from guide import guide_slot_files
|
||||
from learning import cap_final, LEVELS, _threshold
|
||||
from paths import blocks_path, guide_content_path
|
||||
from paths import guide_content_path
|
||||
from textkit import _norm_title
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
@@ -50,14 +50,26 @@ async def load_learnstate() -> tuple[list[dict], dict[str, dict[str, set[str]]]]
|
||||
return await list_guides(), levels
|
||||
|
||||
|
||||
_content_cache: dict[str, tuple[float, dict | None]] = {}
|
||||
|
||||
|
||||
def _content_json(topic: str, fmt: str) -> dict | None:
|
||||
"""Guide-Content-JSON (kann MB groß sein), mtime-gecacht — /stats und /topics/progress
|
||||
lasen die Datei bei JEDEM Frontend-Poll neu und synchron im Event-Loop."""
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
_content_cache.pop(str(path), None)
|
||||
return None
|
||||
cached = _content_cache.get(str(path))
|
||||
if cached is None or cached[0] != mtime:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
data = None
|
||||
_content_cache[str(path)] = (mtime, data)
|
||||
return _content_cache[str(path)][1]
|
||||
|
||||
|
||||
|
||||
@@ -116,13 +128,16 @@ def formats_stats(guides: list[dict], levels: dict[str, dict[str, set[str]]]) ->
|
||||
return formats
|
||||
|
||||
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]],
|
||||
has_blocks: bool) -> str | None:
|
||||
"""Reason why a fresh start for topic+format is locked — None = allowed.
|
||||
|
||||
Exactly the rules from POST /guides: blocks required, no duplicate start,
|
||||
learning debt only for genuine new creations (resume/regenerate are free).
|
||||
`has_blocks` = DB consensus blocks OR the legacy blocks.md exist — checked by the
|
||||
caller, because generate_guide builds from the DB (a paused inventory writes no file).
|
||||
"""
|
||||
if not blocks_path(topic).exists():
|
||||
if not has_blocks:
|
||||
return "Create blocks first"
|
||||
for g in guides:
|
||||
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Direkter Text-API-Pfad (MiniMax) + RAM-Gate für opencode-Spawns."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import agents
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
|
||||
# ── Routing: wann läuft ein Call über die API statt über opencode? ───────────────────
|
||||
|
||||
def test_use_text_api_routing(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.delenv("CREATOR_TEXT_API", raising=False)
|
||||
api = agents._use_text_api
|
||||
assert api("minimax", "minimax/MiniMax-M3", "none", None) is True
|
||||
assert api("minimax", "minimax-kalt/MiniMax-M2.7-highspeed", "none", None) is True
|
||||
# Tools, Streaming, andere Provider/Modelle → Prozess-Pfad
|
||||
assert api("minimax", "minimax/MiniMax-M3", "files", None) is False
|
||||
assert api("minimax", "minimax/MiniMax-M3", "none", lambda s: None) is False
|
||||
assert api("claude", "claude-sonnet-4-6", "none", None) is False
|
||||
assert api("lokal", "ollama/qwen3.5:9b", "none", None) is False
|
||||
# Kill-Switch und fehlender Key → Fallback
|
||||
monkeypatch.setenv("CREATOR_TEXT_API", "0")
|
||||
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
|
||||
monkeypatch.delenv("CREATOR_TEXT_API")
|
||||
monkeypatch.delenv("MINIMAX_API_KEY")
|
||||
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
|
||||
|
||||
|
||||
async def test_run_agent_dispatches_to_api(monkeypatch):
|
||||
"""none+minimax → API-Runner; opencode wird nicht angefasst (auch kein which-Check)."""
|
||||
called = {}
|
||||
|
||||
async def fake_api(agent_key, prompt, timeout, model, label=""):
|
||||
called["api"] = (agent_key, model)
|
||||
return 0, "out", "", {"input": 1, "output": 1, "reasoning": 0, "cache_read": 0, "cache_write": 0}
|
||||
|
||||
async def fail_oc(*a, **kw):
|
||||
raise AssertionError("opencode-Pfad darf nicht laufen")
|
||||
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.setattr(agents, "_run_text_api", fake_api)
|
||||
monkeypatch.setattr(agents, "_run_opencode", fail_oc)
|
||||
monkeypatch.setattr(agents.shutil, "which", lambda c: None) # API-Pfad braucht kein Binary
|
||||
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
|
||||
rc, out, err = await agents.run_agent("blocks-t-a", "p", 5, provider="minimax", role="judge")
|
||||
assert (rc, out) == (0, "out") and called["api"][1] == "minimax/MiniMax-M3"
|
||||
|
||||
|
||||
async def test_run_agent_kill_switch_uses_opencode(monkeypatch):
|
||||
called = {}
|
||||
|
||||
async def fake_oc(agent_key, prompt, timeout, provider, model, capabilities, on_line=None, label=""):
|
||||
called["oc"] = agent_key
|
||||
return 0, "out", ""
|
||||
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.setenv("CREATOR_TEXT_API", "0")
|
||||
monkeypatch.setattr(agents, "_run_opencode", fake_oc)
|
||||
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
|
||||
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
|
||||
rc, *_ = await agents.run_agent("blocks-t-b", "p", 5, provider="minimax")
|
||||
assert rc == 0 and called["oc"] == "blocks-t-b"
|
||||
|
||||
|
||||
async def test_run_agent_api_tokens_in_event_meta(monkeypatch):
|
||||
"""API-Tokens landen im Event-Meta; die OpenCode-Session-DB wird NICHT konsultiert."""
|
||||
recorded = []
|
||||
|
||||
async def sink(**kw):
|
||||
recorded.append(kw)
|
||||
|
||||
async def fake_api(agent_key, prompt, timeout, model, label=""):
|
||||
return 0, "out", "", {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
|
||||
|
||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
monkeypatch.setattr(agents, "on_event", sink)
|
||||
monkeypatch.setattr(agents, "_run_text_api", fake_api)
|
||||
monkeypatch.setattr(agents, "_session_tokens", lambda k: pytest.fail("Session-DB-Lookup im API-Pfad"))
|
||||
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
|
||||
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
|
||||
rc, *_ = await agents.run_agent("blocks-t-tok", "p", 5, provider="minimax", scope=TOPIC)
|
||||
assert rc == 0
|
||||
assert recorded and recorded[0]["meta"]["tokens"] == {
|
||||
"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
|
||||
|
||||
|
||||
# ── API-Runner: Request-Bau, Antwort-Extraktion, Fehlerfälle ─────────────────────────
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code=200, data=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._data = data or {}
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def _fake_client(monkeypatch, seen, resp=None, exc=None):
|
||||
class _Client:
|
||||
def __init__(self, **kw):
|
||||
seen["client_kw"] = kw
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, headers=None):
|
||||
seen.update(url=url, body=json, headers=headers)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(agents.httpx, "AsyncClient", _Client)
|
||||
|
||||
|
||||
async def test_api_request_and_response(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "geheim")
|
||||
seen = {}
|
||||
resp = _FakeResp(data={
|
||||
"content": [{"type": "thinking", "thinking": "hm"},
|
||||
{"type": "text", "text": "A"}, {"type": "text", "text": "B"}],
|
||||
"usage": {"input_tokens": 5, "output_tokens": 2,
|
||||
"cache_read_input_tokens": 100, "cache_creation_input_tokens": 1},
|
||||
"stop_reason": "end_turn"})
|
||||
_fake_client(monkeypatch, seen, resp=resp)
|
||||
rc, out, err, tokens = await agents._run_text_api("k", "PROMPT", 5, "minimax-kalt/MiniMax-M3")
|
||||
assert (rc, out, err) == (0, "AB", "") # Thinking-Block übersprungen
|
||||
assert tokens == {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 1}
|
||||
assert seen["url"] == agents._API_URL
|
||||
assert seen["headers"]["x-api-key"] == "geheim"
|
||||
assert seen["headers"]["anthropic-version"] == agents._API_VERSION
|
||||
b = seen["body"]
|
||||
assert b["model"] == "MiniMax-M3" and b["max_tokens"] == agents._API_MAX_TOKENS
|
||||
assert b["messages"] == [{"role": "user", "content": "PROMPT"}]
|
||||
assert b["temperature"] == 0.2 and b["thinking"] == {"type": "disabled"} # kalt-Route
|
||||
|
||||
|
||||
async def test_api_model_options_per_route(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
seen = {}
|
||||
resp = _FakeResp(data={"content": [{"type": "text", "text": "x"}], "usage": {}})
|
||||
_fake_client(monkeypatch, seen, resp=resp)
|
||||
await agents._run_text_api("k", "p", 5, "minimax-kalt/MiniMax-M2.7-highspeed")
|
||||
assert seen["body"]["temperature"] == 0.3 and "thinking" not in seen["body"]
|
||||
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3") # nativ: Endpunkt-Defaults
|
||||
assert "temperature" not in seen["body"] and "thinking" not in seen["body"]
|
||||
|
||||
|
||||
async def test_api_errors(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
seen = {}
|
||||
_fake_client(monkeypatch, seen, resp=_FakeResp(status_code=500, text="kaputt"))
|
||||
rc, out, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
assert rc == 1 and out == "" and "HTTP 500" in err and tokens is None
|
||||
|
||||
_fake_client(monkeypatch, seen, exc=httpx.ConnectError("down"))
|
||||
rc, _, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
assert rc == 1 and "ConnectError" in err
|
||||
|
||||
_fake_client(monkeypatch, seen, exc=httpx.ReadTimeout("langsam"))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
|
||||
# leere Antwort (nur Thinking) → rc 1, Tokens bleiben sichtbar
|
||||
resp = _FakeResp(data={"content": [{"type": "thinking", "thinking": "…"}],
|
||||
"usage": {"input_tokens": 3}, "stop_reason": "end_turn"})
|
||||
_fake_client(monkeypatch, seen, resp=resp)
|
||||
rc, _, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
assert rc == 1 and "empty response" in err and tokens["input"] == 3
|
||||
|
||||
|
||||
async def test_api_truncation_flagged(monkeypatch):
|
||||
monkeypatch.setenv("MINIMAX_API_KEY", "k")
|
||||
seen = {}
|
||||
resp = _FakeResp(data={"content": [{"type": "text", "text": "halb"}],
|
||||
"usage": {}, "stop_reason": "max_tokens"})
|
||||
_fake_client(monkeypatch, seen, resp=resp)
|
||||
rc, out, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
|
||||
assert rc == 0 and out == "halb" and "max_tokens" in err
|
||||
|
||||
|
||||
# ── RAM-Gate ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def ram_gate(monkeypatch):
|
||||
monkeypatch.setattr(agents, "RAM_MIN_FREE_PCT", 20)
|
||||
monkeypatch.setattr(agents, "_RAM_POLL_S", 0.01)
|
||||
monkeypatch.setattr(agents, "_cli_recent_starts", [])
|
||||
return monkeypatch
|
||||
|
||||
|
||||
_EST = agents._RAM_PER_PROC_KB # default batch-agent estimate
|
||||
|
||||
|
||||
async def test_ram_gate_admits_with_free_ram(ram_gate):
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (4_000_000, 8_000_000)) # 50 % frei
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
assert len(agents._cli_recent_starts) == 1 # Commit registriert
|
||||
|
||||
|
||||
async def test_ram_gate_waits_when_low(ram_gate):
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000)) # 10 % frei
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("k", _EST), 0.1)
|
||||
# RAM wird frei → Gate lässt nach ≥1 Poll durch
|
||||
vals = iter([(800_000, 8_000_000)])
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: next(vals, (4_000_000, 8_000_000)))
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
|
||||
|
||||
async def test_ram_gate_floor_and_fail_open(ram_gate):
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (100_000, 8_000_000)) # fast nichts frei
|
||||
ram_gate.setattr(agents, "_cli_running", 1) # unter Floor
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: None) # kein /proc/meminfo
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
ram_gate.setattr(agents, "RAM_MIN_FREE_PCT", 0) # Gate aus
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: pytest.fail("Gate aus liest kein meminfo"))
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
|
||||
|
||||
async def test_ram_gate_commit_accounting(ram_gate):
|
||||
"""Knapp über der Schwelle, aber 2 frische Zulassungen → deren geschätzter RSS zählt.
|
||||
Ein `full`-Spawn wiegt mehr (MCP-Server) und drückt die verfügbare RAM stärker."""
|
||||
import time as _time
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (1_700_000, 8_000_000)) # 21 % frei
|
||||
agents._cli_recent_starts.extend([(_time.monotonic(), _EST), (_time.monotonic(), _EST)])
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("k", _EST), 0.1)
|
||||
|
||||
|
||||
async def test_ram_gate_full_weighs_more(ram_gate):
|
||||
"""Ein frischer Vorgänger belegt Commit für das Fenster. Ein leichter (350 MB) lässt den
|
||||
nächsten noch durch; ein `full` (1250 MB, MCP-Server) reißt die Schwelle → nächster wartet."""
|
||||
import time as _time
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (2_000_000, 8_000_000)) # 25 % frei
|
||||
agents._cli_recent_starts[:] = [(_time.monotonic(), _EST)] # leichter Vorgänger
|
||||
assert await agents._ram_gate("nach-leicht", _EST) is True
|
||||
agents._cli_recent_starts[:] = [(_time.monotonic(), agents._RAM_PER_PROC_FULL_KB)] # full-Vorgänger
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("nach-full", _EST), 0.1)
|
||||
|
||||
|
||||
async def test_ram_gate_cancelled_scope(ram_gate):
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000))
|
||||
agents.cancel_scope("blocks-cxl-")
|
||||
try:
|
||||
assert await agents._ram_gate("blocks-cxl-x", _EST) is False
|
||||
finally:
|
||||
agents.clear_scope("blocks-cxl-")
|
||||
|
||||
|
||||
def test_meminfo_reads_proc():
|
||||
mem = agents._meminfo()
|
||||
assert mem is not None and 0 < mem[0] <= mem[1] # Linux-Testumgebung
|
||||
|
||||
|
||||
def test_agent_ebene_klassifiziert_board():
|
||||
"""Board-2-Marker (art-/sb-/outline) → artefacts; alles andere → inventory."""
|
||||
T = "shopware"
|
||||
for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y",
|
||||
f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"):
|
||||
assert agents.agent_ebene(key) == "inventory", key
|
||||
for key in (f"blocks-{T}-alpha-sb-enrich-h", f"blocks-{T}-alpha-sb-verify-h-j1",
|
||||
f"blocks-{T}-alpha-art-gen-h-t1", f"blocks-{T}-alpha-art-check-h",
|
||||
f"blocks-{T}-outline-judge"):
|
||||
assert agents.agent_ebene(key) == "artefacts", key
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand (unbewegt), Limit."""
|
||||
import auto_loop
|
||||
|
||||
|
||||
async def test_fertig_ohne_repair():
|
||||
async def rep():
|
||||
raise AssertionError("darf bei 100 % nie reparieren")
|
||||
|
||||
r = await auto_loop.auto_repair_loop("x", 10.0, rep)
|
||||
assert r == {"ebene": "x", "note": 10.0, "runden": 0, "grund": "fertig"}
|
||||
|
||||
|
||||
async def test_verbessert_bis_voll():
|
||||
noten = iter([9.0, 10.0])
|
||||
|
||||
async def rep():
|
||||
return next(noten), True
|
||||
|
||||
r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0
|
||||
assert r["grund"] == "fertig" and r["runden"] == 2
|
||||
|
||||
|
||||
async def test_stillstand():
|
||||
async def rep():
|
||||
return 8.0, False # nichts getan, Note steht
|
||||
|
||||
r = await auto_loop.auto_repair_loop("art", 8.0, rep)
|
||||
assert r["grund"] == "stillstand" and r["runden"] == 1
|
||||
assert r["note"] == 8.0
|
||||
|
||||
|
||||
async def test_bewegt_ohne_notengewinn_laeuft_weiter():
|
||||
"""Eine bewegte Runde (z. B. Lücken-Research senkt die Note transient) ist KEIN
|
||||
Stillstand — erst unbewegt + keine Verbesserung stoppt; max_iter begrenzt Livelocks."""
|
||||
laeufe = iter([(7.5, True), (8.0, True), (8.0, False)])
|
||||
|
||||
async def rep():
|
||||
return next(laeufe)
|
||||
|
||||
r = await auto_loop.auto_repair_loop("inv", 8.0, rep)
|
||||
assert r["grund"] == "stillstand" and r["runden"] == 3
|
||||
|
||||
|
||||
async def test_limit():
|
||||
stand = {"n": 1.0}
|
||||
|
||||
async def rep():
|
||||
stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0
|
||||
return stand["n"], True
|
||||
|
||||
r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10)
|
||||
assert r["grund"] == "limit" and r["runden"] == 10
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Verschmolzene Board-2-Calls (block_calls.py): Generate-Konsens, Verify-Faltung mit
|
||||
Fix-Tail, Artefakte in einem Durchgang — Agenten gefaked, gegen Test-DB."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import block_calls as bc
|
||||
import blocks as blx
|
||||
import board_artefacts as ba
|
||||
from pipeline import FAILED, OK, GenContext
|
||||
from textkit import _norm_title
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def _sub(title, level="beginner", relevance="relevant", kp=None, cf=None):
|
||||
return {"title": title, "level": level, "relevance": relevance,
|
||||
"key_points": [f"kp {title}"] if kp is None else kp,
|
||||
"prerequisites": "", "hurdles": "", "cited_facts": cf or [], "example_idea": ""}
|
||||
|
||||
|
||||
# ── Schemas ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gen_schema_normalisiert():
|
||||
"""Gültige Einträge werden normalisiert; ungültiges level/relevance fällt auf ""
|
||||
(Stimme entfällt, der Sub bleibt); Einträge ohne Titel fliegen."""
|
||||
out = bc._gen_schema({"subs": [
|
||||
{"title": " **A** ", "level": "Beginner", "relevance": "RELEVANT",
|
||||
"key_points": ["k", ""], "cited_facts": [{"text": "t", "source": " s "},
|
||||
{"text": ""}, "quatsch"]},
|
||||
{"title": "B", "level": "profi", "relevance": "mittel"},
|
||||
{"title": " "},
|
||||
]})
|
||||
assert [e["title"] for e in out] == ["A", "B"]
|
||||
assert out[0]["level"] == "beginner" and out[0]["relevance"] == "relevant"
|
||||
assert out[0]["key_points"] == ["k"]
|
||||
assert out[0]["cited_facts"] == [{"text": "t", "source": "s"}]
|
||||
assert out[1]["level"] == "" and out[1]["relevance"] == ""
|
||||
|
||||
|
||||
def test_gen_schema_kaputt_ist_none():
|
||||
assert bc._gen_schema(None) is None
|
||||
assert bc._gen_schema({"subs": "x"}) is None
|
||||
assert bc._gen_schema({"subs": []}) is None
|
||||
assert bc._gen_schema({"subs": [{"level": "beginner"}]}) is None # nur titellose Einträge
|
||||
|
||||
|
||||
def test_verify_schema_pflichtkeys_und_leeres_verdikt():
|
||||
"""Mindestens EIN Pflicht-Key muss da sein; leere Listen heißen „alles ok"."""
|
||||
assert bc._verify_schema({}, 3) is None
|
||||
assert bc._verify_schema({"irgendwas": 1}, 3) is None
|
||||
v = bc._verify_schema({"gruppen": []}, 3)
|
||||
assert v["gruppen"] == [] and v["fremd"] == set() and v["luecken"] == []
|
||||
assert v["uebernehmen"] == {} and v["facts_probleme"] == [] and v["levels"] == {}
|
||||
|
||||
|
||||
def test_verify_schema_grenzen_und_normalisierung():
|
||||
"""ids außerhalb 1..n und bools fallen raus; Ein-Element-Gruppen zählen nicht;
|
||||
uebernehmen/levels werden casefolded bzw. enum-geprüft."""
|
||||
v = bc._verify_schema({
|
||||
"gruppen": [{"haupt": 2, "weitere": [1, 9, True]}, {"haupt": 3, "weitere": []}],
|
||||
"kataloge": [{"titel": " K ", "mitglieder": [1, 2]}, {"titel": "", "mitglieder": [1, 2]}],
|
||||
"fremd": [True, 1, "2", 9],
|
||||
"luecken": [" x ", "", 7],
|
||||
"uebernehmen": {"3": " JA ", "9": "ja"},
|
||||
"facts_probleme": [{"nr": 2, "discard": 1, "hinweis": " h "}, {"nr": 9}, "quatsch"],
|
||||
"levels": {"1": "expert", "2": "quatsch"},
|
||||
"relevanz": {"1": "peripheral"},
|
||||
}, 3)
|
||||
assert v["gruppen"] == [{"haupt": 2, "ids": [1, 2]}]
|
||||
assert v["kataloge"] == [{"titel": "K", "ids": [1, 2]}]
|
||||
assert v["fremd"] == {1, 2}
|
||||
assert v["luecken"] == ["x"]
|
||||
assert v["uebernehmen"] == {3: "ja"}
|
||||
assert v["facts_probleme"] == [{"nr": 2, "discard": True, "hinweis": "h"}]
|
||||
assert v["levels"] == {1: "expert"} and v["relevanz"] == {1: "peripheral"}
|
||||
|
||||
|
||||
def test_art_gen_schema_pattern_ist_pflicht():
|
||||
"""Ohne verwertbares pattern kein Verdikt (Leitner hängt an den Fragen);
|
||||
cards/examples sind best-effort und werden einzeln validiert."""
|
||||
assert bc._art_gen_schema({"cards": [], "examples": []}) is None
|
||||
assert bc._art_gen_schema("x") is None
|
||||
out = bc._art_gen_schema({
|
||||
"pattern": [{"block": "B", "subblock": "S", "question": "F?"},
|
||||
{"block": "B", "subblock": "", "question": "F?"}],
|
||||
"cards": [{"block": "B", "subblock": "S", "question": "F?", "answer": "A"},
|
||||
{"block": "B", "subblock": "S", "question": "F?"}],
|
||||
"examples": [{"block": "B", "subblock": "S", "problem": "P", "steps": ["s1", ""], "result": ""},
|
||||
{"block": "B", "subblock": "S", "problem": "P", "steps": []}],
|
||||
})
|
||||
assert len(out["pattern"]) == 1 and len(out["cards"]) == 1
|
||||
assert out["examples"] == [{"block": "B", "subblock": "S", "problem": "P",
|
||||
"steps": ["s1"], "result": ""}]
|
||||
|
||||
|
||||
def test_art_check_schema_varianten():
|
||||
"""{"ok": true} → leeres Verdikt; ohne bekannten Key None; Beispiel-Indizes sind
|
||||
1-basiert, bools/0 zählen nicht."""
|
||||
ok = bc._art_check_schema({"ok": True})
|
||||
assert ok == {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
|
||||
assert bc._art_check_schema({"foo": 1}) is None
|
||||
v = bc._art_check_schema({"examples_probleme": [1, "2", {"index": 3}, True, 0, -1],
|
||||
"pattern_ergaenzt": [{"block": "B", "subblock": "S", "question": "F?"}]})
|
||||
assert v["examples_probleme"] == {1, 2, 3}
|
||||
assert len(v["pattern_ergaenzt"]) == 1 and v["pattern"] == []
|
||||
|
||||
|
||||
# ── Generate ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def env(testdb, tmp_path, monkeypatch):
|
||||
"""Ohne Modell (exakte Norm-Gleichheit), ohne Korpus (thema-Selbst-Recherche)."""
|
||||
monkeypatch.setattr(bc, "EMBEDDING_AKTIV", False)
|
||||
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", False) # _dedup_subblocks aus
|
||||
monkeypatch.setattr(bc, "material_folder", lambda t: None)
|
||||
monkeypatch.setattr(bc, "load_source", lambda t: {"type": "thema"})
|
||||
return testdb, _ctx(), {"arbeit": tmp_path}
|
||||
|
||||
|
||||
def _mk_enrich_slot(output):
|
||||
"""run_single_slot-Fake für die Anreicherung: schreibt die gescriptete Antwort via
|
||||
payload (wie der Engine-Sink); output=None → Ausfall."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
if output is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(output), ""))
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def test_enrich_reichert_feste_subs_an(env, monkeypatch):
|
||||
"""Die Subs stehen fest (Board 1). EIN Call füllt Facts + Level + Relevanz — er
|
||||
erfindet und entfernt nichts; die Board-1-Subs bleiben in Reihenfolge."""
|
||||
db, ctx, files = env
|
||||
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
|
||||
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [
|
||||
_sub("Sub A", kp=["k1"]),
|
||||
_sub("Sub B", level="expert", relevance="peripheral", kp=["k2"])]}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
|
||||
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
|
||||
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1"]
|
||||
assert gen["unsicher"] == []
|
||||
assert gen["votes"]["sub b"] == {"level": ["expert"], "relevance": ["peripheral"]}
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == rows["sub b"] == "consensus"
|
||||
|
||||
|
||||
async def test_enrich_ohne_subs_kein_call(env, monkeypatch):
|
||||
"""Kein Board-1-Sub → nichts anzureichern, kein Modell-Call."""
|
||||
db, ctx, files = env
|
||||
called = []
|
||||
|
||||
async def spy(*a, **k):
|
||||
called.append(1)
|
||||
return OK, None
|
||||
monkeypatch.setattr(bc, "run_single_slot", spy)
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
assert gen == {"raw": {"Alpha": []}, "facts": {"Alpha": {}}, "unsicher": [], "votes": {}}
|
||||
assert called == []
|
||||
|
||||
|
||||
async def test_enrich_ausgelassener_sub_bleibt_leer(env, monkeypatch):
|
||||
"""Lässt der Call einen Sub aus, bleibt er erhalten (Vollständigkeit) — mit leeren
|
||||
Facts, die der Prüfer/QA als Lücke sieht."""
|
||||
db, ctx, files = env
|
||||
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
|
||||
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
|
||||
assert gen["facts"]["Alpha"]["sub b"]["key_points"] == []
|
||||
assert gen["facts"]["Alpha"]["sub b"]["cited_facts"] == []
|
||||
|
||||
|
||||
async def test_enrich_resume_ohne_neue_calls(env, monkeypatch):
|
||||
"""Vorhandene enrich-Datei → kein neuer Call, Ergebnis wird übernommen."""
|
||||
db, ctx, files = env
|
||||
await _seed_rows(db, "alpha", ["Sub A"])
|
||||
fake = _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
n = len(fake.calls)
|
||||
gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
assert len(fake.calls) == n # aus Datei resumed
|
||||
assert gen2["raw"] == gen1["raw"]
|
||||
|
||||
|
||||
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _gen_von(title, subs, unsicher=None, votes=None):
|
||||
"""Karten-Payload wie aus _generate_block: raw/facts/unsicher/votes."""
|
||||
return {"raw": {title: list(subs)},
|
||||
"facts": {title: {_norm_title(s): {"key_points": [f"kp {s}"], "prerequisites": "",
|
||||
"hurdles": "", "cited_facts": [], "example_idea": ""}
|
||||
for s in subs}},
|
||||
"unsicher": unsicher or [], "votes": votes or {}}
|
||||
|
||||
|
||||
def _judge_slot(antworten, fix=None):
|
||||
"""run_single_slot-Fake: Prüfer-Antwort je j-Suffix, Fix-Antwort für -sb-fix-;
|
||||
fehlender Eintrag = FAILED."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
if "-sb-fix-" in key:
|
||||
if fix is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(fix), ""))
|
||||
j = key.rsplit("-j", 1)[-1]
|
||||
antwort = antworten.get(j)
|
||||
if antwort is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(antwort), ""))
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def _seed_rows(db, bnorm, titles, status="consensus"):
|
||||
for t in titles:
|
||||
await db.put_subblock(TOPIC, bnorm, _norm_title(t), bnorm.title(), t, status=status)
|
||||
|
||||
|
||||
async def test_verify_gruppe_faltet_einstimmig(env, monkeypatch):
|
||||
"""Beide Prüfer gruppieren 1+2 → haupt gewinnt, Verlierer wird variant und seine
|
||||
Facts wandern per Union zum Gewinner; Dissens-Gruppen falten nicht."""
|
||||
db, ctx, files = env
|
||||
subs = ["Marker Regel", "Marker Regel im Detail erklärt", "Eigenes Thema"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
verdikt = {"gruppen": [{"haupt": 2, "weitere": [1]}]}
|
||||
fake = _judge_slot({"1": verdikt, "2": {"gruppen": [{"haupt": 2, "weitere": [1]},
|
||||
{"haupt": 3, "weitere": [2]}]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": [subs[1], subs[2]]} # Gruppe 2+3 war einseitig → kein Fold
|
||||
wf = res["facts"]["Alpha"][_norm_title(subs[1])]
|
||||
assert wf["key_points"] == [f"kp {subs[1]}", f"kp {subs[0]}"] # Union geerbt
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows[_norm_title(subs[0])] == "variant"
|
||||
assert rows[_norm_title(subs[1])] == "consensus"
|
||||
|
||||
|
||||
async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
|
||||
"""Fremd 2/2 → discarded + raus; einseitig fremd → bleibt."""
|
||||
db, ctx, files = env
|
||||
subs = ["CSS Regel", "Echte Regel"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"fremd": [1, 2]}, "2": {"fremd": [1]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Echte Regel"]}
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["css regel"] == "discarded" and rows["echte regel"] == "consensus"
|
||||
|
||||
|
||||
async def test_verify_keep_all_behaelt_alle_atome(env, monkeypatch):
|
||||
"""Bottom-up (keep_all=True): kein Board-1-Atom wird entfernt oder gefaltet, selbst
|
||||
wenn beide Prüfer fremd/gruppen/discard melden. Nur Facts-Korrektur (Hinweis) und
|
||||
Level/Relevanz greifen weiter."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B", "Sub C"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
# Fix-Call liefert die Korrektur für Sub A UND einen belegten NEUEN Fund — der darf
|
||||
# bei keep_all NICHT als Sub eingeschleust werden (Sub-Zahl bleibt bei den Board-1-Atomen).
|
||||
fix = {"subs": [_sub("Sub A", kp=["korrigiert"]),
|
||||
_sub("Extra Fund", kp=["belegt"], cf=[{"text": "x", "source": "s"}])]}
|
||||
verdikt = {"fremd": [3], "gruppen": [{"haupt": 1, "weitere": [2]}],
|
||||
"facts_probleme": [{"nr": 1, "discard": True, "hinweis": "Zahl falsch"},
|
||||
{"nr": 2, "discard": True}],
|
||||
"levels": {"2": "expert"}}
|
||||
fake = _judge_slot({"1": verdikt, "2": verdikt}, fix=fix)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {}, keep_all=True)
|
||||
assert res["raw"] == {"Alpha": subs} # exakt die drei Atome — nichts entfernt, nichts neu
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert all(rows[_norm_title(s)] == "consensus" for s in subs)
|
||||
assert "extra fund" not in rows # der Fix-Neuzugang wurde unterdrückt
|
||||
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
|
||||
assert side["Sub A"]["facts"]["key_points"] == ["korrigiert"] # Hinweis trotz discard=True
|
||||
assert side["Sub B"]["level"] == "expert" # Level-Korrektur greift
|
||||
|
||||
|
||||
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):
|
||||
"""Facts-Discard ist irreversibel → nur 2/2; die einseitige Stimme ohne Hinweis
|
||||
löst auch keinen Fix aus."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "discard": True},
|
||||
{"nr": 2, "discard": True}]},
|
||||
"2": {"facts_probleme": [{"nr": 1, "discard": True}]}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub B"]}
|
||||
assert not any("-sb-fix-" in c["key"] for c in fake.calls)
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "discarded" and rows["sub b"] == "consensus"
|
||||
|
||||
|
||||
async def test_verify_korrektur_ab_einer_stimme(env, monkeypatch):
|
||||
"""Ein Hinweis EINES Prüfers reicht: der Fix-Call läuft und ersetzt die Facts des
|
||||
beanstandeten Subs; die Einstufung bleibt."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fix = {"subs": [_sub("Sub A", kp=["korrigierte Aussage"])]}
|
||||
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "hinweis": "Zahl falsch"}]},
|
||||
"2": {"gruppen": []}}, fix=fix)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert any("-sb-fix-" in c["key"] for c in fake.calls)
|
||||
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
|
||||
assert side["Sub A"]["facts"]["key_points"] == ["korrigierte Aussage"]
|
||||
assert res["facts"]["Alpha"]["sub a"]["key_points"] == ["korrigierte Aussage"]
|
||||
assert side["Sub B"]["facts"]["key_points"] == ["kp Sub B"] # unbeanstandet
|
||||
|
||||
|
||||
async def test_verify_luecke_belegt_wird_neuer_sub(env, monkeypatch):
|
||||
"""Lücken-Schnitt beider Prüfer → Fix legt den belegten Fund als neuen consensus-Sub
|
||||
an; ein unbelegter „Fund" verfällt am Beleg-Gate."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fix = {"subs": [_sub("Escaping von Sonderzeichen", level="expert", kp=["belegt"]),
|
||||
_sub("Unbelegte Behauptung", kp=[])]}
|
||||
fake = _judge_slot({"1": {"luecken": ["Escaping fehlt"]},
|
||||
"2": {"luecken": ["Escaping unbehandelt"]}}, fix=fix)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub A", "Escaping von Sonderzeichen"]}
|
||||
neu = next(s for s in res["sidecar"]["Alpha"] if s["title"] == "Escaping von Sonderzeichen")
|
||||
assert neu["level"] == "expert" and neu["facts"]["key_points"] == ["belegt"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows[_norm_title("Escaping von Sonderzeichen")] == "consensus"
|
||||
assert _norm_title("Unbelegte Behauptung") not in rows
|
||||
|
||||
|
||||
async def test_verify_ersatzrichter_bei_ausfall(env, monkeypatch):
|
||||
"""Fällt EIN Prüfer aus, springt der Ersatz jE ein — Einstimmigkeit mit ihm faltet."""
|
||||
db, ctx, files = env
|
||||
subs = ["CSS Regel", "Echte Regel"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"fremd": [1]}, "E": {"fremd": [1]}}) # j2 → FAILED
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert res["raw"] == {"Alpha": ["Echte Regel"]}
|
||||
assert [c["key"].rsplit("-j", 1)[-1] for c in fake.calls] == ["1", "2", "E"]
|
||||
|
||||
|
||||
async def test_verify_failopen_verwirft_nur_unsicher(env, monkeypatch):
|
||||
"""Nur 1 Prüfer (auch der Ersatz fällt aus) → fail-open: consensus bleibt unangetastet,
|
||||
unsicher wird verworfen (ohne Panel keine Übernahme-Entscheidung)."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
unsicher = [_sub("Unsicher B")]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
await _seed_rows(db, "alpha", ["Unsicher B"], status="candidate")
|
||||
fake = _judge_slot({"1": {"fremd": [1], "uebernehmen": {"2": "ja"}}}) # j2+jE → FAILED
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub A"]} # fremd-Einzelstimme wirkt NICHT
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "consensus" and rows["unsicher b"] == "discarded"
|
||||
|
||||
|
||||
async def test_verify_level_korrektur_wiegt_doppelt(env, monkeypatch):
|
||||
"""Explizite Prüfer-Korrektur (×2) schlägt die Generator-Stimme; Patt fällt auf
|
||||
advanced/relevant (heutige Defaults)."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A", "Sub B"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
votes = {"sub a": {"level": ["beginner"], "relevance": []},
|
||||
"sub b": {"level": ["beginner", "expert"], "relevance": []}}
|
||||
fake = _judge_slot({"1": {"levels": {"1": "expert"}}, "2": {"gruppen": []}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, votes=votes), {})
|
||||
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
|
||||
assert side["Sub A"]["level"] == "expert" # 2× Korrektur > 1× Generator
|
||||
assert side["Sub B"]["level"] == "advanced" # 1:1-Patt → Default
|
||||
assert side["Sub A"]["relevance"] == "relevant" # keine Stimme → Default
|
||||
|
||||
|
||||
async def test_verify_resume_ohne_neue_calls(env, monkeypatch):
|
||||
"""Vorhandene verify-j-Dateien → kein neuer Prüfer-Call."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
fake = _judge_slot({"1": {"gruppen": []}, "2": {"gruppen": []}})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
n = len(fake.calls)
|
||||
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
|
||||
assert len(fake.calls) == n
|
||||
|
||||
|
||||
# ── Artefakte ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sidecar(titles):
|
||||
return [{"title": t, "level": "beginner", "relevance": "relevant",
|
||||
"facts": {"key_points": [f"kp {t}"]}} for t in titles]
|
||||
|
||||
|
||||
def _art_slot(gen_out, check_out):
|
||||
"""run_single_slot-Fake für Artefakte: gen_out je Teil (dict oder callable(prompt)),
|
||||
check_out fürs Prüfer-Verdikt."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
if "-art-gen-" in key:
|
||||
out = gen_out(prompt) if callable(gen_out) else gen_out
|
||||
return OK, payload((0, json.dumps(out), ""))
|
||||
if "-art-check-" in key:
|
||||
if check_out is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(check_out), ""))
|
||||
raise AssertionError(f"unerwarteter Call {key}")
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def test_artefakte_ein_call_liefert_alles(env, monkeypatch):
|
||||
"""EIN Generator-Call liefert pattern+cards+examples, der Prüfer sagt ok →
|
||||
Rohfassung wird übernommen, block-Feld auf den Karten-Block normiert."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Echo", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [{"block": "Echo", "subblock": "Sub A", "question": "F?", "answer": "A"}],
|
||||
"examples": [{"block": "Echo", "subblock": "Sub A", "problem": "P",
|
||||
"steps": ["s1"], "result": "R"}]}
|
||||
fake = _art_slot(gen_out, {"ok": True})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
|
||||
assert [c["key"] for c in fake.calls if "-art-gen-" in c["key"]].__len__() == 1
|
||||
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
|
||||
assert res["artefacts"]["flashcard"] == [{"block": "Alpha", "subblock": "Sub A",
|
||||
"question": "F?", "answer": "A"}]
|
||||
assert res["artefacts"]["example"][0]["block"] == "Alpha" # Agent-Echo „Echo" normiert
|
||||
|
||||
|
||||
async def test_artefakte_check_entfernt_beispiel_und_ergaenzt_frage(env, monkeypatch):
|
||||
"""examples_probleme wirft das beanstandete Beispiel; pattern_ergaenzt füllt die
|
||||
fehlende Frage nach — der Prüfer-Prompt listet den fraglosen Sub."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [],
|
||||
"examples": [{"block": "Alpha", "subblock": "Sub A", "problem": "P1", "steps": ["x"], "result": ""},
|
||||
{"block": "Alpha", "subblock": "Sub A", "problem": "P2", "steps": ["y"], "result": ""}]}
|
||||
check = {"examples_probleme": [{"index": 1}],
|
||||
"pattern_ergaenzt": [{"block": "Alpha", "subblock": "Sub B", "question": "F B?"}]}
|
||||
fake = _art_slot(gen_out, check)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B"]))
|
||||
assert [e["problem"] for e in res["artefacts"]["example"]] == ["P2"]
|
||||
assert res["pattern"]["Alpha"] == [{"subblock": "Sub A", "question": "F?"},
|
||||
{"subblock": "Sub B", "question": "F B?"}]
|
||||
check_prompt = next(c["prompt"] for c in fake.calls if "-art-check-" in c["key"])
|
||||
assert "STILL MISSING A QUESTION" in check_prompt and "Sub B" in check_prompt
|
||||
|
||||
|
||||
async def test_artefakte_split_ab_schwelle(env, monkeypatch):
|
||||
"""> ART_SPLIT_SUBS Subs → ZWEI parallele Generator-Calls, jeder sieht nur seine
|
||||
Hälfte; die Ergebnisse werden zusammengeführt."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "ART_SPLIT_SUBS", 2)
|
||||
|
||||
def gen_out(prompt):
|
||||
subs = [t for t in ("Sub A", "Sub B", "Sub C") if f"- {t}" in prompt]
|
||||
return {"pattern": [{"block": "Alpha", "subblock": s, "question": f"F {s}?"} for s in subs],
|
||||
"cards": [], "examples": []}
|
||||
|
||||
fake = _art_slot(gen_out, {"ok": True})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B", "Sub C"]))
|
||||
gen_keys = [c["key"] for c in fake.calls if "-art-gen-" in c["key"]]
|
||||
assert len(gen_keys) == 2 and gen_keys[0].endswith("-t1") and gen_keys[1].endswith("-t2")
|
||||
assert [p["subblock"] for p in res["pattern"]["Alpha"]] == ["Sub A", "Sub B", "Sub C"]
|
||||
|
||||
|
||||
async def test_artefakte_check_ausfall_uebernimmt_rohfassung(env, monkeypatch):
|
||||
"""Prüfer ohne Ergebnis → fail-open, die Generator-Rohfassung zählt."""
|
||||
db, ctx, files = env
|
||||
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
|
||||
"cards": [], "examples": []}
|
||||
fake = _art_slot(gen_out, None)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
|
||||
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
|
||||
|
||||
|
||||
async def test_artefakte_leerer_block(env):
|
||||
db, ctx, files = env
|
||||
res = await bc._artefakte_block(ctx, files, "Alpha", [])
|
||||
assert res == {"pattern": {"Alpha": []}, "artefacts": {"flashcard": [], "example": []}}
|
||||
|
||||
|
||||
# ── Migration der alten Stage-Treppe ────────────────────────────────────────────────
|
||||
|
||||
async def test_migriere_alt_karten(testdb):
|
||||
"""Karten in alten Stages gehen mit reduziertem Payload zurück nach generate
|
||||
(alte Zwischenstände sind für die verschmolzenen Calls wertlos); Terminal- und
|
||||
Neu-Struktur-Karten bleiben unangetastet."""
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts",
|
||||
{"title": "Alpha", "description": "d", "n_size": 3,
|
||||
"sources": ["s1"], "raw": {"Alpha": ["alt"]},
|
||||
"facts": {"Alpha": {}}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "beta", "ablock", "question_pattern",
|
||||
{"title": "Beta", "sidecar": {}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "done_artefact",
|
||||
{"title": "Gamma", "raw": {"Gamma": ["bleibt"]}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "delta", "ablock", "verify",
|
||||
{"title": "Delta", "raw": {"Delta": ["neu"]}})
|
||||
n = await ba.migriere_alt_karten(TOPIC)
|
||||
assert n == 2
|
||||
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert alpha["stage"] == "generate"
|
||||
assert alpha["payload"] == {"title": "Alpha", "description": "d", "n_size": 3, "sources": ["s1"]}
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "generate"
|
||||
gamma = await db.kanban_get_card(TOPIC, "artefacts", "gamma")
|
||||
assert gamma["stage"] == "done_artefact" and gamma["payload"]["raw"] == {"Gamma": ["bleibt"]}
|
||||
delta = await db.kanban_get_card(TOPIC, "artefacts", "delta")
|
||||
assert delta["stage"] == "verify" and delta["payload"]["raw"] == {"Delta": ["neu"]}
|
||||
@@ -70,47 +70,39 @@ async def board_env(testdb, tmp_path, monkeypatch):
|
||||
return False
|
||||
monkeypatch.setattr(bi, "_emb_ok", no_emb)
|
||||
|
||||
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
|
||||
title = list(entries.values())[0].split(" — ")[0]
|
||||
return {title: ["Sub Eins", "Sub Zwei"]}
|
||||
async def fake_generate(ctx, files, title, description, instructions="", ns="", lbl="",
|
||||
sources=None, seeds=None, melde=None):
|
||||
subs = ["Sub Eins", "Sub Zwei"]
|
||||
return {"raw": {title: list(subs)},
|
||||
"facts": {title: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
|
||||
for s in subs}},
|
||||
"unsicher": [], "votes": {}}
|
||||
|
||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None):
|
||||
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
|
||||
for s in subs} for t, subs in raw.items()}
|
||||
return facts, {}
|
||||
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
|
||||
melde=None, keep_all=False):
|
||||
subs = gen["raw"].get(title) or []
|
||||
bfacts = gen["facts"].get(title) or {}
|
||||
sidecar = [{"title": s, "level": "beginner",
|
||||
"relevance": "relevant" if i == 0 else "peripheral",
|
||||
"facts": bfacts.get(_norm_title(s)) or {}}
|
||||
for i, s in enumerate(subs)]
|
||||
return {"raw": {title: list(subs)}, "facts": {title: bfacts}, "sidecar": {title: sidecar}}
|
||||
|
||||
async def fake_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||
return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()}
|
||||
|
||||
async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {1: "relevant", 2: "peripheral"}
|
||||
|
||||
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {t: [{"subblock": subs[0]["title"], "question": f"Was ist {t}?"}]
|
||||
for t, subs in sidecar.items()}
|
||||
|
||||
async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {"flashcard": [{"block": t, "subblock": subs[0]["title"], "front": "F", "back": "B"}
|
||||
for t, subs in sidecar.items()], "example": []}
|
||||
async def fake_artefakte(ctx, files, title, sidecar_subs, instructions="", ns="", lbl="", melde=None):
|
||||
if not sidecar_subs:
|
||||
return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
|
||||
first = sidecar_subs[0]["title"]
|
||||
return {"pattern": {title: [{"subblock": first, "question": f"Was ist {title}?"}]},
|
||||
"artefacts": {"flashcard": [{"block": title, "subblock": first, "front": "F", "back": "B"}],
|
||||
"example": []}}
|
||||
|
||||
async def fake_outline(ctx, set_p, files, entries, instructions):
|
||||
return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]}
|
||||
|
||||
async def fake_konsolidierung(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
|
||||
return None
|
||||
|
||||
for name, fn in [("_subblocks_block", fake_subblocks), ("_facts_block", fake_facts),
|
||||
("_levels_block", fake_levels), ("_relevance_block", fake_relevance),
|
||||
("_question_pattern_block", fake_pattern), ("_artefacts_block", fake_artefacts),
|
||||
("_outline_block", fake_outline), ("_konsolidiere_subblocks", fake_konsolidierung)]:
|
||||
for name, fn in [("_generate_block", fake_generate), ("_verify_block", fake_verify),
|
||||
("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
|
||||
monkeypatch.setattr(ba, name, fn)
|
||||
|
||||
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
|
||||
@staticmethod
|
||||
def available():
|
||||
return False
|
||||
monkeypatch.setattr(ba, "embedding", _EmbOff)
|
||||
|
||||
work = tmp_path / "arbeit"
|
||||
work.mkdir()
|
||||
files = {"arbeit": work, "final": tmp_path / "blocks.md",
|
||||
@@ -176,6 +168,38 @@ async def test_board1_full_flow(board_env):
|
||||
assert summary["boards"].get("inventory", {}).get("done_block") == 4
|
||||
|
||||
|
||||
async def test_abschluss_qa_events_tragen_run_id(board_env, monkeypatch):
|
||||
"""Abschluss-QA läuft NACH run_flow — ihre Judge-Events müssen trotzdem die run_id
|
||||
des Laufs tragen (Lauf 20260704-1452-b223: run_id leer → aus jeder Aggregation gefallen)."""
|
||||
import asyncio
|
||||
|
||||
import qa as qa_mod
|
||||
db, ctx, files = board_env
|
||||
await _seed(db)
|
||||
|
||||
async def qa_mit_judge_event(topic, llm=False):
|
||||
# wie die echten LLM-Judges: run_agent schreibt ein agent-Event
|
||||
await db.add_event(topic, "agent", key=f"qa-{topic}-bausteine-0", status="ok")
|
||||
return {"note": 10.0, "topic": topic, "quoten": {}, "fremd": [],
|
||||
"artefakte": {"status": "nicht generiert"}}
|
||||
monkeypatch.setattr(qa_mod, "qa_report", qa_mit_judge_event)
|
||||
ok = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||
timeout=30)
|
||||
assert ok
|
||||
summary = json.loads((files["arbeit"] / "lauf-summary.json").read_text(encoding="utf-8"))
|
||||
conn = await db.get_db()
|
||||
rows = await (await conn.execute(
|
||||
"SELECT run_id FROM events WHERE topic=? AND key=?",
|
||||
(TOPIC, f"qa-{TOPIC}-bausteine-0"))).fetchall()
|
||||
assert rows and all(r[0] == summary["run_id"] for r in rows)
|
||||
# Registry nach dem Lauf geleert: manuelle QA bleibt korrekt ohne run_id
|
||||
await db.add_event(TOPIC, "agent", key="qa-manuell", status="ok")
|
||||
row = await (await conn.execute(
|
||||
"SELECT run_id FROM events WHERE topic=? AND key='qa-manuell'", (TOPIC,))).fetchone()
|
||||
assert row[0] == ""
|
||||
|
||||
|
||||
async def test_filter_judges_run_parallel(board_env, monkeypatch):
|
||||
"""40 Blöcke → 2 Filter-Chunks: die Judge-Welle muss parallel laufen (Perf-Fix)."""
|
||||
import asyncio
|
||||
@@ -206,16 +230,17 @@ async def test_filter_judges_run_parallel(board_env, monkeypatch):
|
||||
|
||||
|
||||
async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypatch):
|
||||
"""Legitim leere Subbausteine ({} statt None) → Karte läuft bis done_artefact durch."""
|
||||
"""Legitim leere Subbausteine (leere raw-Liste statt None) → Karte läuft bis done_artefact durch."""
|
||||
import asyncio
|
||||
import board_artefacts as ba
|
||||
import blocks as blx
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
|
||||
return {}
|
||||
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
|
||||
async def empty_gen(ctx, files, title, description, instructions="", ns="", lbl="",
|
||||
sources=None, seeds=None, melde=None):
|
||||
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
|
||||
monkeypatch.setattr(ba, "_generate_block", empty_gen)
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "generate",
|
||||
{"title": "Leerer Block", "description": "d"})
|
||||
ok = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||
@@ -294,8 +319,9 @@ async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
|
||||
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "dedup"
|
||||
|
||||
|
||||
async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
|
||||
"""Nur 1 von 3 Panel-Stimmen bestätigt den Judge-Demote → Karte überlebt (Journal)."""
|
||||
async def test_single_recheck_vote_confirms_proposal(board_env, tmp_path, monkeypatch):
|
||||
"""M3.2: ein pass-1-Vorschlag wird schon von EINER Recheck-Stimme bestätigt → rejected.
|
||||
(15/17 mehrheitlich geretteten Karten waren später QA-Dubletten — Overturn nur einstimmig.)"""
|
||||
db, ctx, files = board_env
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", _confirm_votes({"1"}, {"fragments": {"1": 2}, "drop": []})),
|
||||
@@ -305,6 +331,23 @@ async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
|
||||
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
|
||||
])
|
||||
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
|
||||
assert c1["stage"] == "rejected"
|
||||
assert c1["payload"]["reason"] == "fragment"
|
||||
assert c1["payload"]["parent_norm"] == "codeblock"
|
||||
|
||||
|
||||
async def test_unanimous_recheck_keep_overrules_proposal(board_env, tmp_path, monkeypatch):
|
||||
"""M3.2: nur ein EINSTIMMIGES Recheck-Panel (0 Demote-Stimmen) hebt den pass-1-Vorschlag auf."""
|
||||
db, ctx, files = board_env
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", _confirm_votes(set(), {"fragments": {"1": 2}, "drop": []})),
|
||||
("-filter-", {"fragments": {"1": 2}, "drop": []}),
|
||||
]))
|
||||
await _run_filter(db, ctx, tmp_path, [
|
||||
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
|
||||
])
|
||||
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup"
|
||||
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
|
||||
assert journal["ueberstimmt"] == ["Blockzitat"]
|
||||
@@ -557,6 +600,49 @@ def test_canonical_key_camel_and_catalogue():
|
||||
assert k("Section 2.1 Matching") == k("Matching")
|
||||
|
||||
|
||||
def test_canonical_key_glued_problem_suffix():
|
||||
"""Verklebtes Kompositum-Suffix „…problem" wird abgetrennt, damit „Cliquenproblem" und
|
||||
„Clique" denselben Blocking-Key teilen (aak: sonst nie Dedup-Kandidat). Fugen-n/-s + Plural
|
||||
inklusive; die generische Suffix-Regel darf keine echten Varianten über-mergen."""
|
||||
from blocks import _canonical_key as k
|
||||
assert k("Clique") == k("Cliquenproblem") == k("Cliquenprobleme") != ""
|
||||
assert k("Set Cover") == k("SetCover-Problem") # bestehender Hyphen-Pfad bleibt
|
||||
assert k("SAT") != k("3-SAT") # Varianten-Ziffer bleibt Signal
|
||||
assert k("Problem") != k("Clique") # bloßes „Problem" wird nicht zum Stamm
|
||||
|
||||
|
||||
def test_reference_strip_and_is_reference():
|
||||
"""Katalog-Nummern werden gestrippt, der Konzeptname bleibt; reine Nummern → leer + Referenz.
|
||||
Reale aak-Schadensfälle (Satz-/Bemerkungs-Titel liefen wörtlich bis done)."""
|
||||
from blocks import _reference_strip as strip, _is_reference as isref
|
||||
assert strip("Satz 7.13 (Christofides)") == "Christofides"
|
||||
assert strip("Satz 7.6: Kriterium für Eulerschen Kreis") == "Kriterium für Eulerschen Kreis"
|
||||
assert strip("N P via nicht-deterministische Turingmaschine (Definition 6.19)") \
|
||||
== "N P via nicht-deterministische Turingmaschine"
|
||||
assert strip("Bemerkung 7.22") == ""
|
||||
assert strip("Vertex Cover") == "Vertex Cover" # kein Katalog-Gerüst → unverändert
|
||||
# Zuschreibungs-Klammer vor ':' gehört zum Katalog-Gerüst (aak: "(Lawler 1976): …" überlebte)
|
||||
assert strip("Satz 7.12 (Lawler 1976): Minimum-Weight Perfect Matching") \
|
||||
== "Minimum-Weight Perfect Matching"
|
||||
assert strip("(a1=1)-SubsetSum Problem") == "(a1=1)-SubsetSum Problem" # Mathe-Klammer bleibt
|
||||
assert isref("Bemerkung 7.22") and isref("Satz 7.18") and isref("Korollar 6.18")
|
||||
assert isref("Bedingung (**)")
|
||||
assert not isref("Satz 7.13 (Christofides)") # hat Konzept → keine Referenz
|
||||
assert not isref("Satz 7.6: Kriterium für Eulerschen Kreis")
|
||||
assert not isref("P⊆NP") and not isref("Σ*") # kurze Symbole bleiben echt
|
||||
|
||||
|
||||
def test_is_named_statement_construction_suffix():
|
||||
"""Reduktion mit Konstruktions-Suffix (: / = nach dem Ziel) ist ein Fragment, kein Statement —
|
||||
darf nicht mehr vor Demotion geschützt sein (aak: „3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")."""
|
||||
from blocks import _is_named_statement as named
|
||||
assert not named("3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")
|
||||
assert named("3-SAT ≤ Clique") # saubere Reduktion bleibt geschützt
|
||||
assert named("Clique → Vertex Cover")
|
||||
assert not named("X ist NP-vollständig") # unäre Aussage bleibt demotable
|
||||
assert named("Satz von Cook/Levin: SAT ist NP-vollständig ⇔ …") # benanntes Ergebnis bleibt
|
||||
|
||||
|
||||
def test_relation_guard_ignores_trailing_scaffolding():
|
||||
"""Trailing „Reduktion/Transformation" ist kein Operand — sonst blockt der Guard
|
||||
den korrekten Merge; Richtungs-Konflikte bleiben erkannt."""
|
||||
@@ -680,18 +766,20 @@ def test_agent_priority_order():
|
||||
|
||||
|
||||
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
||||
"""Gliederung startet, sobald alle Karten die facts-Stage passiert haben —
|
||||
"""Gliederung startet, sobald alle Karten die generate-Stage passiert haben —
|
||||
parallel zu den restlichen Artefakt-Stages des langsamsten Blocks."""
|
||||
import asyncio
|
||||
import board_artefacts as ba
|
||||
db, ctx, files = board_env
|
||||
await _seed(db)
|
||||
base_levels = ba._levels_block
|
||||
base_verify = ba._verify_block
|
||||
snapshot = {}
|
||||
|
||||
async def slow_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||
await asyncio.sleep(0.8) # keeps one card in `levels` while the outline fires
|
||||
return await base_levels(ctx, set_p, files, raw, instructions, ns=ns, lbl=lbl)
|
||||
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
|
||||
melde=None, keep_all=False):
|
||||
await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires
|
||||
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl,
|
||||
sources=sources, keep_all=keep_all)
|
||||
|
||||
base_outline = ba._outline_block
|
||||
|
||||
@@ -700,7 +788,7 @@ async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
||||
snapshot["unfinished"] = sum(1 for c in cards if c["stage"] != "done_artefact")
|
||||
return await base_outline(ctx, set_p, files, entries, instructions)
|
||||
|
||||
monkeypatch.setattr(ba, "_levels_block", slow_levels)
|
||||
monkeypatch.setattr(ba, "_verify_block", slow_verify)
|
||||
monkeypatch.setattr(ba, "_outline_block", spy_outline)
|
||||
ok = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||
@@ -718,7 +806,7 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
|
||||
db, ctx, files = board_env
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block",
|
||||
{"title": "Alpha", "description": "d"})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels",
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "verify",
|
||||
{"title": "Alpha", "facts": {"Alpha": {"sub eins": {
|
||||
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
|
||||
@@ -732,16 +820,16 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
|
||||
|
||||
async def test_card_view_stepper(testdb):
|
||||
"""Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar."""
|
||||
r = {"board": "artefacts", "card_id": "alpha", "stage": "facts", "retries": 0,
|
||||
r = {"board": "artefacts", "card_id": "alpha", "stage": "verify", "retries": 0,
|
||||
"payload": {"title": "Alpha"}}
|
||||
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Facts check 1/2…", "step": "Facts check"}})
|
||||
assert v["status"] == "active" and v["info"] == "Facts check 1/2…"
|
||||
assert v["step_i"] == 2 and v["step_n"] == 3 and v["steps"][0] == "Facts find"
|
||||
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Fix 1/2…", "step": "Fix"}})
|
||||
assert v["status"] == "active" and v["info"] == "Fix 1/2…"
|
||||
assert v["step_i"] == 2 and v["step_n"] == 2 and v["steps"][0] == "Verify"
|
||||
# legacy plain-string live info → no stepper, no crash
|
||||
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"})
|
||||
assert v2["info"] == "Facts find 0/1…" and "step_n" not in v2
|
||||
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Verify 0/1…"})
|
||||
assert v2["info"] == "Verify 0/1…" and "step_n" not in v2
|
||||
# step outside the card's stage group (e.g. supplement note) → no stepper
|
||||
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Subblocks find"}})
|
||||
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Generate"}})
|
||||
assert "step_n" not in v3
|
||||
|
||||
|
||||
@@ -776,17 +864,17 @@ async def test_seed_map_resolves_cascade(testdb):
|
||||
|
||||
|
||||
def test_per_block_functions_accept_wrapper_kwargs():
|
||||
"""Die board_artefacts-Wrapper übergeben ns/lbl (subblocks auch seeds) — ein fehlender
|
||||
Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur)."""
|
||||
"""Die board_artefacts-Prozessoren übergeben ns/lbl (generate auch seeds/sources) —
|
||||
ein fehlender Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken
|
||||
die Signatur)."""
|
||||
import inspect
|
||||
import blocks as blx
|
||||
for fn in ("_subblocks_block", "_facts_block", "_levels_block", "_relevance_block",
|
||||
"_question_pattern_block", "_artefacts_block"):
|
||||
params = inspect.signature(getattr(blx, fn)).parameters
|
||||
import block_calls as bc
|
||||
for fn in ("_generate_block", "_verify_block", "_artefakte_block"):
|
||||
params = inspect.signature(getattr(bc, fn)).parameters
|
||||
assert "ns" in params and "lbl" in params, fn
|
||||
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
|
||||
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
|
||||
assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn
|
||||
assert "seeds" in inspect.signature(bc._generate_block).parameters
|
||||
for fn in ("_generate_block", "_verify_block"): # Board 2 reicht die Block-Quellen durch
|
||||
assert "sources" in inspect.signature(getattr(bc, fn)).parameters, fn
|
||||
|
||||
|
||||
# ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ─────────────────────
|
||||
@@ -873,24 +961,115 @@ async def _run_flow(ctx, files, timeout=30, **kw):
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
||||
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in subblocks."""
|
||||
async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
|
||||
"""Auto an, aber Repair schließt die Befunde nicht → Stillstand → Flow pausiert,
|
||||
Board-2-Karten warten in generate."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def bad_qa(topic, llm=False):
|
||||
return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
|
||||
return {"note": 5.0, "aktionen": 0} # bewegt nichts
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="subblocks")
|
||||
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
|
||||
assert len(warten) == 4 # alle Blöcke gespawnt, keiner verarbeitet
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
|
||||
|
||||
|
||||
async def test_qa_gate_luecken_research_runden(board_env, monkeypatch):
|
||||
"""Offene Lücken im Auto-Loop: erst LUECKEN_RESEARCH_MAX Recherche-Runden
|
||||
(Lücken-Urteil ausgesetzt), dann das Urteil — erst danach Stillstand/Pause."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
gap_report = {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {},
|
||||
"luecken": [{"datei": "f.txt", "abschnitt": 1, "vorschau": "x"}],
|
||||
"konzept_luecken": []}
|
||||
|
||||
async def bad_qa(topic, llm=False):
|
||||
return dict(gap_report)
|
||||
|
||||
research_calls = []
|
||||
|
||||
async def fake_producer(ctx2, flow, rep):
|
||||
research_calls.append(1)
|
||||
return 1 # Recherche hat Karten erzeugt → Runde gilt als bewegt
|
||||
|
||||
urteile = []
|
||||
|
||||
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
|
||||
urteile.append(luecken_urteil)
|
||||
return {"note": 8.0, "aktionen": 0}
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "latest_report", lambda t, guide=False: dict(gap_report))
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(bi, "_luecken_producer", fake_producer)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
assert len(research_calls) == 2 # LUECKEN_RESEARCH_MAX
|
||||
assert urteile[:2] == [False, False] and urteile[2] is True
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 # pausiert
|
||||
|
||||
|
||||
async def test_qa_gate_auto_reaches_100(board_env, monkeypatch):
|
||||
"""Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def bad_qa(topic, llm=False):
|
||||
return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def good_repair(topic, ebene=None, luecken_urteil=True):
|
||||
return {"note": 10.0, "aktionen": 1}
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", good_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5
|
||||
|
||||
|
||||
async def test_qa_gate_auto_off_pauses(board_env, monkeypatch):
|
||||
"""Auto aus → nach der QA anhalten (Kontrollpunkt), egal welche Note; kein Repair."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def good_qa(topic, llm=False):
|
||||
return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def no_repair(topic, ebene=None, luecken_urteil=True):
|
||||
raise AssertionError("Auto aus darf nicht reparieren")
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", good_qa)
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", no_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files, auto_inventory=False)
|
||||
assert ok
|
||||
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
|
||||
assert len(warten) == 4 # Board 2 wartet trotz Note 10.0 (Auto aus = Kontrollpunkt)
|
||||
|
||||
|
||||
async def test_qa_gate_force_overrides(board_env, monkeypatch):
|
||||
"""qa_force=True („Trotzdem fortsetzen") übersteuert die schlechte Note."""
|
||||
import qa as qa_mod
|
||||
@@ -907,7 +1086,7 @@ async def test_qa_gate_force_overrides(board_env, monkeypatch):
|
||||
ok = await _run_flow(ctx, files, qa_force=True)
|
||||
assert ok
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5
|
||||
assert called["n"] <= 1 # Gate-Lauf übersprungen; höchstens Abschluss-QA
|
||||
assert called["n"] <= 2 # Gate-QA übersprungen (force); nur Artefakt-Loop + Abschluss-QA
|
||||
|
||||
|
||||
async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch):
|
||||
@@ -924,7 +1103,7 @@ async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch):
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
assert called["n"] == 1 # kein Gate-Lauf; nur die Abschluss-QA der Lauf-Summary
|
||||
assert called["n"] == 2 # kein Gate-Lauf; Artefakt-Loop-QA + Abschluss-QA der Lauf-Summary
|
||||
|
||||
|
||||
async def test_qa_gate_fail_open(board_env, monkeypatch):
|
||||
@@ -949,9 +1128,14 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
|
||||
(tmp_path / TOPIC).mkdir()
|
||||
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
|
||||
{"note": 5.0, "quoten": {"fremd": 0.2}, "fremd": ["X"], "unecht": ["Y"]}), encoding="utf-8")
|
||||
counts = {"inventory": {"done_block": 3}, "artefacts": {"subblocks": 4}}
|
||||
counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
|
||||
v = bi._qa_view(TOPIC, counts, None)
|
||||
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
|
||||
# 9.6 lag über QA_GATE_NOTE, pausiert den Auto-Loop aber trotzdem (Gate öffnet nur bei VOLL)
|
||||
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
|
||||
{"note": 9.6, "quoten": {}, "fremd": [], "unecht": []}), encoding="utf-8")
|
||||
qa_mod._latest_cache.clear()
|
||||
assert bi._qa_view(TOPIC, counts, None)["pausiert"] is True
|
||||
from types import SimpleNamespace
|
||||
laufend = SimpleNamespace(state={})
|
||||
assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False # Flow läuft noch
|
||||
@@ -1083,24 +1267,27 @@ def test_hat_anker_ziffern_suffix():
|
||||
assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker
|
||||
|
||||
|
||||
async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
|
||||
"""Reset auf Spalte subblocks: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
|
||||
weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen."""
|
||||
async def test_reset_generate_behaelt_subs_loescht_artefakte(testdb, tmp_path):
|
||||
"""Reset auf Spalte generate (Artefakte entfernen): die Board-1-Subs BLEIBEN (Board 2
|
||||
reichert sie neu an), aber die abgeleiteten Fragen/Artefakte + globalen Sidecar-Dateien
|
||||
+ ab-*-Resume-Slots gehen weg — sonst mergen Reste in den frischen Lauf zurück."""
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefacts", {"title": "Alpha"})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"})
|
||||
await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus")
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "s1", "Alpha", "S1", "Frage?")
|
||||
arbeit = tmp_path / "arbeit"
|
||||
(arbeit / "ab-alpha").mkdir(parents=True)
|
||||
(arbeit / "ab-alpha" / "facts.json").write_text("{}", encoding="utf-8")
|
||||
files = {"arbeit": arbeit}
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"):
|
||||
files[k] = tmp_path / f"{k}.json"
|
||||
files[k].write_text("{}", encoding="utf-8")
|
||||
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "subblocks", files)
|
||||
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
|
||||
assert moved == 1
|
||||
assert not await db.list_subblocks(TOPIC)
|
||||
assert len(await db.list_subblocks(TOPIC)) == 1 # Sub bleibt (Board-1-Eigentum)
|
||||
assert not await db.list_question_pattern(TOPIC, "alpha") # abgeleitete Frage weg
|
||||
assert not (arbeit / "ab-alpha").exists()
|
||||
assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"))
|
||||
assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"))
|
||||
|
||||
|
||||
# ── Naming-Abstraktion: freier Name nur mit Anker ───────────────────────────────────
|
||||
@@ -1181,3 +1368,275 @@ async def test_namecheck_ok_behaelt_titel(testdb, tmp_path, monkeypatch):
|
||||
block = await db.kanban_get_card(TOPIC, B, "b-c9")
|
||||
assert block["payload"]["title"] == "Eigener Titel"
|
||||
assert block["payload"]["description"] == "Eigene Beschreibung"
|
||||
|
||||
|
||||
async def test_namecheck_strips_source_numbering(testdb, tmp_path, monkeypatch):
|
||||
"""M1.2: wählt der Check-Judge einen numerierten Titel, wird die Katalog-Nummer deterministisch
|
||||
zum Konzept gestrippt ('Satz 7.13 (Christofides)' → 'Christofides')."""
|
||||
db = testdb
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"norm": "satz 7.13 (christofides)", "title": "Satz 7.13 (Christofides)",
|
||||
"description": "3/2-Approximation für metrisches TSP", "readers": ["r1"], "sources": []},
|
||||
{"norm": "b", "title": "B", "description": "db", "readers": ["r2"], "sources": []}]
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
return "ok", payload((0, '{"best": 1}', "")) # Judge wählt den numerierten Member-Titel
|
||||
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
|
||||
payload = {"title": "Satz 7.13 (Christofides)", "description": "3/2-Approximation",
|
||||
"main_norm": "satz 7.13 (christofides)"}
|
||||
await db.kanban_upsert_card(TOPIC, B, "c7", "cluster", "naming_check", payload)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
await bi._namecheck_one(ctx, _mk_flow(tmp_path), {"card_id": "c7", "payload": payload})
|
||||
block = await db.kanban_get_card(TOPIC, B, "b-c7")
|
||||
assert block["payload"]["title"] == "Christofides"
|
||||
|
||||
|
||||
async def test_consensus_gate_single_reader_majority_quorum(testdb, tmp_path, monkeypatch):
|
||||
"""M5.1: Einzel-Reader-Fund → clarify mit Mehrheits-Quorum statt Einstimmigkeit."""
|
||||
db = testdb
|
||||
monkeypatch.setattr(bi, "source_folder", lambda t: None) # thema: kein Korpus
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"title": "Seltenes Konzept", "description": "einmal erwähnt",
|
||||
"readers": ["r1"], "supplement": False}]
|
||||
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
monkeypatch.setattr(bi, "_rep", lambda rows: rows[0])
|
||||
await db.kanban_upsert_card(TOPIC, B, "cs", "cluster", "consensus_gate", {})
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), [{"card_id": "cs", "payload": {}}])
|
||||
card = await db.kanban_get_card(TOPIC, B, "cs")
|
||||
assert card["stage"] == "clarify"
|
||||
assert card["payload"]["quorum"] == "majority"
|
||||
|
||||
|
||||
# ── Sanierung: Titel auf Korpus-Form, Beschreibungspflicht (QA: fremd/hygiene) ──────
|
||||
|
||||
def test_sanierung_schema_varianten():
|
||||
assert bi._sanierung_schema({"title": " k-Color ", "description": "d"}) == ("k-Color", "d")
|
||||
assert bi._sanierung_schema({"title": "", "description": "nur Beschreibung"}) == ("", "nur Beschreibung")
|
||||
assert bi._sanierung_schema({"title": "x" * 90, "description": "d"}) == ("", "d") # zu lang
|
||||
assert bi._sanierung_schema({"title": "", "description": ""}) is None
|
||||
assert bi._sanierung_schema("quatsch") is None
|
||||
|
||||
|
||||
def test_sanierung_noetig():
|
||||
ctoks = {"color", "graph"}
|
||||
assert bi._sanierung_noetig({"title": "k-Color", "description": ""}, ctoks) # leere Beschreibung
|
||||
assert bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, ctoks) # kein Korpus-Anker
|
||||
assert not bi._sanierung_noetig({"title": "k-Color", "description": "d"}, ctoks)
|
||||
assert not bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, None) # thema: kein Korpus
|
||||
# M1.3: Referenz-Titel / unbalancierte Klammer / >80 Zeichen triggern auch ohne Korpus (Singletons)
|
||||
assert bi._sanierung_noetig({"title": "Bemerkung 7.22", "description": "d"}, None)
|
||||
assert bi._sanierung_noetig({"title": "N P (Definition 6.19", "description": "d"}, None)
|
||||
assert bi._sanierung_noetig({"title": "A" * 81, "description": "d"}, None)
|
||||
assert not bi._sanierung_noetig({"title": "Clique", "description": "d"}, None)
|
||||
|
||||
|
||||
def _sanierung_env(tmp_path, monkeypatch, antwort):
|
||||
"""Korpus mit 'k-Color'-Oberflächenform; Judge antwortet mit `antwort`."""
|
||||
(tmp_path / "korpus.txt").write_text(
|
||||
"Das k-Color Problem: Kann der Graph mit k Farben gefärbt werden? NP-vollständig.",
|
||||
encoding="utf-8")
|
||||
monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)
|
||||
seen = {}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
seen[key] = prompt
|
||||
return "ok", payload((0, json.dumps(antwort["val"]), ""))
|
||||
|
||||
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
|
||||
return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False), seen
|
||||
|
||||
|
||||
async def test_singleton_unverankert_wird_saniert(testdb, tmp_path, monkeypatch):
|
||||
"""Singleton-Cluster mit Reader-Titel ohne Korpus-Anker: Naming wird nicht mehr
|
||||
übersprungen — der Titel wird auf die Korpus-Oberflächenform umgeschrieben
|
||||
(gemessener aak-Fall 'k-Coloring' statt 'k-Color')."""
|
||||
db = testdb
|
||||
antwort = {"val": {"title": "k-Color", "description": "Kann der Graph mit k Farben gefärbt werden?"}}
|
||||
ctx, seen = _sanierung_env(tmp_path, monkeypatch, antwort)
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"norm": "k-coloring", "title": "k-Coloring",
|
||||
"description": "Kann der Graph mit k Farben gefärbt werden?",
|
||||
"readers": ["r1", "r2"], "sources": []}]
|
||||
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
payload = {"title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?"}
|
||||
await db.kanban_upsert_card(TOPIC, B, "c1", "cluster", "naming", payload)
|
||||
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c1", "payload": payload})
|
||||
card = await db.kanban_get_card(TOPIC, B, "c1")
|
||||
assert card["stage"] == "naming_check"
|
||||
assert card["payload"]["title"] == "k-Color"
|
||||
assert any("-sanierung-" in k for k in seen)
|
||||
|
||||
|
||||
async def test_leere_beschreibung_wird_gefuellt(testdb, tmp_path, monkeypatch):
|
||||
"""Verankerter Titel, leere Beschreibung (gemessener aak-Fall der SetCover-Fragmente):
|
||||
Beschreibung wird belegt nachgefasst, Titel bleibt."""
|
||||
db = testdb
|
||||
antwort = {"val": {"title": "k-Color", "description": "Färbbarkeit mit k Farben."}}
|
||||
ctx, _seen = _sanierung_env(tmp_path, monkeypatch, antwort)
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"norm": "k-color", "title": "k-Color", "description": "",
|
||||
"readers": ["r1"], "sources": []}]
|
||||
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
payload = {"title": "k-Color", "description": ""}
|
||||
await db.kanban_upsert_card(TOPIC, B, "c5", "cluster", "naming", payload)
|
||||
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c5", "payload": payload})
|
||||
card = await db.kanban_get_card(TOPIC, B, "c5")
|
||||
assert card["payload"]["title"] == "k-Color"
|
||||
assert card["payload"]["description"] == "Färbbarkeit mit k Farben."
|
||||
|
||||
|
||||
async def test_sanierung_unverankerter_vorschlag_verfaellt(testdb, tmp_path, monkeypatch):
|
||||
"""Judge-Vorschlag ohne Korpus-Anker wird verworfen (dieselbe Messlatte wie QA-fremd);
|
||||
die Beschreibung wird trotzdem übernommen."""
|
||||
db = testdb
|
||||
antwort = {"val": {"title": "Graphfärbungsproblem", "description": "Färbbarkeit mit k Farben."}}
|
||||
ctx, _seen = _sanierung_env(tmp_path, monkeypatch, antwort)
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"norm": "k-coloring", "title": "k-Coloring",
|
||||
"description": "Kann der Graph mit k Farben gefärbt werden?",
|
||||
"readers": ["r1"], "sources": []}]
|
||||
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
payload = {"title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?"}
|
||||
await db.kanban_upsert_card(TOPIC, B, "c2", "cluster", "naming", payload)
|
||||
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c2", "payload": payload})
|
||||
card = await db.kanban_get_card(TOPIC, B, "c2")
|
||||
assert card["payload"]["title"] == "k-Coloring" # unverankert → verfällt
|
||||
assert card["stage"] == "naming_check"
|
||||
|
||||
|
||||
async def test_sanierung_fail_open(testdb, tmp_path, monkeypatch):
|
||||
"""Judge-Ausfall → Karte läuft unverändert weiter (kein Deadletter am Naming)."""
|
||||
db = testdb
|
||||
(tmp_path / "korpus.txt").write_text("Der Graph ist endlich.", encoding="utf-8")
|
||||
monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)
|
||||
|
||||
async def broken_slot(*a, **kw):
|
||||
return "failed", None
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"norm": "x", "title": "Graph", "description": "", "readers": ["r1"], "sources": []}]
|
||||
|
||||
monkeypatch.setattr(bi, "run_single_slot", broken_slot)
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
payload = {"title": "Graph", "description": ""}
|
||||
await db.kanban_upsert_card(TOPIC, B, "c3", "cluster", "naming", payload)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c3", "payload": payload})
|
||||
card = await db.kanban_get_card(TOPIC, B, "c3")
|
||||
assert card["stage"] == "naming_check" and card["payload"]["title"] == "Graph"
|
||||
|
||||
|
||||
async def test_sanierung_anker_und_beschreibung_ok_kein_judge(testdb, tmp_path, monkeypatch):
|
||||
"""Verankerter Titel + Beschreibung vorhanden → kein Sanierungs-Call."""
|
||||
db = testdb
|
||||
(tmp_path / "korpus.txt").write_text("Das k-Color Problem im Graph.", encoding="utf-8")
|
||||
monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)
|
||||
|
||||
async def never_slot(*a, **kw):
|
||||
raise AssertionError("Sanierung darf ohne Befund-Form nicht laufen")
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"norm": "k-color", "title": "k-Color", "description": "d", "readers": ["r1"], "sources": []}]
|
||||
|
||||
monkeypatch.setattr(bi, "run_single_slot", never_slot)
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
payload = {"title": "k-Color", "description": "d"}
|
||||
await db.kanban_upsert_card(TOPIC, B, "c4", "cluster", "naming", payload)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c4", "payload": payload})
|
||||
assert (await db.kanban_get_card(TOPIC, B, "c4"))["stage"] == "naming_check"
|
||||
|
||||
|
||||
def test_themen_zielband_skaliert_mit_atomzahl():
|
||||
"""√n-Band: kleine Themen deutlich unter 15, große über dem alten 18er-Cluster-Cap."""
|
||||
lo30, hi30 = bi._themen_zielband(30)
|
||||
assert lo30 >= 2 and hi30 < 15
|
||||
lo285, hi285 = bi._themen_zielband(285)
|
||||
assert hi285 > 18
|
||||
lo4, hi4 = bi._themen_zielband(4)
|
||||
assert lo4 >= 2 and hi4 > lo4
|
||||
# Monotonie: mehr Items → nie kleineres Band
|
||||
prev = (0, 0)
|
||||
for n in (4, 30, 100, 285, 1000):
|
||||
band = bi._themen_zielband(n)
|
||||
assert band >= prev
|
||||
prev = band
|
||||
|
||||
|
||||
def test_gruppierung_prompt_rendert_zielband(tmp_path):
|
||||
"""Template↔Call-Site-Vertrag: alle Platzhalter versorgt, Band-String im Prompt."""
|
||||
from pipeline import _prompt
|
||||
p = _prompt("Blocks-Gruppierung", topic="t", candidates="x", list="1. a",
|
||||
out_path=tmp_path / "g.json", theme_lo=4, theme_hi=8, n_items=9)
|
||||
assert "~4–8 themes" in p and "(9 items)" in p
|
||||
|
||||
|
||||
async def test_attach_nachzuegler_wird_sub_statt_zwergblock(testdb, tmp_path, monkeypatch):
|
||||
"""Nachzügler-Atom (Supplement-/Lücken-Welle) mit passendem fertigen Block →
|
||||
wird dessen Sub (Bottom-up), Karte → grouped/attach, Board-2-Karte → generate.
|
||||
Ohne Zuordnung bleibt das Atom standalone (Erstwelle: keine done_blocks → no-op)."""
|
||||
db = testdb
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
flow = _mk_flow(tmp_path)
|
||||
# fertiger Block mit Board-2-Karte
|
||||
await db.kanban_upsert_card(TOPIC, B, "b1", "block", "done_block",
|
||||
{"title": "Turingmaschinen", "description": "Modelle und Akzeptanz",
|
||||
"mirrored_norm": "turingmaschinen", "children": ["Palindrom-TM"]})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "turingmaschinen", "ablock", "done_artefact",
|
||||
{"title": "Turingmaschinen"})
|
||||
rows = [{"card_id": "n1", "payload": {"title": "Halteproblem", "description": "hält die TM?"},
|
||||
"title": "Halteproblem", "description": "hält die TM?", "title_norm": "halteproblem"}]
|
||||
await db.kanban_upsert_card(TOPIC, B, "n1", "block", "grouping", rows[0]["payload"])
|
||||
|
||||
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
assert "-gruppierung-attach-" in key and "Turingmaschinen" in prompt
|
||||
m = re.search(r"(/\S+\.json)", prompt) # files-Agent: out_path aus dem Prompt schreiben
|
||||
from pathlib import Path
|
||||
Path(m.group(1)).write_text('{"additions": [{"umbrella": 0, "members": [1]}]}', encoding="utf-8")
|
||||
return bi.OK, payload((0, "", ""))
|
||||
|
||||
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
|
||||
attached = await bi._attach_nachzuegler(ctx, flow, rows)
|
||||
assert attached == {"n1"}
|
||||
subs = {r["sub_norm"] for r in await db.list_subblocks(TOPIC, "turingmaschinen")}
|
||||
assert "halteproblem" in subs
|
||||
card = await db.kanban_get_card(TOPIC, B, "n1")
|
||||
assert card["stage"] == "grouped" and card["payload"]["reason"] == "attach"
|
||||
assert card["payload"]["merged_into"] == "Turingmaschinen"
|
||||
b2 = await db.kanban_get_card(TOPIC, "artefacts", "turingmaschinen")
|
||||
assert b2["stage"] == "generate" # Re-Anreicherung des neuen Subs
|
||||
block = await db.kanban_get_card(TOPIC, B, "b1")
|
||||
assert "Halteproblem" in block["payload"]["children"]
|
||||
|
||||
|
||||
async def test_attach_nachzuegler_ohne_zuordnung_bleibt_standalone(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
flow = _mk_flow(tmp_path)
|
||||
await db.kanban_upsert_card(TOPIC, B, "b1", "block", "done_block",
|
||||
{"title": "Turingmaschinen", "mirrored_norm": "turingmaschinen"})
|
||||
rows = [{"card_id": "n2", "payload": {"title": "Ganz neues Konzept"},
|
||||
"title": "Ganz neues Konzept", "description": "", "title_norm": "ganz neues konzept"}]
|
||||
await db.kanban_upsert_card(TOPIC, B, "n2", "block", "grouping", rows[0]["payload"])
|
||||
|
||||
async def fake_slot(ctx2, label, **kw):
|
||||
m = re.search(r"(/\S+\.json)", kw["prompt"])
|
||||
from pathlib import Path
|
||||
Path(m.group(1)).write_text('{"additions": []}', encoding="utf-8")
|
||||
return bi.OK, kw["payload"]((0, "", ""))
|
||||
|
||||
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
|
||||
assert await bi._attach_nachzuegler(ctx, flow, rows) == set()
|
||||
assert (await db.kanban_get_card(TOPIC, B, "n2"))["stage"] == "grouping"
|
||||
@@ -1,7 +1,7 @@
|
||||
"""E2E über die ECHTE Engine mit Fake-Agenten: kompletter Generierungspfad in Sekunden.
|
||||
|
||||
Anders als test_board_inventory (dort sind die Block-Funktionen gefakt) läuft hier alles
|
||||
bis run_agent echt — _race, Quorum, Panels, Konsolidierung, Cross-Block, QA-Gate.
|
||||
bis run_agent echt — _race, Quorum, Panels, In-Block-Konsolidierung, QA-Gate.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
|
||||
import board_inventory as bi
|
||||
from pipeline import GenContext
|
||||
from tests.invarianten import pruefe_invarianten, pruefe_guide_invarianten
|
||||
from invarianten import pruefe_invarianten, pruefe_guide_invarianten
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
@@ -42,11 +42,16 @@ async def test_e2e_thema_vollpfad(fake_welt, testdb, tmp_path):
|
||||
db = testdb
|
||||
done = [c for c in await db.kanban_cards(TOPIC, board="inventory", stage="done_block")]
|
||||
titel = {c["payload"]["title"] for c in done}
|
||||
# Bottom-up: 7 Atome zu 3 Themen-Clustern gruppiert
|
||||
assert titel == {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"}
|
||||
# Cross-Block-Dublette: „Gemeinsamer Grundbegriff" überlebt in genau EINEM Block
|
||||
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
|
||||
# jedes Atom liegt in genau EINEM Cluster — keine Cross-Block-Dublette mehr
|
||||
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
|
||||
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "variant"]
|
||||
assert [r["status"] for r in gemeinsam] == ["consensus"]
|
||||
# Vollständigkeit: alle 7 Atome sind als consensus-Sub erhalten
|
||||
consensus = {r["sub_norm"] for r in subs if r["status"] == "consensus"}
|
||||
assert consensus == {"definition alpha", "alpha eigenschaften", "gemeinsamer grundbegriff",
|
||||
"beta ablauf", "beta grenzen", "gamma praxisfall", "gamma werkzeuge"}
|
||||
fehler = await pruefe_invarianten(TOPIC, files)
|
||||
assert fehler == []
|
||||
|
||||
@@ -67,6 +72,27 @@ async def test_e2e_guide(fake_welt, testdb, tmp_path):
|
||||
assert await pruefe_guide_invarianten(TOPIC) == []
|
||||
|
||||
|
||||
async def test_e2e_only_artefacts(fake_welt, testdb, tmp_path):
|
||||
"""Artefakt-only (inventory=False): nach removeArtefacts läuft NUR Board 2 auf den
|
||||
fertigen Blocks — kein Board 1, keine Research-Agenten. Karten aus done_block geseedet."""
|
||||
ok, files = await _lauf(tmp_path)
|
||||
assert ok
|
||||
db = testdb
|
||||
subs_vorher = {r["sub_norm"] for r in await db.list_subblocks(TOPIC)}
|
||||
assert subs_vorher
|
||||
# Artefakte entfernen (Karten zurück auf generate, Board-1-Subs bleiben)
|
||||
await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
|
||||
fake_welt.calls.clear()
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
ok2 = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
|
||||
research=False, inventory=False), timeout=60)
|
||||
assert ok2
|
||||
assert not any("-research-" in k for k in fake_welt.calls) # Board 1 lief NICHT
|
||||
assert any("-sb-enrich-" in k or "-sb-verify-" in k for k in fake_welt.calls) # Board 2 lief
|
||||
assert {r["sub_norm"] for r in await db.list_subblocks(TOPIC)} == subs_vorher # vollständig
|
||||
|
||||
|
||||
async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
|
||||
"""Zweiter Lauf (Continue, research=False) hinterlässt keine Waisen/Reste."""
|
||||
ok, files = await _lauf(tmp_path)
|
||||
@@ -83,9 +109,8 @@ async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stoerung", [
|
||||
{"muster": r"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE
|
||||
{"muster": r"-sub-konsolidierung-.*-j1$", "modus": "garbage", "mal": 1}, # Retry heilt
|
||||
{"muster": r"-facts-c\d+$", "modus": "fehler", "mal": 1}, # Slot-Restart
|
||||
{"muster": r"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE
|
||||
{"muster": r"-art-gen-.*-t1$", "modus": "fehler", "mal": 1}, # Slot-Restart
|
||||
{"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot
|
||||
])
|
||||
async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
|
||||
@@ -96,35 +121,6 @@ async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
|
||||
assert await pruefe_invarianten(TOPIC, files) == []
|
||||
|
||||
|
||||
async def test_e2e_crossblock_dissent_failopen(fake_welt, testdb, tmp_path):
|
||||
"""j1 sagt a, j2 sagt b, j3 fällt aus → Paar bleibt (fail-open), Rest konsistent."""
|
||||
fake_welt.stoerungen += [
|
||||
{"muster": r"-sub-crossblock-.*-j2$", "modus": "antwort",
|
||||
"antwort": '{"pairs": {"1": "b"}}', "mal": 1, "rest": 1},
|
||||
{"muster": r"-sub-crossblock-.*-j3$", "modus": "fehler", "mal": 3, "rest": 3},
|
||||
]
|
||||
ok, files = await _lauf(tmp_path)
|
||||
assert ok
|
||||
db = testdb
|
||||
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
|
||||
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
|
||||
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "consensus"] # kein Fold
|
||||
assert await pruefe_invarianten(TOPIC, files) == []
|
||||
|
||||
|
||||
async def test_e2e_inblock_gruppe_faltet(fake_welt, testdb, tmp_path):
|
||||
"""Welt-Regel: „Alpha Eigenschaften" faltet unter „Definition Alpha" — beide Judges
|
||||
liefern die Gruppe, der Verlierer wird variant, seine facts wandern zum Gewinner."""
|
||||
fake_welt.gruppen.append(("Definition Alpha", ["Alpha Eigenschaften"]))
|
||||
ok, files = await _lauf(tmp_path)
|
||||
assert ok
|
||||
db = testdb
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha-konzept")}
|
||||
assert rows.get("alpha eigenschaften") == "variant"
|
||||
assert rows.get("definition alpha") == "consensus"
|
||||
assert await pruefe_invarianten(TOPIC, files) == []
|
||||
|
||||
|
||||
async def test_e2e_gate_vollinventur_ohne_fix(fake_welt, testdb, tmp_path):
|
||||
"""Gate-Judge liefert eine Voll-Inventur (belegte Claims mit „Belegt…"-Grund) —
|
||||
der Schema-Filter wirft sie raus, es läuft KEIN Fakten-Fix."""
|
||||
@@ -188,10 +184,11 @@ async def test_e2e_uni_anker_gate(fake_welt, testdb, tmp_path, monkeypatch):
|
||||
assert ok
|
||||
db = testdb
|
||||
alle = [dict(c) for c in await db.kanban_cards(TOPIC, board="inventory")]
|
||||
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Kanon-Klassiker"
|
||||
# das unbelegte Atom „Klassiker Detail" (einziges Atom von Kanon-Klassiker) wird rejected
|
||||
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Klassiker Detail"
|
||||
for c in alle)
|
||||
assert not any(c["kind"] == "block" and c["payload"].get("title") == "Kanon-Klassiker"
|
||||
for c in alle) # nie zum Block geworden
|
||||
assert not any(c["payload"].get("title") in ("Klassiker Detail", "Kanon-Klassiker")
|
||||
and c["stage"] == "done_block" for c in alle) # nie zum Block geworden
|
||||
done = {c["payload"].get("title") for c in alle
|
||||
if c["kind"] == "block" and c["stage"] == "done_block"}
|
||||
assert {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"} <= done
|
||||
+116
-38
@@ -131,8 +131,8 @@ async def test_guide_error_event(testdb):
|
||||
|
||||
def test_timeout_calibration_smoke():
|
||||
from pipeline import _timeout
|
||||
assert _timeout("subblock", 10) == 400 + 150
|
||||
assert _timeout("content", 10) == 450 + 300
|
||||
assert _timeout("subblock_check", 10) == 150 + 100
|
||||
assert _timeout("writer", 10) == 450 + 600
|
||||
|
||||
|
||||
def test_env_file_wins(tmp_path, monkeypatch):
|
||||
@@ -146,41 +146,6 @@ def test_env_file_wins(tmp_path, monkeypatch):
|
||||
assert os.environ["X_CREATOR_TESTKEY"] == "neu"
|
||||
|
||||
|
||||
async def test_restart_artefact_card_wipes_only_that_block(testdb):
|
||||
import board_inventory as bi
|
||||
db = testdb
|
||||
for norm in ("alpha", "beta"):
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact",
|
||||
{"title": norm.title(), "raw": {norm: ["S"]}, "facts": {}})
|
||||
await db.upsert_subblock(TOPIC, norm, "s1", norm.title(), "Sub Eins")
|
||||
await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?")
|
||||
await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}")
|
||||
assert await bi.restart_artefact_card(TOPIC, "alpha") is True
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "subblocks"
|
||||
assert await db.list_subblocks(TOPIC, "alpha") == []
|
||||
assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched
|
||||
assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False
|
||||
|
||||
|
||||
async def test_guide_reset_card_single(testdb):
|
||||
import guide_board as gb
|
||||
db = testdb
|
||||
for n in ("alpha", "beta"):
|
||||
await db.upsert_guide_card(TOPIC, "Guide", n, n.title())
|
||||
await db.set_guide_card(TOPIC, "Guide", n, stage="done", status="ok",
|
||||
writer_rounds=2, md="# SECTION Text", gate_info="x")
|
||||
await db.put_lernziel(TOPIC, n, "z1", "Ziel eins")
|
||||
assert await gb.reset_card(TOPIC, "Guide", "alpha", 0) is True
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||
assert cards["alpha"]["stage"] == "lernziele" and cards["alpha"]["md"] == "" and cards["alpha"]["writer_rounds"] == 0
|
||||
assert cards["beta"]["stage"] == "done" and cards["beta"]["md"] # untouched
|
||||
assert await db.list_lernziele(TOPIC) and all(z["block_norm"] != "alpha" for z in await db.list_lernziele(TOPIC))
|
||||
# ab_stage 3 (fakten_gate) behält md
|
||||
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||
assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"]
|
||||
|
||||
|
||||
async def test_completeness_route(testdb, tmp_path, monkeypatch):
|
||||
import routes, paths
|
||||
db = testdb
|
||||
@@ -253,7 +218,7 @@ async def test_opencode_cmd_sets_title(monkeypatch):
|
||||
"""Der Agent-Key wird Session-Titel — der Join-Schlüssel fürs Token-Logging."""
|
||||
seen = {}
|
||||
|
||||
async def fake_comm(agent_key, cmd, stdin, timeout, stagger=False, on_line=None, label="", env=None):
|
||||
async def fake_comm(agent_key, cmd, stdin, timeout, stagger=False, on_line=None, label="", env=None, est_kb=None):
|
||||
seen["cmd"] = cmd
|
||||
return 0, "", ""
|
||||
|
||||
@@ -317,3 +282,116 @@ async def test_events_run_summary_aggregates(testdb):
|
||||
assert s["agents"]["gesamt"] == 2 and s["agents"]["ok"] == 1 and s["agents"]["timeout"] == 1
|
||||
assert s["agents"]["verlorene_min"] == 2
|
||||
assert s["tokens"] == {"input": 15, "output": 2, "cache_read": 80, "cache_write": 1}
|
||||
|
||||
|
||||
async def test_events_run_summary_per_board(testdb):
|
||||
"""meta.board scoped die Aggregation: board="artefacts" summiert nur Artefakt-Agenten."""
|
||||
db = testdb
|
||||
db.set_current_run(TOPIC, "r2")
|
||||
await db.add_event(TOPIC, "agent", key="inv", status="ok", dur_ms=1000,
|
||||
meta={"board": "inventory", "tokens": {"input": 10, "output": 2}})
|
||||
await db.add_event(TOPIC, "agent", key="art", status="ok", dur_ms=2000,
|
||||
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
|
||||
db.set_current_run(TOPIC, None)
|
||||
art = await db.events_run_summary(TOPIC, "r2", board="artefacts")
|
||||
assert art["agents"]["gesamt"] == 1
|
||||
assert art["tokens"] == {"input": 5, "output": 7, "cache_read": 0, "cache_write": 0}
|
||||
inv = await db.events_run_summary(TOPIC, "r2", board="inventory")
|
||||
assert inv["agents"]["gesamt"] == 1 and inv["tokens"]["input"] == 10
|
||||
assert (await db.events_run_summary(TOPIC, "r2"))["agents"]["gesamt"] == 2 # ohne board = alle
|
||||
|
||||
|
||||
async def test_latest_board_runs_je_ebene(testdb):
|
||||
"""Je Ebene der jüngste Lauf MIT Daten: ein „Nur Artefakte"-Lauf (r2) lässt die
|
||||
Inventar-Anzeige auf r1 stehen; Guide-Läufe (Suffix -g+hex4) haben eine eigene Zeile."""
|
||||
db = testdb
|
||||
assert await db.latest_board_runs(TOPIC) == {"inventory": None, "artefacts": None, "guide": None}
|
||||
db.set_current_run(TOPIC, "20260708-1000-aaaa") # Voll-Lauf: beide Ebenen
|
||||
await db.add_event(TOPIC, "agent", key="i1", status="ok",
|
||||
meta={"board": "inventory", "tokens": {"input": 10, "output": 1}})
|
||||
await db.add_event(TOPIC, "agent", key="a1", status="ok", meta={"board": "artefacts"})
|
||||
db.set_current_run(TOPIC, "20260708-1100-bbbb") # „Nur Artefakte"
|
||||
await db.add_event(TOPIC, "agent", key="a2", status="ok",
|
||||
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
|
||||
db.set_current_run(TOPIC, "20260708-1200-gabc1") # Guide (Events untagged)
|
||||
await db.add_event(TOPIC, "agent", key="g1", status="ok",
|
||||
meta={"tokens": {"input": 3, "output": 4}})
|
||||
latest = await db.latest_board_runs(TOPIC)
|
||||
assert latest["inventory"]["run_id"] == "20260708-1000-aaaa"
|
||||
assert latest["inventory"]["tokens"]["input"] == 10
|
||||
assert latest["artefacts"]["run_id"] == "20260708-1100-bbbb"
|
||||
assert latest["artefacts"]["tokens"]["output"] == 7
|
||||
assert latest["guide"]["run_id"] == "20260708-1200-gabc1"
|
||||
assert latest["guide"]["tokens"]["input"] == 3
|
||||
# aktiv folgt der Registry: nur der Guide-Lauf ist noch der aktuelle
|
||||
assert latest["guide"]["aktiv"] and not latest["inventory"]["aktiv"]
|
||||
db.set_current_run(TOPIC, None)
|
||||
|
||||
|
||||
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 set(res["latest"]) == {"inventory", "artefacts", "guide"} # je Ebene letzter Lauf mit Daten
|
||||
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
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Finalize (board_artefacts._proc_finalize) und blocks-Helfer — Judges gefaked, gegen
|
||||
Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-Umbau in
|
||||
block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
from kanban import Flow
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "finalize"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
|
||||
|
||||
|
||||
def test_neg_set_lemmatisiert():
|
||||
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
|
||||
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
|
||||
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
|
||||
assert a == b == frozenset({"kein"})
|
||||
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
|
||||
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
|
||||
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_finalize(_ctx(), flow, files, [card])
|
||||
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
|
||||
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
|
||||
|
||||
|
||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||||
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
|
||||
"sidecar": {}, "pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
assert row["level"] == "advanced" and row["relevance"] == "relevant"
|
||||
|
||||
|
||||
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
|
||||
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
|
||||
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
|
||||
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows == {"neu": "consensus", "alte-variante": "variant"}
|
||||
@@ -30,48 +30,42 @@ def test_gate_schema():
|
||||
assert [c["text"] for c in claims] == ["B"]
|
||||
|
||||
|
||||
def test_coverage_schema():
|
||||
res = gb._coverage_schema({"ziele": {"z1": True, "z2": "false"},
|
||||
def test_pruefer_schema():
|
||||
"""Verschmolzenes Verdikt: ok-Kurzform, Claims-Normalisierung, Ziel-Vollständigkeit."""
|
||||
assert gb._pruefer_schema({"ok": True}, {"z1"}) == {
|
||||
"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
|
||||
res = gb._pruefer_schema({"claims": [{"text": "A", "grund": "x", "urteil": "FALSCH"}],
|
||||
"ziele": {"z1": True, "z2": "false"},
|
||||
"luecken": [{"ziel": "z2", "fehlt": "Beweis"}],
|
||||
"ballast": ["Abschweifung"]}, {"z1", "z2"})
|
||||
"ballast": ["Abschweifung"],
|
||||
"lese_probleme": [{"problem": "zu lang"}]}, {"z1", "z2"})
|
||||
assert res["claims"] == [{"text": "A", "grund": "x", "urteil": "falsch"}]
|
||||
assert res["ziele"] == {"z1": True, "z2": False}
|
||||
assert res["luecken"][0]["fehlt"] == "Beweis"
|
||||
assert gb._coverage_schema({"ziele": {"z1": True}}, {"z1", "z2"}) is None # z2 missing
|
||||
assert res["luecken"][0]["fehlt"] == "Beweis" and res["lese_probleme"] == ["zu lang"]
|
||||
assert gb._pruefer_schema({"ziele": {"z1": True}}, {"z1", "z2"}) is None # z2 fehlt
|
||||
assert gb._pruefer_schema({"lese_probleme": []}, set()) is not None # leeres Verdikt ok
|
||||
assert gb._pruefer_schema("quatsch", set()) is None
|
||||
|
||||
|
||||
def test_problems_schema():
|
||||
assert gb._problems_schema({"ok": True}) == []
|
||||
assert gb._problems_schema({"problems": [{"section": "S", "problem": "zu lang"}]}) == ["zu lang"]
|
||||
assert gb._problems_schema({"problems": []}) is None
|
||||
def test_auftraege_schwelle_und_kritisch():
|
||||
"""1–2 nur-unbelegt-Claims verfallen (GATE_FIX_MIN); falsch/Lücken sind kritisch."""
|
||||
leer = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
|
||||
z, k = gb._auftraege({**leer, "claims": [{"text": "c", "grund": "", "urteil": "unbelegt"}] * 2}, [])
|
||||
assert z == [] and k is False
|
||||
z, k = gb._auftraege({**leer, "claims": [{"text": "c", "grund": "w", "urteil": "falsch"}]}, [])
|
||||
assert len(z) == 1 and k is True
|
||||
z, k = gb._auftraege({**leer, "luecken": [{"ziel": "z1", "fehlt": "X"}]}, ["Länge 2000"])
|
||||
assert len(z) == 2 and k is True
|
||||
|
||||
|
||||
async def test_reset_from_stage(testdb):
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "a", "A")
|
||||
await db.upsert_guide_card(TOPIC, FMT, "b", "B")
|
||||
await db.set_guide_card(TOPIC, FMT, "a", stage="done", md="text", writer_rounds=2)
|
||||
await db.set_guide_card(TOPIC, FMT, "b", stage="coverage", md="text")
|
||||
await db.put_lernziel(TOPIC, "a", "z1", "Ziel")
|
||||
# reset ab writer (idx 2): beide Karten zurück, md geleert, Ziele bleiben
|
||||
moved = await gb.reset_from_stage(TOPIC, FMT, 2)
|
||||
assert moved == 2
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
|
||||
assert cards["a"]["stage"] == "writer" and cards["a"]["md"] == "" and cards["a"]["writer_rounds"] == 0
|
||||
assert cards["b"]["stage"] == "writer"
|
||||
assert await db.list_lernziele(TOPIC, "a")
|
||||
# reset ab lernziele (idx 0): Ziele weg
|
||||
await gb.reset_from_stage(TOPIC, FMT, 0)
|
||||
assert not await db.list_lernziele(TOPIC, "a")
|
||||
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "lernziele"
|
||||
|
||||
|
||||
async def test_done_step(testdb):
|
||||
db = testdb
|
||||
assert await gb.done_step(TOPIC, FMT) == -1
|
||||
await db.upsert_guide_card(TOPIC, FMT, "a", "A")
|
||||
assert await gb.done_step(TOPIC, FMT) == -1 # alles in lernziele
|
||||
await db.set_guide_card(TOPIC, FMT, "a", stage="coverage")
|
||||
assert await gb.done_step(TOPIC, FMT) == 3 # bis fakten_gate fertig
|
||||
await db.set_guide_card(TOPIC, FMT, "a", stage="pruefer")
|
||||
assert await gb.done_step(TOPIC, FMT) == 2 # bis writer fertig
|
||||
await db.set_guide_card(TOPIC, FMT, "a", stage="done")
|
||||
assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES)
|
||||
|
||||
@@ -162,8 +156,8 @@ def test_writer_template_has_examples_placeholder():
|
||||
assert "VERIFIED FACTS" in text and "2000 characters" in text
|
||||
|
||||
|
||||
async def test_fakten_gate_counts_examples_as_facts(testdb, monkeypatch, tmp_path):
|
||||
"""Gate-Prompt enthält die Beispiele als verifizierte Fakten — sonst fliegen
|
||||
async def test_pruefer_counts_examples_as_facts(testdb, monkeypatch, tmp_path):
|
||||
"""Prüfer-Prompt enthält die Beispiele als verifizierte Fakten — sonst fliegen
|
||||
gerechnete Beispielwerte als „nicht belegt" raus."""
|
||||
import json as _json
|
||||
import guide_board as gb
|
||||
@@ -177,18 +171,19 @@ async def test_fakten_gate_counts_examples_as_facts(testdb, monkeypatch, tmp_pat
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
captured["prompt"] = prompt
|
||||
return "ok", []
|
||||
return "ok", payload((0, '{"ok": true}', ""))
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "FAKT X")
|
||||
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
|
||||
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
|
||||
guide_id="g", topic="t", format="Guide", instructions="",
|
||||
subs_by_title={"Gross": [{"title": "Sub Eins", "level": "beginner"}]},
|
||||
spec="", slot=lambda name: tmp_path / name)
|
||||
card = {"block_norm": "gross", "block": "Gross", "stage": "fakten_gate", "status": "open",
|
||||
"writer_rounds": 0, "gate_info": "",
|
||||
"md": "<!-- section: Gross -->\n<!-- ausführlich -->\nText."}
|
||||
ok = await gb._stage_fakten_gate(env, card)
|
||||
md = ("<!-- section: Gross -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20)
|
||||
card = {"block_norm": "gross", "block": "Gross", "stage": "pruefer", "status": "open",
|
||||
"writer_rounds": 0, "gate_info": "", "md": md}
|
||||
ok = await gb._stage_pruefer(env, card)
|
||||
assert ok is True
|
||||
assert "VERIFIED WORKED EXAMPLES" in captured["prompt"] and "P1" in captured["prompt"]
|
||||
|
||||
@@ -229,7 +224,7 @@ async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path
|
||||
from textkit import _parse_fragment
|
||||
secs = _parse_fragment(card["md"])
|
||||
assert len(secs) == 1 and [s["title"] for s in secs[0]["subs"]] == ["Sub 1", "Sub 2"]
|
||||
assert card["stage"] == "fakten_gate"
|
||||
assert card["stage"] == "pruefer"
|
||||
|
||||
|
||||
async def test_lernziele_retry_bei_leerer_liste(testdb, tmp_path, monkeypatch):
|
||||
@@ -268,8 +263,8 @@ async def test_lernziele_zweimal_leer_laeuft_weiter(testdb, tmp_path, monkeypatc
|
||||
assert not await db.list_lernziele(TOPIC, "alpha")
|
||||
|
||||
|
||||
async def test_lese_check_text_sink(testdb, tmp_path, monkeypatch):
|
||||
"""Lese-Check antwortet als Text, Engine-Sink persistiert; capabilities none."""
|
||||
async def test_pruefer_text_sink_ohne_befund_done(testdb, tmp_path, monkeypatch):
|
||||
"""Prüfer antwortet als Text (Engine-Sink, capabilities none); ohne Befund → done."""
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
env = gb._Env(None, "g-l", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
|
||||
@@ -284,7 +279,7 @@ async def test_lese_check_text_sink(testdb, tmp_path, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
|
||||
assert await gb._stage_lesbarkeit(env, card)
|
||||
assert await gb._stage_pruefer(env, card)
|
||||
assert seen["caps"] == "none"
|
||||
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "done"
|
||||
|
||||
@@ -304,27 +299,25 @@ async def test_writer_prompt_traegt_budget(testdb, tmp_path, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
await gb._stage_writer(env, card)
|
||||
assert str(gb._writer_budget(2)) in seen["prompt"] # 800 + 2×400
|
||||
assert str(gb.block_budget(env.subs_by_title["Alpha"])) in seen["prompt"] # 2× Basis-Budget
|
||||
|
||||
|
||||
async def test_fakten_gate_schwelle(testdb, tmp_path, monkeypatch):
|
||||
"""1–2 Claims → kein Fix-Rewrite (nur Log); ab GATE_FIX_MIN läuft der Fix."""
|
||||
async def test_pruefer_claims_schwelle(testdb, tmp_path, monkeypatch):
|
||||
"""1–2 nur-unbelegt-Claims → kein Fix (Karte direkt done); die Schwelle lebt in _auftraege."""
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
env = gb._Env(None, "g-g", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
|
||||
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\nText."
|
||||
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md}
|
||||
keys = []
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
keys.append(key)
|
||||
if "-gate-" in key:
|
||||
return gb.OK, [{"text": "c1", "grund": ""}, {"text": "c2", "grund": ""}]
|
||||
raise AssertionError("Fix darf unter der Schwelle nicht laufen")
|
||||
antwort = '{"claims": [{"text": "c1", "grund": ""}, {"text": "c2", "grund": ""}]}'
|
||||
return gb.OK, payload((0, antwort, ""))
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
assert await gb._stage_fakten_gate(env, card)
|
||||
assert all("-gate-" in k for k in keys)
|
||||
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
|
||||
assert await gb._stage_pruefer(env, card)
|
||||
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "done"
|
||||
|
||||
|
||||
async def test_load_subblocks_defaultet_levellose(testdb):
|
||||
@@ -340,30 +333,46 @@ async def test_load_subblocks_defaultet_levellose(testdb):
|
||||
assert by_title == {"Mit Level": "beginner", "Ohne Level": "advanced"}
|
||||
|
||||
|
||||
async def test_fakten_gate_falsch_claim_erzwingt_fix(testdb, tmp_path, monkeypatch):
|
||||
"""Ein einzelner FALSCH-Claim läuft in den Fix, auch unter GATE_FIX_MIN —
|
||||
ein durchgerutschter kostete den Guide 1.5 QA-Punkte."""
|
||||
async def test_falsch_claim_erzwingt_fix_und_repruefer(testdb, tmp_path, monkeypatch):
|
||||
"""Ein einzelner FALSCH-Claim läuft in den Fix, auch unter GATE_FIX_MIN; nach
|
||||
angewandtem Fix läuft GENAU EIN Re-Prüfer-Pass (der alte Lese-Fix blieb ungeprüft)."""
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
env = gb._Env(None, "g-gf", 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}
|
||||
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md, "gate_info": ""}
|
||||
keys = []
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
keys.append(key)
|
||||
if "-gate-" in key:
|
||||
return gb.OK, [{"text": "c1", "grund": "widerspricht", "urteil": "falsch"}]
|
||||
return gb.FAILED, None # Fix-Agent liefert nichts — Text bleibt, aber der Call MUSS kommen
|
||||
if "-pruef-" in key and key.endswith("-re"):
|
||||
return gb.OK, payload((0, '{"ok": true}', ""))
|
||||
if "-pruef-" in key:
|
||||
return gb.OK, payload((0, '{"claims": [{"text": "c1", "grund": "widerspricht", "urteil": "falsch"}]}', ""))
|
||||
if "-gfix-" in key: # Fix liefert eine valide Section über die Slot-Datei
|
||||
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)
|
||||
raise AssertionError(key)
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
assert await gb._stage_fakten_gate(env, card)
|
||||
assert any("-gatefix-" in k for k in keys)
|
||||
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
|
||||
assert await gb._stage_pruefer(env, card)
|
||||
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
|
||||
assert karte["stage"] == "fix" and karte["gate_info"].startswith("KRITISCH")
|
||||
card.update(stage="fix", gate_info=karte["gate_info"])
|
||||
assert await gb._stage_fix(env, card)
|
||||
assert any("-gfix-" in k for k in keys)
|
||||
assert any(k.endswith("-re") for k in keys) # Re-Prüfer lief
|
||||
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
|
||||
assert karte["stage"] == "done" and "Korrigiert." in karte["md"]
|
||||
|
||||
|
||||
async def test_laengen_trigger_startet_lesefix(testdb, tmp_path, monkeypatch):
|
||||
"""Ausführlich-Teil über der Obergrenze → deterministisches Längen-Problem
|
||||
mit hartem Zeichenziel landet im Lese-Fix-Auftrag."""
|
||||
async def test_laengen_trigger_startet_fix(testdb, tmp_path, monkeypatch):
|
||||
"""Ausführlich-Teil über der Obergrenze → deterministisches Längen-Problem mit hartem
|
||||
Zeichenziel landet als Auftrag in der Fix-Stage (unkritisch → kein Re-Prüfer)."""
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
env = gb._Env(None, "g-lz", TOPIC, FMT, "", tmp_path / "Guide.json",
|
||||
@@ -371,16 +380,153 @@ async def test_laengen_trigger_startet_lesefix(testdb, tmp_path, monkeypatch):
|
||||
{}, "(q)", "spec")
|
||||
md = ("<!-- section: Alpha -->\n<!-- compact -->\n- x\n<!-- ausführlich -->\n"
|
||||
+ "Viel zu langer Sockeltext. " * 80) # ~2160 Z./Sub > 1200×0.9
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md}
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md, "gate_info": ""}
|
||||
seen = {}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
if "-lesefix-" in key:
|
||||
seen["tasks"] = prompt
|
||||
if "-gfix-" in key:
|
||||
seen["auftraege"] = prompt
|
||||
return gb.FAILED, None
|
||||
return gb.OK, payload((0, '{"ok": true}', "")) # Lese-Check: keine Probleme
|
||||
if key.endswith("-re"):
|
||||
raise AssertionError("unkritischer Befund darf keinen Re-Prüfer starten")
|
||||
return gb.OK, payload((0, '{"ok": true}', "")) # Prüfer: keine LLM-Befunde
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
|
||||
assert await gb._stage_lesbarkeit(env, card)
|
||||
assert "Länge" in seen["tasks"] and str(gb._writer_budget(1)) in seen["tasks"]
|
||||
assert await gb._stage_pruefer(env, card)
|
||||
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "fix"
|
||||
card.update(stage="fix", gate_info=(await db.list_guide_cards(TOPIC, FMT))[0]["gate_info"])
|
||||
assert await gb._stage_fix(env, card)
|
||||
budget = gb.block_budget(env.subs_by_title["Alpha"])
|
||||
assert "Länge" in seen["auftraege"] and f"etwa {budget} Zeichen GESAMT" in seen["auftraege"]
|
||||
|
||||
|
||||
async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, monkeypatch):
|
||||
"""Guide-Repair: Karten mit QA-Befunden (alle Befundklassen-Formate) → pruefer,
|
||||
md bleibt; Karten ohne Befund unangetastet. Kein Report → leere Liste."""
|
||||
import json as _json
|
||||
import qa as qa_mod
|
||||
db = testdb
|
||||
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
|
||||
for n in ("alpha", "beta", "gamma"):
|
||||
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
|
||||
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
|
||||
assert await gb.repair_karten(TOPIC, FMT) == [] # kein Report
|
||||
tdir = tmp_path / "qa" / TOPIC
|
||||
tdir.mkdir(parents=True)
|
||||
(tdir / "guide-20260705-000000.json").write_text(_json.dumps({
|
||||
"marker_fehlend": ["Alpha · sub eins"],
|
||||
"fachlich_falsch": ["Beta"],
|
||||
"ziel_ohne_anker": [], "laengen_ausreisser": [], "redundanz": [], "lesbarkeit": [],
|
||||
}), encoding="utf-8")
|
||||
betroffen = await gb.repair_karten(TOPIC, FMT)
|
||||
assert sorted(betroffen) == ["Alpha", "Beta"]
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
|
||||
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "pruefer"
|
||||
assert cards["alpha"]["md"] # Text bleibt — der Prüfer arbeitet auf dem Bestand
|
||||
assert cards["gamma"]["stage"] == "done"
|
||||
|
||||
|
||||
async def test_repair_karten_stil_befunde_direkt_auf_fix(testdb, tmp_path, monkeypatch):
|
||||
"""Nur-Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit
|
||||
Auftragszeilen im gate_info (KEIN KRITISCH-Präfix → ein Rewrite ohne Re-Prüfer);
|
||||
die Redundanz-Zeile trägt den Cross-Karten-Kontext, den der Prüfer nie sieht."""
|
||||
import json as _json
|
||||
import qa as qa_mod
|
||||
db = testdb
|
||||
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
|
||||
for n in ("alpha", "beta", "gamma"):
|
||||
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
|
||||
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
|
||||
tdir = tmp_path / "qa" / TOPIC
|
||||
tdir.mkdir(parents=True)
|
||||
(tdir / "guide-20260705-000001.json").write_text(_json.dumps({
|
||||
"marker_fehlend": [], "fachlich_falsch": ["Gamma"], "ziel_ohne_anker": [],
|
||||
"redundanz": [{"a": "Alpha: gemeinsamer Absatztext", "b": "Beta: gemeinsamer Absatztext"}],
|
||||
"laengen_ausreisser": [{"block": "Beta", "zeichen": 9000, "budget": 3000}],
|
||||
"lesbarkeit": ["Alpha: Sätze kürzen"],
|
||||
}), encoding="utf-8")
|
||||
betroffen = await gb.repair_karten(TOPIC, FMT)
|
||||
assert sorted(betroffen) == ["Alpha", "Beta", "Gamma"]
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
|
||||
assert cards["gamma"]["stage"] == "pruefer" # kritisch → Prüfer
|
||||
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "fix"
|
||||
assert not cards["beta"]["gate_info"].startswith("KRITISCH")
|
||||
assert "BALLAST" in cards["beta"]["gate_info"] and "Alpha" in cards["beta"]["gate_info"]
|
||||
assert "LÄNGE" in cards["beta"]["gate_info"] and "3000" in cards["beta"]["gate_info"]
|
||||
assert "LESBARKEIT: Sätze kürzen" in cards["alpha"]["gate_info"]
|
||||
|
||||
|
||||
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
|
||||
@@ -35,10 +35,24 @@ def test_ziel_ohne_anker():
|
||||
|
||||
|
||||
def test_laengen_ausreisser():
|
||||
"""Budget-Band statt Festrahmen: zu dünn und zu dick fallen auf, ohne Budget kein Urteil."""
|
||||
duenn = _card("Alpha", "<!-- ausführlich -->\nkurz")
|
||||
ok = _card("Beta", "<!-- ausführlich -->\n" + "x" * 500)
|
||||
out = gq.laengen_ausreisser([duenn, ok], {"alpha": {"s1"}, "beta": {"s1"}})
|
||||
assert [x["block"] for x in out] == ["Alpha"]
|
||||
dick = _card("Gamma", "<!-- ausführlich -->\n" + "x" * 2000)
|
||||
ohne = _card("Delta", "<!-- ausführlich -->\nkurz")
|
||||
budgets = {"alpha": 500, "beta": 500, "gamma": 500} # delta: kein Inventar → übersprungen
|
||||
out = gq.laengen_ausreisser([duenn, ok, dick, ohne], budgets)
|
||||
assert [x["block"] for x in out] == ["Alpha", "Gamma"]
|
||||
assert out[1]["zeichen"] >= 2000 and out[1]["budget"] == 500
|
||||
|
||||
|
||||
def test_budget_aus_substanz():
|
||||
"""Dichte Subs bekommen mehr Budget; periphere zählen nicht."""
|
||||
dicht = {"relevance": "relevant", "facts": {"key_points": ["a", "b"], "cited_facts": [{}], "example_idea": "x"}}
|
||||
duenn = {"relevance": "relevant", "facts": {}}
|
||||
peripher = {"relevance": "peripheral", "facts": {"key_points": ["a"] * 9}}
|
||||
assert gq.sub_budget(dicht["facts"]) > gq.sub_budget(duenn["facts"])
|
||||
assert gq.block_budget([dicht, duenn, peripher]) == gq.block_budget([dicht, duenn])
|
||||
|
||||
|
||||
def test_redundanz_findet_absatz_doppel():
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Guide-Ebene der Auto-Sequenz: generieren → QA → Repair-Loop bis 100 %/Stillstand."""
|
||||
import blocks
|
||||
import guide
|
||||
import guide_qa
|
||||
import guide_board
|
||||
|
||||
|
||||
def _patch(monkeypatch, noten, calls):
|
||||
async def fake_gen(*a, **k):
|
||||
calls["gen"] += 1
|
||||
|
||||
async def fake_rep(topic, fmt):
|
||||
calls["rep"] += 1
|
||||
return []
|
||||
|
||||
async def fake_qa(topic, llm=False):
|
||||
return {"note_guide": noten.pop(0) if len(noten) > 1 else noten[0]}
|
||||
|
||||
monkeypatch.setattr(guide, "generate_guide", fake_gen)
|
||||
monkeypatch.setattr(guide_board, "repair_karten", fake_rep)
|
||||
monkeypatch.setattr(guide_qa, "guide_qa_report", fake_qa)
|
||||
|
||||
|
||||
async def test_guide_fertig_ohne_repair(testdb, monkeypatch):
|
||||
blocks._blocks_errors.pop("G1", None)
|
||||
calls = {"gen": 0, "rep": 0}
|
||||
_patch(monkeypatch, [10.0], calls)
|
||||
await blocks._guide_ebene("G1", "", "claude", lambda: False, lambda *a, **k: None)
|
||||
assert calls["gen"] == 1 and calls["rep"] == 0 # nur initial gebaut
|
||||
assert "G1" not in blocks._blocks_errors
|
||||
|
||||
|
||||
async def test_guide_repariert_bis_100(testdb, monkeypatch):
|
||||
blocks._blocks_errors.pop("G2", None)
|
||||
calls = {"gen": 0, "rep": 0}
|
||||
_patch(monkeypatch, [7.0, 10.0], calls) # erst 7.0, nach einem Repair 10.0
|
||||
await blocks._guide_ebene("G2", "", "claude", lambda: False, lambda *a, **k: None)
|
||||
assert calls["rep"] == 1
|
||||
assert "G2" not in blocks._blocks_errors
|
||||
|
||||
|
||||
async def test_guide_stillstand_meldet_systemfehler(testdb, monkeypatch):
|
||||
blocks._blocks_errors.pop("G3", None)
|
||||
calls = {"gen": 0, "rep": 0}
|
||||
_patch(monkeypatch, [6.0], calls) # Repair bewegt die Note nie
|
||||
await blocks._guide_ebene("G3", "", "claude", lambda: False, lambda *a, **k: None)
|
||||
assert "G3" in blocks._blocks_errors
|
||||
assert "Guide" in blocks._blocks_errors["G3"]
|
||||
@@ -1,608 +0,0 @@
|
||||
"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
|
||||
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
from kanban import Flow
|
||||
from pipeline import FAILED, OK, GenContext
|
||||
|
||||
TOPIC = "konsolidierung"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def _fake_slot(antworten):
|
||||
"""run_single_slot-Fake: pro Judge-Key eine Antwort; schreibt via payload (wie der Engine-Sink)."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
j = key.rsplit("-", 1)[-1] # "j1"/"j2"
|
||||
antwort = antworten.get(j)
|
||||
if antwort is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(antwort), ""))
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def _seed_block(db, bnorm, subs):
|
||||
for s in subs:
|
||||
await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus")
|
||||
|
||||
|
||||
# ── In-Block ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_merge_on_unanimity(testdb, tmp_path, monkeypatch):
|
||||
"""Beide Judges gruppieren 1+2 → Gewinner (mehr key_points) bleibt, facts-Union,
|
||||
Verlierer wird DB-variant und fliegt aus raw/facts_map."""
|
||||
db = testdb
|
||||
subs = ["Durchstreichung: ~~text~~", "Durchstreichung: ~~text~~ streicht Text durch", "Fett: **text**"]
|
||||
await _seed_block(db, "betonung", subs)
|
||||
raw = {"Betonung": list(subs)}
|
||||
facts = {"Betonung": {
|
||||
blocks._norm_title(subs[0]): {"key_points": ["kp-a"], "cited_facts": [{"text": "z1"}]},
|
||||
blocks._norm_title(subs[1]): {"key_points": ["kp-b", "kp-c"], "cited_facts": [{"text": "z1"}, {"text": "z2"}]},
|
||||
}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": ["Marker-Escaping fehlt"]},
|
||||
"j2": {"gruppen": [[2, 1]], "luecken": ["Escaping von Markern"]}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Betonung"] == [subs[1], "Fett: **text**"] # Gewinner: 2 key_points > 1
|
||||
wf = facts["Betonung"][blocks._norm_title(subs[1])]
|
||||
assert wf["key_points"] == ["kp-b", "kp-c", "kp-a"]
|
||||
assert wf["cited_facts"] == [{"text": "z1"}, {"text": "z2"}] # Union ohne Doppel
|
||||
assert blocks._norm_title(subs[0]) not in facts["Betonung"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "betonung")}
|
||||
assert rows[blocks._norm_title(subs[0])] == "variant"
|
||||
assert rows[blocks._norm_title(subs[1])] == "consensus"
|
||||
journale = list(tmp_path.glob("sub-konsolidierung-*.json"))
|
||||
j = json.loads([p for p in journale if "-j" not in p.stem][0].read_text())
|
||||
# Lücken-Schnitt: Token-Überlappung beider Judges, Formulierung von j1 gewinnt
|
||||
assert j["gruppen"][0]["behalten"] == subs[1] and j["luecken"] == ["Marker-Escaping fehlt"]
|
||||
|
||||
|
||||
async def test_dissent_keeps_everything(testdb, tmp_path, monkeypatch):
|
||||
"""Nur ein Judge gruppiert → keine Einstimmigkeit → kein Merge."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j2": {"gruppen": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == subs
|
||||
assert all(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "block"))
|
||||
|
||||
|
||||
async def test_judge_failure_fail_open(testdb, tmp_path, monkeypatch):
|
||||
"""Ein Judge UND der Ersatz ohne Ergebnis → fail-open, nichts ändert sich."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}}) # j2 UND j3 → FAILED
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == subs
|
||||
assert len(fake.calls) == 3 # j1, j2, Ersatz j3
|
||||
|
||||
|
||||
async def test_ersatzrichter_bei_ausfall(testdb, tmp_path, monkeypatch):
|
||||
"""j1 fällt aus → Ersatz j3 springt ein; Einstimmigkeit j2+j3 faltet.
|
||||
Vorher entwertete EIN Timeout die gute Stimme (13 Links-Dubletten überlebten)."""
|
||||
db = testdb
|
||||
subs = ["Kurz", "Deutlich längerer Eintrag"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j2": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j3": {"gruppen": [[2, 1]], "luecken": []}}) # j1 → FAILED
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == ["Deutlich längerer Eintrag"]
|
||||
|
||||
|
||||
async def test_negation_guard_blocks_merge(testdb, tmp_path, monkeypatch):
|
||||
"""Gegensätzliche Aussagen werden selbst bei einstimmigen Judges nicht gefaltet."""
|
||||
db = testdb
|
||||
subs = ["Tabs werden expandiert", "Tabs werden nicht expandiert"]
|
||||
await _seed_block(db, "tabs", subs)
|
||||
raw = {"Tabs": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
|
||||
"j2": {"gruppen": [[1, 2]], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Tabs"] == subs
|
||||
|
||||
|
||||
async def test_resume_skips_judges(testdb, tmp_path, monkeypatch):
|
||||
"""Vorhandene j-Dateien → kein neuer Agenten-Call, Ergebnis wird übernommen."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei lang"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
import hashlib
|
||||
h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8]
|
||||
for j in (1, 2):
|
||||
(tmp_path / f"sub-konsolidierung-{h}-j{j}.json").write_text(
|
||||
json.dumps({"gruppen": [[1, 2]], "luecken": []}), encoding="utf-8")
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("Resume darf keinen Agenten starten")
|
||||
|
||||
monkeypatch.setattr(blocks, "run_single_slot", kein_agent)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == ["Eintrag zwei lang"]
|
||||
|
||||
|
||||
def test_schema_accepts_both_group_forms():
|
||||
"""Alte Listenform [1,4] und neue {haupt, weitere}-Form parsen beide; kataloge/fremd optional."""
|
||||
alt = blocks._konsolidierung_schema({"gruppen": [[1, 4]], "luecken": []}, 5)
|
||||
assert alt["gruppen"] == [{"haupt": None, "ids": [1, 4]}] and alt["fremd"] == set()
|
||||
neu = blocks._konsolidierung_schema(
|
||||
{"gruppen": [{"haupt": 4, "weitere": [1]}],
|
||||
"kataloge": [{"titel": "Katalog: Symbole", "mitglieder": [2, 3]}],
|
||||
"fremd": [5], "luecken": ["x"]}, 5)
|
||||
assert neu["gruppen"] == [{"haupt": 4, "ids": [1, 4]}]
|
||||
assert neu["kataloge"] == [{"titel": "Katalog: Symbole", "ids": [2, 3]}]
|
||||
assert neu["fremd"] == {5} and neu["luecken"] == ["x"]
|
||||
assert blocks._konsolidierung_schema({"gruppen": [{"haupt": 9, "weitere": [1]}]}, 5) == \
|
||||
{"gruppen": [], "kataloge": [], "fremd": set(), "luecken": []} # id out of range
|
||||
|
||||
|
||||
async def test_haupt_beats_heuristic(testdb, tmp_path, monkeypatch):
|
||||
"""Judges nennen den kürzeren Eintrag als haupt → er gewinnt trotz weniger key_points."""
|
||||
db = testdb
|
||||
subs = ["Basis", "Detailregel mit sehr langem Titel und Facts"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts = {"Block": {blocks._norm_title(subs[1]): {"key_points": ["a", "b", "c"]}}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []},
|
||||
"j2": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Block"] == ["Basis"]
|
||||
assert facts["Block"][blocks._norm_title("Basis")]["key_points"] == ["a", "b", "c"] # Union geerbt
|
||||
|
||||
|
||||
async def test_katalog_bundles_to_new_row(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmige Katalog-Mitglieder → neue consensus-Zeile mit Facts-Union, Mitglieder variant."""
|
||||
db = testdb
|
||||
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f", "Eigene Regel"]
|
||||
await _seed_block(db, "mathe", subs)
|
||||
raw = {"Mathe": list(subs)}
|
||||
facts = {"Mathe": {blocks._norm_title(subs[0]): {"key_points": ["kp1"]},
|
||||
blocks._norm_title(subs[1]): {"key_points": ["kp2"]}}}
|
||||
kat = {"titel": "Symbolkatalog: Pfeile und Mengen", "mitglieder": [1, 2]}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [kat], "luecken": []},
|
||||
"j2": {"gruppen": [], "kataloge": [kat], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
assert raw["Mathe"] == ["Eigene Regel", "Symbolkatalog: Pfeile und Mengen"]
|
||||
kn = blocks._norm_title("Symbolkatalog: Pfeile und Mengen")
|
||||
assert sorted(facts["Mathe"][kn]["key_points"]) == ["kp1", "kp2"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "mathe")}
|
||||
assert rows[kn] == "consensus"
|
||||
assert rows[blocks._norm_title(subs[0])] == "variant"
|
||||
|
||||
|
||||
async def test_katalog_dissent_keeps_members(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f"]
|
||||
await _seed_block(db, "mathe", subs)
|
||||
raw = {"Mathe": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [{"titel": "K", "mitglieder": [1, 2]}], "luecken": []},
|
||||
"j2": {"gruppen": [], "kataloge": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Mathe"] == subs
|
||||
|
||||
|
||||
async def test_fremd_unanimous_discards(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmig fremd → discarded + raus; einseitig fremd → bleibt."""
|
||||
db = testdb
|
||||
subs = ["CSS display überschreibt Verhalten", "Echte Markdown-Regel", "Nur einer hält es für fremd"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "fremd": [1, 3], "luecken": []},
|
||||
"j2": {"gruppen": [], "fremd": [1], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert raw["Block"] == [subs[1], subs[2]]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
|
||||
assert rows[blocks._norm_title(subs[0])] == "discarded"
|
||||
assert rows[blocks._norm_title(subs[2])] == "consensus"
|
||||
assert luecken == {}
|
||||
|
||||
|
||||
async def test_luecken_nur_bei_einstimmigkeit(testdb, tmp_path, monkeypatch):
|
||||
"""Nur Lücken mit Token-Überlappung BEIDER Judges überleben; einseitige fallen weg."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "luecken": ["Inline-HTML fehlt", "Front-Matter"]},
|
||||
"j2": {"gruppen": [], "luecken": ["nichts zu Inline-HTML"]}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
|
||||
assert luecken == {"Block": ["Inline-HTML fehlt"]}
|
||||
|
||||
|
||||
async def test_kp_deckel_im_judge_prompt(testdb, tmp_path, monkeypatch):
|
||||
"""Prompt zeigt max. 3 key_points je Sub (Timeout-Schutz); die Union bleibt voll."""
|
||||
db = testdb
|
||||
subs = ["Eintrag eins", "Eintrag zwei"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts = {"Block": {blocks._norm_title(subs[0]): {"key_points": [f"kp{i}" for i in range(1, 6)]}}}
|
||||
fake = _fake_slot({"j1": {"gruppen": [], "luecken": []}, "j2": {"gruppen": [], "luecken": []}})
|
||||
monkeypatch.setattr(blocks, "run_single_slot", fake)
|
||||
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
|
||||
prompt = fake.calls[0]["prompt"]
|
||||
assert "kp3" in prompt and "kp4" not in prompt
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
|
||||
|
||||
|
||||
def test_neg_set_lemmatisiert():
|
||||
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
|
||||
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
|
||||
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
|
||||
assert a == b == frozenset({"kein"})
|
||||
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
# ── Lücken-Nachfass ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _nachfass_env(db, monkeypatch, facts_result):
|
||||
subs = ["Eintrag eins"]
|
||||
await _seed_block(db, "block", subs)
|
||||
raw = {"Block": list(subs)}
|
||||
facts_map = {"Block": {}}
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, cancelled=None, grace=0):
|
||||
return [{"Block": ["Eintrag eins", "Neuer Aspekt"]}]
|
||||
|
||||
async def fake_facts(ctx, set_p, files, fraw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
|
||||
assert slim is True # Nachfass nutzt die schlanke Facts-Variante
|
||||
assert list(fraw["Block"]) == ["Neuer Aspekt"] # nur der frische Fund geht ins Gate
|
||||
return facts_result
|
||||
|
||||
monkeypatch.setattr(blocks, "_race", fake_race)
|
||||
monkeypatch.setattr(blocks, "_facts_block", fake_facts)
|
||||
monkeypatch.setattr(blocks, "EMBEDDING_AKTIV", False)
|
||||
return raw, facts_map
|
||||
|
||||
|
||||
async def test_nachfass_adopts_backed_find(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
nn = blocks._norm_title("Neuer Aspekt")
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch,
|
||||
({"Block": {nn: {"key_points": ["kp"]}}}, {}))
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 1 and raw["Block"] == ["Eintrag eins", "Neuer Aspekt"]
|
||||
assert facts_map["Block"][nn]["key_points"] == ["kp"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
|
||||
assert rows[nn] == "consensus"
|
||||
|
||||
|
||||
async def test_nachfass_drops_unbacked_find(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
nn = blocks._norm_title("Neuer Aspekt")
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {"Block": {nn}}))
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 0 and raw["Block"] == ["Eintrag eins"]
|
||||
assert not any(r["sub_norm"] == nn for r in await db.list_subblocks(TOPIC, "block"))
|
||||
|
||||
|
||||
async def test_nachfass_drops_find_without_facts(testdb, tmp_path, monkeypatch):
|
||||
"""HARTES Gate: kein Facts-Eintrag = kein Beleg = keine Übernahme — nicht nur
|
||||
aktiv Verworfenes fliegt (Bilder-Lauf: 13 von 18 kamen ohne Beleg durch)."""
|
||||
db = testdb
|
||||
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {})) # Facts fand NICHTS
|
||||
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
|
||||
raw, facts_map, {"type": "thema"}, None)
|
||||
assert n == 0 and raw["Block"] == ["Eintrag eins"]
|
||||
|
||||
|
||||
async def test_facts_stage_konsolidiert_nachfass_funde_erneut(testdb, tmp_path, monkeypatch):
|
||||
"""Kreis geschlossen: nach Übernahmen läuft die Konsolidierung ein zweites Mal;
|
||||
deren Lücken lösen KEINEN weiteren Nachfass aus."""
|
||||
db = testdb
|
||||
payload = {"title": "Alpha", "raw": {"Alpha": ["s1"]}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts", payload)
|
||||
calls = {"kons": 0, "nf": 0}
|
||||
|
||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
|
||||
return {"Alpha": {}}, {}
|
||||
|
||||
async def fake_kons(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
|
||||
calls["kons"] += 1
|
||||
return {"Alpha": ["Lücke X"]} # meldet auch in Runde 2 — darf nicht erneut nachfassen
|
||||
|
||||
async def fake_nf(ctx, files, title, luecken, raw, facts_map, q, folder,
|
||||
instructions="", ns="", lbl="", sources=None):
|
||||
calls["nf"] += 1
|
||||
return 2
|
||||
|
||||
monkeypatch.setattr(ba, "_facts_block", fake_facts)
|
||||
monkeypatch.setattr(ba, "_konsolidiere_subblocks", fake_kons)
|
||||
monkeypatch.setattr(ba, "_luecken_runde", fake_nf)
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_facts(_ctx(), flow, {"arbeit": tmp_path}, {"type": "thema"}, None, "",
|
||||
[{"card_id": "alpha", "payload": payload}])
|
||||
assert calls == {"kons": 2, "nf": 1}
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "levels"
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
|
||||
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
|
||||
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_finalize(_ctx(), flow, files, [card])
|
||||
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
|
||||
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
|
||||
|
||||
|
||||
# ── Cross-Block ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeEmb:
|
||||
"""Gleicher Text → gleicher Einheitsvektor, sonst orthogonal (cos 1.0 / 0.0)."""
|
||||
|
||||
@staticmethod
|
||||
def available():
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def embed_sims(texts):
|
||||
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
|
||||
arr = np.zeros((len(texts), max(len(uniq), 1)))
|
||||
for r, t in enumerate(texts):
|
||||
arr[r, uniq[t]] = 1.0
|
||||
return arr @ arr.T
|
||||
|
||||
|
||||
async def _cross_env(db, tmp_path):
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
cards = []
|
||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
|
||||
("beta", ["Gleiche Aussage", "Nur in Beta"])):
|
||||
payload = {"title": bnorm.title(),
|
||||
"raw": {bnorm.title(): list(subs)},
|
||||
"sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
|
||||
"facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": [f"kp {s}"]} for s in subs}}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, bnorm, subs)
|
||||
cards.append({"card_id": bnorm, "payload": payload})
|
||||
return flow, cards
|
||||
|
||||
|
||||
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmig „a" → Beta verliert die geteilte Aussage, Karten wandern zu levels."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
assert "Gleiche Aussage" in fake.calls[0]["prompt"]
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["stage"] == "question_pattern"
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
||||
assert blocks._norm_title("Gleiche Aussage") not in beta["payload"]["facts"]["Beta"]
|
||||
# Barriere liegt jetzt hinter levels/relevance → auch die sidecar muss den Fold tragen
|
||||
assert [e["title"] for e in beta["payload"]["sidecar"]["Beta"]] == ["Nur in Beta"]
|
||||
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert alpha["stage"] == "question_pattern"
|
||||
assert alpha["payload"]["raw"]["Alpha"] == ["Gleiche Aussage", "Nur in Alpha"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
|
||||
|
||||
|
||||
async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
|
||||
"""j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
|
||||
"j3": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
assert len(fake.calls) == 3
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
||||
|
||||
|
||||
async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
|
||||
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}}) # j3 fehlt → FAILED
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["stage"] == "question_pattern"
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Gleiche Aussage", "Nur in Beta"]
|
||||
|
||||
|
||||
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
|
||||
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}}) # j2 → FAILED
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
||||
|
||||
|
||||
async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
|
||||
class _Aus:
|
||||
@staticmethod
|
||||
def available():
|
||||
return False
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("ohne Embedding kein Judge")
|
||||
|
||||
monkeypatch.setattr(ba, "embedding", _Aus)
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"
|
||||
|
||||
|
||||
async def test_crossblock_context_wins(testdb, tmp_path, monkeypatch):
|
||||
"""Kontext-Sub (Block schon hinter der Barrier) gewinnt auch bei Verdict „b" —
|
||||
die Paket-Seite fällt, der Kontext bleibt unangetastet."""
|
||||
db = testdb
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
payload = {"title": "Alpha", "raw": {"Alpha": ["Gleiche Aussage"]}, "facts": {"Alpha": {}}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, "alpha", ["Gleiche Aussage"])
|
||||
cards = [{"card_id": "alpha", "payload": payload}]
|
||||
# Kontext-Block "gamma" ist bereits weiter (Stage levels) und hält dieselbe Aussage
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "levels",
|
||||
{"title": "Gamma", "raw": {"Gamma": ["Gleiche Aussage"]}, "facts": {}})
|
||||
await _seed_block(db, "gamma", ["Gleiche Aussage"])
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
# Verdict „a": das Paket (A) soll behalten — Kontext faltet trotzdem nie
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert alpha["payload"]["raw"].get("Alpha", []) == [] # Paket-Seite gefaltet
|
||||
gamma_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "gamma")}
|
||||
assert gamma_rows[blocks._norm_title("Gleiche Aussage")] == "consensus" # Kontext unberührt
|
||||
|
||||
|
||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||||
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
|
||||
"sidecar": {}, "pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
assert row["level"] == "advanced" and row["relevance"] == "relevant"
|
||||
|
||||
|
||||
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
|
||||
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
|
||||
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
|
||||
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows == {"neu": "consensus", "alte-variante": "variant"}
|
||||
|
||||
|
||||
async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
|
||||
"""Paare werden gechunkt beurteilt (ein Hänger blockiert nur noch seinen Chunk);
|
||||
die Verdicts falten global über alle Chunks."""
|
||||
db = testdb
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
cards = []
|
||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
|
||||
("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
|
||||
payload = {"title": bnorm.title(),
|
||||
"raw": {bnorm.title(): list(subs)},
|
||||
"sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
|
||||
"facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": []} for s in subs}}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, bnorm, subs)
|
||||
cards.append({"card_id": bnorm, "payload": payload})
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1) # 2 Paare → 2 Chunks
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["payload"]["raw"].get("Beta", []) == [] # beide Dubletten global gefaltet
|
||||
|
||||
|
||||
async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch):
|
||||
"""Nachfass ruft den slim-Facts-Lauf NUR mit den facts-losen Subs und merged die Funde;
|
||||
Vorhandenes bleibt unberührt, Subs werden nie verworfen."""
|
||||
gesehen = {}
|
||||
|
||||
async def fake_facts_block(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="",
|
||||
sources=None, slim=False):
|
||||
gesehen["raw"] = raw
|
||||
gesehen["slim"] = slim
|
||||
return ({"Alpha": {"ohne beleg": {"key_points": ["kp neu"]},
|
||||
"mit beleg": {"key_points": ["DARF NICHT GEWINNEN"]}}},
|
||||
{"Alpha": {"ohne beleg"}}) # discard-Urteil wird ignoriert
|
||||
|
||||
monkeypatch.setattr(blocks, "_facts_block", fake_facts_block)
|
||||
raw = {"Alpha": ["Mit Beleg", "Ohne Beleg"]}
|
||||
facts_map = {"Alpha": {"mit beleg": {"key_points": ["kp alt"]}}}
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
n = await blocks._facts_nachfass(ctx, {"arbeit": tmp_path}, raw, facts_map, {}, None)
|
||||
assert n == 1
|
||||
assert gesehen["slim"] and gesehen["raw"] == {"Alpha": ["Ohne Beleg"]}
|
||||
assert facts_map["Alpha"]["ohne beleg"]["key_points"] == ["kp neu"]
|
||||
assert facts_map["Alpha"]["mit beleg"]["key_points"] == ["kp alt"]
|
||||
assert raw["Alpha"] == ["Mit Beleg", "Ohne Beleg"] # kein Verwurf
|
||||
|
||||
|
||||
async def test_levels_merge_fuzzy_match(testdb, tmp_path, monkeypatch):
|
||||
"""Levels-Agent paraphrasiert den Sub-Titel → facts hängen trotzdem am Sidecar-Eintrag
|
||||
(eindeutiger Präfix-Match statt stillem Grounding-Verlust)."""
|
||||
db = testdb
|
||||
payload = {"title": "Alpha", "raw": {"Alpha": ["Marker Regel: Details dazu"]},
|
||||
"facts": {"Alpha": {blocks._norm_title("Marker Regel: Details dazu"):
|
||||
{"key_points": ["kp"]}}}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels", payload)
|
||||
cards = [{"card_id": "alpha", "payload": payload}]
|
||||
|
||||
async def fake_levels_block(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||
return {"Alpha": [{"title": "Marker Regel", "level": "beginner"}]} # gekürzter Titel
|
||||
|
||||
monkeypatch.setattr(ba, "_levels_block", fake_levels_block)
|
||||
await ba._proc_levels(_ctx(), Flow(TOPIC, work_dir=tmp_path), {"arbeit": tmp_path}, "", cards)
|
||||
card = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
||||
assert card["payload"]["sidecar"]["Alpha"][0]["facts"] == {"key_points": ["kp"]}
|
||||
@@ -26,6 +26,7 @@ def test_convert_writes_markdown_txt(tmp_path):
|
||||
|
||||
def test_cache_skips_fresh_txt(tmp_path):
|
||||
_mini_pdf(tmp_path / "a.pdf")
|
||||
(tmp_path / ".pdf-txt-norm").write_text(str(blx._PDF_NORM_VERSION), encoding="utf-8")
|
||||
marker = tmp_path / "a.txt"
|
||||
marker.write_text("MARKER", encoding="utf-8")
|
||||
now = time.time() + 60
|
||||
@@ -34,6 +35,39 @@ def test_cache_skips_fresh_txt(tmp_path):
|
||||
assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert
|
||||
|
||||
|
||||
def test_norm_version_erzwingt_rekonvertierung(tmp_path, monkeypatch):
|
||||
"""Fehlender/alter .pdf-txt-norm-Marker ignoriert den mtime-Cache einmalig."""
|
||||
_mini_pdf(tmp_path / "a.pdf")
|
||||
stale = tmp_path / "a.txt"
|
||||
stale.write_text("ALT", encoding="utf-8")
|
||||
now = time.time() + 60
|
||||
os.utime(stale, (now, now))
|
||||
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
|
||||
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "neu konvertiert")
|
||||
blx._convert_pdfs(tmp_path)
|
||||
assert stale.read_text(encoding="utf-8") == "neu konvertiert"
|
||||
assert (tmp_path / ".pdf-txt-norm").read_text(encoding="utf-8") == str(blx._PDF_NORM_VERSION)
|
||||
stale.write_text("BLEIBT", encoding="utf-8")
|
||||
os.utime(stale, (now, now))
|
||||
blx._convert_pdfs(tmp_path) # Marker aktuell → Cache greift wieder
|
||||
assert stale.read_text(encoding="utf-8") == "BLEIBT"
|
||||
|
||||
|
||||
def test_entzerre_pdf_woerter():
|
||||
"""Small-Caps-Splits mergen; Variablen-Schutzfall und unbelegte Paare bleiben."""
|
||||
# Regel A inkl. Kette
|
||||
assert blx._entzerre_pdf_woerter("Das H ITTING S ET Problem") == "Das HITTING SET Problem"
|
||||
assert blx._entzerre_pdf_woerter("V ERTEX C OVER ist schwer") == "VERTEX COVER ist schwer"
|
||||
# Schutzfall: Großlauf klein fortgesetzt → L ist Variable
|
||||
assert blx._entzerre_pdf_woerter("die Sprache L NP-vollständig ist") == "die Sprache L NP-vollständig ist"
|
||||
# Regel B: Paar nur mit Frequenz-Beleg (≥3× ungespalten im Dokument)
|
||||
belegt = "N P ist zentral. " + "NP NP NP."
|
||||
assert blx._entzerre_pdf_woerter(belegt).startswith("NP ist zentral.")
|
||||
assert blx._entzerre_pdf_woerter("C Y bleibt getrennt") == "C Y bleibt getrennt"
|
||||
# kein Match mitten im Wort
|
||||
assert blx._entzerre_pdf_woerter("HALTTM IST hier") == "HALTTM IST hier"
|
||||
|
||||
|
||||
def test_fallback_to_pdftotext(tmp_path, monkeypatch):
|
||||
_mini_pdf(tmp_path / "b.pdf")
|
||||
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
|
||||
@@ -69,3 +103,13 @@ def test_fidelity_guard_prefers_faithful_plaintext():
|
||||
assert blx._pick_conversion(None, plain)[1] == "pdftotext"
|
||||
assert blx._pick_conversion(md_ok, None)[1] == "pymupdf4llm"
|
||||
assert blx._pick_conversion(None, None) is None
|
||||
|
||||
|
||||
def test_entzerre_hyphen_space():
|
||||
"""Regel C: GROSSLAUF + Leerzeichen vor Bindestrich-Kompositum („NP -vollständig",
|
||||
Makro-Spacing) wird gemerged; echter Gedankenstrich („X - y") bleibt."""
|
||||
assert blx._entzerre_pdf_woerter("die NP -vollständigkeit gilt") == "die NP-vollständigkeit gilt"
|
||||
assert blx._entzerre_pdf_woerter("Begriffe der NP -schwere und NP -vollständigkeit") == \
|
||||
"Begriffe der NP-schwere und NP-vollständigkeit"
|
||||
assert blx._entzerre_pdf_woerter("Term X - y bleibt") == "Term X - y bleibt"
|
||||
assert blx._entzerre_pdf_woerter("Liste:\n-punkt eins") == "Liste:\n-punkt eins"
|
||||
+102
-5
@@ -1,5 +1,7 @@
|
||||
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
|
||||
|
||||
import json
|
||||
|
||||
import qa
|
||||
|
||||
|
||||
@@ -91,9 +93,53 @@ def test_note_kalibrierung():
|
||||
assert qa.note({"fremd": 1.0}) == 0.0 # komplett fremdes Inventar = 0, nicht 7.7
|
||||
|
||||
|
||||
def test_note_verdacht_zaehlt_nicht():
|
||||
"""dubletten_verdacht ist Verdachtsliste, kein Urteil — beeinflusst die Note nicht."""
|
||||
assert qa.note({"dubletten_verdacht": 1.0}) == 10.0
|
||||
def test_note_dubletten_verdacht_zaehlt():
|
||||
"""dubletten_verdacht bildet das Dopplungs-Nutzerproblem ab → zählt jetzt (Gewicht 1.0),
|
||||
bleibt aber das leichteste Gewicht. 36 % Verdacht → −3.6 → 6.4; die Liste kann >1 sein → geklemmt."""
|
||||
assert qa.note({"dubletten_verdacht": 0.0}) == 10.0
|
||||
assert qa.note({"dubletten_verdacht": 0.36}) == 6.4
|
||||
assert qa.note({"dubletten_verdacht": 2.0}) == 0.0 # >1 wird auf 1.0 geklemmt
|
||||
|
||||
# sub_dubletten_verdacht bleibt gewichtslos (Artefakt-Verdacht, kein Urteil)
|
||||
assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0
|
||||
|
||||
|
||||
def test_note_konzept_luecken_zaehlt():
|
||||
"""Fehlende benannte Kernresultate drücken die Note (Gewicht 1.5): 20 % → −3.0 → 7.0."""
|
||||
assert qa.note({"konzept_luecken": 0.0}) == 10.0
|
||||
assert qa.note({"konzept_luecken": 0.2}) == 7.0
|
||||
|
||||
|
||||
def test_named_results_only_numbered(monkeypatch):
|
||||
"""Benannte Ergebnisse: nur NUMMERIERTE Katalog-Referenzen mit Namen zählen — Prosa ohne
|
||||
Nummer ('Satz von Goethe') und nackte Nummern ('Satz 7.18') liefern nichts (generisch)."""
|
||||
corpus = {"skript.txt":
|
||||
"Satz 7.13 (Christofides) liefert eine 3/2-Approximation.\n"
|
||||
"Satz 6.24: Satz von Cook und Levin.\n"
|
||||
"Nach Satz 7.18 folgt daraus die Schranke.\n"
|
||||
"Ein Satz von Goethe steht hier."}
|
||||
named = qa._named_results(corpus)
|
||||
assert "Christofides" in named
|
||||
assert any("Cook" in n and "Levin" in n for n in named)
|
||||
assert not any("Goethe" in n for n in named) # keine Nummer → kein False Positive
|
||||
assert all(n.strip() for n in named)
|
||||
|
||||
|
||||
def test_konzept_luecken_flags_missing():
|
||||
"""Benanntes Kernresultat ohne Baustein = Lücke; ein gedecktes Resultat nicht."""
|
||||
corpus = {"s.txt": "Satz 7.13 (Christofides). Satz 6.16 (Kriterium für P=NP)."}
|
||||
named = qa._named_results(corpus)
|
||||
blocks = [{"title": "Christofides-Algorithmus", "description": "3/2-Approximation für TSP"}]
|
||||
gaps = qa.konzept_luecken(blocks, named)
|
||||
assert "Christofides" not in gaps
|
||||
assert any("P=NP" in g or "Kriterium" in g for g in gaps)
|
||||
|
||||
|
||||
def test_konzept_luecken_declension_tolerant():
|
||||
"""Andere Flexion im Baustein deckt das Resultat trotzdem — kein falscher Lücken-Alarm."""
|
||||
named = qa._named_results({"s.txt": "Satz 7.6: Kriterium für Eulerschen Kreis."})
|
||||
blocks = [{"title": "Kriterium für Eulerscher Kreis", "description": "Grad aller Knoten gerade"}]
|
||||
assert qa.konzept_luecken(blocks, named) == []
|
||||
|
||||
|
||||
def test_note_artefakte_getrennt():
|
||||
@@ -225,7 +271,7 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
|
||||
{"title": titel, "description": "d"})
|
||||
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
|
||||
|
||||
async def fake_verdicts(template, topic, key, items):
|
||||
async def fake_wave(template, topic, key, slot, items, **kw):
|
||||
if template != "QA-Bausteine":
|
||||
return {}
|
||||
if key.startswith("bausteine-b2"): # Bestätiger sieht nur die Geflaggten
|
||||
@@ -233,11 +279,30 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
|
||||
return {1: "nein", 2: "ja"} # nur der erste wird bestätigt
|
||||
return {1: "nein", 2: "nein", 3: "ja"} # Pass 1 flaggt zwei
|
||||
|
||||
monkeypatch.setattr(qa, "_llm_verdicts", fake_verdicts)
|
||||
monkeypatch.setattr(qa, "judge_wave", fake_wave)
|
||||
report = await qa.qa_report("t", llm=True)
|
||||
assert report["unecht"] == ["Wackelkandidat"]
|
||||
|
||||
|
||||
async def test_write_report_spiegelt_note_als_event(testdb, tmp_path, monkeypatch):
|
||||
"""Report-JSONs liegen nur auf der Lauf-Maschine — write_report spiegelt Note/Quoten
|
||||
als kind='qa'-Event in die DB, damit ein DB-Pull für die Run-Analyse reicht."""
|
||||
db = testdb
|
||||
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
|
||||
report = {"topic": "t", "run_id": "20260704-1452-b223", "note": 9.3, "note_artefakte": 8.0,
|
||||
"quoten": {"luecken": 0.1}, "quoten_artefakte": {"verwaiste": 0.0}}
|
||||
path = await qa.write_report(report)
|
||||
assert path.stem == "20260704-1452-b223"
|
||||
conn = await db.get_db()
|
||||
row = await (await conn.execute(
|
||||
"SELECT key, meta, run_id FROM events WHERE topic='t' AND kind='qa'")).fetchone()
|
||||
assert row and row[0] == "20260704-1452-b223"
|
||||
meta = json.loads(row[1])
|
||||
assert meta["note"] == 9.3 and meta["note_artefakte"] == 8.0
|
||||
assert meta["quoten"] == {"luecken": 0.1} and meta["quoten_artefakte"] == {"verwaiste": 0.0}
|
||||
assert row[2] == "" # manuelle QA ohne Lauf → leeres run_id ist korrekt
|
||||
|
||||
|
||||
async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
|
||||
"""DELETE /topics räumt auch storage/qa/<topic>/ — Reports gehören zum Topic."""
|
||||
import routes
|
||||
@@ -248,3 +313,35 @@ async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
|
||||
(qdir / "alt.json").write_text("{}", encoding="utf-8")
|
||||
await routes.remove_topic("t")
|
||||
assert not qdir.exists()
|
||||
|
||||
|
||||
def test_luecken_key_stabil_gegen_whitespace():
|
||||
"""Freispruch-Schlüssel: Datei + normalisierter Vorschau-Hash — Whitespace-Varianten
|
||||
derselben Fundstelle mappen auf denselben Key, andere Vorschau nicht."""
|
||||
a = {"datei": "f.txt", "abschnitt": 3, "vorschau": "Der Satz\nvon Foo"}
|
||||
b = {"datei": "f.txt", "abschnitt": 7, "vorschau": "der satz von foo"}
|
||||
c = {"datei": "f.txt", "abschnitt": 3, "vorschau": "ganz anderer Text"}
|
||||
assert qa.luecken_key(a) == qa.luecken_key(b)
|
||||
assert qa.luecken_key(a) != qa.luecken_key(c)
|
||||
assert qa.luecken_key(a).startswith("f.txt||")
|
||||
|
||||
|
||||
def test_offene_luecken_filtert_freispruch_und_nein():
|
||||
rep = {"luecken": [{"datei": "f", "vorschau": "a", "llm": "ja"},
|
||||
{"datei": "f", "vorschau": "b", "llm": "nein"},
|
||||
{"datei": "f", "vorschau": "c", "freispruch": True}],
|
||||
"konzept_luecken": ["Satz von Foo"]}
|
||||
lk, kl = qa.offene_luecken(rep)
|
||||
assert [x["vorschau"] for x in lk] == ["a"] and kl == ["Satz von Foo"]
|
||||
|
||||
|
||||
def test_named_results_ueberspringt_umbrochene_saetze():
|
||||
"""Doppelpunkt-Fang, der am Zeilenumbruch endet und klein weiterläuft, ist ein
|
||||
umbrochener SATZ, kein Konzeptname — solche Phantom-Lücken kann kein Block ankern.
|
||||
Echte Namen (auch \\n-terminiert mit großer Folgezeile) bleiben."""
|
||||
text = ("Konsequenz von Satz 6.16: Wenn ein NP-vollständiges Entscheidungsproblem in P\n"
|
||||
"liegt, dann sind alle NP Entscheidungsprobleme in P.\n\n"
|
||||
"Satz 7.13 (Christofides). Beweis folgt.\n"
|
||||
"Satz 6.24: Cook-Levin.\n"
|
||||
"Satz 9.1: Vier-Farben-Satz\nDer Beweis nutzt Computer.\n")
|
||||
assert sorted(qa._named_results({"f": text})) == ["Christofides", "Cook-Levin", "Vier-Farben-Satz"]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""_race-Hedging: Stall-Slots bekommen einen parallelen Zwilling statt den Timeout-Cap
|
||||
abzuwarten (gemessen: 4 Panel-Stalls à 160–230 s pro Lauf auf dem kritischen Pfad)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pipeline
|
||||
|
||||
|
||||
def _slot(payload=lambda r: r[1]):
|
||||
return {"key": "k1", "prompt": "p", "role": "judge", "capabilities": "none", "payload": payload}
|
||||
|
||||
|
||||
async def test_hedge_zwilling_rettet_stall(monkeypatch):
|
||||
"""Original stallt → nach HEDGE_NACH_S startet der Zwilling (key -h), sein Ergebnis
|
||||
gewinnt, das hängende Original wird gekillt."""
|
||||
calls, killed = [], []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
calls.append(key)
|
||||
if key.endswith("-h"):
|
||||
return (0, "zwilling", "")
|
||||
await asyncio.sleep(30) # Stall — würde sonst den ganzen Cap verbrennen
|
||||
return (0, "original", "")
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
|
||||
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
|
||||
assert res == ["zwilling"]
|
||||
assert calls == ["k1", "k1-h"]
|
||||
assert "k1" in killed # das hängende Original läuft nicht weiter
|
||||
|
||||
|
||||
async def test_hedge_original_gewinnt_zwilling_wird_gekillt(monkeypatch):
|
||||
"""Kommt das Original doch noch vor dem Zwilling an, wird der Zwilling gekillt
|
||||
und sein spätes Ergebnis nicht gewertet."""
|
||||
killed = []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
await asyncio.sleep(0.3 if key.endswith("-h") else 0.15)
|
||||
return (0, key, "")
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
|
||||
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
|
||||
assert res == ["k1"]
|
||||
assert "k1-h" in killed
|
||||
|
||||
|
||||
async def test_hedge_aus_bei_null(monkeypatch):
|
||||
"""HEDGE_NACH_S=0 → kein Zwilling, Verhalten wie zuvor."""
|
||||
calls = []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
calls.append(key)
|
||||
await asyncio.sleep(0.1)
|
||||
return (0, "ok", "")
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
|
||||
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
|
||||
assert res == ["ok"]
|
||||
assert calls == ["k1"]
|
||||
|
||||
|
||||
async def test_hedge_schwelle_skaliert_mit_timeout(monkeypatch):
|
||||
"""Die Schwelle ist max(HEDGE_NACH_S, timeout/2): ein gesunder Call, der länger als
|
||||
die Untergrenze, aber kürzer als das halbe Timeout läuft, bekommt KEINEN Zwilling
|
||||
(pauschale 90 s hedgten jeden normalen Fix-Call)."""
|
||||
calls = []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
calls.append(key)
|
||||
await asyncio.sleep(0.15) # > Untergrenze 0.05, < timeout/2 = 0.5
|
||||
return (0, "ok", "")
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
|
||||
res = await pipeline._race("t", "Test", [_slot()], 1, 1.0, "claude")
|
||||
assert res == ["ok"]
|
||||
assert calls == ["k1"]
|
||||
|
||||
|
||||
async def test_hedge_zwilling_ersetzt_restart(monkeypatch):
|
||||
"""Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN
|
||||
zusätzlichen Restart — der Zwilling ist der Retry."""
|
||||
calls = []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
calls.append(key)
|
||||
if key.endswith("-h"):
|
||||
await asyncio.sleep(0.2)
|
||||
return (0, "zwilling", "")
|
||||
await asyncio.sleep(0.1)
|
||||
return (1, "", "kaputt") # Fehler NACH dem Hedge-Start
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
|
||||
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
|
||||
assert res == ["zwilling"]
|
||||
assert calls == ["k1", "k1-h"] # kein dritter Spawn
|
||||
@@ -67,7 +67,7 @@ async def test_merge_confirmed_duplicate(env, monkeypatch):
|
||||
calls.append(prompt)
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["merges"] == ["Alpha → Alpha Problem"]
|
||||
assert len(calls) == 1 and "Beta" not in calls[0] # nur das llm=ja-Paar zum Judge
|
||||
@@ -86,9 +86,11 @@ async def test_fremd_removed_only_on_nein(env, monkeypatch):
|
||||
write_report(_report(fremd=["Fremdling", "Echter"]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
if "-st-" in key: # Stichentscheid über den strittigen „Echter": behalten
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(repair, "source_folder", lambda t: None)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["entfernt"] == ["Fremdling"]
|
||||
@@ -106,7 +108,7 @@ async def test_judge_failure_keeps_everything(env, monkeypatch):
|
||||
async def broken_agent(key, prompt, timeout, **kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", broken_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", broken_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["entfernt"] == []
|
||||
card = await db.kanban_get_card(TOPIC, "inventory", cid)
|
||||
@@ -122,7 +124,7 @@ async def test_hygiene_cleans_title_norm_invariant(env, monkeypatch):
|
||||
async def no_agent(*a, **kw):
|
||||
raise AssertionError("Hygiene braucht keinen Agenten")
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", no_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["hygiene"] == ["**Fetter Titel** → Fetter Titel"]
|
||||
card = await db.kanban_get_card(TOPIC, "inventory", cid)
|
||||
@@ -175,7 +177,7 @@ async def test_sub_dubletten_merge(env, monkeypatch):
|
||||
assert "Gibtsnicht" not in prompt
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["sub_merges"] == ["Verlierer Sub → Gewinner Sub"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
|
||||
@@ -197,13 +199,81 @@ async def test_sub_dubletten_zweitmeinung_nein(env, monkeypatch):
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "nein"}}', ""
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["sub_merges"] == []
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
|
||||
assert rows["sub a"] == rows["sub b"] == "consensus"
|
||||
|
||||
|
||||
async def test_sub_dubletten_stichentscheid_faltet(env, monkeypatch):
|
||||
"""Dissens QA (Befund) vs. Zweitmeinung (behalten) → Stichentscheid-Judge (Key -st)
|
||||
entscheidet mit 2:1 für den Befund → Merge. Vorher pendelte die Note dauerhaft
|
||||
unter 10 ohne Fix-Pfad („keine behebbaren Befunde" trotz Befund)."""
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
norm = repair._norm_title("Alpha")
|
||||
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A",
|
||||
facts='{"key_points": ["a"]}', status="consensus")
|
||||
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
|
||||
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
if "-st-" in key: # Stichentscheid bestätigt den QA-Befund
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
return 0, '{"relevant": {"1": "nein"}}', "" # Zweitmeinung widerspricht
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["sub_merges"] == ["Sub B → Sub A"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
|
||||
assert rows["sub b"] == "variant" and rows["sub a"] == "consensus"
|
||||
|
||||
|
||||
async def test_stichentscheid_behalten_persistiert_freispruch(env, monkeypatch):
|
||||
"""2:1 „behalten" (Zweitmeinung + j3 einig gegen den QA-Befund) → Freispruch wird
|
||||
persistiert und der Report des nächsten qa_report zählt das Paar nicht mehr —
|
||||
vorher pendelte die Note dauerhaft unter 10 ohne Fix-Pfad."""
|
||||
import qa as qa_mod
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
norm = repair._norm_title("Alpha")
|
||||
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A", status="consensus")
|
||||
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
|
||||
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "nein"}}', "" # beide Repair-Judges: behalten
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["sub_merges"] == [] and len(res["freigesprochen"]) == 1
|
||||
frei = qa_mod.lade_freispruch(TOPIC)
|
||||
key = qa_mod._paar_key("[Alpha] Sub A", "[Alpha] Sub B")
|
||||
assert key in set(frei.get("sub_dubletten") or [])
|
||||
# QA-Seite: bestätigtes, aber freigesprochenes Paar zählt nicht in die Quote
|
||||
sd = [{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]
|
||||
frei_sub = set(frei["sub_dubletten"])
|
||||
zaehlt = sum(1 for p in sd if p.get("llm") == "ja"
|
||||
and qa_mod._paar_key(p["a"], p["b"]) not in frei_sub)
|
||||
assert zaehlt == 0
|
||||
|
||||
|
||||
async def test_fremd_stichentscheid_behalten(env, monkeypatch):
|
||||
"""Dissens bei fremd, Stichentscheid sagt ebenfalls behalten (ja) → Block bleibt
|
||||
(fail-open bei 1:2 gegen den Befund)."""
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
write_report(_report(fremd=["Alpha"]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "ja"}}', "" # beide: belegt/behalten
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["entfernt"] == []
|
||||
|
||||
|
||||
async def test_waisen_cleanup(env, monkeypatch):
|
||||
"""Artefakte/Fragen auf verworfene oder fehlende Subs fliegen; lebende und
|
||||
mehrdeutig-präfixige bleiben."""
|
||||
@@ -222,9 +292,141 @@ async def test_waisen_cleanup(env, monkeypatch):
|
||||
async def no_agent(*a, **kw):
|
||||
raise AssertionError("Aufräumen braucht keinen Agenten")
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", no_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["aufgeraeumt"] == 3
|
||||
rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
|
||||
assert rest == {("sub0", "flashcard"), ("doppel", "example")}
|
||||
assert not [r for r in await db.list_question_pattern(TOPIC)]
|
||||
|
||||
|
||||
async def test_dubletten_verdacht_freispruch(env, monkeypatch):
|
||||
"""Widerlegtes/unbeurteiltes Verdachtspaar bekommt den Klärungskanal: Judge sagt
|
||||
behalten → Freispruch persistiert; der nächste qa_report zählt das Paar nicht mehr
|
||||
(vorher zählte widerlegter Verdacht ewig in die Quote — Mess-Rauschen ohne Ausweg)."""
|
||||
import qa as qa_mod
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
await seed("Alpha Kreis", "beschr")
|
||||
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "nein"}}', "" # Judge: keine Dublette
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["merges"] == [] and len(res["freigesprochen"]) == 1 and res["aktionen"] == 1
|
||||
frei = qa_mod.lade_freispruch(TOPIC)
|
||||
assert qa_mod._paar_key("Alpha", "Alpha Kreis") in set(frei.get("dubletten") or [])
|
||||
|
||||
|
||||
async def test_dubletten_verdacht_stichentscheid_merged(env, monkeypatch):
|
||||
"""Verdachtspaar (llm=nein), aber Judge+Stichentscheid sagen beide Dublette →
|
||||
Merge mit 2:1 gegen das QA-Urteil."""
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "kurz")
|
||||
await seed("Alpha Kreis", "deutlich längere Beschreibung — Gewinner")
|
||||
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["merges"] == ["Alpha → Alpha Kreis"] and res["freigesprochen"] == []
|
||||
|
||||
|
||||
async def test_beleg_nachfass_discard_und_datenfix(env, monkeypatch):
|
||||
"""mentions=0-Subs: Judge bestätigt „kein Beleg" → Sub verworfen; Dissens mit
|
||||
Stichentscheid „belegt" → mentions=1 nachgetragen (Datenfix, kein Freispruch)."""
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
norm = repair._norm_title("Alpha")
|
||||
await db.put_subblock(TOPIC, norm, "ohne beleg", "Alpha", "Ohne Beleg", status="consensus")
|
||||
await db.put_subblock(TOPIC, norm, "doch belegt", "Alpha", "Doch Belegt", status="consensus")
|
||||
await db.set_subblock_fields(TOPIC, norm, "ohne beleg", mentions=0)
|
||||
await db.set_subblock_fields(TOPIC, norm, "doch belegt", mentions=0)
|
||||
write_report(_report(beleg={"subs_ohne_beleg": ["Alpha · Ohne Beleg", "Alpha · Doch Belegt"]}))
|
||||
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
|
||||
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
if "-beleg-st-" in key:
|
||||
return 0, '{"relevant": {"1": "ja"}}', "" # Stichentscheid: belegt
|
||||
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC, "artefacts")
|
||||
assert sorted(res["beleg_fix"]) == ["belegt: Alpha · Doch Belegt", "entfernt: Alpha · Ohne Beleg"]
|
||||
rows = {r["sub_norm"]: r for r in await db.list_subblocks(TOPIC, norm)}
|
||||
assert rows["ohne beleg"]["status"] == "discarded"
|
||||
assert rows["doch belegt"]["status"] == "consensus" and rows["doch belegt"]["mentions"] == 1
|
||||
|
||||
|
||||
async def test_fix_beschreibung_fuellt_leere(env, monkeypatch):
|
||||
"""Hygiene „leere-beschreibung" → Sanierung schreibt EINE Beschreibung aus den
|
||||
Auszügen; Titel bleibt unverändert."""
|
||||
db, seed, files, write_report = env
|
||||
cid = await seed("Leerling", "")
|
||||
write_report(_report(hygiene=[{"titel": "Leerling", "probleme": ["leere-beschreibung"]}]))
|
||||
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
|
||||
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
assert "-sanierung-" in key
|
||||
return 0, '{"title": "Leerling", "description": "Aus dem Material."}', ""
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC, "inventory")
|
||||
assert "beschrieben: Leerling" in res["hygiene"]
|
||||
card = await db.kanban_get_card(TOPIC, "inventory", cid)
|
||||
assert card["payload"]["description"] == "Aus dem Material."
|
||||
|
||||
|
||||
async def test_fix_suffix_rename_rekeyt_alles(env, monkeypatch):
|
||||
"""Kollisions-Suffix, Basis-Norm frei → Rename inkl. Re-Key von Subblocks/Fragen/
|
||||
Artefakten + Board-2-Karte; danach keine Invarianten-Waisen."""
|
||||
from invarianten import pruefe_invarianten
|
||||
db, seed, files, write_report = env
|
||||
cid = await seed("Gamma (2)", "beschr")
|
||||
norm_alt = repair._norm_title("Gamma (2)")
|
||||
await db.upsert_question_pattern(TOPIC, norm_alt, "sub0", "Gamma (2)", "Sub 0", "F?")
|
||||
write_report(_report(hygiene=[{"titel": "Gamma (2)", "probleme": ["kollisions-suffix"]}]))
|
||||
|
||||
async def no_agent(*a, **kw):
|
||||
raise AssertionError("Rename bei freier Basis braucht keinen Agenten")
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
|
||||
res = await repair.repair_befunde(TOPIC, "inventory")
|
||||
assert "suffix: Gamma (2) → Gamma" in res["hygiene"]
|
||||
card = await db.kanban_get_card(TOPIC, "inventory", cid)
|
||||
assert card["payload"]["title"] == "Gamma" and card["payload"]["mirrored_norm"] == "gamma"
|
||||
assert await db.kanban_get_card(TOPIC, "artefacts", norm_alt) is None
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "gamma"))["payload"]["title"] == "Gamma"
|
||||
assert {r["block_norm"] for r in await db.list_subblocks(TOPIC, "gamma")} == {"gamma"}
|
||||
assert not await db.list_subblocks(TOPIC, norm_alt)
|
||||
assert {r["block_norm"] for r in await db.list_question_pattern(TOPIC)} == {"gamma"}
|
||||
befunde = await pruefe_invarianten(TOPIC) # DB-Sicht: keine Waisen nach dem Re-Key
|
||||
assert not [b for b in befunde if "Waise" in b]
|
||||
|
||||
|
||||
async def test_pruefe_luecken_freispruch(env, monkeypatch):
|
||||
"""Offene Lücken + Konzept-Lücken: 2:1 „keine echte Lücke" → Freispruch persistiert;
|
||||
_zaehlbare_luecken zählt freigesprochene Fundstellen nicht mehr."""
|
||||
import qa as qa_mod
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
lk = [{"datei": "f.txt", "abschnitt": 1, "vorschau": "verwaister Abschnitt", "llm": "ja"}]
|
||||
write_report(_report(luecken=lk, konzept_luecken=["Satz von Foo"]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "nein"}}', "" # beide Judges: keine echte Lücke
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC, "inventory")
|
||||
assert len(res["freigesprochen"]) == 2
|
||||
frei = qa_mod.lade_freispruch(TOPIC)
|
||||
assert qa_mod.luecken_key(lk[0]) in set(frei.get("luecken") or [])
|
||||
assert "satz von foo" in {s.casefold() for s in frei.get("konzept_luecken") or []}
|
||||
lk[0]["freispruch"] = True
|
||||
assert qa_mod._zaehlbare_luecken(lk, llm=True) == []
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Link source = curated URL list (one page per line, no crawl following)."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import blocks
|
||||
from pipeline import GenContext
|
||||
from routes import _validate_source
|
||||
|
||||
|
||||
# --- _validate_source: per-line validation ---------------------------------
|
||||
|
||||
def test_validate_source_link_multiline_ok():
|
||||
_validate_source("link", "https://a.com/x\nhttp://b.com/y") # no raise
|
||||
|
||||
|
||||
def test_validate_source_link_rejects_bad_line():
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_source("link", "https://a.com/x\nftp://bad")
|
||||
|
||||
|
||||
def test_validate_source_link_rejects_empty():
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_source("link", " \n ")
|
||||
|
||||
|
||||
# --- _prepare_source: load every line, all pages as content, no triage -----
|
||||
|
||||
async def test_prepare_source_link_loads_all_urls_as_content(testdb, tmp_path, monkeypatch):
|
||||
topic = "T"
|
||||
folder = tmp_path / "source"
|
||||
folder.mkdir()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_load(urls, target, *, cancelled=None):
|
||||
captured["urls"] = list(urls)
|
||||
for i, u in enumerate(urls):
|
||||
(Path(target) / f"p{i}.txt").write_text(f"QUELLE: {u}\n\nInhalt {i}", encoding="utf-8")
|
||||
return len(urls)
|
||||
|
||||
monkeypatch.setattr(blocks, "load_pages", fake_load)
|
||||
monkeypatch.setattr(blocks, "_convert_pdfs", lambda f: None)
|
||||
monkeypatch.setattr(blocks, "_crawl_done", lambda t: False)
|
||||
monkeypatch.setattr(blocks, "_step_idx", lambda t, n: 0) # step list needs source.json — irrelevant here
|
||||
|
||||
ctx = GenContext(topic=topic, provider="claude", is_cancelled=lambda: False)
|
||||
q = {"type": "link", "location": " https://a.com/x \n\nhttps://b.com/y\n", "spec": ""}
|
||||
ok = await blocks._prepare_source(ctx, lambda *a, **k: None, {"arbeit": tmp_path}, q, folder, "")
|
||||
|
||||
assert ok is True
|
||||
assert captured["urls"] == ["https://a.com/x", "https://b.com/y"] # split + strip + blanks dropped
|
||||
content = await testdb.list_content(topic)
|
||||
assert len(content) == 2 # every page kept, no relevance triage
|
||||
+18
-270
@@ -1,16 +1,14 @@
|
||||
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
|
||||
"""Subbaustein-Helfer: Varianten-Cluster, Evidence-Packs, Outline-Review, Hash/Key-Auflösung.
|
||||
Die verschmolzene Block-Pipeline (Generate/Verify/Artefakte) testet tests/test_block_calls.py."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks as blx
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "t"
|
||||
_MD_PATH = re.compile(r"(/\S+\.md)")
|
||||
|
||||
|
||||
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
|
||||
@@ -37,162 +35,6 @@ def test_variant_clusters_negation_guard():
|
||||
assert len(cl) == 2 # antonyms never merge, no matter the cosine
|
||||
|
||||
|
||||
# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────
|
||||
|
||||
def _fake_sims(texts):
|
||||
"""Markertoken matrix: same first word → 0.95, else 0."""
|
||||
n = len(texts)
|
||||
m = np.eye(n)
|
||||
key = lambda t: t.split()[0].casefold()
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j and key(texts[i]) == key(texts[j]):
|
||||
m[i][j] = 0.95
|
||||
return m
|
||||
|
||||
|
||||
def _mk_race(finder_by_agent):
|
||||
"""Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
|
||||
catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
|
||||
prompts = []
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
||||
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
|
||||
outs = []
|
||||
for slot in slots:
|
||||
key, prompt = slot["key"], slot["prompt"]
|
||||
prompts.append((key, prompt))
|
||||
fake_race.slots_seen.append(slot)
|
||||
if "-subblock-final-" in key:
|
||||
# no-tool judges reply as TEXT; the payload sink writes the j-file itself
|
||||
kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
|
||||
subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
|
||||
if l.startswith("- ") and l != "- (keiner)"]
|
||||
if subs:
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
outs.append(slot["payload"]((0, text, "")))
|
||||
continue
|
||||
if "-r1-" in key:
|
||||
agent = int(key.rsplit("-", 1)[1])
|
||||
subs = finder_by_agent.get(agent) or []
|
||||
if subs and (m := _MD_PATH.search(prompt)):
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
outs.append(slot["payload"](None))
|
||||
outs = [o for o in outs if o]
|
||||
return outs or None
|
||||
fake_race.slots_seen = []
|
||||
return fake_race, prompts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sub_env(testdb, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
|
||||
monkeypatch.setattr(blx.embedding, "available", lambda: True)
|
||||
monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
files = {"arbeit": tmp_path}
|
||||
return testdb, ctx, files
|
||||
|
||||
|
||||
async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
|
||||
fake, prompts = _mk_race(finder_by_agent)
|
||||
monkeypatch.setattr(blx, "_race", fake)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-", seeds=seeds)
|
||||
return raw, prompts
|
||||
|
||||
|
||||
async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
|
||||
"""3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
|
||||
nicht als „Unsicher" ins Panel."""
|
||||
db, ctx, files = sub_env
|
||||
raw, prompts = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Umbruch braucht Marker"],
|
||||
2: ["Umbruch erfordert explizite Marker!"],
|
||||
3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
|
||||
})
|
||||
assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]} # longest = rep
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
status = sorted(r["status"] for r in rows)
|
||||
assert status == ["consensus", "variant", "variant"]
|
||||
clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
|
||||
assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]
|
||||
|
||||
|
||||
async def test_seed_promotes_single_find(sub_env, monkeypatch):
|
||||
"""Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
|
||||
2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Zeilenumbruch Regeln"])
|
||||
assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_norm"] == "zeilenumbruch regeln im detail")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
|
||||
"""Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Fußnoten Syntax"])
|
||||
assert "Fußnoten Syntax" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_title"] == "Fußnoten Syntax")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
|
||||
"""Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
|
||||
}, seeds=["Tabs"])
|
||||
assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}
|
||||
|
||||
|
||||
async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
|
||||
"""Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
|
||||
db, ctx, files = sub_env
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert first == second
|
||||
|
||||
|
||||
async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
|
||||
"""Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
|
||||
Konsens wächst; zweite Runde ohne Neues → Ende."""
|
||||
db, ctx, files = sub_env
|
||||
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
base = fake
|
||||
hit = {"n": 0}
|
||||
|
||||
async def with_catchup(topic, label, slots, *a, **k):
|
||||
if any("-subblock-x" in s["key"] for s in slots):
|
||||
hit["n"] += 1
|
||||
if hit["n"] == 1: # first catch-up round: both agents agree on one new sub
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- block: Alpha -->\n- Vertiefung der Konzepte")
|
||||
return [slot["payload"](None) for slot in slots[:2]]
|
||||
return None
|
||||
return await base(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_catchup)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
|
||||
assert (tmp_path / "subblock-final-c1-x1.md").exists()
|
||||
assert hit["n"] == 2 # round 2 ran, found nothing, loop ended
|
||||
|
||||
|
||||
# ── Outline-Review ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_outline_review_schema():
|
||||
@@ -243,60 +85,8 @@ async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
|
||||
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
|
||||
|
||||
async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
|
||||
"""Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
|
||||
Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
|
||||
db, ctx, files = sub_env
|
||||
base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})
|
||||
|
||||
async def with_r2(topic, label, slots, *a, **k):
|
||||
if any("-r2-" in s["key"] for s in slots):
|
||||
outs = []
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!")
|
||||
outs.append(slot["payload"](None))
|
||||
return outs
|
||||
return await base_fake(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_r2)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert raw["Alpha"] # Konsens steht
|
||||
assert not any("-r3-" in k for k, _ in prompts) # Paraphrase hielt die Schleife NICHT am Leben
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)
|
||||
|
||||
|
||||
async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
|
||||
"""Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
|
||||
db, ctx, files = sub_env
|
||||
_, prompts = _mk_race({})
|
||||
|
||||
async def endless(topic, label, slots, *a, **k):
|
||||
if "-subblock-final-" in slots[0]["key"]:
|
||||
return None # panel fails → consensus fallback
|
||||
outs = []
|
||||
import re as _re
|
||||
rn = _re.search(r"-r(\d+)-", slots[0]["key"])
|
||||
n = rn.group(1) if rn else "x"
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig")
|
||||
prompts.append((slot["key"], slot["prompt"]))
|
||||
outs.append(slot["payload"](None))
|
||||
return outs
|
||||
|
||||
monkeypatch.setattr(blx, "_race", endless)
|
||||
await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
|
||||
assert max_round == blx.SUBBLOCK_MAX_ROUNDS
|
||||
|
||||
|
||||
# ── Inline-Evidenz für Judges (Token-Umbau) ──────────────────────────────────────────
|
||||
# ── Inline-Evidenz (Evidence-Packs für die verschmolzenen Calls) ─────────────────────
|
||||
|
||||
def _corpus(tmp_path):
|
||||
d = tmp_path / "korpus"
|
||||
@@ -353,70 +143,28 @@ def test_cited_evidence_lines_and_fallback(tmp_path):
|
||||
|
||||
def test_sink_json_writes_only_valid(tmp_path):
|
||||
p = tmp_path / "level-final-c1.json"
|
||||
from pipeline import _enum_map_schema
|
||||
levels = _enum_map_schema("levels", ("beginner", "advanced", "expert"))
|
||||
ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
|
||||
lambda d: blx._levels_schema(d, {1}))
|
||||
lambda d: levels(d, {1}))
|
||||
assert ok == {1: "beginner"}
|
||||
assert json.loads(p.read_text(encoding="utf-8"))["levels"]["1"] == "beginner"
|
||||
bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d)
|
||||
assert bad is None and not (tmp_path / "x.json").exists()
|
||||
|
||||
|
||||
async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
|
||||
"""Mit Korpus: Judges bekommen Auszüge inline und laufen ohne Tools (Text-Antwort);
|
||||
die j-Datei schreibt die Engine. Finder bleiben unverändert bei capabilities=files."""
|
||||
db, ctx, files = sub_env
|
||||
d = _corpus(tmp_path)
|
||||
monkeypatch.setattr(blx, "source_folder", lambda t: d)
|
||||
monkeypatch.setattr(blx, "load_source", lambda t: {"type": "uni"})
|
||||
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
monkeypatch.setattr(blx, "_race", fake)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-", sources=["Skript.txt"])
|
||||
assert raw == {"Alpha": ["Alpha Grundlagen"]}
|
||||
judges = [s for s in fake.slots_seen if "-subblock-final-" in s["key"]]
|
||||
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
|
||||
assert judges and all(s["capabilities"] == "none" for s in judges)
|
||||
assert "── Skript.txt" in judges[0]["prompt"]
|
||||
assert "ls/find" not in judges[0]["prompt"] # keine Selbst-Recherche-Anweisung mehr
|
||||
assert finders and all(s["capabilities"] == "files" for s in finders)
|
||||
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
|
||||
|
||||
|
||||
async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path):
|
||||
"""Facts-Check: zitierte Zeilenbereiche gehen inline mit, Judge läuft ohne Tools,
|
||||
die Check-Datei schreibt die Engine aus der Text-Antwort."""
|
||||
db, ctx, files = sub_env
|
||||
d = _corpus(tmp_path)
|
||||
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
|
||||
"prerequisites": "", "hurdles": "",
|
||||
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
|
||||
"example_idea": ""}]}
|
||||
|
||||
sh = blx._subs_hash({"Alpha": ["Sub Eins"]}) # Resume-Dateien tragen den Sub-Satz-Hash
|
||||
|
||||
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
if "-facts-erg-" in key:
|
||||
return blx.FAILED, None
|
||||
(tmp_path / f"facts-{sh}-c0.json").write_text(json.dumps(facts), encoding="utf-8")
|
||||
return blx.OK, None
|
||||
|
||||
seen = []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, provider="", role="", capabilities="", scope=None, label="", **kw):
|
||||
seen.append((key, capabilities, prompt))
|
||||
return (0, '{"ok": true}', "")
|
||||
|
||||
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
|
||||
monkeypatch.setattr(blx, "run_agent", fake_agent)
|
||||
res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
|
||||
{"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
|
||||
assert res is not None
|
||||
facts_map, discarded = res
|
||||
assert "Alpha" in facts_map and not discarded
|
||||
assert len(seen) == blx.FACTS_CHECK_PANEL
|
||||
key, caps, prompt = seen[0]
|
||||
assert caps == "none" and "── Skript.txt · Z." in prompt
|
||||
assert (tmp_path / f"facts-check-{sh}-c0-j1.json").exists() # Engine persistiert die Antwort
|
||||
def test_material_folder_fallbacks(monkeypatch, tmp_path):
|
||||
"""Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None."""
|
||||
monkeypatch.setattr(blx, "source_folder", lambda t: tmp_path / "quelle")
|
||||
assert blx.material_folder("t") == tmp_path / "quelle"
|
||||
monkeypatch.setattr(blx, "source_folder", lambda t: None)
|
||||
monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit")
|
||||
assert blx.material_folder("t") is None # kein Material-Ordner
|
||||
md = tmp_path / "arbeit" / "material"
|
||||
md.mkdir(parents=True)
|
||||
assert blx.material_folder("t") is None # leer zählt nicht
|
||||
(md / "research-1.txt").write_text("x", encoding="utf-8")
|
||||
assert blx.material_folder("t") == md
|
||||
|
||||
|
||||
def test_sub_key_resolves_short_titles():
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Titel-Hygiene: klammer-bewusster Split + Dash-Normalisierung (M1.1).
|
||||
|
||||
Reale aak-Schadensfälle: ein en-Dash mit einseitigem Space INNERHALB eines Titels
|
||||
wurde zum Separator → Split mitten im Titel („Aε-Algorithmus (Güte 1+ε, Laufzeit O(n3").
|
||||
"""
|
||||
|
||||
from textkit import _norm_dash, _title, _split_top, _unclosed, _parse_selection
|
||||
|
||||
|
||||
def test_norm_dash_top_level_only():
|
||||
# einseitiger Space am Dash → Separator (Modelle liefern das so)
|
||||
assert _norm_dash("Titel –Beschreibung") == "Titel — Beschreibung"
|
||||
assert _norm_dash("Titel— Beschreibung") == "Titel — Beschreibung"
|
||||
# Dash INNERHALB offener Klammer bleibt unangetastet (kein Separator)
|
||||
assert _norm_dash("f (a – b) — Rest") == "f (a – b) — Rest"
|
||||
# ASCII-Hyphen und geklebte/rangebasierte Dashes sind nie Separatoren
|
||||
assert _norm_dash("3-SAT") == "3-SAT"
|
||||
assert _norm_dash("12–15") == "12–15"
|
||||
|
||||
|
||||
def test_split_top_bracket_aware():
|
||||
assert _split_top("a (b — c) — d", " — ") == "a (b — c)"
|
||||
assert _split_top("a — b", " — ") == "a"
|
||||
assert _split_top("kein Separator hier", " — ") == "kein Separator hier"
|
||||
|
||||
|
||||
def test_unclosed_bracket_detection():
|
||||
assert _unclosed("a (b") is True
|
||||
assert _unclosed("a [b {c") is True
|
||||
assert _unclosed("a (b) c") is False
|
||||
assert _unclosed("a) b") is False # überzähliger Schließer ist kein Abschnitt
|
||||
|
||||
|
||||
def test_title_keeps_bracketed_dash():
|
||||
"""Der Separator innerhalb der Klammer zerschneidet den Titel nicht mehr."""
|
||||
t = _title("Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³)) — der Approx-Algorithmus")
|
||||
assert t == "Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³))"
|
||||
|
||||
|
||||
def test_title_plain_split_unchanged():
|
||||
assert _title("Vertex Cover — minimale Knotenüberdeckung") == "Vertex Cover"
|
||||
assert _title("Ohne Separator bleibt ganz") == "Ohne Separator bleibt ganz"
|
||||
|
||||
|
||||
def test_title_unclosed_bracket_keeps_entry():
|
||||
"""Malformed: offene Klammer nie geschlossen → keinen abgeschnittenen Titel ausgeben,
|
||||
stattdessen den ganzen Eintrag behalten (Repair = Split rückgängig)."""
|
||||
e = "N P via Turingmaschine (Definition 6.19 — Rest"
|
||||
assert _title(e) == e
|
||||
|
||||
|
||||
def test_parse_selection_then_title_regression():
|
||||
"""End-to-end der Parse-Kette (wie in der Pipeline): _norm_dash + _title lassen den
|
||||
Klammer-Inhalt intakt statt mitten im Titel zu splitten (der reale aak-Bug)."""
|
||||
line = "1. Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³)) — der Approximations-Algorithmus"
|
||||
entries = _parse_selection(line)
|
||||
assert _title(entries[1]) == "Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³))"
|
||||
+12
-12
@@ -25,9 +25,9 @@ def test_registry_spiegelt_config():
|
||||
|
||||
def test_creator_params_override_wirkt_im_subprozess():
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-c", "import config; print(config.FACTS_CHUNK_SUBS, config.TIMEOUTS['subblock_check'][0])"],
|
||||
[sys.executable, "-c", "import config; print(config.GATE_FIX_MIN, config.TIMEOUTS['subblock_check'][0])"],
|
||||
capture_output=True, text=True, cwd=BACKEND,
|
||||
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"FACTS_CHUNK_SUBS": 6, "TIMEOUT_subblock_check_base": 77}'})
|
||||
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"GATE_FIX_MIN": 6, "TIMEOUT_subblock_check_base": 77}'})
|
||||
assert out.stdout.split() == ["6", "77"], out.stderr
|
||||
|
||||
|
||||
@@ -65,17 +65,17 @@ def _trainer(tmp_path, runner, f0, **kw):
|
||||
|
||||
|
||||
async def test_aco_konvergiert_auf_optimum(tmp_path):
|
||||
"""Gepflanztes Optimum (FACTS_CHUNK_SUBS=6) wird gefunden und bestätigt übernommen;
|
||||
"""Gepflanztes Optimum (GEN_PANEL=3) wird gefunden und bestätigt übernommen;
|
||||
die Pheromon-Spur konzentriert sich dort."""
|
||||
def bewertung(params):
|
||||
return _metrics(note=9.5, dauer=7.0) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics()
|
||||
return _metrics(note=9.5, dauer=7.0) if params.get("GEN_PANEL") == 3 else _metrics()
|
||||
|
||||
runner, f0 = _stub(bewertung)
|
||||
t = _trainer(tmp_path, runner, f0, max_trials=120)
|
||||
best = await t.run()
|
||||
assert best.get("FACTS_CHUNK_SUBS") == 6
|
||||
taus = t.pheromon["FACTS_CHUNK_SUBS"]
|
||||
assert max(taus, key=lambda k: taus[k]) == "6"
|
||||
assert best.get("GEN_PANEL") == 3
|
||||
taus = t.pheromon["GEN_PANEL"]
|
||||
assert max(taus, key=lambda k: taus[k]) == "3"
|
||||
|
||||
|
||||
async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
||||
@@ -83,7 +83,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
||||
zustand = {"mal": 0}
|
||||
|
||||
def bewertung(params):
|
||||
if params.get("FACTS_CHUNK_SUBS") == 6:
|
||||
if params.get("GEN_PANEL") == 3:
|
||||
zustand["mal"] += 1
|
||||
return _metrics(note=9.5) if zustand["mal"] == 1 else _metrics(note=8.0)
|
||||
return _metrics()
|
||||
@@ -91,7 +91,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
||||
runner, f0 = _stub(bewertung)
|
||||
t = _trainer(tmp_path, runner, f0, max_trials=40)
|
||||
best = await t.run()
|
||||
assert best.get("FACTS_CHUNK_SUBS") != 6
|
||||
assert best.get("GEN_PANEL") != 3
|
||||
|
||||
|
||||
async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
|
||||
@@ -111,16 +111,16 @@ async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
|
||||
|
||||
async def test_resume_laedt_pheromon_und_cache(tmp_path):
|
||||
def bewertung(params):
|
||||
return _metrics(note=9.5) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics()
|
||||
return _metrics(note=9.5) if params.get("GEN_PANEL") == 3 else _metrics()
|
||||
|
||||
runner, f0 = _stub(bewertung)
|
||||
t = _trainer(tmp_path, runner, f0, max_trials=60)
|
||||
await t.run()
|
||||
best, tau = t.best_params, dict(t.pheromon["FACTS_CHUNK_SUBS"])
|
||||
best, tau = t.best_params, dict(t.pheromon["GEN_PANEL"])
|
||||
runner2, f02 = _stub(bewertung)
|
||||
t2 = _trainer(tmp_path, runner2, f02, max_trials=0) # kein Budget: alles aus Persistenz
|
||||
assert t2.best_params == best
|
||||
assert t2.pheromon["FACTS_CHUNK_SUBS"] == tau
|
||||
assert t2.pheromon["GEN_PANEL"] == tau
|
||||
|
||||
|
||||
async def test_budget_stoppt(tmp_path):
|
||||
|
||||
+58
-3
@@ -3,12 +3,22 @@
|
||||
No state, no IO — safe to import anywhere.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now
|
||||
|
||||
|
||||
def parse_facts(raw) -> dict:
|
||||
"""subblocks.facts ist ein JSON-Blob aus LLM-Hand — leer/kaputt/kein dict → {}."""
|
||||
try:
|
||||
d = json.loads(raw) if raw else {}
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return d if isinstance(d, dict) else {}
|
||||
|
||||
|
||||
def _norm_title(s: str) -> str:
|
||||
"""Normalize a title for key comparison.
|
||||
|
||||
@@ -22,8 +32,46 @@ def _norm_title(s: str) -> str:
|
||||
return s.casefold()
|
||||
|
||||
|
||||
# Bracket-aware separator handling: a ' — ' (or a dash that _norm_dash would normalize) INSIDE
|
||||
# ()[]{} is part of the title, not a title/description separator. Without this a spaced dash within
|
||||
# a math title truncates it: 'Aε-Algorithmus (Güte 1+ε, Laufzeit O(n³)) — …' → 'Aε-Algorithmus (Güte…'.
|
||||
_BRACKETS = {"(": ")", "[": "]", "{": "}"}
|
||||
_CLOSERS = {v: k for k, v in _BRACKETS.items()}
|
||||
|
||||
|
||||
def _unclosed(s: str) -> bool:
|
||||
"""True if s has a dangling opening bracket ()[]{} — the symptom of a mid-bracket cut. Extra
|
||||
closers (more ')' than '(') do NOT count; only an unclosed opener signals a truncated title."""
|
||||
depth = 0
|
||||
for c in s:
|
||||
if c in _BRACKETS:
|
||||
depth += 1
|
||||
elif c in _CLOSERS and depth > 0:
|
||||
depth -= 1
|
||||
return depth > 0
|
||||
|
||||
|
||||
def _split_top(s: str, sep: str) -> str:
|
||||
"""First bracket-depth-0 segment of s split on sep; whole s if sep never occurs at depth 0."""
|
||||
depth = 0
|
||||
for i, c in enumerate(s):
|
||||
if c in _BRACKETS:
|
||||
depth += 1
|
||||
elif c in _CLOSERS and depth > 0:
|
||||
depth -= 1
|
||||
elif depth == 0 and s.startswith(sep, i):
|
||||
return s[:i]
|
||||
return s
|
||||
|
||||
|
||||
def _title(entry: str) -> str:
|
||||
return entry.split(" — ")[0].strip() or entry
|
||||
"""Title = text before the first TOP-LEVEL ' — '. A separator inside ()[]{} does not split
|
||||
(keeps math titles intact); if the extracted title has an unclosed bracket (malformed source),
|
||||
keep the whole entry rather than emit a truncated title."""
|
||||
title = _split_top(entry, " — ")
|
||||
if _unclosed(title):
|
||||
return entry.strip() or entry
|
||||
return title.strip() or entry
|
||||
|
||||
|
||||
def clean_title(s: str) -> str:
|
||||
@@ -64,6 +112,9 @@ def _resolve_title(idx: dict[str, int], t: str) -> int | None:
|
||||
return idx.get(_norm_title(t)) or idx.get(_norm_title(_title(t)))
|
||||
|
||||
|
||||
_DASH_SEP_RE = re.compile(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*")
|
||||
|
||||
|
||||
def _norm_dash(s: str) -> str:
|
||||
"""Dash variants (en/em/figure/bar) with whitespace on AT LEAST ONE side → uniform separator ' — '.
|
||||
Some models (especially non-western ones) use an en-dash "–" instead of the em-dash; without
|
||||
@@ -71,8 +122,12 @@ def _norm_dash(s: str) -> str:
|
||||
space ("Titel —Beschreibung" / "Titel— Beschreibung") also breaks the split and leaks the source
|
||||
filename into the description — so a dash with a space on either side is repaired too. The ASCII
|
||||
hyphen "-" is deliberately NOT in the class (would split "n - 1"/"3-SAT"); requiring ≥1 surrounding
|
||||
space keeps glued compounds like "Backtracking—Verfahren" and number ranges like "12–15" untouched."""
|
||||
return re.sub(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*", " — ", s)
|
||||
space keeps glued compounds like "Backtracking—Verfahren" and number ranges like "12–15" untouched.
|
||||
A qualifying dash INSIDE an open bracket ()[]{} is left untouched — it is part of the title, not a
|
||||
separator (else 'Algorithmus (Güte 1+ε – O(n³)) — …' would split mid-parenthetical)."""
|
||||
def _repl(m):
|
||||
return m.group(0) if _unclosed(s[:m.start()]) else " — "
|
||||
return _DASH_SEP_RE.sub(_repl, s)
|
||||
|
||||
|
||||
def _parse_selection(text: str) -> dict[int, str]:
|
||||
|
||||
+3
-2
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
|
||||
# WICHTIG: config (mit CREATOR_PARAMS) lädt vor allen Pipeline-Modulen
|
||||
import database
|
||||
from config import DEFAULT_PROVIDER
|
||||
from fake_agents import Welt, aktivieren
|
||||
from fsutil import atomic_write_json
|
||||
|
||||
@@ -40,12 +41,12 @@ async def f0(out: str) -> None:
|
||||
"artefakte": tmp / "artefakte.json", "outline": tmp / "outline.json",
|
||||
"outline_slots": [tmp / f"outline-{i}.json" for i in (1, 2, 3)],
|
||||
"research": [work / f"research-{i}.md" for i in (1, 2, 3, 4, 5)]}
|
||||
ctx = GenContext(topic="f0", provider="claude", is_cancelled=lambda: False)
|
||||
ctx = GenContext(topic="f0", provider=DEFAULT_PROVIDER, is_cancelled=lambda: False)
|
||||
start = time.monotonic()
|
||||
ok = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
|
||||
research=True, qa_force=True), timeout=180)
|
||||
from tests.invarianten import pruefe_invarianten
|
||||
from invarianten import pruefe_invarianten
|
||||
fehler = await pruefe_invarianten("f0", files)
|
||||
atomic_write_json(Path(out), {
|
||||
"ok": bool(ok), "invarianten_fehler": fehler, "calls": len(welt.calls),
|
||||
|
||||
@@ -105,7 +105,7 @@ async def _frozen_inventar(topic: str, vorlage: str) -> None:
|
||||
shutil.copy(src, dst)
|
||||
files = _blocks_files(topic)
|
||||
files["arbeit"].mkdir(parents=True, exist_ok=True)
|
||||
await board_inventory.reset_board_from_stage(topic, "artefacts", "subblocks", files)
|
||||
await board_inventory.reset_board_from_stage(topic, "artefacts", "generate", files)
|
||||
|
||||
|
||||
async def _aufraeumen(topic: str) -> None:
|
||||
|
||||
+6
-15
@@ -18,33 +18,24 @@ PARAMS: dict[str, dict] = {
|
||||
"FILTER_CHUNK": {"default": 35, "min": 15, "max": 60, "step": 10, "kategorie": "laufzeit", "fidelity": "voll"},
|
||||
"FILTER_RECHECK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"},
|
||||
"CONSOLIDATION_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"},
|
||||
# Subbausteine
|
||||
"SUBBLOCK_CHUNK": {"default": 10, "min": 4, "max": 20, "step": 2, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"SUBBLOCK_MIN": {"default": 5, "min": 2, "max": 10, "step": 1, "kategorie": "auswahl", "fidelity": "board2"},
|
||||
"SUBBLOCK_MAX_ROUNDS": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "auswahl", "fidelity": "board2"},
|
||||
"SUBBLOCK_EXTRA_ROUNDS": {"default": 2, "min": 0, "max": 4, "step": 1, "kategorie": "auswahl", "fidelity": "board2"},
|
||||
"SUBBLOCK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
# Facts / Artefakte / Fragen
|
||||
"FACTS_CHUNK_SUBS": {"default": 10, "min": 4, "max": 20, "step": 2, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"FACTS_CHECK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
"QUESTION_CHUNK_SUBS": {"default": 25, "min": 10, "max": 50, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"QUESTION_MAX_ROUNDS": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
"ARTEFACT_CHUNK_SUBS": {"default": 25, "min": 10, "max": 50, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
# Board 2, verschmolzene Calls (block_calls.py)
|
||||
"GEN_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
"VERIFY_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
"ART_SPLIT_SUBS": {"default": 20, "min": 10, "max": 40, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
# Embedding-Schwellen (Auswahl-Kern)
|
||||
"SUB_VARIANT_COS": {"default": 0.90, "min": 0.85, "max": 0.96, "step": 0.01, "kategorie": "auswahl", "fidelity": "board2"},
|
||||
"SEED_COVER_COS": {"default": 0.80, "min": 0.7, "max": 0.9, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
|
||||
"SUB_DUP_KANDIDAT_COS": {"default": 0.75, "min": 0.65, "max": 0.85, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
|
||||
"EMBEDDING_BLOCK_FLOOR": {"default": 0.5, "min": 0.35, "max": 0.65, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"},
|
||||
"CROSS_CHUNK_PAARE": {"default": 40, "min": 15, "max": 80, "step": 10, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"GROUP_THEMES_PER_SQRT": {"default": 1.0, "min": 0.6, "max": 1.6, "step": 0.2, "kategorie": "auswahl", "fidelity": "voll"},
|
||||
# Guide
|
||||
"MAX_WRITER_ROUNDS": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
"GATE_FIX_MIN": {"default": 3, "min": 1, "max": 6, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
"WRITER_SPLIT_SUBS": {"default": 30, "min": 15, "max": 45, "step": 5, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
# Engine / Kosten
|
||||
"CONSENSUS_GRACE": {"default": 300, "min": 0, "max": 600, "step": 60, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"MAX_RESTARTS": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"HEDGE_NACH_S": {"default": 90, "min": 30, "max": 240, "step": 30, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"EVIDENCE_BUDGET_CHARS": {"default": 48000, "min": 16000, "max": 64000, "step": 8000, "kategorie": "tokens", "fidelity": "board2"},
|
||||
"QUELLE_RELEVANZ_CHUNK": {"default": 12, "min": 6, "max": 24, "step": 3, "kategorie": "laufzeit", "fidelity": "voll"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-3
@@ -4,9 +4,14 @@ services:
|
||||
context: .
|
||||
container_name: creator
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
||||
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
|
||||
# komplette .env durchreichen — 3 Einzel-Vars ließen ROLE_*/MAX_CONCURRENT_* etc.
|
||||
# still weg (Dev sourct die ganze .env, Prod bekam nur einen Teil)
|
||||
env_file: .env
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- web
|
||||
volumes:
|
||||
|
||||
+48
-66
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
||||
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
||||
import { usePolling } from './composables/usePolling.js'
|
||||
import TopicSidebar from './components/TopicSidebar.vue'
|
||||
import TopicDetail from './components/TopicDetail.vue'
|
||||
@@ -21,7 +21,7 @@ const darkMode = ref(
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: localStorage.getItem('darkMode') === 'true',
|
||||
)
|
||||
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
|
||||
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false }
|
||||
const blocks = ref({ ...EMPTY_BLOCKS })
|
||||
const activeBlocks = ref([])
|
||||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||
@@ -41,6 +41,13 @@ async function guard(label, fn) {
|
||||
try { return await fn() } catch (e) { console.error(label, e) }
|
||||
}
|
||||
|
||||
// Aktion ausführen und einen Backend-Fehler (409/400/500) in der Sidebar-Fehlerzeile
|
||||
// zeigen statt als unhandled rejection zu verschlucken. → true bei Erfolg.
|
||||
async function withUiError(fn) {
|
||||
uiError.value = null
|
||||
try { await fn(); return true } catch (e) { uiError.value = e.message; return false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
|
||||
}
|
||||
@@ -175,12 +182,31 @@ async function loadBlocks() {
|
||||
}
|
||||
}
|
||||
|
||||
// „Auto"-Ebenen-Flags pro Topic (localStorage): nach der QA „Befunde beheben" bis 100 %.
|
||||
const autoInventory = ref(true)
|
||||
const autoArtefacts = ref(true)
|
||||
const autoGuide = ref(true)
|
||||
function ladeAuto(topic) {
|
||||
const g = (e) => { const v = localStorage.getItem(`auto_${topic}_${e}`); return v === null ? true : v === 'true' }
|
||||
autoInventory.value = g('inventory')
|
||||
autoArtefacts.value = g('artefacts')
|
||||
autoGuide.value = g('guide')
|
||||
}
|
||||
function handleSetAuto({ ebene, value }) {
|
||||
const r = { inventory: autoInventory, artefacts: autoArtefacts, guide: autoGuide }[ebene]
|
||||
if (!r) return
|
||||
r.value = value
|
||||
if (selectedTopic.value) localStorage.setItem(`auto_${selectedTopic.value}_${ebene}`, value ? 'true' : 'false')
|
||||
}
|
||||
const autoObj = () => ({ inventory: autoInventory.value, artefacts: autoArtefacts.value, guide: autoGuide.value })
|
||||
|
||||
function selectTopic(topic) {
|
||||
selectedTopic.value = topic
|
||||
previewGuide.value = null
|
||||
sidebarSticky.value = false
|
||||
mainView.value = 'generation' // topic click → generation board (guide only on pill click)
|
||||
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
|
||||
ladeAuto(topic)
|
||||
localStorage.setItem('lastTopic', topic)
|
||||
loadBlocks()
|
||||
}
|
||||
@@ -192,28 +218,12 @@ watch(previewGuide, (g) => {
|
||||
|
||||
async function handleCancelBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiCancelBausteine(selectedTopic.value)
|
||||
await loadBlocks()
|
||||
if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiDeleteBausteine(selectedTopic.value)
|
||||
await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetStage({ board, stage, restart = false }) {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiResetBlocksStage(selectedTopic.value, board, stage)
|
||||
if (restart) await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) // Continue: Queue abarbeiten
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadBlocks()
|
||||
if (restart) startPolling()
|
||||
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleAddResearch() {
|
||||
@@ -231,18 +241,22 @@ async function handleAddResearch() {
|
||||
|
||||
async function handleRequeueDead() {
|
||||
if (!selectedTopic.value) return
|
||||
const ok = await withUiError(async () => {
|
||||
await apiRequeueDead(selectedTopic.value)
|
||||
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
|
||||
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false, false, autoObj())
|
||||
})
|
||||
if (!ok) return
|
||||
await loadBlocks()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleBlocksClick({ instructions = '', research = true, qaForce = false }) {
|
||||
async function handleBlocksClick({ instructions = '', research = true, qaForce = false, onlyArtefacts = false }) {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce)
|
||||
// onlyArtefacts=true = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate).
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj(), onlyArtefacts)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
@@ -292,7 +306,7 @@ function handleOpenBlocksView() {
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
async function handleFormatClick({ format, instructions = '', abStep = null }) {
|
||||
async function handleFormatClick({ format, instructions = '' }) {
|
||||
if (!selectedTopic.value) return
|
||||
// No duplicate start: if a generation is already running for topic+format, ignore
|
||||
const running = guides.value.some(
|
||||
@@ -302,7 +316,7 @@ async function handleFormatClick({ format, instructions = '', abStep = null }) {
|
||||
if (running) { handleOpenGuideBoard(format); return }
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep)
|
||||
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
@@ -336,34 +350,6 @@ async function handleRemoveGuideFormat(format) {
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
async function handleRestartCard(cardId) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiRestartBlocksCard(selectedTopic.value, cardId)
|
||||
await handleBlocksClick({ research: false }) // Continue: der Flow zieht die Karte
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetGuideCard({ format, blockNorm, abStage }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiResetGuideCard(selectedTopic.value, format, blockNorm, abStage)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGuideBoardReset({ format, abStage }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiResetGuideBoard(selectedTopic.value, format, abStage)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function handleGuideBoardPreview() {
|
||||
const g = doneByFormat.value[guideBoardFormat.value]
|
||||
if (g) handlePreview(g)
|
||||
@@ -407,17 +393,12 @@ const polling = usePolling(
|
||||
const startPolling = polling.start
|
||||
|
||||
async function handleCancel(guideId) {
|
||||
await apiCancel(guideId)
|
||||
await loadGuides()
|
||||
if (await withUiError(() => apiCancel(guideId))) await loadGuides()
|
||||
}
|
||||
|
||||
async function handleDeleteTopic(topic) {
|
||||
const topicGuides = guides.value.filter((g) => g.topic === topic)
|
||||
for (const g of topicGuides) {
|
||||
await deleteGuide(g.id)
|
||||
}
|
||||
await apiDeleteBausteine(topic)
|
||||
await apiDeleteTopic(topic)
|
||||
// Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409).
|
||||
if (!await withUiError(() => apiDeleteTopic(topic))) return
|
||||
await loadTopics()
|
||||
if (selectedTopic.value === topic) {
|
||||
selectedTopic.value = null
|
||||
@@ -497,22 +478,23 @@ onMounted(async () => {
|
||||
:progress="blocks.progress"
|
||||
:ready="blocks.ready"
|
||||
:partial="blocks.partial"
|
||||
:error="blocks.error"
|
||||
:guideFormat="guideBoardFormat"
|
||||
:autoInventory="autoInventory"
|
||||
:autoArtefacts="autoArtefacts"
|
||||
:autoGuide="autoGuide"
|
||||
@close="mainView = 'blocks'"
|
||||
@resetStage="handleResetStage"
|
||||
@restartAll="() => handleBlocksClick({ research: true })"
|
||||
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })"
|
||||
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce), onlyArtefacts: !!(opts && opts.onlyArtefacts) })"
|
||||
@addResearch="handleAddResearch"
|
||||
@requeueDead="handleRequeueDead"
|
||||
@removeAll="handleResetBlocks"
|
||||
@cancel="handleCancelBlocks"
|
||||
@cancelGuide="handleCancel"
|
||||
@startGuide="handleFormatClick"
|
||||
@resetGuideStage="handleGuideBoardReset"
|
||||
@preview="handleGuideBoardPreview"
|
||||
@removeFormat="handleRemoveGuideFormat"
|
||||
@restartCard="handleRestartCard"
|
||||
@resetGuideCard="handleResetGuideCard"
|
||||
@setAuto="handleSetAuto"
|
||||
/>
|
||||
<GeneralExamPanel
|
||||
v-else-if="selectedTopic && mainView === 'general'"
|
||||
|
||||
+93
-239
@@ -1,305 +1,159 @@
|
||||
const BASE = '/api'
|
||||
|
||||
// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken
|
||||
async function jsonOrThrow(res) {
|
||||
function qs(query) {
|
||||
if (!query) return ''
|
||||
const p = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null) p.set(k, v)
|
||||
}
|
||||
const s = p.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
// Ein Request-Weg für ALLE Aufrufe: Backend-Fehler (400/409/500) werfen statt sie zu
|
||||
// verschlucken, mit err.status für Aufrufer, die 404 ("noch nichts da") gesondert behandeln.
|
||||
async function req(path, { method = 'GET', body, query } = {}) {
|
||||
const opts = { method }
|
||||
if (body !== undefined) {
|
||||
opts.headers = { 'Content-Type': 'application/json' }
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}${qs(query)}`, opts)
|
||||
if (!res.ok) {
|
||||
let detail = `Fehler (HTTP ${res.status})`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
|
||||
} catch { /* kein JSON-Body */ }
|
||||
throw new Error(detail)
|
||||
const err = new Error(detail)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
const text = await res.text() // DELETE/manche POSTs liefern keinen Body
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
|
||||
export async function fetchGuides() {
|
||||
const res = await fetch(`${BASE}/guides`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchGuides = () => req('/guides')
|
||||
|
||||
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
|
||||
const res = await fetch(`${BASE}/guides`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const createGuide = (topic, format, instructions = '', provider = 'claude') =>
|
||||
req('/guides', { method: 'POST', body: { topic, format, instructions, provider } })
|
||||
|
||||
export async function fetchActiveBlocks() {
|
||||
const res = await fetch(`${BASE}/blocks/active`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchActiveBlocks = () => req('/blocks/active')
|
||||
|
||||
export async function fetchBlocksStatus(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
|
||||
|
||||
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) {
|
||||
const res = await fetch(`${BASE}/blocks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false, auto = {}, onlyArtefacts = false) =>
|
||||
req('/blocks', { method: 'POST', body: {
|
||||
topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce,
|
||||
auto_inventory: auto.inventory ?? true, auto_artefacts: auto.artefacts ?? true, auto_guide: auto.guide ?? true,
|
||||
only_artefacts: onlyArtefacts,
|
||||
} })
|
||||
|
||||
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
|
||||
export async function fetchBlocksBoard(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/board?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
|
||||
|
||||
// Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report.
|
||||
export async function runQa(topic, llm = true) {
|
||||
const res = await fetch(`${BASE}/blocks/qa`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, llm }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const runQa = (topic, llm = true, ebene = null) => req('/blocks/qa', { method: 'POST', body: { topic, llm, ebene } })
|
||||
|
||||
// QA-Befunde gezielt beheben (Hygiene, bestätigte Dubletten, Fremd/Unecht nach Gegen-Judge).
|
||||
export async function runRepair(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/repair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
// QA-Befunde gezielt beheben; ebene = 'inventory' | 'artefacts' | undefined (alle).
|
||||
export const runRepair = (topic, ebene = null) => req('/blocks/repair', { method: 'POST', body: { topic, ebene } })
|
||||
|
||||
// Karten ab Spalte zurücksetzen (keine Generierung).
|
||||
export async function resetBlocksStage(topic, board, stage) {
|
||||
const res = await fetch(`${BASE}/blocks/reset-stage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, board, stage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
// Artefakte-Ebene leeren (Board-2-Daten weg, Inventar bleibt).
|
||||
export const removeArtefacts = (topic) => req('/blocks/artefacts', { method: 'DELETE', query: { topic } })
|
||||
|
||||
// Einen weiteren Research-Agenten anhängen (Attach-or-Start).
|
||||
export async function addBlocksResearch(topic, provider = 'claude') {
|
||||
const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}&provider=${encodeURIComponent(provider)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const addBlocksResearch = (topic, provider = 'claude') =>
|
||||
req('/blocks/research', { method: 'POST', query: { topic, provider } })
|
||||
|
||||
export async function restartBlocksCard(topic, cardId) {
|
||||
const res = await fetch(`${BASE}/blocks/card-restart`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, card_id: cardId }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const removeGuideFormat = (topic, format) =>
|
||||
req('/guides/board/remove', { method: 'POST', body: { topic, format } })
|
||||
|
||||
export async function removeGuideFormat(topic, format) {
|
||||
const res = await fetch(`${BASE}/guides/board/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resetGuideCard(topic, format, blockNorm, abStage) {
|
||||
const res = await fetch(`${BASE}/guides/board/card-reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, block_norm: blockNorm, ab_stage: abStage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function requeueBlocksDead(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const requeueBlocksDead = (topic) =>
|
||||
req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
|
||||
|
||||
// Live-Board der Guide-Erzeugung.
|
||||
export async function fetchGuideBoard(topic, format = 'Guide') {
|
||||
const res = await fetch(`${BASE}/guides/board?topic=${encodeURIComponent(topic)}&format=${encodeURIComponent(format)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchGuideBoard = (topic, format = 'Guide') =>
|
||||
req('/guides/board', { query: { topic, format } })
|
||||
|
||||
export async function resetGuideBoard(topic, format, abStage) {
|
||||
const res = await fetch(`${BASE}/guides/board/reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, ab_stage: abStage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
|
||||
export const repairGuideBoard = (topic, format) =>
|
||||
req('/guides/board/repair', { method: 'POST', body: { topic, format } })
|
||||
|
||||
export async function cancelBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })
|
||||
|
||||
export async function deleteBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||
}
|
||||
export const deleteBlocks = (topic) => req('/blocks', { method: 'DELETE', query: { topic } })
|
||||
|
||||
// Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
|
||||
export const fetchRuns = (topic, limit = 10) => req('/runs', { query: { topic, limit } })
|
||||
|
||||
// --- Block-Learning: Chat, Exam ---
|
||||
|
||||
export async function fetchBlockLearnState(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlockLearnState = (topic) => req('/blocks/learnstate', { query: { topic } })
|
||||
|
||||
export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) {
|
||||
const res = await fetch(`${BASE}/blocks/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const chatBlock = ({ topic, block, section, section_compact = '', messages, provider }) =>
|
||||
req('/blocks/chat', { method: 'POST', body: { topic, block, section, section_compact, messages, provider } })
|
||||
|
||||
export async function examBlock({
|
||||
export const examBlock = ({
|
||||
topic, block, section, section_compact = '', provider,
|
||||
action = 'question', question = '', last_rating = '', avoid = [],
|
||||
asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
|
||||
selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/blocks/exam`, {
|
||||
}) => req('/blocks/exam', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
body: { topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer },
|
||||
})
|
||||
|
||||
export async function fetchQuestionPattern(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchQuestionPattern = (topic, block) =>
|
||||
req('/blocks/question-pattern', { query: { topic, block } })
|
||||
|
||||
export async function fetchTopicProgress(topic) {
|
||||
const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchTopicProgress = (topic) => req('/topics/progress', { query: { topic } })
|
||||
|
||||
export async function fetchStats() {
|
||||
const res = await fetch(`${BASE}/stats`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchStats = () => req('/stats')
|
||||
|
||||
export async function fetchProviders() {
|
||||
const res = await fetch(`${BASE}/providers`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchProviders = () => req('/providers')
|
||||
|
||||
export async function fetchFolders(kind) {
|
||||
const res = await fetch(`${BASE}/folders?kind=${encodeURIComponent(kind)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchFolders = (kind) => req('/folders', { query: { kind } })
|
||||
|
||||
export async function fetchSource(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchSource = (topic) => req('/blocks/source', { query: { topic } })
|
||||
|
||||
export async function updateSource(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/blocks/source`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, type, location: ort, spec }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const updateSource = (topic, { type, ort = '', spec = '' }) =>
|
||||
req('/blocks/source', { method: 'PUT', body: { topic, type, location: ort, spec } })
|
||||
|
||||
export async function fetchBlocksCompleteness(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksCompleteness = (topic) => req('/blocks/completeness', { query: { topic } })
|
||||
|
||||
export async function fetchBlocksOverview(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksOverview = (topic) => req('/blocks/overview', { query: { topic } })
|
||||
|
||||
export async function cancelGuide(id) {
|
||||
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
|
||||
}
|
||||
export const cancelGuide = (id) => req(`/guides/${id}/cancel`, { method: 'POST' })
|
||||
|
||||
export async function deleteGuide(id, slots = false) {
|
||||
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
|
||||
}
|
||||
export const deleteGuide = (id, slots = false) =>
|
||||
req(`/guides/${id}`, { method: 'DELETE', query: slots ? { slots: 1 } : undefined })
|
||||
|
||||
export async function fetchGuideContent(id, level = 4) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
|
||||
if (!res.ok) throw new Error(`Content not available (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchGuideContent = (id, level = 4) => req(`/guides/${id}/content`, { query: { level } })
|
||||
|
||||
// Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
|
||||
export async function fetchPracticeDeck(topic) {
|
||||
const res = await fetch(`${BASE}/practice/deck?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchPracticeDeck = (topic) => req('/practice/deck', { query: { topic } })
|
||||
|
||||
// Leitner-Schritt buchen (correct = „Gewusst").
|
||||
export async function answerPracticeCard({ topic, block_norm, sub_norm, correct }) {
|
||||
const res = await fetch(`${BASE}/practice/answer`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block_norm, sub_norm, correct }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const answerPracticeCard = ({ topic, block_norm, sub_norm, correct }) =>
|
||||
req('/practice/answer', { method: 'POST', body: { topic, block_norm, sub_norm, correct } })
|
||||
|
||||
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
|
||||
export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, snippet, hint, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const pruefeBlock = (id, { block, spot, snippet, hint = '', provider }) =>
|
||||
req(`/guides/${id}/block/pruefen`, { method: 'POST', body: { block, spot, snippet, hint, provider } })
|
||||
|
||||
// Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
|
||||
export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, alt, revised, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const uebernehmeBlock = (id, { block, spot, alt, revised, provider }) =>
|
||||
req(`/guides/${id}/block/uebernehmen`, { method: 'POST', body: { block, spot, alt, revised, provider } })
|
||||
|
||||
// Reset a block's learning progress to zero (score/streak/flags/open question).
|
||||
export async function resetBlockProgress(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const resetBlockProgress = (topic, block) =>
|
||||
req('/blocks/progress', { method: 'DELETE', query: { topic, block } })
|
||||
|
||||
export async function fetchTopics() {
|
||||
const res = await fetch(`${BASE}/topics`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchTopics = () => req('/topics')
|
||||
|
||||
export async function createTopic(name) {
|
||||
await fetch(`${BASE}/topics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
}
|
||||
export const createTopic = (name) => req('/topics', { method: 'POST', body: { name } })
|
||||
|
||||
export async function deleteTopic(name) {
|
||||
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ section, outline, messages, provider }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
export const deleteTopic = (name) => req('/topics', { method: 'DELETE', query: { topic: name } })
|
||||
|
||||
export const chatGuide = (id, { section, outline, messages, provider = 'claude' }) =>
|
||||
req(`/guides/${id}/chat`, { method: 'POST', body: { section, outline, messages, provider } })
|
||||
@@ -89,3 +89,48 @@
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* Stufen-Segmente (Vollansicht): dezenter Rand + A/F/E-Badge. Global, weil TopicDetail
|
||||
das Markup per v-html injiziert (scoped-Styles greifen dort nicht). */
|
||||
.sub-stufe {
|
||||
position: relative;
|
||||
border-left: 3px solid color-mix(in srgb, var(--stufe-farbe) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--stufe-farbe) 8%, transparent);
|
||||
padding: 0.15rem 0.5rem 0.15rem 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.sub-stufe-badge {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--stufe-farbe);
|
||||
border: 1px solid color-mix(in srgb, var(--stufe-farbe) 60%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Neu freigeschaltete Subbausteine (Auto-Stufe): kräftigerer Rand + Stufen-Badge */
|
||||
.sub-neu {
|
||||
position: relative;
|
||||
border-left: 3px solid var(--neu-farbe);
|
||||
background: color-mix(in srgb, var(--neu-farbe) 12%, transparent);
|
||||
padding: 0.15rem 0.5rem 0.15rem 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.sub-neu-badge {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--neu-farbe);
|
||||
border: 1px solid var(--neu-farbe);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import { renderMarkdown, renderBlocks } from '../markdown.js'
|
||||
import { stufeFuer } from '../levels.js'
|
||||
import { stufeFuer, SUB_RANK, VIEW_KURZ, VIEW_FARBE } from '../levels.js'
|
||||
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
|
||||
import { clearPruef } from '../pruefungCache.js'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
@@ -18,6 +18,7 @@ const props = defineProps({
|
||||
hasPrev: { type: Boolean, default: false },
|
||||
hasNext: { type: Boolean, default: false },
|
||||
fortschritt: { type: Object, default: () => ({ total: 0, beginner: 0, advanced: 0, expert: 0, master: 0 }) },
|
||||
stufe: { type: Number, default: 4 }, // höchste sichtbare Stufe (1=A … 4=V) — wie die Standardansicht
|
||||
})
|
||||
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
@@ -98,7 +99,27 @@ const level = computed(() => {
|
||||
const displayedText = computed(() =>
|
||||
props.ansicht === 'compact' ? (props.block.compact || props.block.md) : props.block.md,
|
||||
)
|
||||
const blocks = computed(() => renderBlocks(displayedText.value))
|
||||
// Stufen-Segmente: mit subs wird je Sub separat gerendert (Rand+Badge in Stufen-Farbe),
|
||||
// ohne subs (Legacy) bleibt der flache Pfad. Globaler Block-Index läuft über alle
|
||||
// Segmente durch — Check/Vorschläge arbeiten unverändert über Index + raw.
|
||||
const segments = computed(() => {
|
||||
const compact = props.ansicht === 'compact'
|
||||
const subs = props.block.subs
|
||||
let i = 0
|
||||
const seg = (level, text) => ({ level, blocks: renderBlocks(text).map((b) => ({ ...b, i: i++ })) })
|
||||
if (!subs || !subs.length) return [seg('', displayedText.value)]
|
||||
const out = []
|
||||
const anchor = compact ? (props.block.anker_compact || '') : (props.block.anchor || '')
|
||||
if (anchor.trim()) out.push(seg('', anchor))
|
||||
for (const sub of subs) {
|
||||
const rank = SUB_RANK[sub.level] || 1
|
||||
if (rank > props.stufe) continue // Stufen-Filter (wie die Standardansicht)
|
||||
const body = compact ? (sub.compact || sub.md) : (sub.md || sub.compact)
|
||||
if (body && body.trim()) out.push(seg(sub.level, body)) // jede Stufe A/F/E/V markiert (Filter bleibt)
|
||||
}
|
||||
return out
|
||||
})
|
||||
const blocks = computed(() => segments.value.flatMap((s) => s.blocks))
|
||||
// Field that edits are applied to (must match the displayed text).
|
||||
const spot = computed(() => (props.ansicht === 'compact' && props.block.compact) ? 'compact' : 'ausführlich')
|
||||
|
||||
@@ -176,21 +197,26 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
<div class="fokus-body">
|
||||
<div ref="guideEl" class="fokus-col left">
|
||||
<div class="markdown">
|
||||
<template v-for="(b, i) in blocks" :key="i">
|
||||
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(i, $event)"></div>
|
||||
<div v-if="suggestions[i]" class="block-vorschlag">
|
||||
<div v-if="suggestions[i].running" class="bv-status">Check Section…</div>
|
||||
<template v-for="(seg, si) in segments" :key="si">
|
||||
<div :class="{ 'sub-stufe': !!seg.level }"
|
||||
:style="seg.level ? { '--stufe-farbe': VIEW_FARBE[SUB_RANK[seg.level] || 1] } : null">
|
||||
<template v-for="b in seg.blocks" :key="b.i">
|
||||
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(b.i, $event)"></div>
|
||||
<div v-if="suggestions[b.i]" class="block-vorschlag">
|
||||
<div v-if="suggestions[b.i].running" class="bv-status">Check Section…</div>
|
||||
<template v-else>
|
||||
<p v-if="suggestions[i].error" class="bv-fehler">{{ suggestions[i].error }}</p>
|
||||
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[i].revised)"></div>
|
||||
<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="bv-aktionen">
|
||||
<button class="bv-btn ja" title="Apply" @click="applyBlock(i)">✓</button>
|
||||
<button class="bv-btn" title="Discard" @click="discardBlock(i)">✗</button>
|
||||
<button class="bv-btn" :class="{ aktiv: suggestions[i].editOpen }" title="Add hint" @click="editBlock(i)">✏️</button>
|
||||
<button class="bv-btn ja" title="Übernehmen" @click="applyBlock(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>
|
||||
</div>
|
||||
<div v-if="suggestions[i].editOpen" class="bv-edit">
|
||||
<input v-model="suggestions[i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(i)" />
|
||||
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(i)">↻</button>
|
||||
<div v-if="suggestions[b.i].editOpen" class="bv-edit">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -257,10 +283,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
border-radius: 999px; border: 1px solid; white-space: nowrap;
|
||||
}
|
||||
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
|
||||
/* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */
|
||||
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
|
||||
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */
|
||||
@@ -277,9 +299,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
);
|
||||
}
|
||||
.xp-seg { height: 100%; transition: width 0.3s ease; }
|
||||
.xp-seg.gold { background: #d4af37; }
|
||||
.xp-seg.lila { background: #8b5cf6; }
|
||||
.xp-seg.gruen { background: var(--success-border); }
|
||||
.fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
|
||||
.fokus-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
|
||||
@@ -5,12 +5,13 @@ import { usePruefSlot } from '../pruefungCache.js'
|
||||
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
|
||||
import { stufeFuer, malusRegel } from '../levels.js'
|
||||
import { useChat, istUnten } from '../composables/useChat.js'
|
||||
import ChatTranscript from './ChatTranscript.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
block: { type: String, required: true },
|
||||
section: { type: String, default: '' }, // detailed version
|
||||
sectionKompakt: { type: String, default: '' }, // compact version (key points) — exam/chat context
|
||||
sectionCompact: { type: String, default: '' }, // compact version (key points) — exam/chat context
|
||||
provider: { type: String, default: 'claude' },
|
||||
status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered}
|
||||
cap: { type: Number, default: 6 }, // score cap = max of the highest format (6/12/18/30)
|
||||
@@ -70,7 +71,7 @@ function tabClick(tab) {
|
||||
|
||||
// --- Block chat (ephemeral) ---
|
||||
const chat = useChat((msgs) => chatBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
messages: msgs, provider: props.provider,
|
||||
}))
|
||||
|
||||
@@ -130,7 +131,7 @@ async function examSend(payload, onOk) {
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), ...payload,
|
||||
})
|
||||
if (run !== examRun) return
|
||||
@@ -243,7 +244,7 @@ function buildSingleQuestion(mode = nextMode()) {
|
||||
const pattern = takePattern()
|
||||
const base = {
|
||||
topic: props.topic, block: props.block, section: props.section,
|
||||
section_compact: props.sectionKompakt, provider: props.provider,
|
||||
section_compact: props.sectionCompact, provider: props.provider,
|
||||
}
|
||||
if (form === 'quiz' && pattern) {
|
||||
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
|
||||
@@ -381,7 +382,7 @@ async function quizAnswer() {
|
||||
try {
|
||||
const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0)
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap,
|
||||
selection: q.gewaehlt, correct, schwer: q.schwer,
|
||||
})
|
||||
@@ -412,7 +413,7 @@ async function clozeAnswer() {
|
||||
? { schwer: true, solution: l.solution, alternatives: l.alternatives, input: l.input }
|
||||
: { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) }
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, action: 'gap_answer', question: l.sentence, cap: props.cap, ...specific,
|
||||
})
|
||||
l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback
|
||||
@@ -451,7 +452,7 @@ async function quickEvaluate() {
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(),
|
||||
})
|
||||
if (mine !== evalRun) return
|
||||
@@ -477,7 +478,7 @@ async function preciseEvaluate(thorough = false, reason = '') {
|
||||
if (thorough) examLoading.value = true
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason,
|
||||
})
|
||||
applyExam(res)
|
||||
@@ -629,26 +630,8 @@ function onExamKey(e) {
|
||||
<div v-if="mode === 'full' && activeTab" class="bp-panel">
|
||||
<!-- Block chat -->
|
||||
<div v-if="activeTab === 'chat'">
|
||||
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
|
||||
<p v-if="!chat.messages.value.length" class="bp-hint">Ask something about this block. The history is not saved.</p>
|
||||
<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>
|
||||
<ChatTranscript :chat="chat" hint="Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert."
|
||||
placeholder="Frage zum Baustein…" />
|
||||
</div>
|
||||
|
||||
<!-- Exam: guided dialog -->
|
||||
@@ -663,7 +646,7 @@ function onExamKey(e) {
|
||||
<!-- Quiz: question + multiple choice (widget stays even at the cap — practice without points) -->
|
||||
<template v-if="shownForm === 'quiz'">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="bp-quiz">
|
||||
@@ -696,7 +679,7 @@ function onExamKey(e) {
|
||||
<!-- Cloze: sentence with gap + input -->
|
||||
<template v-else-if="shownForm === 'gaptext'">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="bp-gap">
|
||||
@@ -707,7 +690,7 @@ function onExamKey(e) {
|
||||
id="bp-gap-input"
|
||||
v-model="clozeCurrent.input"
|
||||
:disabled="clozeCurrent.done"
|
||||
placeholder="Term for the gap…"
|
||||
placeholder="Begriff für die Lücke…"
|
||||
@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>
|
||||
@@ -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)">
|
||||
<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>
|
||||
<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" @click="thoroughMsg = null">×</button>
|
||||
</div>
|
||||
@@ -757,7 +740,7 @@ function onExamKey(e) {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -767,11 +750,11 @@ function onExamKey(e) {
|
||||
ref="examInputEl"
|
||||
v-model="examInput"
|
||||
rows="2"
|
||||
placeholder="Answer — or ask if unclear…"
|
||||
placeholder="Antwort — oder nachfragen…"
|
||||
></textarea>
|
||||
</div>
|
||||
<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'">
|
||||
<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>
|
||||
@@ -831,8 +814,8 @@ function onExamKey(e) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.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.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
.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, var(--level-master) 20%, var(--panel)); border-color: var(--level-master); color: #8a6d12; }
|
||||
|
||||
.bp-panel {
|
||||
margin-top: 0.6rem;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
@@ -26,13 +27,10 @@ async function loadCompleteness() {
|
||||
watch(() => [props.topic, props.ready, props.generating], loadCompleteness, { immediate: true })
|
||||
|
||||
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
|
||||
// das Kanban-Board selbst lebt in der Generierungs-View).
|
||||
let timer = null
|
||||
function startPoll() { stopPoll(); timer = setInterval(load, 5000) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
// das Kanban-Board selbst lebt in der Generierungs-View). Visibility-Pause via usePolling.
|
||||
const { start: startPoll } = usePolling(load, () => props.generating, 5000)
|
||||
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
|
||||
watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true })
|
||||
onUnmounted(stopPoll)
|
||||
watch(() => props.generating, (g) => { if (g) startPoll(); else load() }, { immediate: true })
|
||||
|
||||
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
|
||||
const LEVELS = [
|
||||
@@ -73,10 +71,10 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
<div class="bk-view">
|
||||
<header class="bk-head">
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
</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="!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">
|
||||
<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>
|
||||
<ul>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="bk-no-subs">No subblocks.</p>
|
||||
<p v-else class="bk-no-subs">Keine Subbausteine.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,9 +178,8 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
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; } }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchBlocksBoard, runQa, runRepair } from '../api.js'
|
||||
import { fetchBlocksBoard, fetchRuns, runQa, runRepair, removeArtefacts } 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 GuideBoardSection from './GuideBoardSection.vue'
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
@@ -10,112 +14,141 @@ const props = defineProps({
|
||||
progress: { type: String, default: null },
|
||||
ready: { type: Boolean, default: false },
|
||||
partial: { type: Boolean, default: false },
|
||||
error: { type: String, default: null },
|
||||
guideFormat: { type: String, default: 'Guide' },
|
||||
autoInventory: { type: Boolean, default: true },
|
||||
autoArtefacts: { type: Boolean, default: true },
|
||||
autoGuide: { type: Boolean, default: true },
|
||||
})
|
||||
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
|
||||
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
|
||||
const emit = defineEmits(['close', 'restartAll', 'continueAll', 'addResearch', 'requeueDead',
|
||||
'removeAll', 'removeArtefacts', 'cancel', 'cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
|
||||
|
||||
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
|
||||
// ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
|
||||
const board = ref(null)
|
||||
let timer = null
|
||||
const pollError = ref(null)
|
||||
|
||||
async function pollBoard() {
|
||||
try {
|
||||
board.value = await fetchBlocksBoard(props.topic)
|
||||
} catch { /* Board noch leer */ }
|
||||
pollError.value = null
|
||||
} catch (e) {
|
||||
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.generating, (g) => {
|
||||
if (g) startPoll()
|
||||
else { stopPoll(); pollBoard() } // Endstand nachladen
|
||||
else pollBoard() // Endstand nachladen
|
||||
}, { immediate: true })
|
||||
onUnmounted(stopPoll)
|
||||
|
||||
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
|
||||
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
|
||||
const dead = computed(() => board.value?.dead || [])
|
||||
const qa = computed(() => board.value?.qa || null)
|
||||
|
||||
// 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',
|
||||
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
|
||||
'subblocks', 'facts', 'konsolidierung', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
|
||||
const sel = ref(null) // gewählte Spalte {board, key, label}
|
||||
const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts)
|
||||
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
|
||||
// Ehrlicher Fortschritt aus den Spaltenzahlen: gewichteter Spaltenindex (Terminal = 1,0).
|
||||
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)
|
||||
|
||||
function stageClick(c) {
|
||||
if (props.generating || !RESETTABLE.has(c.key)) return
|
||||
confirm.value = null
|
||||
selCard.value = null
|
||||
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
|
||||
}
|
||||
// Score 0–10 → „100 %"
|
||||
const pct = (note) => Math.round(note * 10) + ' %'
|
||||
|
||||
function cardClick(k) {
|
||||
if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten
|
||||
confirm.value = null
|
||||
sel.value = null
|
||||
selCard.value = selCard.value?.card_id === k.card_id ? null : k
|
||||
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
|
||||
const run = ref(null)
|
||||
const latest = ref(null) // jüngster Lauf MIT Daten je Ebene — überlebt „Nur Artefakte"-Läufe
|
||||
const now = ref(Date.now())
|
||||
let clock = null
|
||||
async function loadRun() {
|
||||
try {
|
||||
const res = await fetchRuns(props.topic, 1)
|
||||
run.value = res.runs[0] || null
|
||||
latest.value = res.latest || null
|
||||
} catch { run.value = null; latest.value = null }
|
||||
}
|
||||
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
|
||||
// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte. Live tickt nur die Ebene,
|
||||
// deren Lauf der AKTUELLE ist (b.aktiv) — sonst zählt die alte Inventar-Zeile mit hoch.
|
||||
function boardZeit(b) {
|
||||
if (!b?.start) return null
|
||||
const start = Date.parse(b.start)
|
||||
const ende = b.aktiv ? now.value : Date.parse(b.ende || b.start)
|
||||
return fmtRuntime((ende - start) / 1000)
|
||||
}
|
||||
function boardTokens(b) {
|
||||
const t = b?.tokens
|
||||
return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null
|
||||
}
|
||||
const invStat = computed(() => latest.value?.inventory)
|
||||
const artStat = computed(() => latest.value?.artefacts)
|
||||
watch(() => props.generating, (g) => {
|
||||
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
|
||||
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
|
||||
}, { immediate: true })
|
||||
watch(() => props.topic, () => { run.value = null; latest.value = null; loadRun() })
|
||||
onUnmounted(() => { if (clock) clearInterval(clock) })
|
||||
|
||||
function restartCard() {
|
||||
const k = selCard.value
|
||||
selCard.value = null
|
||||
confirm.value = null
|
||||
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)
|
||||
fn()
|
||||
setTimeout(pollBoard, 600)
|
||||
}
|
||||
|
||||
function resetHere(restart) {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
confirm.value = null
|
||||
later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
|
||||
}
|
||||
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung
|
||||
function later(fn) { fn(); setTimeout(pollBoard, 600) }
|
||||
|
||||
// ── QA + Befunde beheben (je Ebene) ──────────────────────────────────────────────
|
||||
const qaBusy = ref(false)
|
||||
const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden
|
||||
async function runQaClick() {
|
||||
async function runQaClick(ebene = null) {
|
||||
if (qaBusy.value) return
|
||||
qaBusy.value = true
|
||||
qaBusy.value = ebene || true // markiert die geklickte Ebene (Button-Label)
|
||||
try { await runQa(props.topic, true, ebene) }
|
||||
finally { qaBusy.value = false; pollBoard(); guideRefresh.value++ }
|
||||
}
|
||||
|
||||
const repairBusy = ref('') // '' | 'inventory' | 'artefacts'
|
||||
const repairInfo = ref({}) // { inventory, artefacts }
|
||||
function repairText(r) {
|
||||
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
|
||||
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
|
||||
return n === 0 ? 'keine behebbaren Befunde'
|
||||
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt`
|
||||
}
|
||||
// Auto an → „Befunde beheben"-Loop bis 100 % / Stillstand / 10×; sonst genau ein Durchlauf.
|
||||
async function repairClick(ebene) {
|
||||
if (repairBusy.value) return
|
||||
const auto = ebene === 'inventory' ? props.autoInventory : props.autoArtefacts
|
||||
const max = auto ? 10 : 1
|
||||
repairBusy.value = ebene
|
||||
repairInfo.value = { ...repairInfo.value, [ebene]: '' }
|
||||
let prev = null
|
||||
try {
|
||||
await runQa(props.topic)
|
||||
for (let i = 0; i < max; i++) {
|
||||
const r = await runRepair(props.topic, ebene)
|
||||
const note = ebene === 'artefacts' ? r.note_artefakte : r.note
|
||||
const suffix = max > 1 ? ` — Runde ${i + 1}${note != null ? ` (${Math.round(note * 10)} %)` : ''}` : ''
|
||||
repairInfo.value = { ...repairInfo.value, [ebene]: repairText(r) + suffix }
|
||||
if (note == null || note >= 10) break // 100 % erreicht
|
||||
if (r.aktionen === 0 && prev != null && note <= prev) break // Stillstand: nichts getan, Note steht
|
||||
prev = note
|
||||
}
|
||||
} catch (e) {
|
||||
repairInfo.value = { ...repairInfo.value, [ebene]: String(e.message || e) }
|
||||
} finally {
|
||||
qaBusy.value = false
|
||||
pollBoard()
|
||||
guideRefresh.value++
|
||||
repairBusy.value = ''; pollBoard()
|
||||
}
|
||||
}
|
||||
|
||||
const repairBusy = ref(false)
|
||||
const repairInfo = ref('')
|
||||
async function repairClick() {
|
||||
if (repairBusy.value) return
|
||||
repairBusy.value = true
|
||||
repairInfo.value = ''
|
||||
try {
|
||||
const r = await runRepair(props.topic)
|
||||
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
|
||||
+ (r.entfernt || []).length + (r.aufgeraeumt || 0)
|
||||
repairInfo.value = n === 0
|
||||
? 'keine behebbaren Befunde'
|
||||
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt`
|
||||
} catch (e) {
|
||||
repairInfo.value = String(e.message || e)
|
||||
} finally {
|
||||
repairBusy.value = false
|
||||
pollBoard()
|
||||
}
|
||||
function removeArtefactsClick() {
|
||||
later(() => removeArtefacts(props.topic).then(pollBoard))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -125,86 +158,74 @@ async function repairClick() {
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="gen-sub">Generierung</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>
|
||||
|
||||
<div v-if="qa && qa.pausiert" class="qa-pause">
|
||||
<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>
|
||||
<button class="gen-act" @click="emit('continueAll', { qaForce: true })">Trotzdem fortsetzen</button>
|
||||
</div>
|
||||
<div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</div>
|
||||
|
||||
<section class="gen-section">
|
||||
<div class="gen-steps-top">
|
||||
<span class="gen-title">Bausteine</span>
|
||||
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
|
||||
<div v-if="!generating" class="gen-actions">
|
||||
<button class="gen-act" :disabled="qaBusy" title="QA-Lauf wie am Gate (inkl. LLM-Stichprobe)"
|
||||
@click="runQaClick">{{ qaBusy ? 'QA läuft…' : 'QA' }}</button>
|
||||
<button v-if="qa" class="gen-act" :disabled="repairBusy"
|
||||
title="QA-Befunde gezielt beheben: Hygiene, bestätigte Dubletten mergen, Fremd/Unecht nach Gegen-Judge entfernen"
|
||||
@click="repairClick">{{ repairBusy ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
|
||||
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
|
||||
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Continue</button>
|
||||
<button
|
||||
v-if="ready || partial"
|
||||
class="gen-act danger"
|
||||
:class="{ armed: confirm === 'remove' }"
|
||||
@click="arm('remove', () => later(() => emit('removeAll')))"
|
||||
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
|
||||
</div>
|
||||
<div v-else class="gen-actions">
|
||||
<button class="gen-act" @click="emit('addResearch')">+ Research</button>
|
||||
<button class="gen-act danger" @click="emit('cancel')">Cancel</button>
|
||||
<span v-if="run?.fails?.length" class="gen-run-fail" :title="run.fails.map((f) => f.key + ': ' + f.error).join('\n')">⚠ {{ run.fails.length }} Fehler</span>
|
||||
<div v-if="generating" class="gen-actions">
|
||||
<button class="gen-act" @click="emit('addResearch')">+ Recherche</button>
|
||||
<button class="gen-act danger" @click="emit('cancel')">Abbrechen</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="dead.length"
|
||||
v-if="dead.length && generating"
|
||||
class="gen-act"
|
||||
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
|
||||
@click="later(() => emit('requeueDead'))"
|
||||
>⟳ {{ dead.length }} dead</button>
|
||||
</div>
|
||||
|
||||
<!-- Ebene Inventar -->
|
||||
<div class="gen-board-label">
|
||||
Inventar
|
||||
<span v-if="qa" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'"
|
||||
:title="'QA-Schwelle ' + qa.schwelle">QA {{ qa.note.toFixed(1) }}/10</span>
|
||||
<span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note) }}</span>
|
||||
<span v-if="boardZeit(invStat)" class="gen-run">⏱ {{ boardZeit(invStat) }}<template v-if="boardTokens(invStat)"> · {{ boardTokens(invStat) }} Tokens</template></span>
|
||||
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
|
||||
<input type="checkbox" :checked="autoInventory" @change="emit('setAuto', { ebene: 'inventory', value: $event.target.checked })"> Auto
|
||||
</label>
|
||||
</div>
|
||||
<KanbanBoard
|
||||
:columns="inventoryCols"
|
||||
:agents="board?.agents || []"
|
||||
:generating="generating"
|
||||
:selectable="!generating"
|
||||
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
|
||||
@stageClick="stageClick"
|
||||
/>
|
||||
<div v-if="!generating" class="gen-bar">
|
||||
<button class="gen-act" :disabled="!!qaBusy" @click="runQaClick('inventory')">{{ qaBusy === 'inventory' ? 'QA läuft…' : 'QA' }}</button>
|
||||
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('inventory')">{{ repairBusy === 'inventory' ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo.inventory" class="repair-info">{{ repairInfo.inventory }}</span>
|
||||
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Recherche' : 'Generieren' }}</button>
|
||||
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
|
||||
<button v-if="ready || partial || error" class="gen-act danger" :class="{ armed: isArmed('remove') }"
|
||||
title="Löscht das gesamte Topic-Inventar (Artefakte + Guide hängen daran)"
|
||||
@click="armOrRun('remove', () => later(() => emit('removeAll')))">{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
</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 :columns="inventoryCols" :agents="(board?.agents || []).filter(a => a.ebene !== 'artefacts')" :generating="generating" />
|
||||
|
||||
<!-- Ebene Artefakte -->
|
||||
<div class="gen-board-label">
|
||||
Artefakte
|
||||
<span v-if="qa && qa.note_artefakte != null" class="qa-note"
|
||||
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'"
|
||||
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span>
|
||||
<span v-if="qa && qa.note_artefakte != null" class="qa-note" :class="qa.note_artefakte >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note_artefakte) }}</span>
|
||||
<span v-if="boardZeit(artStat)" class="gen-run">⏱ {{ boardZeit(artStat) }}<template v-if="boardTokens(artStat)"> · {{ boardTokens(artStat) }} Tokens</template></span>
|
||||
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
|
||||
<input type="checkbox" :checked="autoArtefacts" @change="emit('setAuto', { ebene: 'artefacts', value: $event.target.checked })"> Auto
|
||||
</label>
|
||||
</div>
|
||||
<KanbanBoard
|
||||
:columns="artefactCols"
|
||||
:generating="generating"
|
||||
:selectable="!generating"
|
||||
:cardSelectable="!generating"
|
||||
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
|
||||
@stageClick="stageClick"
|
||||
@cardClick="cardClick"
|
||||
/>
|
||||
|
||||
<div v-if="selCard && !generating" class="gen-step-actions">
|
||||
<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 ghost" @click="selCard = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
<div v-if="sel && !generating" class="gen-step-actions">
|
||||
<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 danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : '✕ nur zurücksetzen' }}</button>
|
||||
<button class="gen-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
|
||||
<div v-if="!generating" class="gen-bar">
|
||||
<button class="gen-act" :disabled="!!qaBusy" @click="runQaClick('artefacts')">{{ qaBusy === 'artefacts' ? 'QA läuft…' : 'QA' }}</button>
|
||||
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('artefacts')">{{ repairBusy === 'artefacts' ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo.artefacts" class="repair-info">{{ repairInfo.artefacts }}</span>
|
||||
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen — nur Artefakte, ohne Board 1" @click="emit('continueAll', { qaForce: true, onlyArtefacts: true })">Generieren</button>
|
||||
<button v-if="ready || partial" class="gen-act danger" :class="{ armed: isArmed('remove-art') }"
|
||||
title="Nur die Artefakte leeren — Inventar bleibt"
|
||||
@click="armOrRun('remove-art', removeArtefactsClick)">{{ isArmed('remove-art') ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
</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 :columns="artefactCols" :agents="(board?.agents || []).filter(a => a.ebene === 'artefacts')" :generating="generating" />
|
||||
</section>
|
||||
|
||||
<section class="gen-section">
|
||||
@@ -212,12 +233,12 @@ async function repairClick() {
|
||||
:topic="topic"
|
||||
:format="guideFormat"
|
||||
:refresh="guideRefresh"
|
||||
:auto="autoGuide"
|
||||
@cancelGuide="(id) => emit('cancelGuide', id)"
|
||||
@startGuide="(p) => emit('startGuide', p)"
|
||||
@resetStage="(p) => emit('resetGuideStage', p)"
|
||||
@preview="emit('preview')"
|
||||
@removeFormat="(f) => emit('removeFormat', f)"
|
||||
@resetCard="(p) => emit('resetGuideCard', p)"
|
||||
@setAuto="(v) => emit('setAuto', { ebene: 'guide', value: v })"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@@ -266,12 +287,15 @@ async function repairClick() {
|
||||
}
|
||||
.gen-title { font-size: 0.9rem; font-weight: 700; }
|
||||
.gen-board-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
margin: 0.7rem 0 0.3rem;
|
||||
}
|
||||
.gen-progress {
|
||||
display: flex;
|
||||
@@ -286,22 +310,25 @@ async function repairClick() {
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
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-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
|
||||
|
||||
.gen-step-actions {
|
||||
display: flex;
|
||||
.auto-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
gap: 0.25rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
.auto-box input { cursor: pointer; margin: 0; }
|
||||
|
||||
.gen-act {
|
||||
border: 1px solid var(--border-strong);
|
||||
@@ -314,30 +341,23 @@ async function repairClick() {
|
||||
font-weight: 600;
|
||||
}
|
||||
.gen-act:hover { border-color: var(--accent); }
|
||||
.gen-act:disabled { opacity: 0.55; cursor: default; }
|
||||
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.gen-act.play:hover { background: var(--accent-hover); }
|
||||
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.gen-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
.gen-act.ghost { color: var(--text-muted); }
|
||||
.qa-note {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.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; }
|
||||
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
|
||||
.qa-pause {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0.8rem;
|
||||
margin-bottom: 0.8rem;
|
||||
border: 1px solid color-mix(in srgb, #ef4444 40%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, #ef4444 8%, transparent);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
@@ -1,125 +1,168 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchGuideBoard } from '../api.js'
|
||||
import { fetchGuideBoard, fetchRuns, repairGuideBoard, runQa } 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 ProgressBar from './ProgressBar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
format: { type: String, default: 'Guide' },
|
||||
refresh: { type: Number, default: 0 }, // Eltern-Signal: QA/Repair schrieben einen Guide-Report
|
||||
auto: { type: Boolean, default: true },
|
||||
})
|
||||
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard'])
|
||||
const emit = defineEmits(['cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
|
||||
|
||||
const board = ref(null)
|
||||
let timer = null
|
||||
const pollError = ref(null)
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
board.value = await fetchGuideBoard(props.topic, props.format)
|
||||
} catch { /* Board noch leer */ }
|
||||
pollError.value = null
|
||||
} catch (e) {
|
||||
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 } }
|
||||
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
|
||||
|
||||
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
|
||||
watch(() => props.refresh, () => poll())
|
||||
watch(() => board.value?.generating, (g) => { if (g) startPoll(); else stopPoll() })
|
||||
onUnmounted(stopPoll)
|
||||
watch(() => board.value?.generating, (g) => { if (g) startPoll() })
|
||||
|
||||
const generating = computed(() => !!board.value?.generating)
|
||||
const columns = computed(() => board.value?.columns || [])
|
||||
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 progressValue = computed(() => (total.value ? done.value / total.value : 0))
|
||||
// QA-Badge nur wenn es ein Board gibt — sonst zeigt ein verwaister Report eine alte Note.
|
||||
const qaNote = computed(() => (total.value ? board.value?.qa_guide : null))
|
||||
|
||||
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
|
||||
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
|
||||
const sel = ref(null)
|
||||
const selCard = ref(null)
|
||||
const confirm = ref(null)
|
||||
// Laufzeit + Tokens aus /api/runs — `latest.guide` ist der jüngste GUIDE-Lauf
|
||||
// (der jüngste Lauf insgesamt kann ein Blocks-Lauf sein und zeigte hier dessen Zahlen).
|
||||
const run = ref(null)
|
||||
const now = ref(Date.now())
|
||||
let clock = null
|
||||
async function loadRun() {
|
||||
try {
|
||||
run.value = (await fetchRuns(props.topic, 1)).latest?.guide || 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) })
|
||||
|
||||
function stageClick(c) {
|
||||
if (generating.value || !STAGES.includes(c.key)) return
|
||||
confirm.value = null
|
||||
selCard.value = null
|
||||
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
|
||||
const { isArmed, armOrRun } = useConfirm() // 2-Klick-Bestätigung
|
||||
|
||||
const qaBusy = ref(false)
|
||||
async function runQaClick() {
|
||||
if (qaBusy.value) return
|
||||
qaBusy.value = true
|
||||
try { await runQa(props.topic) }
|
||||
finally { qaBusy.value = false; poll() }
|
||||
}
|
||||
|
||||
function cardClick(k) {
|
||||
if (generating.value || !k.card_id) return
|
||||
confirm.value = null
|
||||
sel.value = null
|
||||
const idx = Math.max(0, STAGES.indexOf(k.column))
|
||||
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
|
||||
function removeClick() {
|
||||
emit('removeFormat', props.format)
|
||||
board.value = null // optimistisch: Karten + QA-Badge sofort weg, poll bestätigt
|
||||
setTimeout(poll, 800)
|
||||
}
|
||||
|
||||
function resetCardHere() {
|
||||
const k = selCard.value
|
||||
selCard.value = null
|
||||
confirm.value = null
|
||||
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
|
||||
setTimeout(poll, 400)
|
||||
const repairBusy = ref(false)
|
||||
const repairInfo = ref('')
|
||||
// Wartet, bis die Guide-Regenerierung der offenen Karten fertig ist (für den Auto-Loop).
|
||||
function warteBisFertig(timeoutMs = 300000) {
|
||||
return new Promise((resolve) => {
|
||||
const t0 = Date.now()
|
||||
const iv = setInterval(async () => {
|
||||
await poll()
|
||||
if (!board.value?.generating || Date.now() - t0 > timeoutMs) { clearInterval(iv); resolve() }
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
function restartHere() {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
emit('startGuide', { format: props.format, abStep: s.idx })
|
||||
// Auto an → Loop: Befunde beheben → regenerieren → QA, bis 100 % / Stillstand / 10×.
|
||||
// Auto aus → genau ein Durchlauf (nur anstoßen).
|
||||
async function repairClick() {
|
||||
if (repairBusy.value) return
|
||||
const max = props.auto ? 10 : 1
|
||||
repairBusy.value = true
|
||||
repairInfo.value = ''
|
||||
let prev = null
|
||||
try {
|
||||
for (let i = 0; i < max; i++) {
|
||||
const r = await repairGuideBoard(props.topic, props.format)
|
||||
if (!(r.betroffen || []).length) {
|
||||
repairInfo.value = i === 0 ? 'keine Befunde im letzten QA-Report' : `fertig nach ${i} Runde(n)`
|
||||
break
|
||||
}
|
||||
repairInfo.value = `Runde ${i + 1}: ${r.betroffen.length} Abschnitt(e) → Prüfen…`
|
||||
startPoll()
|
||||
}
|
||||
function resetHere() {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
emit('resetStage', { format: props.format, abStage: s.idx })
|
||||
setTimeout(poll, 400)
|
||||
if (max === 1) break // ohne Auto: nur anstoßen, nicht auf Fertigstellung warten
|
||||
await warteBisFertig() // Regenerierung abwarten
|
||||
await runQa(props.topic) // neue Guide-QA messen
|
||||
await poll()
|
||||
const note = board.value?.qa_guide
|
||||
if (note == null || note >= 10) { repairInfo.value = `100 % nach ${i + 1} Runde(n)`; break }
|
||||
if (prev != null && note <= prev) { repairInfo.value = `Stillstand bei ${Math.round(note * 10)} %`; break }
|
||||
prev = note
|
||||
}
|
||||
} catch (e) {
|
||||
repairInfo.value = String(e.message || e)
|
||||
} finally {
|
||||
repairBusy.value = false
|
||||
poll()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="gb-board">
|
||||
<div class="gb-top">
|
||||
<span class="gb-title">Guide · {{ format }}</span>
|
||||
<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>
|
||||
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
|
||||
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
|
||||
<div class="gen-board-label">
|
||||
Guide · {{ format }}
|
||||
<span v-if="qaNote != null" class="qa-note" :class="qaNote >= 10 ? 'ok' : 'bad'">QA {{ Math.round(qaNote * 10) }} %</span>
|
||||
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
|
||||
<input type="checkbox" :checked="auto" @change="emit('setAuto', $event.target.checked)"> Auto
|
||||
</label>
|
||||
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten</span>
|
||||
<span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
|
||||
<div v-if="board?.progress && generating" class="gen-progress"><span class="gen-progress-dot"></span>{{ board.progress }}</div>
|
||||
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
|
||||
<div class="gb-actions">
|
||||
<template v-if="generating">
|
||||
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
|
||||
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="pollError" class="gb-error">Board nicht erreichbar: {{ pollError }}</div>
|
||||
</div>
|
||||
|
||||
<KanbanBoard
|
||||
:columns="columns"
|
||||
:agents="board?.agents || []"
|
||||
:generating="generating"
|
||||
:selectable="!generating"
|
||||
:cardSelectable="!generating"
|
||||
:selectedKey="sel?.key || null"
|
||||
@stageClick="stageClick"
|
||||
@cardClick="cardClick"
|
||||
/>
|
||||
<div v-if="generating" class="gen-bar">
|
||||
<button class="gen-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
|
||||
</div>
|
||||
<div v-else class="gen-bar">
|
||||
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft…' : 'QA' }}</button>
|
||||
<button v-if="total && qaNote != null && qaNote < 10" class="gen-act" :disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
|
||||
<button class="gen-act play" @click="emit('startGuide', { format }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
|
||||
<button v-if="done === total && total" class="gen-act" @click="emit('preview')">Guide öffnen</button>
|
||||
<button v-if="total" class="gen-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', removeClick)">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selCard && !generating" class="gb-stage-actions">
|
||||
<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 ghost" @click="selCard = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
<div v-if="sel && !generating" class="gb-stage-actions">
|
||||
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
|
||||
<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 ghost" @click="sel = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
<ProgressBar v-if="total" :value="progressValue"
|
||||
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
|
||||
|
||||
<KanbanBoard :columns="columns" :agents="board?.agents || []" :generating="generating" />
|
||||
|
||||
<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>
|
||||
</section>
|
||||
@@ -127,48 +170,63 @@ function resetHere() {
|
||||
|
||||
<style scoped>
|
||||
.gb-board { padding: 0.85rem 0 0; }
|
||||
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; }
|
||||
.gb-title { font-size: 0.9rem; font-weight: 700; }
|
||||
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
|
||||
.gb-qa {
|
||||
.gen-board-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
margin: 0.2rem 0 0.3rem;
|
||||
}
|
||||
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
|
||||
.gb-count { color: var(--text-muted); font-size: 0.82rem; text-transform: none; letter-spacing: 0; font-weight: 400; }
|
||||
.qa-note {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.gb-qa.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
|
||||
.gb-qa.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
|
||||
.gb-progress {
|
||||
.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; }
|
||||
.auto-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auto-box input { cursor: pointer; margin: 0; }
|
||||
.gen-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.gb-progress-dot {
|
||||
.gen-progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
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-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
|
||||
.gb-stage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
.gb-stage-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
.gb-error { color: var(--danger); font-size: 0.82rem; text-transform: none; letter-spacing: 0; font-weight: 400; }
|
||||
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
|
||||
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
|
||||
|
||||
.gb-act {
|
||||
.gen-act {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
@@ -178,10 +236,10 @@ function resetHere() {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gb-act:hover { border-color: var(--accent); }
|
||||
.gb-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.gb-act.play:hover { background: var(--accent-hover); }
|
||||
.gb-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.gb-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
.gb-act.ghost { color: var(--text-muted); }
|
||||
.gen-act:hover { border-color: var(--accent); }
|
||||
.gen-act:disabled { opacity: 0.55; cursor: default; }
|
||||
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.gen-act.play:hover { background: var(--accent-hover); }
|
||||
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.gen-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
</style>
|
||||
@@ -1,24 +1,13 @@
|
||||
<script setup>
|
||||
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
|
||||
// Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen.
|
||||
// Karten-Titeln. Reines Display — Steuerung läuft über die Ebenen-Buttons.
|
||||
import { fmtRuntime } from '../format.js'
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}]
|
||||
agents: { type: Array, default: () => [] }, // [{label, runtime}]
|
||||
generating: { type: Boolean, default: false },
|
||||
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
|
||||
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
|
||||
selectedKey: { type: String, default: null },
|
||||
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
|
||||
})
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -29,26 +18,20 @@ function fmtRuntime(s) {
|
||||
</div>
|
||||
<div class="kb-cols">
|
||||
<div
|
||||
v-for="c in columns.filter(visible)"
|
||||
v-for="c in columns"
|
||||
:key="(c.board || '') + c.key"
|
||||
class="kb-col"
|
||||
:class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }"
|
||||
>
|
||||
<button
|
||||
class="kb-col-head"
|
||||
:disabled="!selectable"
|
||||
:title="selectable ? `Aktionen ab «${c.label}»` : c.label"
|
||||
@click="selectable && emit('stageClick', c)"
|
||||
:class="{ active: c.total > 0, collapsed: !c.total }"
|
||||
>
|
||||
<div class="kb-col-head" :title="c.label">
|
||||
<span class="kb-col-label">{{ c.label }}</span>
|
||||
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="c.cards && c.cards.length" class="kb-cards">
|
||||
<li
|
||||
v-for="(k, i) in c.cards" :key="i" class="kb-card"
|
||||
:class="[k.status, { klickbar: cardSelectable && k.card_id }]"
|
||||
:title="cardSelectable && k.card_id ? `${k.title} — Klick: Karte neu generieren` : (k.info || k.title)"
|
||||
@click="cardSelectable && k.card_id && emit('cardClick', { ...k, column: c.key, colBoard: c.board })"
|
||||
:class="k.status"
|
||||
:title="k.info || k.title"
|
||||
>
|
||||
<div class="kb-card-row">
|
||||
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,73 @@
|
||||
<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>
|
||||
<textarea
|
||||
v-if="modelValue.type === 'link'"
|
||||
class="dlg-input dlg-textarea" :value="modelValue.ort"
|
||||
rows="4"
|
||||
placeholder="https://… — ein Link pro Zeile"
|
||||
@input="setOrt($event.target.value)"
|
||||
/>
|
||||
<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; }
|
||||
.dlg-textarea { resize: vertical; min-height: 5rem; }
|
||||
</style>
|
||||
@@ -2,10 +2,11 @@
|
||||
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchBlockLearnState } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, viewLevelFuer } from '../levels.js'
|
||||
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, BADGE_VIEW_RANK } from '../levels.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import BlockFocus from './BlockFocus.vue'
|
||||
import ChatTranscript from './ChatTranscript.vue'
|
||||
|
||||
const props = defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
@@ -35,8 +36,12 @@ let mdObserver = null
|
||||
// Auto: Ansichtsstufe des Blocks folgt dem Prüfungs-Score (Erreicht + 1); Override = global fest.
|
||||
function viewLevelFor(title) {
|
||||
if (props.stufeAnsicht !== 'auto') return Number(props.stufeAnsicht)
|
||||
const l = learnstate.value[title]
|
||||
return viewLevelFuer(l?.good_answers || 0, l?.cap || 10)
|
||||
// Auto = an die erreichte Badge-Stufe gekoppelt (gleiche Schwelle wie das Level-Abzeichen):
|
||||
// Advanced-Badge → advanced-Subs sichtbar. Vorher hing es an `freie_level` (Sub-Zahl × 25),
|
||||
// das bei vielen beginner-Subs SPÄTER umschaltet als der Badge (0.4 × cap) — dann zeigte der
|
||||
// Badge „Advanced", die advanced-Inhalte blieben aber verborgen.
|
||||
const st = levelOf(title)
|
||||
return st ? (BADGE_VIEW_RANK[st.key] || 1) : 1
|
||||
}
|
||||
|
||||
function htmlFor(s) {
|
||||
@@ -54,10 +59,12 @@ function htmlFor(s) {
|
||||
const rank = SUB_RANK[sub.level] || 1
|
||||
if (rank > lvl) continue
|
||||
const body = renderMarkdown(compact ? (sub.compact || sub.md) : (sub.md || sub.compact))
|
||||
if (props.stufeAnsicht === 'auto' && lvl > 1 && rank === lvl) {
|
||||
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}"><span class="sub-neu-badge" title="Neu ab Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
|
||||
if (props.stufeAnsicht === 'auto' && rank > 1 && rank === lvl) {
|
||||
// gerade neu freigeschaltete Stufe: kräftigere Färbung (Rand), ohne Badge
|
||||
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}">${body}</div>`)
|
||||
} else {
|
||||
parts.push(body)
|
||||
// jede Stufe A/F/E/V leicht in ihrer Farbe kennzeichnen (Rand + Färbung), ohne Badge
|
||||
parts.push(`<div class="sub-stufe" style="--stufe-farbe:${VIEW_FARBE[rank]}">${body}</div>`)
|
||||
}
|
||||
}
|
||||
h = parts.join('')
|
||||
@@ -196,8 +203,7 @@ const chat = useChat((msgs) => {
|
||||
section, outline, messages: msgs, provider: props.provider,
|
||||
})
|
||||
})
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll, send } = chat
|
||||
const autoGrow = () => chat.autoGrow()
|
||||
const { inputEl } = chat // fürs Fokussieren beim Öffnen; Rendering übernimmt ChatTranscript
|
||||
const chatOpen = ref(false)
|
||||
const panelEl = ref(null)
|
||||
|
||||
@@ -332,6 +338,7 @@ function extractContext() {
|
||||
:fortschritt="progress"
|
||||
:status="learnstate[focusBlock.title]"
|
||||
:cap="capOf(focusBlock.title)"
|
||||
:stufe="viewLevelFor(focusBlock.title)"
|
||||
:tab="focusTab"
|
||||
:ansicht="ansichtModus"
|
||||
:has-prev="focusIndex > 0"
|
||||
@@ -352,30 +359,8 @@ function extractContext() {
|
||||
<span>Questions about the guide</span>
|
||||
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
|
||||
</header>
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Ask a question about the current section.</p>
|
||||
<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>
|
||||
<ChatTranscript :chat="chat" hint="Stelle eine Frage zum aktuellen Abschnitt."
|
||||
placeholder="Frage stellen…" :rows="3" auto-grow max-height="none" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -463,8 +448,8 @@ function extractContext() {
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border: 1px solid #d4af37;
|
||||
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
|
||||
border: 1px solid var(--level-master);
|
||||
color: #8a6d12;
|
||||
}
|
||||
|
||||
@@ -527,26 +512,26 @@ function extractContext() {
|
||||
|
||||
/* Understood blocks (10/10): purple */
|
||||
.block-done.understood {
|
||||
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
|
||||
border-color: #8b5cf6;
|
||||
background: color-mix(in srgb, var(--level-expert) 16%, var(--panel));
|
||||
border-color: var(--level-expert);
|
||||
color: #6d28d9;
|
||||
}
|
||||
.guide-content .section-card.understood {
|
||||
border-color: #8b5cf6;
|
||||
border-top: 3px solid #8b5cf6;
|
||||
background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
|
||||
border-color: var(--level-expert);
|
||||
border-top: 3px solid var(--level-expert);
|
||||
background: color-mix(in srgb, var(--level-expert) 7%, var(--panel));
|
||||
}
|
||||
|
||||
/* Mastered blocks (master path 25/25): gold */
|
||||
.block-done.mastered {
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border-color: #d4af37;
|
||||
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
|
||||
border-color: var(--level-master);
|
||||
color: #8a6d12;
|
||||
}
|
||||
.guide-content .section-card.mastered {
|
||||
border-color: #d4af37;
|
||||
border-top: 3px solid #d4af37;
|
||||
background: color-mix(in srgb, #d4af37 8%, var(--panel));
|
||||
border-color: var(--level-master);
|
||||
border-top: 3px solid var(--level-master);
|
||||
background: color-mix(in srgb, var(--level-master) 8%, var(--panel));
|
||||
}
|
||||
|
||||
/* Guides: cards carry the chapter accent color */
|
||||
@@ -669,120 +654,7 @@ function extractContext() {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
/* Chat-Transkript + Eingabe leben jetzt in ChatTranscript.vue (geteilt mit BlockPanel). */
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Neu freigeschaltete Subbausteine (Auto-Stufe): dezenter Rand + Stufen-Badge */
|
||||
.sub-neu {
|
||||
position: relative;
|
||||
border-left: 3px solid var(--neu-farbe);
|
||||
padding-left: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.sub-neu-badge {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: var(--neu-farbe);
|
||||
border: 1px solid var(--neu-farbe);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
/* .sub-neu/.sub-stufe: global in assets/markdown.css — scoped greift nicht auf v-html-Inhalt. */
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
import { fetchSource } from '../api.js'
|
||||
import SourceForm from './SourceForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topics: { type: Array, required: true },
|
||||
@@ -179,10 +180,15 @@ async function toggleTopicPanel(t) {
|
||||
}
|
||||
}
|
||||
|
||||
function setEditType(t) {
|
||||
editForm.value.type = t
|
||||
editForm.value.ort = ''
|
||||
}
|
||||
// v-model-Brücken für SourceForm ({type, ort}) auf die beiden State-Objekte
|
||||
const createSource = computed({
|
||||
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() {
|
||||
if (!canSave.value || !editTopic.value) return
|
||||
@@ -241,29 +247,12 @@ function saveSource() {
|
||||
|
||||
<!-- Create: inline expandable (no modal) -->
|
||||
<div v-if="dlg" class="thema-panel">
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Topic name…" @keyup.enter="createTopic" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<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>
|
||||
<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="Mehr Infos (optional)…"></textarea>
|
||||
<SourceForm v-model="createSource" :folders="folders" @submit="createTopic" />
|
||||
<div class="dlg-actions">
|
||||
<button class="dlg-cancel" @click="dlg = false">Cancel</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button>
|
||||
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-toggle" v-if="providers.length">
|
||||
@@ -279,7 +268,7 @@ function saveSource() {
|
||||
<div class="format-section" v-if="selectedTopic">
|
||||
<div class="format-error ui-error" v-if="uiError">
|
||||
<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 class="progress-info" v-if="activeGenerations.length">
|
||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||
@@ -329,7 +318,7 @@ function saveSource() {
|
||||
>{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
|
||||
<div v-if="errorMsg(f.key)" class="format-error">
|
||||
<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 class="format-row ord-exam">
|
||||
@@ -352,37 +341,20 @@ function saveSource() {
|
||||
>
|
||||
<div class="topic-row">
|
||||
<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 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>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<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>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
|
||||
<SourceForm v-model="editSource" :folders="folders" @submit="saveSource" />
|
||||
<div class="dlg-actions">
|
||||
<button
|
||||
class="dlg-delete"
|
||||
:class="{ armed: pendingConfirm === 'topic-' + t }"
|
||||
@click="confirmDeleteTopic(t)"
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sure?' : 'Delete' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button>
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Aktualisieren</button>
|
||||
</div>
|
||||
</template>
|
||||
</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 {
|
||||
flex-shrink: 0;
|
||||
@@ -658,9 +615,8 @@ function saveSource() {
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
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 {
|
||||
order: 2;
|
||||
@@ -771,28 +727,6 @@ function saveSource() {
|
||||
.panel-btn.danger:hover { 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,
|
||||
.delete-topic.armed {
|
||||
display: inline-block;
|
||||
@@ -855,45 +789,6 @@ function saveSource() {
|
||||
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 {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
@@ -938,18 +833,6 @@ function saveSource() {
|
||||
}
|
||||
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.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-actions { display: flex; justify-content: flex-end; gap: 0.4rem; margin-top: 0.1rem; }
|
||||
.dlg-actions button {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export function malusRegel(score, cap) {
|
||||
return '−20'
|
||||
}
|
||||
|
||||
// Reached badge level (LEVELS key) → view level 1..4, so unlocked SUBS match the badge:
|
||||
// Advanced-Badge → advanced subs visible. Same threshold as stufeFuer, no drift.
|
||||
export const BADGE_VIEW_RANK = { beginner: 1, advanced: 2, expert: 3, master: 4 }
|
||||
|
||||
// Sub-level tag (from the guide markers) → view level 1..4 (A/F/E/V).
|
||||
export const SUB_RANK = { beginner: 1, advanced: 2, expert: 3, peripheral: 4, einfach: 1, mittel: 2, schwer: 3 }
|
||||
export const VIEW_KURZ = { 1: 'A', 2: 'F', 3: 'E', 4: 'V' }
|
||||
@@ -35,11 +39,3 @@ export const VIEW_FARBE = {
|
||||
1: 'var(--level-beginner)', 2: 'var(--level-advanced)',
|
||||
3: 'var(--level-expert)', 4: 'var(--level-master)',
|
||||
}
|
||||
|
||||
// Auto view level per block: reached learning level + 1 (nothing reached → A).
|
||||
// beginner → F unlocked, advanced → E, expert/master → V.
|
||||
export function viewLevelFuer(score, cap) {
|
||||
const s = stufeFuer(score, cap)
|
||||
if (!s) return 1
|
||||
return Math.min(4, LEVELS.findIndex((l) => l.key === s.key) + 2)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/markdown.css'
|
||||
import './assets/shared.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,21 @@
|
||||
You are the quality checker for the learning artefacts of ONE block of the topic "{topic}". Another agent produced question patterns and worked examples. Check both.
|
||||
|
||||
GROUND TRUTH — the supported facts (measure only against these):
|
||||
{facts}
|
||||
|
||||
QUESTION PATTERNS (rows: (subblock) question):
|
||||
{table}
|
||||
{fehlend}
|
||||
WORKED EXAMPLES (numbered; PROBLEM / STEPS / RESULT):
|
||||
{examples}
|
||||
|
||||
TASKS:
|
||||
1. **pattern** — return the CLEANED-UP final version of ALL patterns: each question EXACTLY ONE question mark, one thing, 1–2 sentences, neutral, hits the subblock's core, answerable from the facts without invented assumptions. Keep good ones unchanged; rephrase violations. Exactly one pattern per subblock; carry `block`/`subblock` over unchanged. Questions in GERMAN.
|
||||
2. **pattern_ergaenzt** — for each subblock listed above as MISSING a question: create ONE pattern (same rules). Empty list if none are missing.
|
||||
3. **examples_probleme** — object to an example ONLY if clearly faulty: calculation error, wrong inference, contradicts/invents beyond the facts, or would imprint a wrong path. Recompute yourself; conservative — when in doubt, keep. Give 1-based `index`.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text. Format:
|
||||
{{"pattern": [{{"block": "…", "subblock": "…", "question": "…"}}],
|
||||
"pattern_ergaenzt": [{{"block": "…", "subblock": "…", "question": "…"}}],
|
||||
"examples_probleme": [{{"index": 2}}]}}
|
||||
{extra}
|
||||
@@ -0,0 +1,25 @@
|
||||
Build the learning artefacts for the subblocks of ONE block of the topic "{topic}": per subblock ONE question pattern and ONE flashcard, plus a worked example where it carries.
|
||||
|
||||
BLOCK WITH SUBBLOCKS AND THEIR FACTS (process EVERY subblock):
|
||||
{blocks}
|
||||
|
||||
1. **pattern** — exactly ONE question pattern per subblock (exam question pool):
|
||||
- ONE concrete, concise question about the subblock's CORE concept, phrased neutrally (difficulty is set later by the learner's level). Exactly one question mark, one thing — no multi-parter, no "name three …". At most 1–2 sentences, no scenario build-up.
|
||||
- Answerable from the supplied core points and cited facts. Assume NOTHING that is not in the facts (no "shown" code/diagrams, no invented values).
|
||||
|
||||
2. **cards** — exactly ONE flashcard per subblock (active recall):
|
||||
- `question`: brief recall question testing exactly one key point (≤ 15 words). `answer`: the short, precise answer from the supported facts (≤ 25 words). No prose, no lead-in, invent nothing.
|
||||
|
||||
3. **examples** — a worked example ONLY where it carries understanding:
|
||||
- `problem` (1 sentence) → `steps` (2–5 followable steps, right order) → `result` (1 sentence). Rely on `example_idea` and the supported facts; compute cleanly. A pure definition or meta-knowledge gets NO forced example — leave it out.
|
||||
|
||||
Common rules:
|
||||
- `block`/`subblock` are exactly the titles above.
|
||||
- **Mathematics ALWAYS as LaTeX**, never raw characters: inline `$…$`, longer set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$` instead. The supplied facts often contain raw Unicode math — convert it. Real code/paths/identifiers in backticks.
|
||||
- ALL content in GERMAN.
|
||||
|
||||
Reply with ONLY the JSON as your final message — no code fences, no other text. EXACTLY this format:
|
||||
{{"pattern": [{{"block": "…", "subblock": "…", "question": "…"}}],
|
||||
"cards": [{{"block": "…", "subblock": "…", "question": "…", "answer": "…"}}],
|
||||
"examples": [{{"block": "…", "subblock": "…", "problem": "…", "steps": ["…"], "result": "…"}}]}}
|
||||
{extra}
|
||||
@@ -1,25 +0,0 @@
|
||||
You are a correctness checker for worked examples on the topic "{topic}". Another agent produced them — they are meant to demonstrate a **correct** solution to the learner. A wrong example imprints a wrong solution path. Find the faulty ones.
|
||||
|
||||
GROUND TRUTH — the supported facts (measure only against these):
|
||||
{facts}
|
||||
|
||||
EXAMPLES TO CHECK (numbered; PROBLEM / STEPS / RESULT):
|
||||
{examples}
|
||||
|
||||
Object to an example if ANY of these applies:
|
||||
- **Calculation error** in a step (a number, a transformation, a unit).
|
||||
- **Wrong inference:** a step does not follow from the previous one; the RESULT does not follow from the steps.
|
||||
- **Not covered:** a step/value contradicts the facts or invents something not stated there.
|
||||
- **Incomplete/misleading:** the path would lead the learner to a wrong conclusion.
|
||||
|
||||
Rules:
|
||||
- Measure ONLY against the facts + internal logic. Recompute it yourself.
|
||||
- **Conservative:** object only to what is **clearly** wrong. When in doubt, keep it.
|
||||
- Give the 1-based number (`index`) of each faulty example.
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences — one of the two:
|
||||
{{"ok": true}}
|
||||
{{"problems": [{{"index": 2}}, {{"index": 5}}]}}
|
||||
|
||||
Output no other text.
|
||||
{extra}
|
||||
@@ -1,24 +0,0 @@
|
||||
Build a worked example for the subblocks of the topic "{topic}" — a fully worked-through case that carries understanding.
|
||||
|
||||
BLOCKS WITH SUBBLOCKS AND THEIR FACTS (go through EVERY subblock):
|
||||
{blocks}
|
||||
|
||||
A good worked example:
|
||||
- **problem**: a concrete, small task/question about the subblock (1 sentence).
|
||||
- **steps**: 2–5 followable steps from the problem to the solution. Each step a brief sentence, in the right order.
|
||||
- **result**: the final result / the insight (1 sentence).
|
||||
- Rely on `example_idea` and the supported facts of the subblock. Compute cleanly; invent no values that contradict the facts.
|
||||
- **Only where it carries:** if a subblock cannot be sensibly shown with an example (a pure definition, meta-knowledge), LEAVE IT OUT — no forced example.
|
||||
- **Mathematics ALWAYS as LaTeX**, never as raw characters: inline `$…$` (e.g. `$T_A(n)$`, `$\Sigma^*$`, `$|x| = 5$`, `$O(n^d)$`, `$q_a$`), longer/worked-through formulas set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$`, `$q_a$` instead. Real code/paths/identifiers (not math) in backticks.
|
||||
- **The supplied facts often contain math as raw Unicode — convert it to LaTeX, do not take it over raw.**
|
||||
|
||||
Write `problem`, the `steps` and `result` in GERMAN.
|
||||
|
||||
Reply with ONLY the JSON (all examples) as your final message — no code fences, do NOT write a file. EXACTLY like this:
|
||||
{{"examples": [
|
||||
{{"block": "<exact block title>", "subblock": "<exact subblock title>",
|
||||
"problem": "…", "steps": ["…", "…"], "result": "…"}}
|
||||
]}}
|
||||
|
||||
Output no other text.
|
||||
{extra}
|
||||
@@ -1,23 +0,0 @@
|
||||
Build ONE flashcard (question→answer) for active recall for each subblock of the topic "{topic}".
|
||||
|
||||
BLOCKS WITH SUBBLOCKS AND THEIR FACTS (process EVERY subblock):
|
||||
{blocks}
|
||||
|
||||
A good flashcard:
|
||||
- **question**: a brief recall question that tests exactly one key point (not "explain everything"). One question, one thing.
|
||||
- **answer**: the short, precise answer — based on the supported facts/key points of the subblock. Invent nothing extra.
|
||||
- Rely on the supplied facts. Where a supported fact exists, the answer must match it.
|
||||
- Brief: question ≤ 15 words, answer ≤ 25 words. No prose, no lead-in.
|
||||
- **Mathematics ALWAYS as LaTeX**, never as raw characters: inline `$…$` (e.g. `$T_A(n)$`, `$\Sigma^*$`, `$|x| = 5$`, `$O(n^d)$`, `$q_a$`), longer calculations set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$`, `$q_a$` instead. Real code/paths/identifiers (not math) in backticks.
|
||||
- **The supplied facts often contain math as raw Unicode — convert it to LaTeX, do not take it over raw.**
|
||||
|
||||
Write `question` and `answer` in GERMAN.
|
||||
|
||||
Reply with ONLY the JSON (all cards) as your final message — no code fences, do NOT write a file. EXACTLY like this:
|
||||
{{"cards": [
|
||||
{{"block": "<exact block title>", "subblock": "<exact subblock title>",
|
||||
"question": "…", "answer": "…"}}
|
||||
]}}
|
||||
|
||||
Output no other text.
|
||||
{extra}
|
||||
@@ -27,5 +27,8 @@ HARD RULES:
|
||||
- Address the learner directly, in clear German, no fluff.
|
||||
- `$…$` ONLY for real mathematics. Code, paths, namespaces, file names, JSON and identifiers ALWAYS in backticks (`` `…` ``) — NEVER as bare text, NEVER in `$…$` (not `$Acme\Example$`, but `` `Acme\Example` ``).
|
||||
|
||||
CHECKER'S NOTES ON THE LAST VERSION:
|
||||
{kritik_block}
|
||||
|
||||
Output ONLY this JSON (no other text):
|
||||
{{"question": "exactly one short question"}}
|
||||
@@ -1,5 +1,7 @@
|
||||
The FINAL block inventory for the topic "{topic}" was assembled from several sources. Despite earlier filtering it can still carry duplicates: the same concept listed under two names. For EACH pair below, decide: do A and B denote the SAME block → **ja**, or TWO DIFFERENT blocks → **nein**?
|
||||
|
||||
Decide on the underlying CONCEPT — from title and description — not on wording alone. Merge only when A and B are EQUIVALENT (the same statement, mutually interchangeable); a special case, a restriction, or one direction of a relation is NOT equivalent → **nein**.
|
||||
|
||||
THE MOST COMMON ERROR is merging a named VARIANT with its base entity. A variant is NEVER its base: "3-X" ≠ "X", "Max-X" ≠ "Max-3-X", "Modified X" ≠ "X", "k-X" ≠ "X". A digit or qualifier prefix that restricts the entity makes it a DIFFERENT entity → **nein**.
|
||||
|
||||
PAIRS:
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
Topic "{topic}". Umbrella blocks were formed, each bundling the constituent parts of ONE model/definition. Some parts were missed and are still listed as standalone blocks. Your job: for each umbrella, find which of the remaining standalone blocks are ALSO constituent parts of that same parent, so the model is complete.
|
||||
Topic "{topic}". Theme blocks were formed by card-sorting a fine-grained item list. Some items were left unassigned. Your job: attach EACH remaining item to the theme it belongs to, so every item lands in a theme. This is organisation, not judgement — items are never discarded.
|
||||
|
||||
UMBRELLAS (parent — already-collected parts):
|
||||
THEMES (heading — already-assigned members):
|
||||
{umbrellas}
|
||||
|
||||
REMAINING STANDALONE BLOCKS (numbered):
|
||||
REMAINING ITEMS (numbered):
|
||||
{rest}
|
||||
|
||||
## Rule — attach a block to an umbrella only if BOTH hold
|
||||
1. **Presupposition:** the block's definition **requires the umbrella's parent to already exist** — it makes no sense as a topic on its own without that model (e.g. „Alphabet Σ", „Übergangsfunktion δ", „Konfiguration", „Akzeptierende Berechnung" all presuppose the Turing-machine; „Literale", „Klausel", „Belegung" presuppose the KNF/logic definition). The parent must NOT presuppose the block (directional).
|
||||
2. **Not standalone:** the block is a *definitional component / notation*, NOT itself a named **algorithm, problem, theorem, reduction, or complexity class** (those stay their own block — a downstream guard will reject them anyway).
|
||||
|
||||
Do NOT attach a block merely because it shares a topic. When unsure → leave it standalone. Most remaining blocks will NOT be attached; a few genuine missed parts will.
|
||||
## Rule
|
||||
- Attach each remaining item to the ONE theme whose subject it shares — the theme a learner would meet it under (a variable/flag/field/instance/facet of that theme's model, system, setup, tool family, or category).
|
||||
- Assign as many as you reasonably can. Only leave an item unassigned if it fits NO theme at all — it then becomes its own single-item block (a genuinely standalone concept).
|
||||
- Each item goes into at most ONE theme.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (`additions` may be empty; `umbrella` = the UMBRELLA index, `members` = standalone block numbers to attach):
|
||||
Format (`additions` may be empty; `umbrella` = the THEME index, `members` = item numbers to attach):
|
||||
{{"additions": [{{"umbrella": 0, "members": [8, 12, 34]}}]}}
|
||||
@@ -1,34 +1,25 @@
|
||||
Topic "{topic}". A previous step produced a flat list of learning blocks that is TOO FINE-GRAINED — several blocks are **constituent sub-definitions / notation of ONE larger definition or model** and should become a single umbrella block. Find these groups. A good run finds several genuine umbrellas AND leaves most blocks standalone; judge each candidate on its merits.
|
||||
Topic "{topic}". A previous step produced a flat list of learning items that is TOO FINE-GRAINED for a table of contents — single environment variables, single CLI flags, single config fields, near-synonyms. Your job is **card sorting**: group the items into a small set of **theme blocks**, so that EVERY item lands in exactly one theme. Nothing is discarded — grouping never loses an item; it only organises them.
|
||||
|
||||
**Propose generously.** A deterministic guard downstream rejects any umbrella that swallows a named algorithm/problem/theorem, so a wrong-but-plausible merge is cheap — a missed umbrella is not. Do NOT withhold a merge merely because the members are lexically dissimilar (facets of one model routinely are) or because you are unsure of the parent's exact name.
|
||||
Think of the result as chapters: ~{theme_lo}–{theme_hi} themes for a whole topic of this size ({n_items} items), each a coherent learning block that a learner meets as one unit. A theme with only one fitting item is fine (a genuinely standalone concept), but prefer pulling related items together over leaving many singletons.
|
||||
|
||||
CANDIDATES (your starting point):
|
||||
CANDIDATES you may group (your starting point — a pre-clustered neighbourhood):
|
||||
{candidates}
|
||||
|
||||
FULL BLOCK LIST (you may pull in ANY numbers below that are constituents of the same definition):
|
||||
FULL ITEM LIST (you may pull in ANY numbers below that share the theme):
|
||||
{list}
|
||||
|
||||
## Merge test — propose an umbrella when ALL THREE hold
|
||||
1. **One parent.** The members are constituent parts/facets of ONE named parent model or definition — each member PRESUPPOSES that parent (you cannot introduce the member without first invoking the parent). TM-model parts (Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung) presuppose „Turingmaschine"; KNF parts (Literale, Klauseln, Boolesche Variablen) presuppose „Konjunktive Normalform".
|
||||
2. **Studied together.** A learner meets them together as one unit.
|
||||
3. **No standalone unit among them.** No member is itself a named **algorithm, problem, theorem, reduction/transformation, or complexity class** („List Scheduling", „3-SAT", „Cook/Levin", „3-SAT ≤ Clique", „NP", „NP-Vollständigkeit", „Polynomielle Transformation"). Each of those is its own concept, so a group containing one is a set of SIBLINGS, not the decomposition of one model — keep them separate.
|
||||
## How to form a theme
|
||||
- **One coherent subject.** The members belong together because a learner would study them as one block — the parts/facets/instances of one model, system, setup, tool family, or category. Examples of the *kind* of grouping (not domain rules): many individual settings/variables/flags of one configuration → one „…-Konfiguration" theme; the parts of one setup → one „…-Setup" theme; a base concept plus its variants/instances → one theme.
|
||||
- **Most specific home wins.** An item tied to ONE concrete object (an algorithm/theorem/counterexample FOR object X, a setting OF component X) belongs in X's theme — not in a technique/property family that also fits. A learner meets „the algorithm for X" while studying X, not in a generic „algorithms" chapter.
|
||||
- **The title must be true of EVERY member.** Never assert a property or class in the theme title (a complexity claim, „deprecated …", „…-vollständige …") unless every single member has that property — otherwise pick a neutral title for the shared subject.
|
||||
- **Lexical dissimilarity is NORMAL.** Facets of one theme often read very differently — that is not a reason to split them.
|
||||
- **Title:** name the theme after the shared subject. A real self-contained heading; must NOT contain „ — " (reserved separator) — use „(…)" or „:".
|
||||
- **Description:** name EVERY member explicitly (the next step recovers them as sub-points). E.g. „Environment-Konfiguration: APP_ENV, APP_SECRET, DATABASE_URL, MAILER_DSN, …".
|
||||
- **Members:** the item NUMBERS from the full list. Each number goes into exactly ONE theme; do not repeat a number across themes.
|
||||
|
||||
*Note on test 1: „can this be defined at all?" is the WRONG question — Alphabet Σ and DTM CAN be stated in isolation, yet in THIS topic they are parts of the Turing-machine model and belong together. The question is whether the member PRESUPPOSES the shared parent, not whether a standalone sentence exists.*
|
||||
|
||||
## Examples
|
||||
DO NOT MERGE — distinct named units that merely share a topic:
|
||||
- „Greedy-Algorithmus GA" + „ModifiedGreedy" + „Multiple-Choice-Knapsack" → two algorithms + a problem, each standalone (test 3 fails). Keep separate.
|
||||
|
||||
MERGE — one definition decomposed (the canonical cases — end here so this is your default lens):
|
||||
- „Alphabet Σ" + „NDTM" + „DTM" + „Akzeptierende Berechnung" + „Folgekonfiguration" → ONE umbrella **„Turingmaschine (Modell)"**. (The members are lexically very different from each other — that is EXPECTED for facets of one model and is NOT a reason to keep them apart.)
|
||||
- „Klausel" + „Boolesche Variable" + „Erfüllende Belegung" + „KNF" → ONE umbrella **„Aussagenlogik & KNF"**.
|
||||
|
||||
## Synthesize each umbrella
|
||||
- `title`: the parent concept's name (e.g. „Turingmaschine (Modell)"). A real self-contained definition; must NOT contain „ — " (a reserved separator) — use „(…)" or „:".
|
||||
- `description`: **name EVERY merged child explicitly** — the next step recovers the children as sub-points from the source. E.g. „Formales TM-Modell: Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung, Folgekonfiguration."
|
||||
- `members`: the block NUMBERS (from the full list) folded in. At least 2 per umbrella; each number appears in at most one umbrella.
|
||||
Assign as many items as you confidently can. Items you are unsure about you may leave out — a later completion step and a singleton fallback catch them; never force a bad fit.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (`umbrellas` may be empty):
|
||||
{{"umbrellas": [{{"title": "Turingmaschine (Modell)", "description": "Formales TM-Modell: Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung, Folgekonfiguration.", "members": [1, 2, 5, 12, 34]}}]}}
|
||||
Format (`umbrellas` = the themes; may be empty if nothing fits together):
|
||||
{{"umbrellas": [{{"title": "Environment-Konfiguration", "description": "Konfiguration über Umgebungsvariablen: APP_ENV, APP_SECRET, DATABASE_URL, MAILER_DSN, .env-Dateien.", "members": [1, 2, 5, 12, 34]}}]}}
|
||||
@@ -14,7 +14,7 @@ What is NOT a block (→ discard, EVEN if a description is attached):
|
||||
- Pure references WITHOUT substantive description: "Satz 7.18" (nothing meaningful after the —).
|
||||
- Too vague or evidently fabricated.
|
||||
|
||||
**Reference title WITH real content → include AND rename:** If an entry has an uninformative title ("Satz 7.18", "Korollar 6.18", "Lemma 6.2") but the description describes a real concept → include it and give a speaking name (in GERMAN) drawn from the content in the `rename` field. In the `keep` entry the **original title** stays (for matching); the new name goes ONLY in `rename`.
|
||||
**Reference title WITH real content → include AND rename:** If an entry has an uninformative title ("Satz 7.18", "Korollar 6.18", "Lemma 6.2") but the description describes a real concept → include it and give a speaking name (in GERMAN) drawn from the content in the `rename` field. In the `keep` entry the **original title** stays (for matching); the new name goes ONLY in `rename`. A source numbering is never a canonical title — even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium …"), rename to the concept/result ("Satz von Christofides", "Kriterium für Eulerschen Kreis"); the numbering may remain in the description. A named theorem that states its own result stays a block — named after the RESULT, not the number.
|
||||
|
||||
Examples:
|
||||
- "Clique-Member — k-Clique, die einen festen Knoten v enthält" → **keep** (standalone problem).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
Topic "{topic}". A quality audit found GAPS: source-material passages and named results that NO existing inventory block covers. Your job: propose new learning blocks ONLY for these gaps — nothing else. An empty list is a valid answer (when the gaps are filler or already covered under another name).
|
||||
|
||||
EXISTING BLOCKS (do NOT re-propose these or near-synonyms of them):
|
||||
{blocks}
|
||||
|
||||
UNCOVERED MATERIAL (gap passages and named results, with source excerpts):
|
||||
{gaps}
|
||||
|
||||
Rules:
|
||||
- One block per genuinely missing, teachable concept — self-contained, exam-relevant.
|
||||
- Ground every proposal in the material above; never invent canon knowledge the material does not treat.
|
||||
- Title: concrete concept name (max 8 words, no catalog references like "(Satz 6.33)").
|
||||
- Description: ONE sentence naming what the block teaches, grounded in the material.
|
||||
- Skip organizational text, prefaces, exercise scaffolding, duplicates of existing blocks.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (`blocks` may be empty):
|
||||
{{"blocks": [{{"title": "…", "description": "…"}}]}}
|
||||
{extra}
|
||||
@@ -10,6 +10,7 @@ Check whether the current title is the best canonical name for this block.
|
||||
Rules:
|
||||
- Current title good (concrete, precise, self-explanatory, no reference/placeholder like "Satz 7.18") → confirm it.
|
||||
- Otherwise pick the best member ("best" REQUIRED) and optionally propose a SHORTER name: max 8 words, ONLY terms that appear in the members, exactly as concrete as the shared content — never a broader umbrella term, never a textbook concept the members don't mention. NO catalog/reference brackets ("(Satz 6.33)"); a known acronym in brackets ("(VC)") is fine.
|
||||
- A source numbering is never a canonical title, even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium …") → treat such a current title as NOT good and rename to the concept/result; the numbering belongs in the description.
|
||||
- When in doubt, confirm the current title.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text.
|
||||
|
||||
@@ -7,6 +7,7 @@ Rules:
|
||||
- "best" is REQUIRED: the member whose title fits the shared concept best (most concrete, precise, self-explanatory, established term; avoid reference/placeholder titles like "Satz 7.18", "Punkt 3", "(**)").
|
||||
- "name" is OPTIONAL — set it ONLY when no member title states the shared core well. It must be SHORT (max 8 words), use ONLY terms that appear in the members themselves, and stay exactly as concrete as the shared content — never a broader umbrella term, never a textbook concept the members don't mention.
|
||||
- In "name": NO catalog/reference brackets ("(Satz 6.33)", "(Kap. 4)"); a known acronym in brackets ("(VC)") is fine.
|
||||
- The title is the CONCEPT name — a source numbering is never canonical. Even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium für Eulerschen Kreis"), pick or propose the concept/result ("Satz von Christofides", "Kriterium für Eulerschen Kreis"); the numbering belongs in the description, never in the title.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ PAIRS:
|
||||
- a distinct **variant** is its own entity: „GA" ≠ „ModifiedGreedy (MGA)"; „SAT" ≠ „3-SAT"; „Knapsack" ≠ „Multiple-Choice-Knapsack";
|
||||
- a **reduction between two problems** is its own entity: „Clique" ≠ „3-SAT ≤ Clique";
|
||||
- two reductions/relations that share ONE side but differ on the OTHER (or run in the opposite direction) are DIFFERENT results → **nein**: „SAT ≤ Clique" ≠ „SAT ≤ 3-Dim-Matching", „VertexCover ≤ FVS" ≠ „VertexCover ≤ Δ-Cover". A restriction/special case („3-SAT ≤ X") is NARROWER than the general („SAT ≤ X"), never the same.
|
||||
- a **statement that RELATES several standalone objects** (an implication, a shared bound, a joint consequence — „Hypothese H ⇒ keine schnelle Lösung für X und Y") is its own entity, like a reduction. It is NEVER a duplicate of ONE of the objects it mentions — merging „X" into such a statement silently deletes X's own definition/properties from the inventory.
|
||||
- **SAME canonical entity → ja**, even when A and B emphasize DIFFERENT FACETS of it. Facets of one and the same object include: its **formal definition**, a **mechanism/step** (how it works), a **property** (approximation ratio, a bound, complexity, ∈ NP), a **characterization**, a **naming variant**. Two entries describing different facets of the SAME entity are duplicates.
|
||||
|
||||
**STEP 3 — „When in doubt → nein" applies ONLY when STEP 1 is ambiguous** (you genuinely cannot tell whether the two names denote the same object). It does NOT fire merely because the two descriptions differ — differing descriptions of the SAME entity are **ja**.
|
||||
@@ -27,6 +28,7 @@ NOT A DUPLICATE (nein) — different entity:
|
||||
- A: „Greedy-Algorithmus GA" B: „ModifiedGreedy (MGA)" → two different algorithms → **nein**
|
||||
- A: „Lower Bound Clique bzgl. Knoten" B: „Lower Bound Clique bzgl. Kanten" → different parameter → **nein**
|
||||
- A: „Clique" B: „3-SAT ≤ Clique" → a problem vs. a reduction (its own block) → **nein**
|
||||
- A: „Partition" B: „Satz: ETH ⇒ kein 2^o(n) für Partition, SubSet Sum" → a problem vs. a joint bound over TWO problems → **nein**
|
||||
- A: „VertexCover ≤ FVS" B: „VertexCover ≤ Δ-Cover" → same source, different target → different reductions → **nein**
|
||||
- A: „Cliquenproblem" B: „Vertex-Cover-Problem" → different problems → **nein**
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ Rules:
|
||||
- Granularity: a block is a LEARNING UNIT, not a dictionary entry. Families learned together (e.g. the font-* properties, the modal verbs) are ONE block.
|
||||
- Abstraction level (important): a block is self-contained — explainable WITHOUT presupposing ANOTHER block as its subject. These are problems, methods/algorithms, definitions, named theorems with their own statement. A detail that presupposes another concept belongs IN that concept's block (as a part), not as its own entry:
|
||||
- A PROPERTY of a concept belongs to the concept: „Clique" is a block; „Clique ist NP-vollständig", „Clique ∈ NP", „Laufzeit von Clique" are parts of it — not their own entry.
|
||||
- A PROOF/reduction component belongs to the theorem/reduction: „A-Komponente", „Dummy-Item", „αEnde", „Hilfsvariable" — not its own entry.
|
||||
- A PROOF/reduction component or a constructed HELPER OBJECT belongs to the theorem/reduction it serves: „A-Komponente", „Dummy-Item", „αEnde", „Hilfsvariable", helper functions/weightings, helper schedules, constructed graphs/tours/gadgets — not its own entry.
|
||||
- A single COMPUTED instance or result value belongs to the argument that produced it: a specific number, graph, tour, or schedule built inside one example/proof („OPT(…) = n", a concrete constructed input) is not its own entry.
|
||||
- PARAMETER INSTANCES of the SAME argument are ONE block: the identical technique applied to different parameters or cases („… via k / via n / via |E|", „für n=1 / n=2") is a single block, not one entry per parameter.
|
||||
- Pure NOTATION/symbols belong to their definition: „|x|", „Σ∗" — not their own entry.
|
||||
- A standalone REDUCTION between two problems, however, is its own block („3-SAT ≤ Clique").
|
||||
- A standalone REDUCTION between two problems, however, is its own block („3-SAT ≤ Clique"); likewise a named theorem that states its OWN result stays a block — named after the result, not the source numbering.
|
||||
- NO categories, NO ranking, NO ordering by importance — only a flat, numbered list.
|
||||
- Aim for the natural number of genuine learning units for this material — there is no hard quota, but do NOT split hairs to inflate the count (prefer the "families learned together = ONE block" rule above). STOP when the only remaining candidates are exercises, meta-questions, administrative notes, or duplicates of blocks you already listed.
|
||||
- **EXCLUDE non-content — these are NEVER learning blocks:** exercise/task/assignment scaffolding ("Aufgabe 3", "Bonusaufgabe", "Übung", "Blatt 11", point values), meta/assessment items ("welche der folgenden…", true/false prompts, hand-in / exam-date notes), administrative & boilerplate (headings, page/room/exam numbers, names, copyright), and any **file names, paths, or URLs** (those are provenance, recorded separately — never a block or part of one).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
The block below was distilled from source material for the topic "{topic}", but its wording drifted from the material: the title may use terms that never literally appear in the sources (translated, expanded, or spelling-corrected), and the description may be missing.
|
||||
|
||||
BLOCK:
|
||||
Title: {title}
|
||||
Description: {description}
|
||||
|
||||
MATERIAL EXCERPTS:
|
||||
{excerpts}
|
||||
|
||||
Tasks:
|
||||
- "title": If the title's terms do not literally appear in the excerpts, rewrite it using ONLY surface forms (exact spellings, even unusual ones) that appear in the excerpts — same meaning, same concreteness, max 8 words. If the title already matches the material wording, repeat it unchanged. Never broaden to a textbook term the excerpts don't use; NO catalog/reference brackets ("(Satz 6.33)", "(Kap. 4)"). A source numbering ("Satz 7.13", "Definition 6.19") is NOT a valid title even when it appears literally in the excerpts — use the concept/result name from the excerpts; the numbering may go into the description.
|
||||
- "description": If the description is "(leer)", write ONE precise sentence grounded in the excerpts — no invented facts. Otherwise repeat it unchanged.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text.
|
||||
|
||||
Format:
|
||||
{{"title": "…", "description": "…"}}
|
||||
@@ -1,26 +0,0 @@
|
||||
You are the fact-checker for the learning facts of the topic "{topic}". Another agent has extracted facts per subblock. Check ONLY the **backed facts** for truth — not the examples.
|
||||
|
||||
{source}
|
||||
|
||||
FACTS TO CHECK (per subblock):
|
||||
{facts}
|
||||
|
||||
Check per subblock:
|
||||
1. **Evidence fidelity**: Does each `cited_facts` entry appear that way in the source (accurate in substance)? Is the source citation correct? With source material (folder or excerpts): check against it. Without a source: is it established standard knowledge?
|
||||
2. **Factual correctness**: Are formulas, values, definitions, signatures technically correct? A wrong formula/value is a defect.
|
||||
3. **Fact vs. example**: Is a self-computed/invented example wrongly declared as `cited_facts`? That is a defect — it belongs in `example_idea`.
|
||||
4. Do NOT check examples (`example_idea`) for source evidence — they are generative.
|
||||
|
||||
Note only REAL defects (wrong fact, wrong source, example disguised as a fact). Not matters of taste.
|
||||
|
||||
**Discard vs. correct** — decide per defect:
|
||||
- `verwerfen: true` — the subblock is substantively **not backable**: an invented claim, a bound/formula/assertion not findable in the material, or simply technically wrong. The subblock is then REMOVED. Be sure — when in doubt, `false`.
|
||||
- `verwerfen: false` — the core is right, only a fact/value/source is imprecise and correctable.
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences.
|
||||
|
||||
Format — all in order:
|
||||
{{"ok": true}}
|
||||
Otherwise (subblock title EXACTLY as above):
|
||||
{{"problems": [{{"subblock": "<exakter Subblock-Title>", "problem": "…", "discard": true}}]}}
|
||||
{extra}
|
||||
@@ -1,30 +0,0 @@
|
||||
Extract the learning facts for each subblock of the topic "{topic}". These facts are the binding basis from which the guide text, the levels, and the exam questions are later created — they must be **correct**.
|
||||
|
||||
{source}
|
||||
|
||||
BLOCKS WITH SUBBLOCKS (process EVERY subblock):
|
||||
{blocks}
|
||||
|
||||
Collect per subblock ONLY the essentials (superfluous material harms learning); write all field content in GERMAN (technical terms/code identifiers stay original):
|
||||
- **key_points**: 1–3 concise statements — what must one understand about this subblock?
|
||||
- **prerequisites**: what must one know beforehand (a half-sentence)? Empty if nothing.
|
||||
- **hurdles**: typical beginner misconception (a half-sentence). Empty if none.
|
||||
- **cited_facts**: hard facts (definitions, formulas, values, names, signatures) — **only what you can back up**. Each with a source:
|
||||
- With a source file/script: cite **accurately in substance** and give the location (e.g. „Skript Def. 6.3, Z.66"). Invent no values, compute nothing yourself.
|
||||
- Without a source (pure topic): only established standard knowledge; verify uncertain points via web search; source = „allgemein" or the URL.
|
||||
- **example_idea**: ONE example that carries understanding — freely phrased. **Here** is where self-formed sentences, mini-scenarios, worked examples belong. Empty if an example adds nothing.
|
||||
|
||||
HARD SEPARATION — important:
|
||||
- `cited_facts` = only backable material from the source/established knowledge. **NEVER** output a self-computed or invented example as a backed fact.
|
||||
- A worked example, an invented sentence, a constructed case → belongs in `example_idea`, not in `cited_facts`.
|
||||
- When in doubt: better to leave out than to claim falsely.
|
||||
|
||||
Reply with ONLY the JSON as your final message — no code fences, no prose around it. Do NOT write any file; your tools are for research only. EXACTLY this format:
|
||||
{{"facts": [
|
||||
{{"block": "<exakter Block-Title>", "subblock": "<exakter Subblock-Title>",
|
||||
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
|
||||
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
|
||||
]}}
|
||||
|
||||
Output no other text.
|
||||
{extra}
|
||||
@@ -1,25 +0,0 @@
|
||||
You are filling **gaps**. For each subblock of the topic "{topic}", the facts already captured are listed below. A first pass extracted them — and experience shows backable material slips through. Your task: add **only what is missing** from the source.
|
||||
|
||||
{source}
|
||||
|
||||
SUBBLOCKS WITH FACTS ALREADY CAPTURED:
|
||||
{blocks}
|
||||
|
||||
Rules:
|
||||
- Output **only NEW** points per subblock that are backed by the source and still **missing** above.
|
||||
- **Repeat nothing** from what is already "captured" — not even rephrased. If it's already there → leave it out.
|
||||
- **Only backable material.** With a source file/script: cite accurately in substance + give the location. Invent no values, compute nothing yourself. Without a source file (pure topic): only established standard knowledge.
|
||||
- **HARD SEPARATION:** `cited_facts` = only backable material. A self-computed/invented example belongs in `example_idea`, NEVER in `cited_facts`.
|
||||
- Add `key_points` only if an **essential** aspect is missing — no duplications, no trivialities.
|
||||
- **If you find nothing new for a subblock → leave it out.** If you find nothing at all → empty list. Better nothing than fabrication.
|
||||
- Write all field content (key_points, prerequisites, hurdles, cited_facts text, example_idea) in GERMAN, matching the source material (technical terms/code identifiers stay original).
|
||||
|
||||
Reply with ONLY the NEW facts as ONE JSON in your final message — no code fences, no file writing (tools are for research only). EXACTLY this format:
|
||||
{{"facts": [
|
||||
{{"block": "<exakter Block-Title>", "subblock": "<exakter Subblock-Title>",
|
||||
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
|
||||
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
|
||||
]}}
|
||||
|
||||
Output no other text.
|
||||
{extra}
|
||||
@@ -1,19 +0,0 @@
|
||||
Coverage gate ("Was fehlt?") for ONE written guide section on the topic "{topic}": check the text against its learning objectives. Objective without content = gap; content without objective = ballast.
|
||||
|
||||
LEARNING OBJECTIVES of block "{block}":
|
||||
{ziele}
|
||||
|
||||
SECTION — current content (subblocks are marked with `<!-- sub: … -->`):
|
||||
{section}
|
||||
|
||||
Procedure:
|
||||
1. For EACH objective decide binary: does the text teach it well enough that a beginner could achieve the objective afterwards? Mentioning a keyword is NOT teaching — the how/why must be there.
|
||||
2. For each NOT-covered objective state precisely WHAT is missing (German, concrete — the writer will patch exactly this).
|
||||
3. List BALLAST: passages that serve none of the objectives (digressions, redundant repetition). Shortening candidates only — never a whole subblock. Worked-example passages that apply a concept belonging to an objective are teaching, not ballast.
|
||||
4. Judge strictly binary per objective; no partial credit.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (`ziele` maps EVERY objective id to true/false):
|
||||
{{"ziele": {{"z1": true, "z2": false}}, "luecken": [{{"ziel": "z2", "fehlt": "…"}}], "ballast": ["passage …"]}}
|
||||
{extra}
|
||||
@@ -1,19 +0,0 @@
|
||||
Remove unsupported claims from ONE guide section on the topic "{topic}" — minimal edit, everything else stays VERBATIM.
|
||||
|
||||
SECTION (block "{block}") — current content:
|
||||
{section}
|
||||
|
||||
UNSUPPORTED CLAIMS (from the fact gate) — ONLY these may be touched:
|
||||
{claims}
|
||||
|
||||
VERIFIED FACTS (the only allowed factual basis — for rephrasing, if a claim can be corrected instead of removed):
|
||||
{facts}
|
||||
|
||||
Rules:
|
||||
- Per claim: correct it IF the verified facts state the right version; otherwise DELETE it (smooth the surrounding sentence so the text stays fluent).
|
||||
- Everything else stays word-for-word identical — no rewriting, no shortening, no new content.
|
||||
- STRUCTURE INVARIANT (mandatory, the level filter dies without it): keep ALL marker lines exactly — `<!-- kapitel: … -->`, `<!-- section: … -->`, `<!-- compact -->`, `<!-- ausführlich -->` and every `<!-- sub: LABEL | title -->` in BOTH blocks, same order. Never delete a whole subblock; if all its claims fall, keep a minimal supported sentence.
|
||||
- German text, same tone as the original.
|
||||
|
||||
Write ONLY the file {out_path} — the COMPLETE corrected section in exactly the original marker format.
|
||||
{extra}
|
||||
@@ -1,23 +0,0 @@
|
||||
Fact-check ONE written guide section on the topic "{topic}" — Chain-of-Verification style, binary per claim.
|
||||
|
||||
SECTION (block "{block}"):
|
||||
{section}
|
||||
|
||||
VERIFIED FACTS — the ONLY allowed factual basis (extract-once from the source):
|
||||
{facts}
|
||||
|
||||
Procedure:
|
||||
1. Decompose the section text into its ATOMIC factual claims (definitions, properties, numbers, procedure steps, causal statements). Ignore pure didactics (transitions, framing, mnemonic phrasing).
|
||||
2. Worked-example passages (a concrete problem worked through in steps to a result) are DIDACTICS when they merely APPLY or ILLUSTRATE a verified fact or a provided worked example: their concretely chosen values and computed intermediates do NOT count as over-specific. Flag an example step ONLY if it contradicts the facts or smuggles in a NEW general claim not derivable from them.
|
||||
3. CONTEXT sentences are DIDACTICS too, not claims: introductions and summaries that only preview/recap the section, uncontroversial general knowledge that merely places the topic (history, origin, what an adjacent well-known technology is), and paraphrases of the verified facts. Ignore them — a guide needs connective tissue. This exemption ends the moment a sentence makes a checkable statement about THIS block's own syntax, behavior or rules: that is a claim.
|
||||
4. Check EACH claim INDIVIDUALLY and BINARY against the VERIFIED FACTS above: derivable from them → belegt. Not derivable, contradicting, or over-specific beyond the facts → nicht belegt. For unsupported claims set "urteil": **"falsch"** when the claim CONTRADICTS the verified facts or contradicts the section itself (e.g. a rule its own example violates); **"unbelegt"** when it is merely not derivable from the facts.
|
||||
5. Do NOT search the web, do NOT use outside knowledge as EVIDENCE — a claim about the block that is true in the world but absent from the facts is still "nicht belegt".
|
||||
6. When a sentence is genuinely ambiguous between context and claim → treat it as a claim (safety before cost).
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format — everything supported:
|
||||
{{"ok": true}}
|
||||
Otherwise (ONLY unsupported claims, VERBATIM as they appear in the text — NEVER list supported ones):
|
||||
{{"claims": [{{"text": "verbatim claim from the section", "grund": "why unsupported (German, short)", "urteil": "falsch|unbelegt"}}]}}
|
||||
{extra}
|
||||
@@ -0,0 +1,27 @@
|
||||
Revise ONE guide section on the topic "{topic}" (format: {format_name}) in a single pass — fix exactly the findings below, everything else stays unchanged in substance.
|
||||
|
||||
SECTION (block "{block}") — current content:
|
||||
{section}
|
||||
|
||||
VERIFIED FACTS (the only allowed factual basis):
|
||||
{facts}
|
||||
|
||||
SECTION SPECIFICATION:
|
||||
{spec}
|
||||
|
||||
FINDINGS TO FIX:
|
||||
{auftraege}
|
||||
|
||||
Rules per finding type:
|
||||
- **Claims (falsch/unbelegt):** correct IF the verified facts state the right version, otherwise DELETE and smooth the surrounding sentence. Touch nothing else.
|
||||
- **Lücken:** add the missing content for the named objective — grounded in the verified facts, at the fitting subblock, beginner-friendly.
|
||||
- **Ballast:** shorten the named passages without information loss — never drop a subblock.
|
||||
- **Lesbarkeit/Länge:** fix exactly the noted problems (split sentences, lists instead of prose enumerations, cut repetition/filler; hit the stated length target when one is given).
|
||||
|
||||
STRUCTURE INVARIANT (mandatory — the level filter dies without it): keep the marker skeleton exactly — `<!-- kapitel: … -->`, `<!-- section: … -->`, `<!-- compact -->`, `<!-- ausführlich -->` and one `<!-- sub: LABEL | title -->` per subblock in BOTH blocks, labels/titles/order as in the subblock list:
|
||||
{sub_list}
|
||||
|
||||
Write the revised section in GERMAN, same tone as the original.
|
||||
|
||||
Write ONLY the file {out_path} — the COMPLETE revised section in exactly the original marker format. No text outside the section.
|
||||
{extra}
|
||||
@@ -1,33 +0,0 @@
|
||||
Review written sections of a learning guide on the topic "{topic}" (format: {format_name}) for readability.
|
||||
Audience: beginners.
|
||||
|
||||
SECTION SPECIFICATION (target state):
|
||||
{spec}
|
||||
|
||||
SECTIONS:
|
||||
{sections}
|
||||
|
||||
Review each section:
|
||||
1. Does the section teach the concept understandably for a beginner with no prior knowledge — does it frame it, explain the how/why, make an example concrete? Not so dense that only someone who already knows the topic can follow it.
|
||||
2. Readability (note genuine flaws):
|
||||
- Sentences over ~20 words (never over 25), or nested sentences with several interjections.
|
||||
- An enumeration (steps/options/requirements) written as one long prose sentence that should be a Markdown list.
|
||||
- Wall of text: one dense block without paragraphs that could be split into several.
|
||||
- More than ~4 new technical terms left unexplained at first occurrence.
|
||||
3. Conciseness (superfluous material harms learning — note genuine flaws):
|
||||
- Repetition: the same statement multiple times, just reworded.
|
||||
- Trivia spelled out at length, filler sentences, preambles, meta-comments with no new content.
|
||||
- An example that contributes nothing to understanding.
|
||||
→ note as "too long/redundant — shorten without loss of information". IMPORTANT: shortening never means dropping a subblock — each one stays.
|
||||
4. Are the examples short, simple, plausibly correct — and in the topic-appropriate format per the specification (no code block around prose examples, no prose pseudo-example where code is required)?
|
||||
5. Is the Markdown clean (no broken code blocks, no placeholders, no foreign text)?
|
||||
|
||||
You only REVIEW and note problems — you change nothing. Note only genuine flaws, no matters of taste.
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences.
|
||||
|
||||
Format — all in order:
|
||||
{{"ok": true}}
|
||||
Otherwise (section title EXACTLY as above):
|
||||
{{"problems": [{{"section": "exact section title", "problem": "…"}}]}}
|
||||
{extra}
|
||||
@@ -0,0 +1,36 @@
|
||||
You are the combined quality gate for ONE written guide section on the topic "{topic}" — fact check (Chain-of-Verification style), coverage against the learning objectives, and readability. One verdict, three lenses.
|
||||
|
||||
SECTION (block "{block}") — subblocks are marked with `<!-- sub: … -->`:
|
||||
{section}
|
||||
|
||||
VERIFIED FACTS — the ONLY allowed factual basis (extract-once from the source):
|
||||
{facts}
|
||||
|
||||
LEARNING OBJECTIVES of this block:
|
||||
{ziele}
|
||||
|
||||
SECTION SPECIFICATION (target state for readability):
|
||||
{spec}
|
||||
{hinweise}
|
||||
LENS 1 — FACTS (binary per claim):
|
||||
1. Decompose the text into its ATOMIC factual claims (definitions, properties, numbers, procedure steps, causal statements). Ignore pure didactics: transitions, framing, previews/recaps, uncontroversial placement knowledge, paraphrases of the verified facts. Worked-example passages that merely APPLY a verified fact are didactics; flag an example step ONLY if it contradicts the facts or smuggles in a NEW general claim.
|
||||
2. Check each claim against the VERIFIED FACTS only — no web, no outside knowledge as evidence. Not derivable → claim with "urteil": **"falsch"** when it CONTRADICTS the facts or the section itself, **"unbelegt"** when merely not derivable. List ONLY unsupported claims, VERBATIM.
|
||||
3. Genuinely ambiguous between context and claim → treat as claim.
|
||||
|
||||
LENS 2 — COVERAGE (binary per objective):
|
||||
4. For EACH objective id: does the text teach it well enough that a beginner could achieve it afterwards (mentioning a keyword is NOT teaching)? `ziele` maps EVERY id to true/false.
|
||||
5. Per uncovered objective one `luecken`-entry: WHAT exactly is missing (German, concrete — the fix will patch exactly this).
|
||||
6. `ballast`: passages serving none of the objectives (digressions, redundant repetition) — shortening candidates only, never a whole subblock.
|
||||
|
||||
LENS 3 — READABILITY (note genuine flaws only, no taste):
|
||||
7. Beginner-followable (framing, how/why, concrete example)? Sentences over ~25 words or nested; enumerations as prose instead of lists; walls of text; >4 unexplained new terms; repetition/filler/preambles; examples that add nothing; broken Markdown. → one `lese_probleme`-entry each (German, short).
|
||||
|
||||
Everything in order → `{{"ok": true}}`.
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences. Format:
|
||||
{{"claims": [{{"text": "verbatim claim", "grund": "warum unbelegt (kurz)", "urteil": "falsch|unbelegt"}}],
|
||||
"ziele": {{"z1": true, "z2": false}},
|
||||
"luecken": [{{"ziel": "z2", "fehlt": "…"}}],
|
||||
"ballast": ["passage …"],
|
||||
"lese_probleme": [{{"problem": "…"}}]}}
|
||||
{extra}
|
||||
@@ -1,38 +0,0 @@
|
||||
Revise individual sections of a learning guide on the topic "{topic}" (format: {format_name}).
|
||||
|
||||
{facts}
|
||||
|
||||
SECTION SPECIFICATION:
|
||||
{spec}
|
||||
|
||||
TO REVISE — per section, its subblocks, the noted problem, and the current content:
|
||||
{tasks}
|
||||
|
||||
Per section, fix ONLY the noted problem; whatever is in order stays unchanged in substance.
|
||||
|
||||
MANDATORY STRUCTURE (same as the writer) — otherwise the staged display breaks:
|
||||
- Each section has TWO versions: **compact** (one mnemonic per subblock) and **detailed** (the `ausführlich` layer: a coherent beginner text).
|
||||
- In BOTH versions, each subblock carries a `<!-- sub: LABEL | subblock title -->` marker. LABEL and title EXACTLY from the subblock list above, same order in both blocks. The detailed text starts with a short anchor (framing) BEFORE the first marker.
|
||||
- The markers are invisible interfaces, NOT visible headings — write fluently regardless.
|
||||
|
||||
KEEP IT CONCISE (if the problem is "too long/redundant"):
|
||||
- Every sentence carries new information. Cut repetition, filler and meta sentences, preambles.
|
||||
- Explain trivia briefly; give depth only where the material needs it. When in doubt, leave it out, don't add.
|
||||
- An example only where it genuinely carries the understanding — not dutifully for every subblock.
|
||||
- Shortening never means dropping a subblock. Each one stays with its marker.
|
||||
|
||||
Write all revised sections in GERMAN (the guide is for German-speaking learners), even though these instructions are in English.
|
||||
|
||||
Write ONLY the file {out_path} in EXACTLY this format — one section marker per flagged section (title EXACTLY as above):
|
||||
|
||||
<!-- section: exact section title -->
|
||||
<!-- compact -->
|
||||
<!-- sub: beginner | exact subblock title -->
|
||||
- one mnemonic per subblock (concise, no explanation)
|
||||
<!-- ausführlich -->
|
||||
Anchor: brief framing of the block — before the first subblock.
|
||||
<!-- sub: beginner | exact subblock title -->
|
||||
Beginner-friendly prose for this subblock.
|
||||
|
||||
Write the marker lines exactly like that. No text outside the sections.
|
||||
{extra}
|
||||
@@ -24,7 +24,7 @@ HOW TO WRITE the detailed version — ONE coherent text for a junior who is lear
|
||||
- Resolve EVERY technical term at first mention in half a sentence. Use "Prerequisite" hints as anchors, "Hurdle" hints to clear misconceptions up front.
|
||||
- Order of the subblocks as in the assignment: first `[beginner]`, then `[advanced]`, then `[expert]`, last `[peripheral]`.
|
||||
- The NUMBER of subblocks is the depth signal: many subblocks → the block deserves length; few → keep it short. Never pad.
|
||||
- LENGTH TARGET for the detailed version: about {budget} characters total (guideline, not a hard limit — covering ALL learning objectives always beats brevity). Unguided sections came out 2–4× too long and had to be rewritten.
|
||||
- LENGTH CAP for the detailed version: {budget} characters total — a HARD ceiling, not a target. Cover EVERY learning objective, but write DENSELY: fewer words, not more; prefer cutting a sentence to adding one. If you approach the cap, tighten — do not spill over. Overshooting gets rewritten.
|
||||
- CONCISE: every sentence carries new information. No repetition, no filler, no preamble. When in doubt, leave it out.
|
||||
- Show "how" procedures step by step. An example ONLY where it genuinely carries the understanding.
|
||||
- If VERIFIED WORKED EXAMPLES are provided above, weave each into its subblock as instructed there; do NOT invent additional worked examples for subblocks that already have one.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
Several agents have classified the subblocks of the topic "{topic}" by learning-path position. On some they disagree. Decide the final level for each disputed subblock.
|
||||
|
||||
DISPUTED SUBBLOCKS (with the votes cast):
|
||||
{disputed}
|
||||
|
||||
Level = learning-path position (WHEN you need the point), NOT difficulty:
|
||||
- **beginner**: foundation — what you need first/at all; a prerequisite for the rest. Even if conceptually demanding.
|
||||
- **advanced**: builds on it; common variants, typical application.
|
||||
- **expert**: subtleties, special cases, niches, rare details — even if substantively simple (e.g. an obscure flag, a version detail).
|
||||
|
||||
Rules:
|
||||
- A simple niche detail is `expert`; a fundamental complex concept is `beginner`.
|
||||
- Weigh the votes, decide on the merits by learning-path position.
|
||||
- **`advanced` is not a fallback choice.** Don't pick the middle because the votes are scattered — decide by the criterion "when do you need this?". Hit `beginner`/`expert` clearly where they apply.
|
||||
- Exactly one level for EACH disputed number.
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences.
|
||||
|
||||
Format (no other text):
|
||||
{{"levels": {{"1": "beginner", "4": "expert"}}}}
|
||||
{extra}
|
||||
@@ -1,33 +0,0 @@
|
||||
Assign each subblock of the topic "{topic}" to its **learning-path position**: beginner, advanced, or expert. The level controls when a learner sees the point (beginners see only „beginner", advanced learners more).
|
||||
|
||||
SUBBLOCKS (grouped by block; many state their core points with „Kern:"):
|
||||
{subblocks}
|
||||
|
||||
Use the core points for a well-founded classification — judge on the content, not just the title.
|
||||
|
||||
DECISIVE — the level measures WHEN you need the point in the learning path, NOT how hard it is to understand:
|
||||
- **beginner**: foundation. What you need first/at all to understand the block in the first place. A prerequisite for the rest. Even if the concept is conceptually demanding — if it is the foundation, it belongs here.
|
||||
- **advanced**: builds on the foundation. Common variants, typical application, the next step.
|
||||
- **expert**: subtleties, special cases, edge cases, niches, rare details. What you need only once you've mastered the basics — **even if it is substantively simple** (e.g. an obscure flag, a version detail).
|
||||
|
||||
IMPORTANT — difficulty is NOT the criterion:
|
||||
- A **simple niche detail** (rarely needed, e.g. „seit 6.4.12.0") is `expert`, not `beginner`.
|
||||
- A **fundamental but complex concept** (needed early) is `beginner`, not `expert`.
|
||||
- Ask: "when in the learning path do you need this?" — not "how hard is it?".
|
||||
|
||||
Rules:
|
||||
- Level RELATIVE within each block: what is foundation here, what is build-up, what is fine detail?
|
||||
- Not every block needs a `beginner` point. Some topics presuppose prior knowledge and are entirely `advanced`/`expert` — that is intended. Level honestly by learning-path position, without inventing an artificial entry point.
|
||||
- Only judge — invent nothing, change no subblocks.
|
||||
|
||||
Don't flee to the middle:
|
||||
- **`advanced` is not a fallback choice.** Don't pick the middle because you're unsure.
|
||||
- Decide each level by the question **"when in the learning path do you need this?"** — not by the safest middle option.
|
||||
- **Differentiate within the block:** What is foundation (beginner), what is build-up (advanced), what is fine detail (expert)? Not everything is the middle.
|
||||
- Hit `beginner` and `expert` clearly where they apply — no bonus for the middle.
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences.
|
||||
|
||||
Format (exactly one level for EACH number; no other text):
|
||||
{{"levels": {{"1": "beginner", "2": "advanced", "3": "expert"}}}}
|
||||
{extra}
|
||||
@@ -1,23 +0,0 @@
|
||||
You are the quality reviewer for the question pool of a learning exam on the topic "{topic}". Another agent created question patterns for **several blocks** — exactly one pattern per subblock. Review them and return a **cleaned-up** final version.
|
||||
|
||||
PATTERNS TO REVIEW (grouped by block; rows: (subblock) question):
|
||||
{table}
|
||||
|
||||
REVIEW AND CLEAN UP AGAINST THESE CRITERIA (per block, separately):
|
||||
- **Style:** each question is EXACTLY ONE question, one question mark, one thing. No "and"/"as well as", no enumeration. At most 1-2 sentences. Violations → rephrase.
|
||||
- **Unambiguously answerable:** clearly answerable from the block's knowledge, with no invented assumptions. Unanswerable ones → fix.
|
||||
- **Core hit:** the question targets the subblock's core concept, phrased neutrally (not deliberately easy or hard — the difficulty is set later by the learner's level). If it misses the core → rephrase.
|
||||
- **Exactly one per subblock:** exactly one pattern remains per subblock. Duplicates → keep one.
|
||||
- Carry over the `block` and `subblock` of the entries unchanged (only `question` may change); do not invent new ones.
|
||||
|
||||
Keep good patterns unchanged. Change only what genuinely violates the criteria.
|
||||
|
||||
Keep every question in GERMAN (the questions are for German-speaking learners), even though these instructions are in English.
|
||||
|
||||
Reply with the cleaned-up final version of ALL blocks as ONE JSON — no other text, no code fences — EXACTLY in this format:
|
||||
{{"pattern": [
|
||||
{{"block": "<exact block title>", "subblock": "<exact title>", "question": "<one concrete question>"}}
|
||||
]}}
|
||||
|
||||
Output no other text.
|
||||
{extra}
|
||||
@@ -1,28 +0,0 @@
|
||||
You are building the question pool for a learning exam on the topic "{topic}". Create **question patterns** — **exactly one** concrete example question **per subblock**. Each pattern is later turned, at exam time, into a slightly varied question — matched to the learner's level (beginner to expert). So the difficulty is NOT in the pattern; it only comes in at exam time. Therefore ask about the **core concept** of the subblock, phrased neutrally.
|
||||
|
||||
You are working on **several blocks**. Each block has its own subblocks — many with core points, cited facts, and an example:
|
||||
{blocks}
|
||||
|
||||
TASK (for **each** of the blocks above):
|
||||
- For **each** of its subblocks, create **exactly one** pattern — a concrete, concise question about that subblock's core concept.
|
||||
- Each pattern is ONE concrete question (1-2 sentences, exactly one question mark, one thing) — no multi-parter, no enumeration.
|
||||
- The question must be answerable from the block's knowledge. Do not invent extra assumptions.
|
||||
- Base the question on the supplied **core points and cited facts** (the reliable foundation). Hit the CORE CONCEPT of the subblock, not a detail to be memorized. Assume NOTHING that is not in the facts: no "shown" code snippets/diagrams, no concrete values/lists/numbers from some later guide text.
|
||||
- The question hits the **core** of the subblock — the central point one must have understood. Not too narrow on a detail, not too broad across the whole block.
|
||||
- `block` is exactly one of the block titles above. `subblock` is exactly one of the subblock titles of the respective block.
|
||||
|
||||
HARD STYLE RULES PER QUESTION:
|
||||
- Exactly ONE question, one question mark, one thing. No "and"/"as well as", no "name three …".
|
||||
- At most 1-2 sentences, no preamble, no scenario build-up.
|
||||
- Phrased neutrally — not deliberately easy, not deliberately hard. The difficulty is set by the exam via the learner's level.
|
||||
- Direct address, clear German.
|
||||
|
||||
Write every question in GERMAN (the questions are for German-speaking learners), even though these instructions are in English.
|
||||
{extra}
|
||||
|
||||
Reply with ONLY the JSON (all patterns of all blocks) as your final message — no code fences, do NOT write a file. EXACTLY this format:
|
||||
{{"pattern": [
|
||||
{{"block": "<exact block title>", "subblock": "<exact subblock title>", "question": "<one concrete question>"}}
|
||||
]}}
|
||||
|
||||
Output no other text.
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user