Compare commits
13 Commits
8488737303
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07e14fb82e | ||
|
|
fb3e967fdb | ||
|
|
6a765b7d89 | ||
|
|
9d5940e21f | ||
|
|
54cb6f10e7 | ||
|
|
9b6bfa45c6 | ||
|
|
db58d52567 | ||
|
|
219993ffd5 | ||
|
|
d25824229e | ||
|
|
4105146c59 | ||
|
|
2d9ca00b47 | ||
|
|
c05421a8c1 | ||
|
|
92c69c1561 |
14
.env.example
14
.env.example
@@ -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
|
||||
|
||||
12
CLAUDE.md
Normal file
12
CLAUDE.md
Normal file
@@ -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
|
||||
7
Makefile
7
Makefile
@@ -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
|
||||
|
||||
@@ -129,6 +129,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; \
|
||||
|
||||
@@ -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,7 +42,8 @@ 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"])
|
||||
|
||||
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
||||
@@ -95,7 +99,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 +124,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 +158,55 @@ 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: RSS ramps up slowly after spawn
|
||||
_RAM_COMMIT_WINDOW_S = 10.0 # fresh admissions count as already-spent RAM
|
||||
_RAM_POLL_S = 2.0
|
||||
_opencode_running = 0 # opencode spawns only (stagger path), not claude
|
||||
_opencode_recent_starts: list[float] = [] # monotonic timestamps of 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) -> bool:
|
||||
"""True = start admitted (commit registered), False = scope cancelled while waiting.
|
||||
Check and commit-append happen in the same synchronous block (no await between) —
|
||||
concurrent waiters on the loop 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 _opencode_running < _RAM_GATE_FLOOR:
|
||||
break # fail open / floor
|
||||
avail_kb, total_kb = mem
|
||||
now = time.monotonic()
|
||||
_opencode_recent_starts[:] = [t for t in _opencode_recent_starts
|
||||
if now - t < _RAM_COMMIT_WINDOW_S]
|
||||
if avail_kb - len(_opencode_recent_starts) * _RAM_PER_PROC_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)
|
||||
_opencode_recent_starts.append(time.monotonic())
|
||||
return True
|
||||
|
||||
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
|
||||
|
||||
# Capability → Claude --allowedTools
|
||||
@@ -170,6 +226,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 +325,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 +338,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)
|
||||
@@ -293,7 +365,9 @@ async def run_agent(
|
||||
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
|
||||
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,
|
||||
@@ -316,9 +390,14 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
env=env,
|
||||
)
|
||||
|
||||
global _opencode_running
|
||||
if stagger:
|
||||
await _opencode_slot() # spaced start slot; spawn itself is not serialized
|
||||
if not await _ram_gate(agent_key):
|
||||
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:
|
||||
_opencode_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 +445,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:
|
||||
_opencode_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:
|
||||
@@ -441,6 +522,67 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
||||
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")
|
||||
|
||||
|
||||
|
||||
696
backend/block_calls.py
Normal file
696
backend/block_calls.py
Normal file
@@ -0,0 +1,696 @@
|
||||
"""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 json
|
||||
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:
|
||||
"""GEN_PANEL unabhängige Generatoren liefern je Subs+Facts+Level/Relevanz in EINEM Call;
|
||||
Konsens im Code (Variant-Cluster über beide Ausgaben, ≥2 unabhängige Generatoren =
|
||||
consensus). Einzelnennungen und ungedeckte Seeds werden „unsicher" — der Prüfer
|
||||
entscheidet mit Material (ersetzt Sättigungsrunden + Clarify-Panel). Degraded: liefert
|
||||
nur EIN Generator, wird alles unsicher. → {raw, facts, unsicher, votes} | None."""
|
||||
topic, provider = ctx.topic, ctx.provider
|
||||
work_dir = files["arbeit"]
|
||||
bnorm = _norm_title(title)
|
||||
source, caps = await asyncio.to_thread(
|
||||
_inline_source, topic, sources, [f"{title} {description}"])
|
||||
seeds_txt = ""
|
||||
if seeds:
|
||||
seeds_txt = ("\nAlready identified sub-point CANDIDATES of this block (verify against "
|
||||
"the material; if backed AND not already covered by another entry, include "
|
||||
"them — rephrased as a standalone statement):\n"
|
||||
+ "\n".join(f"- {s}" for s in dict.fromkeys(seeds) if s) + "\n")
|
||||
if melde:
|
||||
melde("Generate")
|
||||
h = _h8(title, description, "gen")
|
||||
paths = [work_dir / f"gen-{h}-g{g}.json" for g in range(1, GEN_PANEL + 1)]
|
||||
prompt = _prompt("Subblock-Generate", topic=topic,
|
||||
block=f"{title} — {description}" if description else title,
|
||||
source=source, seeds=seeds_txt, extra=_extra(instructions))
|
||||
pending = [(g, p) for g, p in enumerate(paths, 1) if _gen_schema(_json_file(p)) is None]
|
||||
if pending:
|
||||
slots = [{
|
||||
"key": f"blocks-{topic}-{ns}sb-gen-{h}-g{g}",
|
||||
"prompt": prompt, "role": "quick", "capabilities": caps,
|
||||
"payload": (lambda result, p=p: _sink_json(result, p, _gen_schema)),
|
||||
} for g, p in pending]
|
||||
await _race(topic, f"{lbl}Generate", slots, len(slots),
|
||||
_timeout("generate", 10), provider, cancelled=ctx.is_cancelled)
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
outs = [o for p in paths if (o := _gen_schema(_json_file(p))) is not None]
|
||||
if not outs:
|
||||
return None
|
||||
if len(outs) < len(paths):
|
||||
_log(topic, f"Generate {title}: nur {len(outs)}/{len(paths)} Generatoren — alles unsicher, Prüfer entscheidet")
|
||||
|
||||
# Mentions in die DB (QA-Beleg-Signal), Karten-Re-Spawn darf nicht kumulieren
|
||||
await db.delete_subblocks(topic, bnorm)
|
||||
alle: list[tuple[dict, int]] = [] # (sub-Eintrag, Generator-Index)
|
||||
for gi, subs in enumerate(outs):
|
||||
seen: set[str] = set()
|
||||
for e in subs:
|
||||
sn = _norm_title(e["title"])
|
||||
if not sn or sn in seen:
|
||||
continue
|
||||
seen.add(sn)
|
||||
alle.append((e, gi))
|
||||
await db.upsert_subblock(topic, bnorm, sn, title, e["title"])
|
||||
if not alle:
|
||||
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
|
||||
|
||||
sims = await _sims_of([e["title"] for e, _ in alle])
|
||||
raw: list[str] = []
|
||||
facts: dict[str, dict] = {}
|
||||
votes: dict[str, dict] = {}
|
||||
unsicher: list[dict] = []
|
||||
for c in _variant_clusters([e["title"] for e, _ in alle], [1] * len(alle), sims):
|
||||
rep = alle[c["rep"]][0]
|
||||
sn = _norm_title(rep["title"])
|
||||
fk = _fk_of(rep)
|
||||
for m in c["members"]:
|
||||
if m != c["rep"]:
|
||||
_facts_union(fk, _fk_of(alle[m][0]))
|
||||
await db.set_subblock_fields(topic, bnorm, _norm_title(alle[m][0]["title"]),
|
||||
status="variant")
|
||||
votes[sn] = {"level": [v for m in c["members"] if (v := alle[m][0]["level"])],
|
||||
"relevance": [v for m in c["members"] if (v := alle[m][0]["relevance"])]}
|
||||
gens = {alle[m][1] for m in c["members"]}
|
||||
# degraded (1 Generator): kein Konsens möglich — alles unsicher, Prüfer entscheidet
|
||||
if len(gens) >= 2 and len(outs) >= 2:
|
||||
raw.append(rep["title"])
|
||||
facts[sn] = fk
|
||||
await db.set_subblock_fields(topic, bnorm, sn, status="consensus")
|
||||
else:
|
||||
unsicher.append({**rep, **fk})
|
||||
|
||||
# Seed-Garantie: ungedeckte Seeds gehen als unsicher zum Prüfer (der ist das Beleg-Gate)
|
||||
for seed in dict.fromkeys(s for s in (seeds or []) if s):
|
||||
st = _sub_tokens(seed)
|
||||
gedeckt = [t for t in raw + [u["title"] for u in unsicher]]
|
||||
if not st or any(st <= _sub_tokens(t) for t in gedeckt):
|
||||
continue
|
||||
if gedeckt and EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available):
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + gedeckt)
|
||||
if sims is not None and max(float(sims[0][j]) for j in range(1, len(gedeckt) + 1)) >= SEED_COVER_COS:
|
||||
continue
|
||||
unsicher.append({"title": seed, "level": "", "relevance": "", "key_points": [],
|
||||
"prerequisites": "", "hurdles": "", "cited_facts": [], "example_idea": ""})
|
||||
|
||||
raw_map = {title: raw}
|
||||
await _dedup_subblocks(topic, raw_map) # deterministischer Near-Dup-Filter
|
||||
facts = {sn: fk for sn, fk in facts.items()
|
||||
if sn in {_norm_title(s) for s in raw_map[title]}}
|
||||
return {"raw": raw_map, "facts": {title: facts}, "unsicher": 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) -> 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.
|
||||
→ {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]
|
||||
# 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"]
|
||||
else: # Lücken-Fund: neuer consensus-Sub
|
||||
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]}}
|
||||
1710
backend/blocks.py
1710
backend/blocks.py
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,12 @@
|
||||
"""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 zwei topic-weite BARRIEREN: `konsolidierung` (cross-block
|
||||
sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order),
|
||||
re-run once per generation run — outline läuft parallel zur Dedup-Barriere."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -18,11 +17,8 @@ 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 block_calls import _artefakte_block, _generate_block, _verify_block
|
||||
from blocks import ARTEFACT_TYPES, _match_sub, _neg_set, _sink_json, _outline_block
|
||||
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
@@ -99,7 +95,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
|
||||
@@ -171,88 +167,91 @@ 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))
|
||||
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)
|
||||
|
||||
|
||||
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"]
|
||||
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")
|
||||
|
||||
await _gather_cards(ctx, flow, cards, one)
|
||||
|
||||
@@ -274,61 +273,56 @@ def _cross_schema(data) -> dict[int, str] | 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 ≥
|
||||
"""BARRIER/drain am RUN-ENDE — 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."""
|
||||
statement. Sitzt seit dem Umbau NACH finalize: als Mittel-Barriere wartete jede fertige
|
||||
Karte auf die langsamste (gemessen: 8:46 min Leerlauf pro Block, kanban-smoke). Der
|
||||
Verlierer wird per repair.falte_sub gefaltet (variant + Fragen/Artefakte umhängen) —
|
||||
die wenigen Cross-Dubletten kosten so ein paar umsonst generierte Artefakte statt
|
||||
Minuten Wandzeit für alle. Fail-open on judge failure/dissent."""
|
||||
from repair import falte_sub
|
||||
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
|
||||
# Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig
|
||||
# generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen
|
||||
# ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln.
|
||||
nachzuegler = [(c["card_id"], "artefakte" if "sidecar" in c["payload"] else "generate")
|
||||
for c in cards if "pattern" not in c["payload"]]
|
||||
if nachzuegler:
|
||||
await db.kanban_advance_many(topic, BOARD, nachzuegler)
|
||||
flow.wake.set()
|
||||
return
|
||||
|
||||
async def _advance_all():
|
||||
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], "question_pattern") for c in cards])
|
||||
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) 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):
|
||||
rows = [r for r in await db.list_subblocks(topic) if r["status"] == "consensus"]
|
||||
if len(rows) < 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])
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
|
||||
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]
|
||||
negs = [_neg_set(r["sub_title"]) for r in rows]
|
||||
pairs = [(i, j) for i in range(len(rows)) for j in range(i + 1, len(rows))
|
||||
if rows[i]["block_norm"] != rows[j]["block_norm"] 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 _kp(r: dict) -> list:
|
||||
try:
|
||||
return (json.loads(r.get("facts") or "{}")).get("key_points") or []
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
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))
|
||||
def _side(tag: str, r: dict) -> str:
|
||||
return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r))
|
||||
|
||||
# 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)
|
||||
@@ -338,7 +332,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
||||
"""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])}"
|
||||
f"{k}.\n{_side('A', rows[i])}\n{_side('B', rows[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)]
|
||||
@@ -374,7 +368,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
||||
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]])}"
|
||||
f"{x}.\n{_side('A', rows[chunk[k - 1][0]])}\n{_side('B', rows[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))
|
||||
@@ -397,39 +391,23 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
||||
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()
|
||||
gone: set[tuple] = 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]}",
|
||||
journal["verdicts"].append({"a": f"{rows[i]['block']} · {rows[i]['sub_title']}",
|
||||
"b": f"{rows[j]['block']} · {rows[j]['sub_title']}",
|
||||
"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
|
||||
win, lose = (rows[i], rows[j]) if verdict == "a" else (rows[j], rows[i])
|
||||
wk = (win["block_norm"], win["sub_norm"])
|
||||
lk = (lose["block_norm"], lose["sub_norm"])
|
||||
if lk in gone or wk 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)
|
||||
await falte_sub(topic, files, win, lose)
|
||||
gone.add(lk)
|
||||
journal["gefaltet"].append({"weg": f"{lose['block']} · {lose['sub_title']}",
|
||||
"bleibt": f"{win['block']} · {win['sub_title']}"})
|
||||
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]
|
||||
@@ -437,93 +415,6 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
||||
await _advance_all()
|
||||
|
||||
|
||||
async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
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
|
||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||
await db.kanban_advance(topic, BOARD, norm, "finalize")
|
||||
|
||||
await _gather_cards(ctx, flow, cards, one)
|
||||
|
||||
|
||||
# ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
|
||||
def _merge_json(path, block_keys: dict) -> None:
|
||||
data = _json_file(path)
|
||||
@@ -594,7 +485,7 @@ async def _proc_finalize(ctx: GenContext, flow: Flow, files: dict, cards):
|
||||
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
|
||||
ensure_ascii=False)
|
||||
await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
|
||||
await db.kanban_advance(topic, BOARD, c["card_id"], DONE)
|
||||
await db.kanban_advance(topic, BOARD, c["card_id"], "konsolidierung")
|
||||
_log(topic, f"Artefakte fertig: {title}")
|
||||
flow.wake.set()
|
||||
|
||||
@@ -639,23 +530,40 @@ 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")
|
||||
|
||||
|
||||
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, "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 als END-Barriere: als Mittel-Barriere idelte jede fertige Karte
|
||||
# auf die langsamste (8:46 min/Block gemessen); jetzt faltet sie nach finalize
|
||||
# per repair.falte_sub — spät gefundene Dubletten kosten Artefakt-Tokens, keine Wandzeit
|
||||
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, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
|
||||
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
|
||||
barrier=True, drain=True, gate=research_done),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
@@ -470,6 +488,16 @@ 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). 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')."""
|
||||
if not (p.get("description") or "").strip():
|
||||
return True
|
||||
return bool(ctoks) and not _hat_anker(p.get("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
|
||||
@@ -703,9 +731,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)]
|
||||
@@ -1591,21 +1669,24 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
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)
|
||||
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:
|
||||
# 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"))
|
||||
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 AND
|
||||
# zur Cross-Dedup-Barriere 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")]
|
||||
("verify", "artefakte", "finalize", "konsolidierung")]
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
|
||||
|
||||
async def _as_producer(coro):
|
||||
@@ -1625,19 +1706,23 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
watcher = (asyncio.create_task(_qa_gate_watch(ctx, flow, inv_names, set_p))
|
||||
if artefacts and QA_GATE_NOTE > 0 else None)
|
||||
try:
|
||||
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
|
||||
try:
|
||||
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
|
||||
finally:
|
||||
stopper.cancel()
|
||||
if watcher:
|
||||
watcher.cancel()
|
||||
if ctx.is_cancelled():
|
||||
return False
|
||||
if flow.state.get("qa_paused"):
|
||||
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in generate
|
||||
await _write_final(topic, files)
|
||||
await _write_run_summary(topic, flow)
|
||||
return True
|
||||
finally:
|
||||
stopper.cancel()
|
||||
if watcher:
|
||||
watcher.cancel()
|
||||
# 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)
|
||||
if ctx.is_cancelled():
|
||||
return False
|
||||
if flow.state.get("qa_paused"):
|
||||
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in subblocks
|
||||
await _write_final(topic, files)
|
||||
await _write_run_summary(topic, flow)
|
||||
return True
|
||||
|
||||
|
||||
_QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters
|
||||
@@ -1667,7 +1752,7 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
|
||||
flow.state["qa_note"] = note
|
||||
if report:
|
||||
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:
|
||||
@@ -1707,7 +1792,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,14 +1837,11 @@ 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", "konsolidierung", "Konsolidierung", "ablock"),
|
||||
("artefacts", "outline", "Gliederung", "outline"),
|
||||
("artefacts", "done_artefact", "Fertig", "ablock"),
|
||||
]
|
||||
@@ -1767,18 +1849,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", "konsolidierung", "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}
|
||||
|
||||
|
||||
@@ -1846,7 +1926,7 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None:
|
||||
note = r.get("note")
|
||||
if note is None:
|
||||
return None
|
||||
wartend = counts.get("artefacts", {}).get("subblocks", 0)
|
||||
wartend = counts.get("artefacts", {}).get("generate", 0)
|
||||
pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None)
|
||||
return {"note": note, "note_artefakte": r.get("note_artefakte"),
|
||||
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
|
||||
@@ -1915,7 +1995,7 @@ 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
|
||||
if stage == "generate": # full artefact re-derive → also the DB mirrors
|
||||
await db.delete_subblocks(topic)
|
||||
await db.delete_question_pattern(topic)
|
||||
await db.delete_sub_artefakte(topic)
|
||||
@@ -1931,7 +2011,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
|
||||
|
||||
async def restart_artefact_card(topic: str, card_id: str) -> bool:
|
||||
"""Restart ONE artefacts card from `subblocks` — wipes only ITS derived DB rows
|
||||
"""Restart ONE artefacts card from `generate` — 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)
|
||||
@@ -1942,7 +2022,7 @@ async def restart_artefact_card(topic: str, card_id: str) -> bool:
|
||||
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.kanban_advance(topic, "artefacts", card_id, "generate")
|
||||
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
|
||||
return True
|
||||
|
||||
|
||||
@@ -114,9 +114,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).
|
||||
@@ -147,11 +155,6 @@ QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL i
|
||||
QA_GATE_NOTE = 9.5 # 0 = gate off; quota-based, so the tolerated finding count scales with topic size
|
||||
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
|
||||
|
||||
# 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
|
||||
# run's tokens were cache reads from those loops).
|
||||
@@ -169,10 +172,6 @@ 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
|
||||
@@ -186,14 +185,25 @@ 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)
|
||||
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
|
||||
# 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
|
||||
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)
|
||||
@@ -220,6 +230,12 @@ TIMEOUTS = {
|
||||
"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)
|
||||
@@ -242,7 +258,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",
|
||||
|
||||
@@ -118,6 +118,13 @@ 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:
|
||||
@@ -133,16 +140,17 @@ class Welt:
|
||||
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-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 +163,29 @@ 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": []})
|
||||
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 "-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:
|
||||
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 +199,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)
|
||||
|
||||
@@ -306,7 +290,7 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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,8 +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,
|
||||
from config import (FORMAT_PURPOSE, READABILITY_ACTIVE,
|
||||
TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC)
|
||||
from guide_qa import block_budget
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||
@@ -31,11 +33,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).
|
||||
@@ -87,33 +88,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
|
||||
if isinstance(p, dict) and str(p.get("problem", "")).strip()]
|
||||
return out or None
|
||||
lese = [str(p.get("problem", "")).strip() for p in data.get("lese_probleme", [])
|
||||
if isinstance(p, dict) and str(p.get("problem", "")).strip()]
|
||||
return {"claims": claims, "ziele": ziele, "luecken": luecken, "ballast": ballast,
|
||||
"lese_probleme": lese}
|
||||
|
||||
|
||||
def _first_section(md: str) -> dict | None:
|
||||
@@ -276,12 +275,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 +311,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))
|
||||
@@ -335,11 +328,6 @@ 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")
|
||||
# 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 +336,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 +354,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,190 +364,178 @@ 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"])
|
||||
if not (0.5 * budget <= zeichen <= 1.2 * budget):
|
||||
out.append(
|
||||
f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(0.5 * budget)}–{round(1.2 * 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 db.list_lernziele(env.topic, norm)
|
||||
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
|
||||
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("fakten_gate", 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:
|
||||
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:
|
||||
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 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']}",
|
||||
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)"
|
||||
fixp = env.slot(f"fix-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||
fixp.unlink(missing_ok=True)
|
||||
|
||||
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"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="judge", capabilities="none",
|
||||
payload=lambda result: _sink_json(result, path, _problems_schema),
|
||||
timeout=_timeout("lese_check", 1))
|
||||
if status == CANCELLED:
|
||||
role="guide", capabilities="files", payload=_fixload,
|
||||
timeout=_timeout("writer", 1))
|
||||
if fstatus == 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
|
||||
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.unlink(missing_ok=True)
|
||||
|
||||
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"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)),
|
||||
role="guide", capabilities="files", payload=_fixload,
|
||||
timeout=_timeout("writer", 1))
|
||||
if fstatus == CANCELLED:
|
||||
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"Fix {card['block']} ohne Sub-Marker — verworfen")
|
||||
else:
|
||||
card["md"] = fixed
|
||||
angewandt = True
|
||||
rest = ""
|
||||
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 fstatus == OK and fixed:
|
||||
new_sec = _first_section(fixed)
|
||||
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")
|
||||
else:
|
||||
card["md"] = fixed
|
||||
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
|
||||
if verdict:
|
||||
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:
|
||||
@@ -618,73 +594,75 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
|
||||
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||||
subs_raw = await _load_subblocks(topic)
|
||||
project = source_folder(topic)
|
||||
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
|
||||
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)
|
||||
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)
|
||||
try:
|
||||
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||||
subs_raw = await _load_subblocks(topic)
|
||||
project = source_folder(topic)
|
||||
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
|
||||
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)
|
||||
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)
|
||||
async def _progress():
|
||||
while True:
|
||||
counts = await db.guide_stage_counts(topic, format_name)
|
||||
done = counts.get("done", 0)
|
||||
total = sum(counts.values())
|
||||
await db.update_guide(guide_id, progress=f"Board: {done}/{total} Karten fertig")
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
reporter = asyncio.create_task(_progress())
|
||||
try:
|
||||
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
|
||||
finally:
|
||||
reporter.cancel()
|
||||
db.set_current_run(topic, None)
|
||||
else:
|
||||
reporter = asyncio.create_task(_progress())
|
||||
try:
|
||||
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
|
||||
finally:
|
||||
reporter.cancel()
|
||||
if is_guide_cancelled(guide_id):
|
||||
return None
|
||||
# assembly — identical shape to the legacy pipeline
|
||||
cards = await db.list_guide_cards(topic, format_name)
|
||||
chapters: list[dict] = []
|
||||
by_chapter: dict[str, list[dict]] = {}
|
||||
order: list[str] = []
|
||||
for c in sorted(cards, key=lambda c: (c["ord"], c["block_norm"])):
|
||||
if c["stage"] != "done":
|
||||
_log(topic, f"Guide: Karte '{c['block']}' nicht fertig ({c['stage']}) — Abschnitt fehlt")
|
||||
continue
|
||||
sec = _first_section(c["md"])
|
||||
if sec is None:
|
||||
continue
|
||||
ch = c["chapter"] or "Inhalte"
|
||||
if ch not in by_chapter:
|
||||
by_chapter[ch] = []
|
||||
order.append(ch)
|
||||
by_chapter[ch].append({
|
||||
"num": c["ord"], "title": c["block"], "md": sec["md"],
|
||||
"compact": sec.get("compact", ""), "anchor": sec.get("anchor", ""),
|
||||
"anker_compact": sec.get("anker_compact", ""), "subs": sec.get("subs", []),
|
||||
"checkable": format_name == "Guide" or bool(
|
||||
any(s.get("relevance") == "relevant" for s in subs_raw.get(c["block"], []))),
|
||||
})
|
||||
for ch in order:
|
||||
chapters.append({"title": ch, "sections": by_chapter[ch]})
|
||||
if chapters:
|
||||
try: # Abschluss-Guide-QA (best-effort): speist das Badge mit einer frischen Note
|
||||
import guide_qa
|
||||
rep = await guide_qa.guide_qa_report(topic, llm=True)
|
||||
if rep:
|
||||
await asyncio.to_thread(guide_qa._write_report, rep)
|
||||
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)
|
||||
if is_guide_cancelled(guide_id):
|
||||
return None
|
||||
# assembly — identical shape to the legacy pipeline
|
||||
cards = await db.list_guide_cards(topic, format_name)
|
||||
chapters: list[dict] = []
|
||||
by_chapter: dict[str, list[dict]] = {}
|
||||
order: list[str] = []
|
||||
for c in sorted(cards, key=lambda c: (c["ord"], c["block_norm"])):
|
||||
if c["stage"] != "done":
|
||||
_log(topic, f"Guide: Karte '{c['block']}' nicht fertig ({c['stage']}) — Abschnitt fehlt")
|
||||
continue
|
||||
sec = _first_section(c["md"])
|
||||
if sec is None:
|
||||
continue
|
||||
ch = c["chapter"] or "Inhalte"
|
||||
if ch not in by_chapter:
|
||||
by_chapter[ch] = []
|
||||
order.append(ch)
|
||||
by_chapter[ch].append({
|
||||
"num": c["ord"], "title": c["block"], "md": sec["md"],
|
||||
"compact": sec.get("compact", ""), "anchor": sec.get("anchor", ""),
|
||||
"anker_compact": sec.get("anker_compact", ""), "subs": sec.get("subs", []),
|
||||
"checkable": format_name == "Guide" or bool(
|
||||
any(s.get("relevance") == "relevant" for s in subs_raw.get(c["block"], []))),
|
||||
})
|
||||
for ch in order:
|
||||
chapters.append({"title": ch, "sections": by_chapter[ch]})
|
||||
if chapters:
|
||||
try: # Abschluss-Guide-QA (best-effort): speist das Badge mit einer frischen Note
|
||||
import guide_qa
|
||||
rep = await guide_qa.guide_qa_report(topic, llm=True)
|
||||
if rep:
|
||||
await asyncio.to_thread(guide_qa._write_report, rep)
|
||||
except Exception:
|
||||
log.exception("[%s] Abschluss-Guide-QA fehlgeschlagen", topic)
|
||||
return chapters or None
|
||||
|
||||
|
||||
async def done_step(topic: str, format_name: str) -> int:
|
||||
@@ -729,6 +707,39 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
|
||||
return {"columns": columns, "qa_guide": note_guide}
|
||||
|
||||
|
||||
async def repair_karten(topic: str, format_name: str) -> list[str]:
|
||||
"""QA-Befund-getriebenes Guide-Repair: Karten, die im jüngsten Guide-QA-Report
|
||||
Befunde tragen, gehen zurück auf `pruefer` (md bleibt) — Prüfer+Fix beheben gezielt,
|
||||
generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum
|
||||
Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel."""
|
||||
import qa as qa_mod
|
||||
tdir = qa_mod.QA_DIR / topic
|
||||
reports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
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)}
|
||||
norms: set[str] = set()
|
||||
for e in rep.get("marker_fehlend", []): # "Block · sub"
|
||||
norms.add(_norm_title(str(e).split(" · ")[0]))
|
||||
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
|
||||
norms.add(str(e).split(" · ")[0])
|
||||
for e in rep.get("laengen_ausreisser", []): # {"block": titel}
|
||||
norms.add(_norm_title(e.get("block", "") if isinstance(e, dict) else str(e)))
|
||||
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
|
||||
norms.add(_norm_title(str(e).split(":")[0]))
|
||||
for t in rep.get("fachlich_falsch", []) or []:
|
||||
norms.add(_norm_title(str(t)))
|
||||
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
|
||||
for seite in ("a", "b"):
|
||||
norms.add(_norm_title(str(e.get(seite, "")).split(":")[0]))
|
||||
betroffen = []
|
||||
for n in sorted(norms & set(cards)):
|
||||
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
|
||||
betroffen.append(cards[n]["block"])
|
||||
return betroffen
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -10,6 +10,7 @@ Report: storage/qa/<topic>/guide-<ts>.json + Konsolen-Digest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
@@ -20,8 +21,6 @@ import readability
|
||||
from fsutil import atomic_write_json
|
||||
from textkit import _norm_title
|
||||
|
||||
from config import GUIDE_LAENGE_MAX as LAENGE_MAX, GUIDE_LAENGE_MIN as LAENGE_MIN
|
||||
|
||||
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
|
||||
LLM_SECTION_CHARS = 2500 # Section-Auszug je Judge-Item
|
||||
@@ -71,13 +70,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 = 200 # Einstieg/Übergang je Sub
|
||||
BUDGET_KEY_POINT = 160 # ~1–2 Sätze Erklärung je key_point
|
||||
BUDGET_FAKT = 60 # zitierter Fakt, in den Text eingewoben
|
||||
BUDGET_BEISPIEL = 250 # ausgearbeitetes 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
|
||||
|
||||
|
||||
@@ -148,14 +174,23 @@ 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
|
||||
try:
|
||||
facts = json.loads(r["facts"]) if r["facts"] else {}
|
||||
except (ValueError, TypeError):
|
||||
facts = {}
|
||||
subs_by_norm.setdefault(r["block_norm"], []).append(
|
||||
{"relevance": r["relevance"], "facts": facts if isinstance(facts, dict) else {}})
|
||||
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
|
||||
|
||||
@@ -7,6 +7,8 @@ FormatType = Literal[
|
||||
"Rest",
|
||||
]
|
||||
|
||||
from config import DEFAULT_PROVIDER
|
||||
|
||||
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||
|
||||
SourceType = Literal["thema", "projekt", "uni", "link"]
|
||||
@@ -16,7 +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"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
ab_step: int | None = Field(default=None, ge=0, le=5) # re-run from board stage (0 lernziele … 5 lesbarkeit); None = full/resume
|
||||
|
||||
|
||||
@@ -42,7 +44,7 @@ class RepairRequest(BaseModel):
|
||||
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)
|
||||
research: bool = True # False = Continue: drain the existing kanban queue, no new search
|
||||
@@ -157,7 +159,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 +174,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 +205,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 +248,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 +260,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):
|
||||
|
||||
@@ -164,10 +164,18 @@ _relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈
|
||||
_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
|
||||
|
||||
# Detached Nachzügler-Tasks (late-Fold): Referenz gegen GC, Aufräumen via done-callback.
|
||||
_NACHZUEGLER: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None) -> list | None:
|
||||
def _detached(task: asyncio.Task) -> None:
|
||||
_NACHZUEGLER.add(task)
|
||||
task.add_done_callback(_NACHZUEGLER.discard)
|
||||
|
||||
|
||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None, late=None) -> list | None:
|
||||
"""Starts all slots in parallel and collects `quorum` valid results.
|
||||
|
||||
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||
@@ -185,24 +193,62 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
elapses while agents are still running — gives them time to search thoroughly.
|
||||
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
|
||||
(or None if nothing), killing the rest. Both default off; only Research sets them.
|
||||
|
||||
`late(value)` (async): Nachzügler werden beim Quorum-Return NICHT gekillt, sondern
|
||||
laufen detached weiter; jedes noch eintreffende valide Ergebnis geht an `late`.
|
||||
Ersetzt den grace-Timer der Finder-Runden — der hielt die Runde bis 300 s offen,
|
||||
nur damit die dritte Stimme zählt (gemessen: 73 s Warten pro Runde).
|
||||
"""
|
||||
attempts = {i: 0 for i in range(len(slots))}
|
||||
tasks: dict[asyncio.Task, int] = {}
|
||||
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()
|
||||
|
||||
spaet: set[int] = set() # je Slot zählt nur EIN spätes Ergebnis (Hedge-Zwilling = Echo)
|
||||
|
||||
def _detach_rest() -> None:
|
||||
"""Quorum steht: Nachzügler an `late` übergeben statt killen (nur Erfolgs-Return)."""
|
||||
if late is None:
|
||||
return
|
||||
for t, i in list(tasks.items()):
|
||||
tasks.pop(t)
|
||||
keys.pop(t, None)
|
||||
born.pop(t, None)
|
||||
|
||||
async def _warte(t=t, i=i):
|
||||
try:
|
||||
r = await t
|
||||
if i in spaet:
|
||||
return
|
||||
if r and r[0] == 0 and (val := slots[i]["payload"](r)) is not None:
|
||||
spaet.add(i)
|
||||
await late(val)
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001 — Nachzügler sind best-effort
|
||||
pass
|
||||
_detached(asyncio.create_task(_warte()))
|
||||
|
||||
for i in range(len(slots)):
|
||||
spawn(i)
|
||||
@@ -218,8 +264,20 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
return results or None
|
||||
min_ok = min_deadline is None or loop.time() >= min_deadline
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
|
||||
_detach_rest()
|
||||
return results
|
||||
# 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, min, max, or next hedge).
|
||||
waits = []
|
||||
if deadline is not None and len(results) >= quorum:
|
||||
waits.append(deadline - loop.time())
|
||||
@@ -227,12 +285,21 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
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,6 +316,10 @@ 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")
|
||||
@@ -256,23 +327,26 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
on_update(len(results))
|
||||
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
|
||||
and (min_deadline is None or loop.time() >= min_deadline)):
|
||||
_detach_rest()
|
||||
return results
|
||||
continue
|
||||
|
||||
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {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 attempts[i] <= _MAX_RESTARTS and not enough and not zwilling and not (cancelled and cancelled()):
|
||||
spawn(i)
|
||||
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
|
||||
_detach_rest()
|
||||
return results
|
||||
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
|
||||
return None
|
||||
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)
|
||||
|
||||
@@ -294,6 +294,24 @@ async def _llm_verdicts(template: str, topic: str, key: str, items: list[str]) -
|
||||
|
||||
# ── Report ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 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:
|
||||
@@ -317,6 +335,11 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
hy = hygiene(blocks)
|
||||
n_sections = sum(len(_sections(t)) for t in corpus.values()) or 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]
|
||||
|
||||
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]])
|
||||
@@ -334,6 +357,10 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in chunk])
|
||||
for k, p in enumerate(chunk, 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 = []
|
||||
@@ -350,6 +377,8 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
v2 = await _llm_verdicts("QA-Bausteine", topic, "bausteine-b2",
|
||||
[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,8 +391,10 @@ 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(),
|
||||
@@ -377,6 +408,7 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
},
|
||||
"quoten_artefakte": quoten_art,
|
||||
**({"unecht": unecht} if unecht is not None else {}),
|
||||
**({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}),
|
||||
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "fremd": fr, "beleg": bl, "hygiene": hy,
|
||||
"artefakte": art,
|
||||
"lauf": summary,
|
||||
@@ -412,6 +444,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 +490,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()
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur n
|
||||
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
|
||||
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -39,17 +38,18 @@ async def repair_befunde(topic: str) -> dict:
|
||||
|
||||
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)
|
||||
sub_merges, frei_subs = await _merge_sub_dubletten(topic, report, files)
|
||||
entfernt, frei_bloecke = await _entferne_fremd_unecht(topic, report, by_norm, files)
|
||||
aufgeraeumt = await _raeume_waisen(topic)
|
||||
|
||||
# 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)
|
||||
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
|
||||
"aufgeraeumt": aufgeraeumt, "braucht_research": len(report.get("luecken", []))}
|
||||
"aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke,
|
||||
"braucht_research": len(report.get("luecken", []))}
|
||||
|
||||
|
||||
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
|
||||
@@ -70,6 +70,44 @@ async def _judge(template: str, topic: str, key: str, slot: str, items: list[str
|
||||
return verdicts
|
||||
|
||||
|
||||
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) -> 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)
|
||||
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])
|
||||
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_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."""
|
||||
@@ -99,8 +137,8 @@ async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict)
|
||||
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])
|
||||
v, _frei = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in paare], "ja")
|
||||
merged = []
|
||||
for i, p in enumerate(paare, 1):
|
||||
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
|
||||
@@ -135,7 +173,32 @@ def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]:
|
||||
return (a, b) if score(a) >= score(b) else (b, a)
|
||||
|
||||
|
||||
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[str]:
|
||||
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")
|
||||
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"]:
|
||||
continue
|
||||
if win["sub_norm"] not in w_fragen:
|
||||
await db.upsert_question_pattern(topic, win["block_norm"], win["sub_norm"],
|
||||
win["block"], win["sub_title"], r["question"])
|
||||
await db.delete_frage_row(topic, lose["block_norm"], lose["sub_norm"])
|
||||
w_typen = {r["type"] for r in await db.get_sub_artefakte(topic, block_norm=win["block_norm"])
|
||||
if r["sub_norm"] == win["sub_norm"]}
|
||||
for r in await db.get_sub_artefakte(topic, block_norm=lose["block_norm"]):
|
||||
if r["sub_norm"] != lose["sub_norm"]:
|
||||
continue
|
||||
if r["type"] not in w_typen:
|
||||
await db.put_sub_artifact(topic, win["block_norm"], win["sub_norm"], r["type"],
|
||||
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."""
|
||||
@@ -150,10 +213,13 @@ async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[st
|
||||
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])
|
||||
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):
|
||||
@@ -161,29 +227,10 @@ async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[st
|
||||
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 db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant")
|
||||
await falte_sub(topic, files, win, lose)
|
||||
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"]:
|
||||
continue
|
||||
if win["sub_norm"] not in w_fragen:
|
||||
await db.upsert_question_pattern(topic, win["block_norm"], win["sub_norm"],
|
||||
win["block"], win["sub_title"], r["question"])
|
||||
await db.delete_frage_row(topic, lose["block_norm"], lose["sub_norm"])
|
||||
w_typen = {r["type"] for r in await db.get_sub_artefakte(topic, block_norm=win["block_norm"])
|
||||
if r["sub_norm"] == win["sub_norm"]}
|
||||
for r in await db.get_sub_artefakte(topic, block_norm=lose["block_norm"]):
|
||||
if r["sub_norm"] != lose["sub_norm"]:
|
||||
continue
|
||||
if r["type"] not in w_typen:
|
||||
await db.put_sub_artifact(topic, win["block_norm"], win["sub_norm"], r["type"],
|
||||
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"])
|
||||
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 +261,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 +272,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 +284,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
|
||||
|
||||
@@ -9,9 +9,9 @@ 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 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,
|
||||
@@ -175,7 +175,7 @@ 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
|
||||
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
|
||||
import guide_qa
|
||||
@@ -217,7 +217,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}
|
||||
@@ -681,6 +681,27 @@ async def get_guide_board(topic: str, format: str = "Guide"):
|
||||
return snap
|
||||
|
||||
|
||||
@router.post("/guides/board/repair")
|
||||
async def repair_guide_board(req: GuideBoardResetRequest):
|
||||
"""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 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.post("/guides/board/reset")
|
||||
async def reset_guide_board(req: GuideBoardResetRequest):
|
||||
"""Reset cards from a stage onward — without generation (pendant to blocks reset-stage)."""
|
||||
|
||||
255
backend/tests/test_agents_api.py
Normal file
255
backend/tests/test_agents_api.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""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, "_opencode_recent_starts", [])
|
||||
return monkeypatch
|
||||
|
||||
|
||||
async def test_ram_gate_admits_with_free_ram(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (4_000_000, 8_000_000)) # 50 % frei
|
||||
assert await agents._ram_gate("k") is True
|
||||
assert len(agents._opencode_recent_starts) == 1 # Commit registriert
|
||||
|
||||
|
||||
async def test_ram_gate_waits_when_low(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_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"), 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") 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, "_opencode_running", 1) # unter Floor
|
||||
assert await agents._ram_gate("k") is True
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: None) # kein /proc/meminfo
|
||||
assert await agents._ram_gate("k") 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") 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."""
|
||||
import time as _time
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (1_700_000, 8_000_000)) # 21 % frei
|
||||
agents._opencode_recent_starts.extend([_time.monotonic(), _time.monotonic()])
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("k"), 0.1)
|
||||
|
||||
|
||||
async def test_ram_gate_cancelled_scope(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_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") 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
|
||||
538
backend/tests/test_block_calls.py
Normal file
538
backend/tests/test_block_calls.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""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_gen_race(outputs):
|
||||
"""_race-Fake: pro Generator-Slot (…-gN) die gescriptete Antwort als Text;
|
||||
fehlender Eintrag = Ausfall."""
|
||||
calls = []
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
||||
cancelled=None, **kw):
|
||||
outs = []
|
||||
for slot in slots:
|
||||
calls.append(slot["key"])
|
||||
g = int(slot["key"].rsplit("-g", 1)[1])
|
||||
out = outputs.get(g)
|
||||
if out is not None:
|
||||
outs.append(slot["payload"]((0, json.dumps(out), "")))
|
||||
return [o for o in outs if o] or None
|
||||
|
||||
fake_race.calls = calls
|
||||
return fake_race
|
||||
|
||||
|
||||
async def test_generate_schnittmenge_wird_consensus(env, monkeypatch):
|
||||
"""Von beiden Generatoren genannt → consensus (Facts-Union); Einzelnennungen
|
||||
werden unsicher und gehen zum Prüfer."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({
|
||||
1: {"subs": [_sub("Sub A", kp=["k1"]), _sub("Sub B")]},
|
||||
2: {"subs": [_sub("Sub A", kp=["k2"]), _sub("Sub C")]},
|
||||
}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
|
||||
assert gen["raw"] == {"Alpha": ["Sub A"]}
|
||||
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1", "k2"] # Union beider Nennungen
|
||||
assert {u["title"] for u in gen["unsicher"]} == {"Sub B", "Sub C"}
|
||||
assert gen["votes"]["sub a"]["level"] == ["beginner", "beginner"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "consensus"
|
||||
assert rows["sub b"] == rows["sub c"] == "candidate"
|
||||
|
||||
|
||||
async def test_generate_degraded_alles_unsicher(env, monkeypatch):
|
||||
"""Liefert nur EIN Generator, ist kein Konsens möglich — alles wird unsicher,
|
||||
der Prüfer entscheidet mit Material."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({
|
||||
1: {"subs": [_sub("Sub A"), _sub("Sub B")]}, # g2 fällt aus
|
||||
}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
|
||||
assert gen["raw"] == {"Alpha": []}
|
||||
assert {u["title"] for u in gen["unsicher"]} == {"Sub A", "Sub B"}
|
||||
assert not any(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
|
||||
|
||||
async def test_generate_beide_ausgefallen_ist_none(env, monkeypatch):
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({}))
|
||||
assert await bc._generate_block(ctx, files, "Alpha", "d") is None
|
||||
|
||||
|
||||
async def test_generate_seed_garantie(env, monkeypatch):
|
||||
"""Ungedeckte Seeds gehen als unsicher zum Prüfer (Beleg-Gate liegt dort);
|
||||
lexikalisch gedeckte Seeds erzeugen keine Dublette."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({
|
||||
1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]},
|
||||
}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "d",
|
||||
seeds=["Escaping Regeln", "Sub"])
|
||||
assert gen["raw"] == {"Alpha": ["Sub A"]}
|
||||
assert [u["title"] for u in gen["unsicher"]] == ["Escaping Regeln"] # „Sub" ist gedeckt
|
||||
assert gen["unsicher"][0]["key_points"] == [] # Seeds kommen ohne Beleg
|
||||
|
||||
|
||||
async def test_generate_resume_ohne_neue_calls(env, monkeypatch):
|
||||
"""Vorhandene gen-Dateien → kein neuer _race-Call, Ergebnis wird übernommen."""
|
||||
db, ctx, files = env
|
||||
fake = _mk_gen_race({1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]}})
|
||||
monkeypatch.setattr(bc, "_race", 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 # alles 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_uebernahme_braucht_beide(env, monkeypatch):
|
||||
"""Unsicher-Eintrag wird nur bei 2/2 „ja" consensus (samt Generator-Facts);
|
||||
sonst discarded."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
unsicher = [_sub("Unsicher B", kp=["kp b"]), _sub("Unsicher C")]
|
||||
await _seed_rows(db, "alpha", subs)
|
||||
await _seed_rows(db, "alpha", ["Unsicher B", "Unsicher C"], status="candidate")
|
||||
fake = _judge_slot({"1": {"uebernehmen": {"2": "ja", "3": "ja"}},
|
||||
"2": {"uebernehmen": {"2": "ja", "3": "nein"}}})
|
||||
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", "Unsicher B"]}
|
||||
assert res["facts"]["Alpha"]["unsicher b"]["key_points"] == ["kp b"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["unsicher b"] == "consensus" and rows["unsicher c"] == "discarded"
|
||||
# der Prüfer-Prompt weist die Unsicher-Nummern aus
|
||||
assert "UNSICHER" in fake.calls[0]["prompt"] and "entries 2–3" in fake.calls[0]["prompt"]
|
||||
|
||||
|
||||
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,39 +70,36 @@ 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):
|
||||
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
|
||||
@@ -176,6 +173,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 +235,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),
|
||||
@@ -680,18 +710,18 @@ 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):
|
||||
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)
|
||||
|
||||
base_outline = ba._outline_block
|
||||
|
||||
@@ -700,7 +730,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 +748,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 +762,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 +806,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 ─────────────────────
|
||||
@@ -874,7 +904,7 @@ async def _run_flow(ctx, files, timeout=30, **kw):
|
||||
|
||||
|
||||
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
||||
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in subblocks."""
|
||||
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate."""
|
||||
import qa as qa_mod
|
||||
db, ctx, files = board_env
|
||||
|
||||
@@ -886,7 +916,7 @@ async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
||||
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
|
||||
|
||||
@@ -949,7 +979,7 @@ 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"]
|
||||
from types import SimpleNamespace
|
||||
@@ -1083,11 +1113,11 @@ 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
|
||||
async def test_reset_generate_loescht_globale_dateien(testdb, tmp_path):
|
||||
"""Reset auf Spalte generate: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
|
||||
weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen."""
|
||||
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")
|
||||
arbeit = tmp_path / "arbeit"
|
||||
(arbeit / "ab-alpha").mkdir(parents=True)
|
||||
@@ -1096,7 +1126,7 @@ async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
||||
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 not (arbeit / "ab-alpha").exists()
|
||||
@@ -1181,3 +1211,144 @@ 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"
|
||||
|
||||
|
||||
# ── 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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -84,8 +84,9 @@ 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"-sb-gen-.*-g1$", "modus": "fehler", "mal": 1}, # degraded: 1 Generator
|
||||
{"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):
|
||||
|
||||
@@ -156,7 +156,7 @@ async def test_restart_artefact_card_wipes_only_that_block(testdb):
|
||||
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.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
|
||||
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
|
||||
@@ -175,10 +175,10 @@ async def test_guide_reset_card_single(testdb):
|
||||
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
|
||||
# ab_stage 3 (pruefer) 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"]
|
||||
assert cards["beta"]["stage"] == "pruefer" and cards["beta"]["md"]
|
||||
|
||||
|
||||
async def test_completeness_route(testdb, tmp_path, monkeypatch):
|
||||
|
||||
@@ -30,19 +30,32 @@ 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"},
|
||||
"luecken": [{"ziel": "z2", "fehlt": "Beweis"}],
|
||||
"ballast": ["Abschweifung"]}, {"z1", "z2"})
|
||||
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"],
|
||||
"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):
|
||||
@@ -50,7 +63,7 @@ async def test_reset_from_stage(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.set_guide_card(TOPIC, FMT, "b", stage="pruefer", 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)
|
||||
@@ -70,8 +83,8 @@ async def test_done_step(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 +175,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 +190,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 +243,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 +282,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 +298,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 +318,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 +352,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 +399,48 @@ 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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
|
||||
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""
|
||||
"""Cross-Block-Konsolidierung (board_artefacts._proc_konsolidierung) und Finalize —
|
||||
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 json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
@@ -39,218 +39,6 @@ async def _seed_block(db, bnorm, 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
|
||||
@@ -266,91 +54,6 @@ def test_neg_set_lemmatisiert():
|
||||
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
|
||||
@@ -388,85 +91,88 @@ class _FakeEmb:
|
||||
return arr @ arr.T
|
||||
|
||||
|
||||
async def _cross_env(db, tmp_path):
|
||||
async def _cross_env(db, tmp_path, finalisiert=True):
|
||||
"""Zwei finalisierte Karten in der End-Barriere; die Sub-Rows liegen in der DB
|
||||
(post-finalize ist die DB die Wahrheit, nicht mehr das Karten-Payload)."""
|
||||
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}}}
|
||||
payload = {"title": bnorm.title()}
|
||||
if finalisiert:
|
||||
payload.update(pattern={}, artefacts={})
|
||||
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
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
return flow, cards, files
|
||||
|
||||
|
||||
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmig „a" → Beta verliert die geteilte Aussage, Karten wandern zu levels."""
|
||||
"""Einstimmig „a" → Betas geteilte Aussage wird variant, ihre Frage wandert zum
|
||||
Gewinner (falte_sub), Karten gehen auf DONE."""
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
sn = blocks._norm_title("Gleiche Aussage")
|
||||
await db.upsert_question_pattern(TOPIC, "beta", sn, "Beta", "Gleiche Aussage", "F?")
|
||||
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)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", 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"
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||
beta_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert beta_rows[sn] == "variant"
|
||||
alpha_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert alpha_rows[sn] == "consensus"
|
||||
fragen = await db.list_question_pattern(TOPIC)
|
||||
assert {(r["block_norm"], r["sub_norm"]) for r in fragen} == {("alpha", sn)} # umgehängt
|
||||
|
||||
|
||||
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)
|
||||
flow, cards, files = 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)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert len(fake.calls) == 3
|
||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
||||
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_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)
|
||||
flow, cards, files = 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"]
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == ba.DONE
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||
|
||||
|
||||
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)
|
||||
flow, cards, files = 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"]
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
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_without_embedding_advances(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
flow, cards = await _cross_env(db, tmp_path)
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
|
||||
class _Aus:
|
||||
@staticmethod
|
||||
@@ -478,33 +184,29 @@ async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypat
|
||||
|
||||
monkeypatch.setattr(ba, "embedding", _Aus)
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||
|
||||
|
||||
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."""
|
||||
async def test_crossblock_nachzuegler_zurueck_zum_erzeugen(testdb, tmp_path, monkeypatch):
|
||||
"""Resume-Karte ohne pattern im Payload → zurück nach generate (bzw. artefakte bei
|
||||
vorhandenem sidecar), KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
|
||||
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"])
|
||||
flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False)
|
||||
cards[1]["payload"]["sidecar"] = {"Beta": []} # hat Verify schon hinter sich
|
||||
await db.kanban_set_payload(TOPIC, "artefacts", "beta", cards[1]["payload"])
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
|
||||
|
||||
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
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "artefakte"
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||
|
||||
|
||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||||
@@ -547,62 +249,17 @@ async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
|
||||
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}}}
|
||||
payload = {"title": bnorm.title(), "pattern": {}, "artefacts": {}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, bnorm, subs)
|
||||
cards.append({"card_id": bnorm, "payload": payload})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
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)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", 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"]}
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
|
||||
|
||||
import json
|
||||
|
||||
import qa
|
||||
|
||||
|
||||
@@ -238,6 +240,25 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
|
||||
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
|
||||
|
||||
156
backend/tests/test_race.py
Normal file
156
backend/tests/test_race.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""_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_late_fold_nachzuegler_zaehlt_nach(monkeypatch):
|
||||
"""Quorum 2 kehrt sofort zurück; der dritte Slot wird nicht gekillt, sein Ergebnis
|
||||
geht an `late` (ersetzt den grace-Timer der Finder-Runden)."""
|
||||
import time
|
||||
killed, spaet = [], []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
if key == "k3":
|
||||
await asyncio.sleep(0.2)
|
||||
return (0, "dritter", "")
|
||||
return (0, key, "")
|
||||
|
||||
async def late(val):
|
||||
spaet.append(val)
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
|
||||
slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none",
|
||||
"payload": lambda r: r[1]} for i in (1, 2, 3)]
|
||||
t0 = time.monotonic()
|
||||
res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late)
|
||||
assert time.monotonic() - t0 < 0.15 # kein Warten auf k3
|
||||
assert sorted(res) == ["k1", "k2"]
|
||||
assert "k3" not in killed
|
||||
await asyncio.sleep(0.3)
|
||||
assert spaet == ["dritter"]
|
||||
|
||||
|
||||
async def test_late_fold_invalider_nachzuegler_ignoriert(monkeypatch):
|
||||
"""Nachzügler mit invalidem Payload löst late NICHT aus (best-effort)."""
|
||||
spaet = []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
if key == "k3":
|
||||
await asyncio.sleep(0.1)
|
||||
return (1, "", "kaputt")
|
||||
return (0, key, "")
|
||||
|
||||
async def late(val):
|
||||
spaet.append(val)
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
|
||||
slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none",
|
||||
"payload": lambda r: r[1]} for i in (1, 2, 3)]
|
||||
res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late)
|
||||
assert res is not None
|
||||
await asyncio.sleep(0.25)
|
||||
assert spaet == []
|
||||
|
||||
|
||||
async def test_hedge_zwilling_ersetzt_restart(monkeypatch):
|
||||
"""Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN
|
||||
zusätzlichen Restart — der Zwilling ist der Retry."""
|
||||
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
|
||||
@@ -86,6 +86,8 @@ 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)
|
||||
@@ -204,6 +206,74 @@ async def test_sub_dubletten_zweitmeinung_nein(env, monkeypatch):
|
||||
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
|
||||
|
||||
monkeypatch.setattr(repair, "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
|
||||
|
||||
monkeypatch.setattr(repair, "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
|
||||
|
||||
monkeypatch.setattr(repair, "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."""
|
||||
|
||||
@@ -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"
|
||||
@@ -361,62 +151,18 @@ def test_sink_json_writes_only_valid(tmp_path):
|
||||
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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,7 +41,7 @@ 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, "",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -18,18 +18,10 @@ 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"},
|
||||
@@ -37,12 +29,12 @@ PARAMS: dict[str, dict] = {
|
||||
"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"},
|
||||
# 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"},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
environment:
|
||||
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
||||
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
|
||||
- DEFAULT_PROVIDER=${DEFAULT_PROVIDER:-}
|
||||
networks:
|
||||
- web
|
||||
volumes:
|
||||
|
||||
@@ -135,6 +135,16 @@ export async function resetGuideBoard(topic, format, abStage) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
|
||||
export async function repairGuideBoard(topic, format) {
|
||||
const res = await fetch(`${BASE}/guides/board/repair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, ab_stage: 0 }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function cancelBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
@@ -89,3 +89,46 @@
|
||||
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);
|
||||
padding-left: 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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -98,7 +98,25 @@ 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 body = compact ? (sub.compact || sub.md) : (sub.md || sub.compact)
|
||||
if (body && body.trim()) out.push(seg(sub.level, body))
|
||||
}
|
||||
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 +194,28 @@ 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-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>
|
||||
<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>
|
||||
</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>
|
||||
<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">
|
||||
<span v-if="seg.level" class="sub-stufe-badge"
|
||||
:title="`Stufe ${VIEW_KURZ[SUB_RANK[seg.level] || 1]}`">{{ VIEW_KURZ[SUB_RANK[seg.level] || 1] }}</span>
|
||||
<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[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(b.i)">✓</button>
|
||||
<button class="bv-btn" title="Discard" @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[b.i].editOpen" class="bv-edit">
|
||||
<input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(b.i)" />
|
||||
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(b.i)">↻</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -106,10 +106,10 @@ async function repairClick() {
|
||||
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)
|
||||
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
|
||||
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`
|
||||
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt · ${(r.freigesprochen || []).length} freigesprochen`
|
||||
} catch (e) {
|
||||
repairInfo.value = String(e.message || e)
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchGuideBoard } from '../api.js'
|
||||
import { fetchGuideBoard, repairGuideBoard } from '../api.js'
|
||||
import KanbanBoard from './KanbanBoard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -32,7 +32,7 @@ const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
|
||||
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
|
||||
|
||||
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
|
||||
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
|
||||
const STAGES = ['lernziele', 'zuweisung', 'writer', 'pruefer', 'fix']
|
||||
const sel = ref(null)
|
||||
const selCard = ref(null)
|
||||
const confirm = ref(null)
|
||||
@@ -63,6 +63,23 @@ function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
const repairBusy = ref(false)
|
||||
const repairInfo = ref('')
|
||||
async function repairClick() {
|
||||
repairBusy.value = true
|
||||
repairInfo.value = ''
|
||||
try {
|
||||
const r = await repairGuideBoard(props.topic, props.format)
|
||||
repairInfo.value = (r.betroffen || []).length
|
||||
? `${r.betroffen.length} Abschnitt(e) → Prüfen`
|
||||
: 'keine Befunde im letzten QA-Report'
|
||||
if ((r.betroffen || []).length) startPoll()
|
||||
} catch (e) {
|
||||
repairInfo.value = String(e.message || e)
|
||||
} finally {
|
||||
repairBusy.value = false
|
||||
}
|
||||
}
|
||||
function restartHere() {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
@@ -93,6 +110,9 @@ function resetHere() {
|
||||
<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 && board?.qa_guide != null && board.qa_guide < 10" class="gb-act"
|
||||
:disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span>
|
||||
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,9 @@ function htmlFor(s) {
|
||||
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>`)
|
||||
} else if (Number(props.stufeAnsicht) === 4) {
|
||||
// Vollansicht: jede Stufe dezent kennzeichnen (Rand + Badge in Stufen-Farbe)
|
||||
parts.push(`<div class="sub-stufe" style="--stufe-farbe:${VIEW_FARBE[rank]}"><span class="sub-stufe-badge" title="Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
|
||||
} else {
|
||||
parts.push(body)
|
||||
}
|
||||
@@ -765,24 +768,5 @@ function extractContext() {
|
||||
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>
|
||||
|
||||
21
templates/Prompt/Artefakt-Check.md
Normal file
21
templates/Prompt/Artefakt-Check.md
Normal file
@@ -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}
|
||||
25
templates/Prompt/Artefakt-Generate.md
Normal file
25
templates/Prompt/Artefakt-Generate.md
Normal file
@@ -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}
|
||||
17
templates/Prompt/Blocks-Sanierung.md
Normal file
17
templates/Prompt/Blocks-Sanierung.md
Normal file
@@ -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)").
|
||||
- "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}
|
||||
27
templates/Prompt/Guide-Fix.md
Normal file
27
templates/Prompt/Guide-Fix.md
Normal file
@@ -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}
|
||||
36
templates/Prompt/Guide-Pruefer.md
Normal file
36
templates/Prompt/Guide-Pruefer.md
Normal file
@@ -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}
|
||||
@@ -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.
|
||||
@@ -1,18 +0,0 @@
|
||||
Several agents have rated the subblocks of the topic "{topic}" by relevance (relevant/peripheral). For some they disagree. Decide the final relevance for each disputed subblock.
|
||||
|
||||
DISPUTED SUBBLOCKS (with the votes cast):
|
||||
{disputed}
|
||||
|
||||
Relevance:
|
||||
- **relevant**: core/standard knowledge that carries the topic — central concepts, definitions, properties. For theoretical topics, also central theorems with no practical use.
|
||||
- **peripheral**: a side note, niche/special case, "nice to know", deprecated/exotic, purely historical details, concrete numeric examples, mere cross-references. NOT peripheral: a central theorem or a fundamental property. For the complete guide only.
|
||||
|
||||
Rules:
|
||||
- Weigh the votes and decide by the criterion **core vs. peripheral within ITS OWN block**. `peripheral` is a genuine category — mark peripheral items deliberately as such. Only true core concepts/central theorems are never `peripheral`.
|
||||
- Exactly one value for EACH disputed number.
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences.
|
||||
|
||||
Format (no other text):
|
||||
{{"relevance": {{"1": "relevant", "4": "peripheral"}}}}
|
||||
{extra}
|
||||
@@ -1,24 +0,0 @@
|
||||
Rate each subblock of the topic "{topic}" by relevance: relevant or peripheral. The relevance controls whether a point goes into the focused guides or only into the complete guide.
|
||||
|
||||
SUBBLOCKS (in brackets the parent block of the number; many state their core points with "Kern:"):
|
||||
{subblocks}
|
||||
|
||||
Use the core points for a well-grounded rating — judge on the content, not just the title.
|
||||
|
||||
Relevance:
|
||||
- **relevant**: core/standard knowledge that CARRIES the topic — central concepts, definitions, properties, common practice. For practical topics: what you need in actual use. For theoretical topics: central theorems/properties, EVEN without practical use. What you genuinely should know.
|
||||
- **peripheral**: a side note, special/niche case, "nice to know", deprecated/exotic — PLUS purely historical details (who/when), concrete numeric examples of a general principle, mere cross-references. NOT peripheral: a central theorem or a fundamental property just because it seems "theoretical". Belongs only in a complete guide.
|
||||
|
||||
Rules:
|
||||
- These subblocks **all already** belong to the topic — the question is not *whether* but *how central*. Judge **core vs. peripheral within its own block**, one at a time.
|
||||
- **The basic syntax / core rule of a block — what the block TITLE promises — is NEVER peripheral.** A block "Headings" without its heading syntax marked relevant is broken. Check this per block before anything else.
|
||||
- **When the BLOCK itself is a fringe/extension feature** (non-standard, niche, an add-on rather than core of the topic), only its basic syntax and purpose are relevant — its detail rules and special cases are `peripheral`. A fringe feature must not be decomposed deeper than the core topics (measured: a non-standard block carried 22 "relevant" detail rules while core blocks had 6).
|
||||
- `peripheral` is a **genuine category** for clear cases: nice-to-know, special/niche cases, detail/numeric examples, historical notes, cross-references, style tips.
|
||||
- `peripheral` items are CUT from the focused guides — a wrong `peripheral` loses content, a wrong `relevant` only adds a paragraph. **When in doubt → relevant.**
|
||||
- Only judge — invent nothing, change no subblocks.
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences.
|
||||
|
||||
Format (exactly one value for EACH number; no other text):
|
||||
{{"relevance": {{"1": "relevant", "2": "peripheral", "3": "relevant"}}}}
|
||||
{extra}
|
||||
22
templates/Prompt/Subblock-Fix.md
Normal file
22
templates/Prompt/Subblock-Fix.md
Normal file
@@ -0,0 +1,22 @@
|
||||
You are correcting and completing the subblocks of ONE block of the topic "{topic}" — strictly from the source excerpts.
|
||||
|
||||
BLOCK: {block}
|
||||
|
||||
{source}
|
||||
|
||||
TASKS:
|
||||
{auftraege}
|
||||
|
||||
Rules:
|
||||
- Work ONLY from the excerpts. Invent no values, compute nothing yourself.
|
||||
- For corrections: return the subblock with its EXACT title and the complete corrected fact set.
|
||||
- For gaps: ONE new subblock per gap, atomic, GERMAN title (max ~10 words), fully backed — if the excerpts don't support it, leave it out entirely.
|
||||
- Field rules as always: key_points 1–3; cited_facts only backable with location; example_idea free; level beginner|advanced|expert; relevance relevant|peripheral. All content in GERMAN.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text. EXACTLY this format (empty list if nothing is backable):
|
||||
{{"subs": [
|
||||
{{"title": "…", "level": "…", "relevance": "…",
|
||||
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
|
||||
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
|
||||
]}}
|
||||
{extra}
|
||||
35
templates/Prompt/Subblock-Generate.md
Normal file
35
templates/Prompt/Subblock-Generate.md
Normal file
@@ -0,0 +1,35 @@
|
||||
Break the block below (topic "{topic}") into its SUBBLOCKS — the individual learnable sub-points — and extract per subblock the learning facts from the material. A later guide writes a short text PER subblock; the facts are the binding basis for guide text, levels, and exam questions — they must be **correct**.
|
||||
|
||||
BLOCK:
|
||||
{block}
|
||||
|
||||
{source}
|
||||
|
||||
What a subblock is:
|
||||
- A single sub-point you must learn for the block. One statement, one aspect, one pitfall.
|
||||
- Examples: block `<img>` → `src` (Bildquelle), `alt` (Alternativtext), empty `alt` for decorative images. Block `<p>` → text paragraph as a block, allowed inline children, no nesting.
|
||||
|
||||
DECISIVE — the count follows the difficulty:
|
||||
- A simple, trivial block has FEW subblocks (1–2). Do NOT inflate it.
|
||||
- A complex, rich block has MANY subblocks. Leave out nothing essential the material covers.
|
||||
- Each subblock is atomic (one sub-point), backed by the excerpts — a point the excerpts do not support is left out.
|
||||
- Subblock titles in GERMAN (code identifiers stay original), max. ~10 words, one statement.
|
||||
{seeds}
|
||||
Per subblock, collect ONLY the essentials (all field content in GERMAN; technical terms/code identifiers stay original):
|
||||
- **level**: beginner | advanced | expert — difficulty within this block.
|
||||
- **relevance**: relevant (core of the topic) | peripheral (edge knowledge).
|
||||
- **key_points**: 1–3 concise statements — what must one understand?
|
||||
- **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) — **only what the excerpts back**, each with the location (e.g. „Skript Def. 6.3, Z.66"). Invent no values, compute nothing yourself.
|
||||
- **example_idea**: ONE example that carries understanding — freely phrased. Empty if an example adds nothing.
|
||||
|
||||
HARD SEPARATION: `cited_facts` = only backable material. A worked example, an invented sentence, a constructed case → `example_idea`, NEVER `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 other text. EXACTLY this format:
|
||||
{{"subs": [
|
||||
{{"title": "…", "level": "beginner|advanced|expert", "relevance": "relevant|peripheral",
|
||||
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
|
||||
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
|
||||
]}}
|
||||
{extra}
|
||||
@@ -1,29 +0,0 @@
|
||||
Du prüfst die Zerlegung EINES Lernbausteins in Subbausteine für das Thema "{topic}". Jeder Subbaustein wird später ein eigener Absatz im Lernguide. Ziel ist eine 100%-Zerlegung: keinen Eintrag kann man weglassen, ohne dass eine Lücke entsteht — und keinen ergänzen, ohne dass Dopplung entsteht.
|
||||
|
||||
BLOCK: {block}
|
||||
|
||||
SUBBAUSTEINE (nummeriert, mit ihren Kernpunkten):
|
||||
{subs}
|
||||
|
||||
## Auftrag (vier Urteile)
|
||||
1. **gruppen — Dopplungen**: Einträge, die DIESELBE Aussage treffen (anders formuliert) oder reine TEILMENGE eines anderen sind. Pro Gruppe: `haupt` = der GRUNDLEGENDSTE, allgemeinste Eintrag (Basis vor Detail, Grundsyntax vor Sonderfall, nie ein inhaltsleerer Stub).
|
||||
2. **kataloge — Aufzählungs-Bündel**: Einträge, die REINE Nachschlage-Listen gleicher Art sind (z. B. Symbol-, Befehls-, Namenslisten ohne eigene Verhaltensregel). Solche Listen bündelst du zu EINEM Katalog-Eintrag mit sprechendem Titel. Einträge mit eigener Regel oder eigenem Verhalten gehören NICHT in ein Bündel.
|
||||
3. **fremd — Weglass-Test**: Einträge, deren Aussage nicht zum Thema "{topic}" gehört (anderes Fachgebiet, Grundlagen einer anderen Technologie) — auch wenn die Aussage stimmt und belegbar ist. Ließe man sie weg, fehlte dem Thema nichts.
|
||||
4. **luecken**: Ein KERN-Aspekt des Blocks fehlt — ohne ihn ist die Zerlegung UNVOLLSTÄNDIG (die Grundregel oder ein Pflichtbestandteil fehlt). Katalog-, Detail- und Randwissen ist KEINE Lücke. Was man ergänzen KÖNNTE, ist keine Lücke — nur was fehlen DARF nicht. Im Zweifel: keine Lücke. Meist ist die Liste leer.
|
||||
|
||||
## Regeln — der häufigste Fehler zuerst
|
||||
- **Verschiedene Schreibweisen, Marker, Befehle oder Syntaxen sind IMMER eigene Einheiten** — auch wenn sie derselben Kategorie angehören oder im selben Satz erklärt werden könnten. „Gehört zusammen" oder „ähnliches Thema" ist KEIN Gruppierungs-Grund. Nur „sagt DASSELBE" zählt.
|
||||
- Zwei Einträge zum gleichen Konzept mit VERSCHIEDENEN Facetten (Grundregel vs. Sonderfall vs. Randbedingung) sind KEINE Dopplung.
|
||||
- Eine benannte Variante oder ein Spezialfall ist NIE Dopplung seiner Basis.
|
||||
- Einträge mit gegensätzlicher Aussage (Negation) nie gruppieren.
|
||||
- Ein inhaltsleerer oder generischer Eintrag, dessen Titel ein anderer Eintrag vollständig abdeckt, ist Teilmenge → gruppieren, der konkrete Eintrag ist `haupt`.
|
||||
- Im Zweifel: NICHT gruppieren, NICHT bündeln, NICHT als fremd markieren.
|
||||
|
||||
Antworte NUR mit JSON, ohne weiteren Text:
|
||||
{{"gruppen": [{{"haupt": 1, "weitere": [4]}}], "kataloge": [{{"titel": "Symbolkatalog: Operatoren und Relationen", "mitglieder": [2, 5, 9]}}], "fremd": [7], "luecken": ["fehlender Kernaspekt"]}}
|
||||
|
||||
- gruppen: pro Gruppe haupt + weitere (Nummern). Keine Dopplungen → [].
|
||||
- kataloge: pro Bündel Titel + Mitglieds-Nummern. Keine → [].
|
||||
- fremd: Nummern themenfremder Einträge. Keine → [].
|
||||
- luecken: fehlende Kernaspekte in Stichworten. Keine → [].
|
||||
{extra}
|
||||
@@ -1,27 +0,0 @@
|
||||
Below, for each block of the topic "{topic}", are the subblocks found. Return the **cleaned** final list per block.
|
||||
|
||||
{source}
|
||||
|
||||
FOUND SUBBLOCKS (two groups per block):
|
||||
{blocks}
|
||||
|
||||
The groups:
|
||||
- **Consensus (≥2 finders):** found independently multiple times — keep by default. Remove only fabrications/duplicates.
|
||||
- **Uncertain (1×):** named by only one finder — **scrutinize strictly**. Include an uncertain entry ONLY if it is **clearly backed by the source AND a standalone point**. When in doubt, leave it out.
|
||||
|
||||
Rules:
|
||||
- **Evidence check (important):** Check each subblock against the source. **Discard whatever is NOT backable in the material or clearly invented** — fabricated bounds, formulas, values, or claims not actually in the source. With source material (folder or excerpts): check against it. Without a source (pure topic): keep only established standard knowledge, drop the doubtful/false.
|
||||
- **Merge duplicates:** Subblocks that state the same point in other words are ONE. Keep the clearest, drop the rephrasings. (e.g. „Broadcast Mode verfügbar" and „Broadcast-Modus aktivieren" → one.)
|
||||
- Keep all technically **DISTINCT** and backable sub-points in full — leave out nothing essential.
|
||||
- Discard whatever is too fine-grained, at the edge of the topic, or technically doubtful.
|
||||
- Each point atomic (one statement). Copy kept points VERBATIM, do not rephrase, invent nothing.
|
||||
- The count follows the difficulty: better few distinct than many redundant points.
|
||||
|
||||
Reply with ONLY the final lists — no other text, no code fences. One block marker per block (title EXACTLY as above), with the final subblock list below it:
|
||||
|
||||
<!-- block: Exact block title -->
|
||||
- Subblock
|
||||
- Subblock
|
||||
|
||||
Output the marker line exactly like this. Every block must appear. No text outside the blocks.
|
||||
{extra}
|
||||
@@ -1,29 +0,0 @@
|
||||
Break each assigned block of the topic "{topic}" into its SUBBLOCKS — the individual learnable sub-points the concept is made of. A later guide will write a short text PER subblock.
|
||||
|
||||
Assigned to you — binding: every block must appear, invent no additional ones:
|
||||
{assignment}
|
||||
|
||||
What a subblock is:
|
||||
- A single sub-point you must learn for the block. One statement, one aspect, one pitfall.
|
||||
- Examples: block `<img>` → `src` (Bildquelle), `alt` (Alternativtext), empty `alt` for decorative images, `width`/`height` against layout shifts, void element without a closing tag. Block `<p>` → text paragraph as a block, allowed inline children, no nesting.
|
||||
|
||||
DECISIVE — the count follows the difficulty:
|
||||
- A simple, trivial block has FEW subblocks (1–2). Do NOT inflate it.
|
||||
- A complex, rich block has MANY subblocks. Leave out nothing essential.
|
||||
- There is NO target count. `<img>` must have noticeably more subblocks than `<p>`.
|
||||
|
||||
Rules:
|
||||
- Each subblock is atomic: exactly one sub-point. No two aspects in one point.
|
||||
- Only what THIS block yields (scope). Don't inflate anything mentioned only in passing.
|
||||
- Backed, not invented. Verify uncertain points via web search.
|
||||
- Subblock titles in GERMAN (code identifiers stay original), max. ~10 words, one statement.
|
||||
|
||||
Write ONLY the file {out_path} — one block marker per block (title EXACTLY from the assignment), with the subblocks as a list below it:
|
||||
|
||||
<!-- block: Exact block title -->
|
||||
- First subblock
|
||||
- Second subblock
|
||||
|
||||
Write the marker line exactly like this. No text outside the blocks.
|
||||
{known}
|
||||
{extra}
|
||||
26
templates/Prompt/Subblock-Verify.md
Normal file
26
templates/Prompt/Subblock-Verify.md
Normal file
@@ -0,0 +1,26 @@
|
||||
You are auditing the decomposition of ONE block of the topic "{topic}" into subblocks. Below are the numbered subblocks with their captured facts, then source excerpts. Apply the 100%-decomposition test: no entry removable without a gap, none addable without duplication — and verify the facts.
|
||||
|
||||
BLOCK: {block}
|
||||
|
||||
SUBBLOCKS (numbered; key points indented):
|
||||
{subs}
|
||||
{unsicher}
|
||||
{source}
|
||||
|
||||
Judge ALL of the following (use the numbers):
|
||||
1. **gruppen** — entries that state the SAME thing or where one is a subset of the other: group them and name the number that should remain (`haupt` = the base statement, not the detail).
|
||||
2. **kataloge** — pure enumeration entries of ONE kind (e.g. five option rows): bundle them under ONE short GERMAN collective title.
|
||||
3. **fremd** — entries off-topic for the topic "{topic}" (not this block — the TOPIC).
|
||||
4. **luecken** — essential aspects of THIS block that the excerpts cover but no entry captures (short German phrases). Only real gaps, no nice-to-haves.
|
||||
5. **uebernehmen** — for each UNSICHER-numbered entry: "ja" if the excerpts back it and it fills a real spot, else "nein".
|
||||
6. **facts_probleme** — entries whose facts contain something wrong or unbackable: `discard: true` ONLY if the entry as a whole is unsupportable in substance; otherwise `discard: false` with a short `hinweis` what to correct.
|
||||
7. **levels** / **relevanz** — ONLY entries whose level (beginner/advanced/expert) or relevance (relevant/peripheral) is clearly wrong: number → correct value.
|
||||
|
||||
Judge ONLY from the excerpts. If everything is fine, return empty lists/objects.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text. Format:
|
||||
{{"gruppen": [{{"haupt": 1, "weitere": [4]}}], "kataloge": [{{"titel": "…", "mitglieder": [2, 5]}}],
|
||||
"fremd": [7], "luecken": ["…"], "uebernehmen": {{"9": "ja"}},
|
||||
"facts_probleme": [{{"nr": 3, "discard": false, "hinweis": "…"}}],
|
||||
"levels": {{"2": "expert"}}, "relevanz": {{"5": "peripheral"}}}}
|
||||
{extra}
|
||||
Reference in New Issue
Block a user