update
This commit is contained in:
11
.env.example
11
.env.example
@@ -12,3 +12,14 @@ MINIMAX_API_KEY=
|
|||||||
#ROLE_JUDGE=
|
#ROLE_JUDGE=
|
||||||
#ROLE_GUIDE=
|
#ROLE_GUIDE=
|
||||||
#ROLE_FAST=
|
#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
|
||||||
@@ -22,13 +22,16 @@ import urllib.request
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
|
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
|
||||||
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_INTERACTIVE,
|
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_API_AGENTS,
|
||||||
resolve_role)
|
MAX_CONCURRENT_INTERACTIVE, RAM_MIN_FREE_PCT, resolve_role)
|
||||||
|
|
||||||
log = logging.getLogger("creator.agents")
|
log = logging.getLogger("creator.agents")
|
||||||
|
|
||||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
_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_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)
|
_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()
|
now = time.time()
|
||||||
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
|
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
|
||||||
for k, t in list(_active_started.items())
|
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"])
|
return sorted(out, key=lambda a: -a["runtime"])
|
||||||
|
|
||||||
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
||||||
@@ -95,7 +99,8 @@ class _PrioritySemaphore:
|
|||||||
self._value += 1
|
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)
|
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
||||||
|
|
||||||
# Per-topic caps (lazily created): each topic gets its own priority semaphore of size
|
# 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
|
@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
|
"""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
|
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:
|
if topic_sem is not None:
|
||||||
await topic_sem.acquire(priority)
|
await topic_sem.acquire(priority)
|
||||||
await _batch_sem.acquire(priority)
|
await global_sem.acquire(priority)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
_batch_sem.release()
|
global_sem.release()
|
||||||
if topic_sem is not None:
|
if topic_sem is not None:
|
||||||
topic_sem.release()
|
topic_sem.release()
|
||||||
|
|
||||||
@@ -151,6 +158,55 @@ async def _opencode_slot() -> None:
|
|||||||
_opencode_next_start = start_at + _OPENCODE_START_DELAY
|
_opencode_next_start = start_at + _OPENCODE_START_DELAY
|
||||||
await asyncio.sleep(max(0.0, start_at - now))
|
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"
|
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
|
||||||
|
|
||||||
# Capability → Claude --allowedTools
|
# 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:
|
def provider_available(provider: str) -> bool:
|
||||||
cfg = PROVIDERS.get(provider)
|
cfg = PROVIDERS.get(provider)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
@@ -258,10 +325,11 @@ async def run_agent(
|
|||||||
provider, model = run_provider, PROVIDERS[run_provider].get(role, "")
|
provider, model = run_provider, PROVIDERS[run_provider].get(role, "")
|
||||||
if not model:
|
if not model:
|
||||||
return 1, "", f"No model for role '{role}' (provider: {provider})"
|
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})"
|
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
|
||||||
queued = time.monotonic()
|
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:
|
async with gate:
|
||||||
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
||||||
return 1, "", "cancelled"
|
return 1, "", "cancelled"
|
||||||
@@ -270,9 +338,13 @@ async def run_agent(
|
|||||||
status = "error"
|
status = "error"
|
||||||
rc = None
|
rc = None
|
||||||
err_tail = ""
|
err_tail = ""
|
||||||
|
api_tokens = None
|
||||||
try:
|
try:
|
||||||
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
|
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)
|
res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label)
|
||||||
else:
|
else:
|
||||||
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
|
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}
|
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
|
||||||
if err_tail:
|
if err_tail:
|
||||||
meta["stderr"] = 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)):
|
if (tok := await asyncio.to_thread(_session_tokens, agent_key)):
|
||||||
meta["tokens"] = tok
|
meta["tokens"] = tok
|
||||||
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
|
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,
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
global _opencode_running
|
||||||
if stagger:
|
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()
|
process = await spawn()
|
||||||
|
if stagger:
|
||||||
|
_opencode_running += 1
|
||||||
# Collision-safe tracking: identical keys (e.g. same chunk label from parallel cards)
|
# 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.
|
# get a ~n suffix — prefix-based kill/cancel still matches, nothing becomes an orphan.
|
||||||
track_key = agent_key
|
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")
|
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||||
finally:
|
finally:
|
||||||
|
if stagger:
|
||||||
|
_opencode_running -= 1
|
||||||
# Pop only on identity: a slot restart under the same key must not evict
|
# Pop only on identity: a slot restart under the same key must not evict
|
||||||
# the NEW process from tracking.
|
# the NEW process from tracking.
|
||||||
if _active_processes.get(track_key) is process:
|
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)
|
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")
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
686
backend/block_calls.py
Normal file
686
backend/block_calls.py
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
"""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) -> 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")
|
||||||
|
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) -> 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]
|
||||||
|
|
||||||
|
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():
|
||||||
|
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 = "") -> 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": []}}
|
||||||
|
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:
|
||||||
|
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]}}
|
||||||
1794
backend/blocks.py
1794
backend/blocks.py
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,12 @@
|
|||||||
"""Board 2 „Artefakte": per finished block, prepare the learning artefacts the guide presents.
|
"""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:
|
A card is spawned by board 1's `done` column per mirrored block and runs through:
|
||||||
subblocks → facts → levels → relevance → question_pattern (+artefacts parallel) → 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
|
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
|
||||||
files + the DB tables. Danach zwei topic-weite BARRIEREN: `konsolidierung` (cross-block
|
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),
|
sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order),
|
||||||
re-run once per generation run.
|
re-run once per generation run — outline läuft parallel zur Dedup-Barriere."""
|
||||||
|
|
||||||
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."""
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -19,11 +17,8 @@ import re
|
|||||||
import database as db
|
import database as db
|
||||||
import blocks
|
import blocks
|
||||||
import embedding
|
import embedding
|
||||||
from blocks import (
|
from block_calls import _artefakte_block, _generate_block, _verify_block
|
||||||
ARTEFACT_TYPES, _artefacts_block, _facts_block, _facts_nachfass, _konsolidiere_subblocks,
|
from blocks import ARTEFACT_TYPES, _match_sub, _neg_set, _sink_json, _outline_block
|
||||||
_levels_block, _luecken_runde, _match_sub, _neg_set, _question_pattern_block, _relevance_block,
|
|
||||||
_sink_json, _subblocks_block, _outline_block,
|
|
||||||
)
|
|
||||||
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
|
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
|
||||||
from fsutil import atomic_write_json
|
from fsutil import atomic_write_json
|
||||||
from jsonio import read_json_file as _json_file
|
from jsonio import read_json_file as _json_file
|
||||||
@@ -100,7 +95,7 @@ def make_spawner(topic: str, files: dict):
|
|||||||
norm = payload.get("mirrored_norm")
|
norm = payload.get("mirrored_norm")
|
||||||
if not norm:
|
if not norm:
|
||||||
return
|
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", ""),
|
"title": payload.get("title", ""),
|
||||||
"description": payload.get("description", ""),
|
"description": payload.get("description", ""),
|
||||||
"n_size": payload.get("n_size", 0), # LPT estimate until subs_n exists
|
"n_size": payload.get("n_size", 0), # LPT estimate until subs_n exists
|
||||||
@@ -172,88 +167,76 @@ async def _seed_map(topic: str) -> dict[str, list[str]]:
|
|||||||
return seeds
|
return seeds
|
||||||
|
|
||||||
|
|
||||||
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
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
|
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)
|
seeds = await _seed_map(topic)
|
||||||
|
|
||||||
async def one(c):
|
async def one(c):
|
||||||
p = c["payload"]
|
p = c["payload"]
|
||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
instr = instructions
|
gen = await _generate_block(ctx, _pfiles(files, norm), p.get("title", ""),
|
||||||
sd = [s for s in seeds.get(norm, []) if s]
|
p.get("description", ""), instructions,
|
||||||
if sd:
|
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ",
|
||||||
instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
|
sources=p.get("sources"),
|
||||||
"Blocks (prüfen; wenn belegt UND noch nicht durch einen anderen Eintrag "
|
seeds=[s for s in seeds.get(norm, []) if s] or None)
|
||||||
"abgedeckt, aufnehmen — nicht wörtlich übernehmen, sondern als eigenständige "
|
if gen is None:
|
||||||
"Aussage formulieren):\n"
|
return _fail_or_cancel(ctx, f"Generate {p.get('title', norm)}")
|
||||||
+ "\n".join(f"- {s}" for s in sd))
|
p["raw"], p["facts"] = gen["raw"], gen["facts"]
|
||||||
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
p["unsicher"], p["votes"] = gen["unsicher"], gen["votes"]
|
||||||
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-",
|
p["subs_n"] = sum(len(v) for v in gen["raw"].values()) + len(gen["unsicher"])
|
||||||
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
|
|
||||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
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)
|
await _gather_cards(ctx, flow, cards, one)
|
||||||
|
|
||||||
|
|
||||||
async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
async def _proc_verify(ctx: GenContext, flow: Flow, files: dict, q: dict, instructions: str, cards):
|
||||||
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
|
topic = flow.topic
|
||||||
|
|
||||||
async def one(c):
|
async def one(c):
|
||||||
p = c["payload"]
|
p = c["payload"]
|
||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
raw = p.get("raw") or {}
|
title = p.get("title", "")
|
||||||
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q,
|
gen = {"raw": p.get("raw") or {}, "facts": p.get("facts") or {},
|
||||||
folder, instructions, ns=f"{_safe(norm)}-",
|
"unsicher": p.get("unsicher") or [], "votes": p.get("votes") or {}}
|
||||||
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
|
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"))
|
||||||
if res is None:
|
if res is None:
|
||||||
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
|
return None # nur Cancel — Karte bleibt liegen
|
||||||
facts_map, discarded = res
|
p["raw"], p["facts"], p["sidecar"] = res["raw"], res["facts"], res["sidecar"]
|
||||||
if discarded: # unsupportable subs vanish from raw too (guide never sees them)
|
p.pop("unsicher", None)
|
||||||
for bt, sns in discarded.items():
|
p.pop("votes", None)
|
||||||
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
|
|
||||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
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} · ")
|
||||||
|
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)
|
await _gather_cards(ctx, flow, cards, one)
|
||||||
|
|
||||||
@@ -290,8 +273,8 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
|||||||
# Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig
|
# 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
|
# 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.
|
# ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln.
|
||||||
nachzuegler = [(c["card_id"], "question_pattern") for c in cards
|
nachzuegler = [(c["card_id"], "artefakte" if "sidecar" in c["payload"] else "generate")
|
||||||
if "pattern" not in c["payload"]]
|
for c in cards if "pattern" not in c["payload"]]
|
||||||
if nachzuegler:
|
if nachzuegler:
|
||||||
await db.kanban_advance_many(topic, BOARD, nachzuegler)
|
await db.kanban_advance_many(topic, BOARD, nachzuegler)
|
||||||
flow.wake.set()
|
flow.wake.set()
|
||||||
@@ -417,101 +400,6 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
|||||||
await _advance_all()
|
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, "question_pattern")
|
|
||||||
|
|
||||||
await _gather_cards(ctx, flow, cards, one)
|
|
||||||
|
|
||||||
|
|
||||||
async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
|
||||||
"""Fragen UND Artefakte im Fächer: beide brauchen nur den sidecar, nichts voneinander —
|
|
||||||
als Stage-Treppe kosteten sie zwei serielle Call-Segmente auf dem kritischen Pfad.
|
|
||||||
Die artefacts-Stage bleibt für Resume-Karten alter Läufe registriert."""
|
|
||||||
topic = flow.topic
|
|
||||||
|
|
||||||
async def one(c):
|
|
||||||
p = c["payload"]
|
|
||||||
norm = c["card_id"]
|
|
||||||
pattern, artefacts = await asyncio.gather(
|
|
||||||
_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)} · "),
|
|
||||||
_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 pattern is None:
|
|
||||||
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
|
|
||||||
p["pattern"] = pattern
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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 ─────────────────────
|
# ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
|
||||||
def _merge_json(path, block_keys: dict) -> None:
|
def _merge_json(path, block_keys: dict) -> None:
|
||||||
data = _json_file(path)
|
data = _json_file(path)
|
||||||
@@ -627,18 +515,33 @@ async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions:
|
|||||||
|
|
||||||
|
|
||||||
# ── Stage list (appended after board 1 in chain order) ─────────────────────────────
|
# ── 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,
|
def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
||||||
instructions: str) -> list[Stage]:
|
instructions: str) -> list[Stage]:
|
||||||
research_done = lambda: flow.research_done # noqa: E731
|
research_done = lambda: flow.research_done # noqa: E731
|
||||||
return [
|
return [
|
||||||
Stage(BOARD, "subblocks", lambda cs: _proc_subblocks(ctx, flow, files, instructions, cs)),
|
Stage(BOARD, "generate", lambda cs: _proc_generate(ctx, flow, files, instructions, cs)),
|
||||||
Stage(BOARD, "facts", lambda cs: _proc_facts(ctx, flow, files, q, folder, instructions, cs)),
|
Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)),
|
||||||
Stage(BOARD, "levels", lambda cs: _proc_levels(ctx, flow, files, instructions, cs)),
|
Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)),
|
||||||
Stage(BOARD, "relevance", lambda cs: _proc_relevance(ctx, flow, files, instructions, cs)),
|
|
||||||
Stage(BOARD, "question_pattern",
|
|
||||||
lambda cs: _proc_question_pattern(ctx, flow, files, instructions, cs)),
|
|
||||||
# Resume-Pfad: Karten alter Läufe, die noch in artefacts stehen
|
|
||||||
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, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
|
||||||
# Cross-Block-Dedup als END-Barriere: als Mittel-Barriere idelte jede fertige Karte
|
# 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
|
# auf die langsamste (8:46 min/Block gemessen); jetzt faltet sie nach finalize
|
||||||
|
|||||||
@@ -1669,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
|
import board_artefacts # lazy — board_artefacts imports blocks too
|
||||||
flow.state["spawn_artefact"] = board_artefacts.make_spawner(topic, files)
|
flow.state["spawn_artefact"] = board_artefacts.make_spawner(topic, files)
|
||||||
await board_artefacts.ensure_outline_card(topic)
|
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)
|
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
|
||||||
if QA_GATE_NOTE > 0:
|
if QA_GATE_NOTE > 0:
|
||||||
# QA gate: board 2 waits until the inventory QA passed (or the user forces).
|
# 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
|
# 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.
|
# 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"))
|
sub.gate = lambda: bool(flow.state.get("qa_ok") or flow.state.get("qa_force"))
|
||||||
stages = chain_stages(stages)
|
stages = chain_stages(stages)
|
||||||
if artefacts:
|
if artefacts:
|
||||||
# Outline needs every block's TITLE + FACTS, nothing later: cut the post-facts
|
# Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the
|
||||||
# artefact stages from its barrier so it runs parallel to levels…finalize of the
|
# post-generate stages from its barrier so it runs parallel to verify…finalize AND
|
||||||
# slowest block (makespan tail). Inventory stages all stay — no late blocks.
|
# zur Cross-Dedup-Barriere des langsamsten Blocks (makespan tail).
|
||||||
outline = next(s for s in stages if s.stage == "outline")
|
outline = next(s for s in stages if s.stage == "outline")
|
||||||
outline.upstream = [u for u in outline.upstream if u not in
|
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 []
|
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
|
||||||
|
|
||||||
async def _as_producer(coro):
|
async def _as_producer(coro):
|
||||||
@@ -1712,7 +1715,7 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
|||||||
if ctx.is_cancelled():
|
if ctx.is_cancelled():
|
||||||
return False
|
return False
|
||||||
if flow.state.get("qa_paused"):
|
if flow.state.get("qa_paused"):
|
||||||
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in subblocks
|
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in generate
|
||||||
await _write_final(topic, files)
|
await _write_final(topic, files)
|
||||||
await _write_run_summary(topic, flow)
|
await _write_run_summary(topic, flow)
|
||||||
return True
|
return True
|
||||||
@@ -1834,14 +1837,11 @@ COLUMNS = [
|
|||||||
("inventory", "done_block", "Fertig", "block"),
|
("inventory", "done_block", "Fertig", "block"),
|
||||||
("inventory", "rejected", "Verworfen", None),
|
("inventory", "rejected", "Verworfen", None),
|
||||||
("inventory", "grouped", "Zusammengelegt", "block"),
|
("inventory", "grouped", "Zusammengelegt", "block"),
|
||||||
("artefacts", "subblocks", "Subbausteine", "ablock"),
|
("artefacts", "generate", "Erzeugen", "ablock"),
|
||||||
("artefacts", "facts", "Fakten", "ablock"),
|
("artefacts", "verify", "Prüfen", "ablock"),
|
||||||
("artefacts", "levels", "Stufen", "ablock"),
|
("artefacts", "artefakte", "Lernmittel", "ablock"),
|
||||||
("artefacts", "relevance", "Relevanz", "ablock"),
|
|
||||||
("artefacts", "konsolidierung", "Konsolidierung", "ablock"),
|
|
||||||
("artefacts", "question_pattern", "Fragen", "ablock"),
|
|
||||||
("artefacts", "artefacts", "Lernkarten", "ablock"),
|
|
||||||
("artefacts", "finalize", "Zusammenführen", "ablock"),
|
("artefacts", "finalize", "Zusammenführen", "ablock"),
|
||||||
|
("artefacts", "konsolidierung", "Konsolidierung", "ablock"),
|
||||||
("artefacts", "outline", "Gliederung", "outline"),
|
("artefacts", "outline", "Gliederung", "outline"),
|
||||||
("artefacts", "done_artefact", "Fertig", "ablock"),
|
("artefacts", "done_artefact", "Fertig", "ablock"),
|
||||||
]
|
]
|
||||||
@@ -1849,18 +1849,16 @@ COLUMNS = [
|
|||||||
_TITLE_STAGES = ["ingest", "cluster"]
|
_TITLE_STAGES = ["ingest", "cluster"]
|
||||||
_CLUSTER_STAGES = ["pair_check", "consensus_gate", "clarify", "naming", "naming_check"]
|
_CLUSTER_STAGES = ["pair_check", "consensus_gate", "clarify", "naming", "naming_check"]
|
||||||
_BLOCK_STAGES = ["fragment_filter", "dedup", "grouping", "gap_check", "done"]
|
_BLOCK_STAGES = ["fragment_filter", "dedup", "grouping", "gap_check", "done"]
|
||||||
_ART_STAGES = ["subblocks", "facts", "levels", "relevance", "konsolidierung", "question_pattern",
|
_ART_STAGES = ["generate", "verify", "artefakte", "finalize", "konsolidierung", "outline"]
|
||||||
"artefacts", "finalize", "outline"]
|
|
||||||
# where a requeued dead card restarts, by kind
|
# where a requeued dead card restarts, by kind
|
||||||
_DEAD_RESTART = {"title": "cluster", "cluster": "pair_check", "block": "fragment_filter",
|
_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")
|
_VERDICT_KEYS = ("reason", "votes", "judges", "merged_into", "parent_norm", "mirrored_norm")
|
||||||
DONE_ART = "done_artefact"
|
DONE_ART = "done_artefact"
|
||||||
|
|
||||||
|
|
||||||
# Board-2 stage → its fine-step group in blocks.PHASEN (drives the per-card stepper).
|
# Board-2 stage → its fine-step group in blocks.PHASEN (drives the per-card stepper).
|
||||||
_STAGE_PHASE = {"subblocks": "Subblocks", "facts": "Facts", "levels": "Levels",
|
_STAGE_PHASE = {"generate": "Generate", "verify": "Verify", "artefakte": "Artefakte"}
|
||||||
"relevance": "Relevance", "question_pattern": "Questions", "artefacts": "Artefacts"}
|
|
||||||
_PHASE_STEPS = {name: steps for name, steps in blocks.PHASEN}
|
_PHASE_STEPS = {name: steps for name, steps in blocks.PHASEN}
|
||||||
|
|
||||||
|
|
||||||
@@ -1928,7 +1926,7 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None:
|
|||||||
note = r.get("note")
|
note = r.get("note")
|
||||||
if note is None:
|
if note is None:
|
||||||
return 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)
|
pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None)
|
||||||
return {"note": note, "note_artefakte": r.get("note_artefakte"),
|
return {"note": note, "note_artefakte": r.get("note_artefakte"),
|
||||||
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
|
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
|
||||||
@@ -1997,7 +1995,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
|||||||
await _requeue(r, "outline")
|
await _requeue(r, "outline")
|
||||||
elif r["stage"] in later:
|
elif r["stage"] in later:
|
||||||
await _requeue(r, stage)
|
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_subblocks(topic)
|
||||||
await db.delete_question_pattern(topic)
|
await db.delete_question_pattern(topic)
|
||||||
await db.delete_sub_artefakte(topic)
|
await db.delete_sub_artefakte(topic)
|
||||||
@@ -2013,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:
|
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).
|
(per-block work-dir slots overwrite themselves; finalize re-upserts later).
|
||||||
Only call while nothing is generating (the route guards). → False if unknown."""
|
Only call while nothing is generating (the route guards). → False if unknown."""
|
||||||
card = await db.kanban_get_card(topic, "artefacts", card_id)
|
card = await db.kanban_get_card(topic, "artefacts", card_id)
|
||||||
@@ -2024,7 +2022,7 @@ async def restart_artefact_card(topic: str, card_id: str) -> bool:
|
|||||||
await db.delete_sub_artefakte(topic, card_id)
|
await db.delete_sub_artefakte(topic, card_id)
|
||||||
p = {k: v for k, v in card["payload"].items() if k in ("title", "description")}
|
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_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")
|
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
|
||||||
return True
|
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).
|
# 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.
|
# 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 = 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
|
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
|
# 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
|
# valid result the remaining agents may still become done for this many seconds
|
||||||
# (kill only once the minimum is already in).
|
# (kill only once the minimum is already in).
|
||||||
@@ -186,6 +194,12 @@ ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples bulk chunk
|
|||||||
FACTS_CHECK_PANEL = 3 # judges per facts-check chunk (majority)
|
FACTS_CHECK_PANEL = 3 # judges per facts-check chunk (majority)
|
||||||
CONSOLIDATION_PANEL = 3 # mapping judges per chunk
|
CONSOLIDATION_PANEL = 3 # mapping judges per chunk
|
||||||
SUBBLOCK_PANEL = 3 # judges in the subblock clarification
|
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
|
FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck
|
||||||
MAX_WRITER_ROUNDS = 2 # guide coverage→writer loop cap
|
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 this → log only (falsch fixt immer)
|
||||||
@@ -225,6 +239,12 @@ TIMEOUTS = {
|
|||||||
"relevance_check": (150, 8), # judge decides contested relevance in the chunk
|
"relevance_check": (150, 8), # judge decides contested relevance in the chunk
|
||||||
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
||||||
"question_pattern_check": (150, 8), # critic cleans up the pattern table per block
|
"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
|
"writer": (450, 60), # per section — split keeps sections ≤30 subs
|
||||||
"lese_check": (300, 10), # per section in the package
|
"lese_check": (300, 10), # per section in the package
|
||||||
# guide board (per card = one block)
|
# guide board (per card = one block)
|
||||||
|
|||||||
@@ -140,16 +140,17 @@ class Welt:
|
|||||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
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"}})
|
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
|
||||||
|
|
||||||
# Board 2 / Artefakte
|
# Board 2 / Artefakte (verschmolzene Calls, block_calls.py)
|
||||||
if "-luecken-" in key:
|
if "-sb-gen-" in key:
|
||||||
return "<!-- block: leer -->\n" # Lücken-Nachfass findet nichts Neues
|
subs = []
|
||||||
if "-subblock-final-" in key or "-subblock-" in key: # Finder + Judge, gleiches Format
|
|
||||||
teile = []
|
|
||||||
for t in self._bloecke_im_prompt(prompt):
|
for t in self._bloecke_im_prompt(prompt):
|
||||||
subs = "\n".join(f"- {s}" for s in self.bloecke[t]["subs"])
|
for s in self.bloecke[t]["subs"]:
|
||||||
teile.append(f"<!-- block: {t} -->\n{subs}")
|
f = self._fakt(t, s)
|
||||||
return "\n".join(teile) or "<!-- block: leer -->\n"
|
subs.append({"title": s, "level": "beginner", "relevance": "relevant",
|
||||||
if "-sub-konsolidierung-" in key:
|
**{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)}
|
nummern = {_norm(m.group(2)): m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||||
gruppen = []
|
gruppen = []
|
||||||
for haupt, weitere in self.gruppen:
|
for haupt, weitere in self.gruppen:
|
||||||
@@ -162,50 +163,29 @@ class Welt:
|
|||||||
m = [int(nummern[_norm(x)]) for x in mitglieder if _norm(x) in nummern]
|
m = [int(nummern[_norm(x)]) for x in mitglieder if _norm(x) in nummern]
|
||||||
if len(m) >= 2:
|
if len(m) >= 2:
|
||||||
kataloge.append({"titel": kt, "mitglieder": m})
|
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:
|
if "-sub-crossblock-" in key:
|
||||||
urteile = {}
|
urteile = {}
|
||||||
for m in _PAIR_RE.finditer(prompt):
|
for m in _PAIR_RE.finditer(prompt):
|
||||||
urteile[m.group(1)] = "a" # identischer Text (nur so wird gepaart) → A behält
|
urteile[m.group(1)] = "a" # identischer Text (nur so wird gepaart) → A behält
|
||||||
return j({"pairs": urteile or {"1": "nein"}})
|
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})
|
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:
|
if "-outline-prereqs" in key:
|
||||||
return j({"prereqs": {}})
|
return j({"prereqs": {}})
|
||||||
if "-outline-review" in key:
|
if "-outline-review" in key:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
aiosqlite
|
aiosqlite
|
||||||
|
httpx
|
||||||
playwright
|
playwright
|
||||||
trafilatura
|
trafilatura
|
||||||
pymupdf4llm
|
pymupdf4llm
|
||||||
|
|||||||
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
|
return False
|
||||||
monkeypatch.setattr(bi, "_emb_ok", no_emb)
|
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):
|
async def fake_generate(ctx, files, title, description, instructions="", ns="", lbl="",
|
||||||
title = list(entries.values())[0].split(" — ")[0]
|
sources=None, seeds=None):
|
||||||
return {title: ["Sub Eins", "Sub Zwei"]}
|
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):
|
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None):
|
||||||
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
|
subs = gen["raw"].get(title) or []
|
||||||
for s in subs} for t, subs in raw.items()}
|
bfacts = gen["facts"].get(title) or {}
|
||||||
return facts, {}
|
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=""):
|
async def fake_artefakte(ctx, files, title, sidecar_subs, instructions="", ns="", lbl=""):
|
||||||
return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()}
|
if not sidecar_subs:
|
||||||
|
return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
|
||||||
async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
first = sidecar_subs[0]["title"]
|
||||||
return {1: "relevant", 2: "peripheral"}
|
return {"pattern": {title: [{"subblock": first, "question": f"Was ist {title}?"}]},
|
||||||
|
"artefacts": {"flashcard": [{"block": title, "subblock": first, "front": "F", "back": "B"}],
|
||||||
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
"example": []}}
|
||||||
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_outline(ctx, set_p, files, entries, instructions):
|
async def fake_outline(ctx, set_p, files, entries, instructions):
|
||||||
return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]}
|
return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]}
|
||||||
|
|
||||||
async def fake_konsolidierung(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
|
for name, fn in [("_generate_block", fake_generate), ("_verify_block", fake_verify),
|
||||||
return None
|
("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
|
||||||
|
|
||||||
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)]:
|
|
||||||
monkeypatch.setattr(ba, name, fn)
|
monkeypatch.setattr(ba, name, fn)
|
||||||
|
|
||||||
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
|
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
|
||||||
@@ -238,16 +235,17 @@ async def test_filter_judges_run_parallel(board_env, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
async def test_empty_subblocks_completes_without_deadletter(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 asyncio
|
||||||
import board_artefacts as ba
|
import board_artefacts as ba
|
||||||
import blocks as blx
|
import blocks as blx
|
||||||
db, ctx, files = board_env
|
db, ctx, files = board_env
|
||||||
|
|
||||||
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
|
async def empty_gen(ctx, files, title, description, instructions="", ns="", lbl="",
|
||||||
return {}
|
sources=None, seeds=None):
|
||||||
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
|
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
|
||||||
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
|
monkeypatch.setattr(ba, "_generate_block", empty_gen)
|
||||||
|
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "generate",
|
||||||
{"title": "Leerer Block", "description": "d"})
|
{"title": "Leerer Block", "description": "d"})
|
||||||
ok = await asyncio.wait_for(
|
ok = await asyncio.wait_for(
|
||||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||||
@@ -712,18 +710,18 @@ def test_agent_priority_order():
|
|||||||
|
|
||||||
|
|
||||||
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
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."""
|
parallel zu den restlichen Artefakt-Stages des langsamsten Blocks."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import board_artefacts as ba
|
import board_artefacts as ba
|
||||||
db, ctx, files = board_env
|
db, ctx, files = board_env
|
||||||
await _seed(db)
|
await _seed(db)
|
||||||
base_levels = ba._levels_block
|
base_verify = ba._verify_block
|
||||||
snapshot = {}
|
snapshot = {}
|
||||||
|
|
||||||
async def slow_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None):
|
||||||
await asyncio.sleep(0.8) # keeps one card in `levels` while the outline fires
|
await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires
|
||||||
return await base_levels(ctx, set_p, files, raw, instructions, ns=ns, lbl=lbl)
|
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl, sources=sources)
|
||||||
|
|
||||||
base_outline = ba._outline_block
|
base_outline = ba._outline_block
|
||||||
|
|
||||||
@@ -732,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")
|
snapshot["unfinished"] = sum(1 for c in cards if c["stage"] != "done_artefact")
|
||||||
return await base_outline(ctx, set_p, files, entries, instructions)
|
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)
|
monkeypatch.setattr(ba, "_outline_block", spy_outline)
|
||||||
ok = await asyncio.wait_for(
|
ok = await asyncio.wait_for(
|
||||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||||
@@ -750,7 +748,7 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
|
|||||||
db, ctx, files = board_env
|
db, ctx, files = board_env
|
||||||
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block",
|
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block",
|
||||||
{"title": "Alpha", "description": "d"})
|
{"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": {
|
{"title": "Alpha", "facts": {"Alpha": {"sub eins": {
|
||||||
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
|
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
|
||||||
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
|
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
|
||||||
@@ -764,16 +762,16 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
|
|||||||
|
|
||||||
async def test_card_view_stepper(testdb):
|
async def test_card_view_stepper(testdb):
|
||||||
"""Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar."""
|
"""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"}}
|
"payload": {"title": "Alpha"}}
|
||||||
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Facts check 1/2…", "step": "Facts check"}})
|
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Fix 1/2…", "step": "Fix"}})
|
||||||
assert v["status"] == "active" and v["info"] == "Facts check 1/2…"
|
assert v["status"] == "active" and v["info"] == "Fix 1/2…"
|
||||||
assert v["step_i"] == 2 and v["step_n"] == 3 and v["steps"][0] == "Facts find"
|
assert v["step_i"] == 2 and v["step_n"] == 2 and v["steps"][0] == "Verify"
|
||||||
# legacy plain-string live info → no stepper, no crash
|
# legacy plain-string live info → no stepper, no crash
|
||||||
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"})
|
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Verify 0/1…"})
|
||||||
assert v2["info"] == "Facts find 0/1…" and "step_n" not in v2
|
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
|
# 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
|
assert "step_n" not in v3
|
||||||
|
|
||||||
|
|
||||||
@@ -808,17 +806,17 @@ async def test_seed_map_resolves_cascade(testdb):
|
|||||||
|
|
||||||
|
|
||||||
def test_per_block_functions_accept_wrapper_kwargs():
|
def test_per_block_functions_accept_wrapper_kwargs():
|
||||||
"""Die board_artefacts-Wrapper übergeben ns/lbl (subblocks auch seeds) — ein fehlender
|
"""Die board_artefacts-Prozessoren übergeben ns/lbl (generate auch seeds/sources) —
|
||||||
Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur)."""
|
ein fehlender Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken
|
||||||
|
die Signatur)."""
|
||||||
import inspect
|
import inspect
|
||||||
import blocks as blx
|
import block_calls as bc
|
||||||
for fn in ("_subblocks_block", "_facts_block", "_levels_block", "_relevance_block",
|
for fn in ("_generate_block", "_verify_block", "_artefakte_block"):
|
||||||
"_question_pattern_block", "_artefacts_block"):
|
params = inspect.signature(getattr(bc, fn)).parameters
|
||||||
params = inspect.signature(getattr(blx, fn)).parameters
|
|
||||||
assert "ns" in params and "lbl" in params, fn
|
assert "ns" in params and "lbl" in params, fn
|
||||||
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
|
assert "seeds" in inspect.signature(bc._generate_block).parameters
|
||||||
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
|
for fn in ("_generate_block", "_verify_block"): # Board 2 reicht die Block-Quellen durch
|
||||||
assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn
|
assert "sources" in inspect.signature(getattr(bc, fn)).parameters, fn
|
||||||
|
|
||||||
|
|
||||||
# ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ─────────────────────
|
# ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ─────────────────────
|
||||||
@@ -906,7 +904,7 @@ async def _run_flow(ctx, files, timeout=30, **kw):
|
|||||||
|
|
||||||
|
|
||||||
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
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
|
import qa as qa_mod
|
||||||
db, ctx, files = board_env
|
db, ctx, files = board_env
|
||||||
|
|
||||||
@@ -918,7 +916,7 @@ async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
|||||||
await _seed(db)
|
await _seed(db)
|
||||||
ok = await _run_flow(ctx, files)
|
ok = await _run_flow(ctx, files)
|
||||||
assert ok
|
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 len(warten) == 4 # alle Blöcke gespawnt, keiner verarbeitet
|
||||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
|
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
|
||||||
|
|
||||||
@@ -981,7 +979,7 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
|
|||||||
(tmp_path / TOPIC).mkdir()
|
(tmp_path / TOPIC).mkdir()
|
||||||
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
|
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
|
||||||
{"note": 5.0, "quoten": {"fremd": 0.2}, "fremd": ["X"], "unecht": ["Y"]}), encoding="utf-8")
|
{"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)
|
v = bi._qa_view(TOPIC, counts, None)
|
||||||
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
|
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -1115,11 +1113,11 @@ def test_hat_anker_ziffern_suffix():
|
|||||||
assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker
|
assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker
|
||||||
|
|
||||||
|
|
||||||
async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
|
async def test_reset_generate_loescht_globale_dateien(testdb, tmp_path):
|
||||||
"""Reset auf Spalte subblocks: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
|
"""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."""
|
weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen."""
|
||||||
db = testdb
|
db = testdb
|
||||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefacts", {"title": "Alpha"})
|
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"})
|
||||||
await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus")
|
await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus")
|
||||||
arbeit = tmp_path / "arbeit"
|
arbeit = tmp_path / "arbeit"
|
||||||
(arbeit / "ab-alpha").mkdir(parents=True)
|
(arbeit / "ab-alpha").mkdir(parents=True)
|
||||||
@@ -1128,7 +1126,7 @@ async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
|
|||||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
||||||
files[k] = tmp_path / f"{k}.json"
|
files[k] = tmp_path / f"{k}.json"
|
||||||
files[k].write_text("{}", encoding="utf-8")
|
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 moved == 1
|
||||||
assert not await db.list_subblocks(TOPIC)
|
assert not await db.list_subblocks(TOPIC)
|
||||||
assert not (arbeit / "ab-alpha").exists()
|
assert not (arbeit / "ab-alpha").exists()
|
||||||
|
|||||||
@@ -84,8 +84,9 @@ async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
|
|||||||
|
|
||||||
@pytest.mark.parametrize("stoerung", [
|
@pytest.mark.parametrize("stoerung", [
|
||||||
{"muster": r"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE
|
{"muster": r"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE
|
||||||
{"muster": r"-sub-konsolidierung-.*-j1$", "modus": "garbage", "mal": 1}, # Retry heilt
|
{"muster": r"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE
|
||||||
{"muster": r"-facts-c\d+$", "modus": "fehler", "mal": 1}, # Slot-Restart
|
{"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
|
{"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot
|
||||||
])
|
])
|
||||||
async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
|
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.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?")
|
||||||
await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}")
|
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 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 await db.list_subblocks(TOPIC, "alpha") == []
|
||||||
assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched
|
assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched
|
||||||
assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False
|
assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
|
"""Cross-Block-Konsolidierung (board_artefacts._proc_konsolidierung) und Finalize —
|
||||||
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""
|
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 json
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
|
||||||
|
|
||||||
import blocks
|
import blocks
|
||||||
import board_artefacts as ba
|
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")
|
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():
|
def test_luecken_schnitt_cap():
|
||||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
assert blocks._luecken_schnitt(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")
|
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):
|
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||||
db = testdb
|
db = testdb
|
||||||
@@ -486,11 +189,13 @@ async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypat
|
|||||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||||
|
|
||||||
|
|
||||||
async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkeypatch):
|
async def test_crossblock_nachzuegler_zurueck_zum_erzeugen(testdb, tmp_path, monkeypatch):
|
||||||
"""Resume-Karte aus der alten Stage-Position (kein pattern im Payload) → zurück nach
|
"""Resume-Karte ohne pattern im Payload → zurück nach generate (bzw. artefakte bei
|
||||||
question_pattern, KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
|
vorhandenem sidecar), KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
|
||||||
db = testdb
|
db = testdb
|
||||||
flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False)
|
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):
|
async def kein_agent(*a, **kw):
|
||||||
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
|
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
|
||||||
@@ -498,8 +203,8 @@ async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkey
|
|||||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||||
for cid in ("alpha", "beta"):
|
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
|
||||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"
|
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")}
|
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||||
|
|
||||||
@@ -558,47 +263,3 @@ async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
|
|||||||
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
|
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
|
||||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||||
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet
|
assert set(rows.values()) == {"variant"} # 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"]}
|
|
||||||
|
|||||||
@@ -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 json
|
||||||
import re
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
|
||||||
|
|
||||||
import blocks as blx
|
import blocks as blx
|
||||||
from pipeline import GenContext
|
from pipeline import GenContext
|
||||||
|
|
||||||
TOPIC = "t"
|
TOPIC = "t"
|
||||||
_MD_PATH = re.compile(r"(/\S+\.md)")
|
|
||||||
|
|
||||||
|
|
||||||
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
|
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
|
||||||
@@ -37,157 +35,6 @@ def test_variant_clusters_negation_guard():
|
|||||||
assert len(cl) == 2 # antonyms never merge, no matter the cosine
|
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, late=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: # Finder antworten als TEXT (Marker-Format), kein out_path mehr
|
|
||||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
|
||||||
outs.append(slot["payload"]((0, text, "")))
|
|
||||||
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
|
|
||||||
text = "<!-- block: Alpha -->\n- Vertiefung der Konzepte"
|
|
||||||
return [slot["payload"]((0, text, "")) 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 ───────────────────────────────────────────────────────────────────
|
# ── Outline-Review ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_outline_review_schema():
|
def test_outline_review_schema():
|
||||||
@@ -238,52 +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, "")
|
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||||
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
|
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):
|
# ── Inline-Evidenz (Evidence-Packs für die verschmolzenen Calls) ─────────────────────
|
||||||
if any("-r2-" in s["key"] for s in slots):
|
|
||||||
text = "<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!"
|
|
||||||
return [slot["payload"]((0, text, "")) for slot in slots[:2]]
|
|
||||||
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]:
|
|
||||||
prompts.append((slot["key"], slot["prompt"]))
|
|
||||||
outs.append(slot["payload"]((0, f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig", "")))
|
|
||||||
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) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
def _corpus(tmp_path):
|
def _corpus(tmp_path):
|
||||||
d = tmp_path / "korpus"
|
d = tmp_path / "korpus"
|
||||||
@@ -348,51 +151,6 @@ def test_sink_json_writes_only_valid(tmp_path):
|
|||||||
assert bad is None and not (tmp_path / "x.json").exists()
|
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 UND Finder bekommen Auszüge inline und laufen ohne Tools
|
|
||||||
(Text-Antwort); die Dateien schreibt die Engine. Der Finder verlor vorher 3–13
|
|
||||||
Tool-Runden pro Call mit der Material-Suche via glob/grep/bash."""
|
|
||||||
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"] == "none" for s in finders)
|
|
||||||
assert "── Skript.txt" in finders[0]["prompt"] # Auszüge inline statt Dateisystem-Suche
|
|
||||||
assert "web search" not in finders[0]["prompt"]
|
|
||||||
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
|
|
||||||
|
|
||||||
|
|
||||||
async def test_thema_nutzt_research_material_inline(sub_env, monkeypatch, tmp_path):
|
|
||||||
"""thema mit Research-Fundstellen (arbeit/material/*.txt): Finder bekommt sie inline
|
|
||||||
und läuft ohne Tools — vorher eigene Websuche pro Call (Reasoning-Schleifen, Retries)."""
|
|
||||||
db, ctx, files = sub_env
|
|
||||||
monkeypatch.setattr(blx, "source_folder", lambda t: None)
|
|
||||||
md = tmp_path / "arbeit" / "material"
|
|
||||||
md.mkdir(parents=True)
|
|
||||||
(md / "research-1.txt").write_text(
|
|
||||||
"https://example.org/alpha\nAlpha Grundlagen: der Kernbegriff, gut belegt.\n",
|
|
||||||
encoding="utf-8")
|
|
||||||
monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit")
|
|
||||||
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-")
|
|
||||||
assert raw == {"Alpha": ["Alpha Grundlagen"]}
|
|
||||||
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
|
|
||||||
assert finders and all(s["capabilities"] == "none" for s in finders)
|
|
||||||
assert "── research-1.txt" in finders[0]["prompt"] # Fundstellen inline
|
|
||||||
|
|
||||||
|
|
||||||
def test_material_folder_fallbacks(monkeypatch, tmp_path):
|
def test_material_folder_fallbacks(monkeypatch, tmp_path):
|
||||||
"""Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None."""
|
"""Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None."""
|
||||||
monkeypatch.setattr(blx, "source_folder", lambda t: tmp_path / "quelle")
|
monkeypatch.setattr(blx, "source_folder", lambda t: tmp_path / "quelle")
|
||||||
@@ -407,115 +165,6 @@ def test_material_folder_fallbacks(monkeypatch, tmp_path):
|
|||||||
assert blx.material_folder("t") == md
|
assert blx.material_folder("t") == md
|
||||||
|
|
||||||
|
|
||||||
async def test_panel_2of3_kehrt_bei_einigkeit_zurueck(tmp_path):
|
|
||||||
"""Zwei übereinstimmende Verdicts → Rückkehr ohne den langsamen Dritten; sein
|
|
||||||
Ergebnis wird detached nachpersistiert (Resume)."""
|
|
||||||
import asyncio as aio
|
|
||||||
gesunken = {}
|
|
||||||
|
|
||||||
async def judge(j, delay, antwort):
|
|
||||||
await aio.sleep(delay)
|
|
||||||
return (0, antwort, "")
|
|
||||||
|
|
||||||
tasks = {aio.create_task(judge(1, 0.01, "a")): 1,
|
|
||||||
aio.create_task(judge(2, 0.02, "a")): 2,
|
|
||||||
aio.create_task(judge(3, 5.0, "b")): 3}
|
|
||||||
|
|
||||||
def sink(j, r):
|
|
||||||
gesunken[j] = r[1]
|
|
||||||
|
|
||||||
import time
|
|
||||||
t0 = time.monotonic()
|
|
||||||
await blx._panel_2of3(tasks, sink, lambda: list(gesunken.values()), lambda s: s)
|
|
||||||
assert time.monotonic() - t0 < 1.0 # nicht auf j3 gewartet
|
|
||||||
assert gesunken == {1: "a", 2: "a"}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_panel_2of3_dissens_wartet_auf_dritten():
|
|
||||||
"""Uneinige erste zwei → der dritte wird abgewartet (Mehrheit braucht ihn)."""
|
|
||||||
import asyncio as aio
|
|
||||||
gesunken = {}
|
|
||||||
|
|
||||||
async def judge(j, delay, antwort):
|
|
||||||
await aio.sleep(delay)
|
|
||||||
return (0, antwort, "")
|
|
||||||
|
|
||||||
tasks = {aio.create_task(judge(1, 0.01, "a")): 1,
|
|
||||||
aio.create_task(judge(2, 0.02, "b")): 2,
|
|
||||||
aio.create_task(judge(3, 0.1, "a")): 3}
|
|
||||||
await blx._panel_2of3(tasks, lambda j, r: gesunken.__setitem__(j, r[1]),
|
|
||||||
lambda: list(gesunken.values()), lambda s: s)
|
|
||||||
assert gesunken == {1: "a", 2: "b", 3: "a"}
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
monkeypatch.setattr(blx, "source_folder", lambda t: d)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def test_facts_find_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
|
|
||||||
"""Facts find/erg mit Korpus: Auszüge inline, Agent ohne Tools — Tool-Agenten
|
|
||||||
verloren sich in Reasoning-Schleifen und endeten mit leerem Turn (Retry-Wellen)."""
|
|
||||||
db, ctx, files = sub_env
|
|
||||||
d = _corpus(tmp_path)
|
|
||||||
monkeypatch.setattr(blx, "source_folder", lambda t: d)
|
|
||||||
seen = []
|
|
||||||
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
|
|
||||||
"prerequisites": "", "hurdles": "",
|
|
||||||
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
|
|
||||||
"example_idea": ""}]}
|
|
||||||
|
|
||||||
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
|
||||||
seen.append((key, capabilities, prompt))
|
|
||||||
return blx.OK, payload((0, json.dumps(facts), ""))
|
|
||||||
|
|
||||||
async def fake_agent(key, prompt, timeout, **kw): # Check-Panel
|
|
||||||
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
|
|
||||||
finder = [s for s in seen if "-facts-c0" in s[0] or "-facts-erg-" in s[0]]
|
|
||||||
assert finder and all(caps == "none" for _, caps, _ in finder)
|
|
||||||
assert all("── Skript.txt" in prompt for _, _, prompt in finder) # Auszüge inline
|
|
||||||
assert all("ls/find" not in prompt for _, _, prompt in finder)
|
|
||||||
|
|
||||||
|
|
||||||
def test_sub_key_resolves_short_titles():
|
def test_sub_key_resolves_short_titles():
|
||||||
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
|
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
|
||||||
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""
|
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""
|
||||||
|
|||||||
@@ -65,17 +65,17 @@ def _trainer(tmp_path, runner, f0, **kw):
|
|||||||
|
|
||||||
|
|
||||||
async def test_aco_konvergiert_auf_optimum(tmp_path):
|
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."""
|
die Pheromon-Spur konzentriert sich dort."""
|
||||||
def bewertung(params):
|
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)
|
runner, f0 = _stub(bewertung)
|
||||||
t = _trainer(tmp_path, runner, f0, max_trials=120)
|
t = _trainer(tmp_path, runner, f0, max_trials=120)
|
||||||
best = await t.run()
|
best = await t.run()
|
||||||
assert best.get("FACTS_CHUNK_SUBS") == 6
|
assert best.get("GEN_PANEL") == 3
|
||||||
taus = t.pheromon["FACTS_CHUNK_SUBS"]
|
taus = t.pheromon["GEN_PANEL"]
|
||||||
assert max(taus, key=lambda k: taus[k]) == "6"
|
assert max(taus, key=lambda k: taus[k]) == "3"
|
||||||
|
|
||||||
|
|
||||||
async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
||||||
@@ -83,7 +83,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
|||||||
zustand = {"mal": 0}
|
zustand = {"mal": 0}
|
||||||
|
|
||||||
def bewertung(params):
|
def bewertung(params):
|
||||||
if params.get("FACTS_CHUNK_SUBS") == 6:
|
if params.get("GEN_PANEL") == 3:
|
||||||
zustand["mal"] += 1
|
zustand["mal"] += 1
|
||||||
return _metrics(note=9.5) if zustand["mal"] == 1 else _metrics(note=8.0)
|
return _metrics(note=9.5) if zustand["mal"] == 1 else _metrics(note=8.0)
|
||||||
return _metrics()
|
return _metrics()
|
||||||
@@ -91,7 +91,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
|
|||||||
runner, f0 = _stub(bewertung)
|
runner, f0 = _stub(bewertung)
|
||||||
t = _trainer(tmp_path, runner, f0, max_trials=40)
|
t = _trainer(tmp_path, runner, f0, max_trials=40)
|
||||||
best = await t.run()
|
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):
|
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):
|
async def test_resume_laedt_pheromon_und_cache(tmp_path):
|
||||||
def bewertung(params):
|
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)
|
runner, f0 = _stub(bewertung)
|
||||||
t = _trainer(tmp_path, runner, f0, max_trials=60)
|
t = _trainer(tmp_path, runner, f0, max_trials=60)
|
||||||
await t.run()
|
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)
|
runner2, f02 = _stub(bewertung)
|
||||||
t2 = _trainer(tmp_path, runner2, f02, max_trials=0) # kein Budget: alles aus Persistenz
|
t2 = _trainer(tmp_path, runner2, f02, max_trials=0) # kein Budget: alles aus Persistenz
|
||||||
assert t2.best_params == best
|
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):
|
async def test_budget_stoppt(tmp_path):
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ async def _frozen_inventar(topic: str, vorlage: str) -> None:
|
|||||||
shutil.copy(src, dst)
|
shutil.copy(src, dst)
|
||||||
files = _blocks_files(topic)
|
files = _blocks_files(topic)
|
||||||
files["arbeit"].mkdir(parents=True, exist_ok=True)
|
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:
|
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_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"},
|
"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"},
|
"CONSOLIDATION_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"},
|
||||||
# Subbausteine
|
# Board 2, verschmolzene Calls (block_calls.py)
|
||||||
"SUBBLOCK_CHUNK": {"default": 10, "min": 4, "max": 20, "step": 2, "kategorie": "laufzeit", "fidelity": "board2"},
|
"GEN_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||||
"SUBBLOCK_MIN": {"default": 5, "min": 2, "max": 10, "step": 1, "kategorie": "auswahl", "fidelity": "board2"},
|
"VERIFY_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||||
"SUBBLOCK_MAX_ROUNDS": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "auswahl", "fidelity": "board2"},
|
"ART_SPLIT_SUBS": {"default": 20, "min": 10, "max": 40, "step": 5, "kategorie": "laufzeit", "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"},
|
|
||||||
# Embedding-Schwellen (Auswahl-Kern)
|
# Embedding-Schwellen (Auswahl-Kern)
|
||||||
"SUB_VARIANT_COS": {"default": 0.90, "min": 0.85, "max": 0.96, "step": 0.01, "kategorie": "auswahl", "fidelity": "board2"},
|
"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"},
|
"SEED_COVER_COS": {"default": 0.80, "min": 0.7, "max": 0.9, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
|
||||||
|
|||||||
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}
|
|
||||||
@@ -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,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,27 +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}
|
|
||||||
{material}
|
|
||||||
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.
|
|
||||||
- {backing}
|
|
||||||
- Subblock titles in GERMAN (code identifiers stay original), max. ~10 words, one statement.
|
|
||||||
|
|
||||||
Reply with ONLY the subblock lists in this exact marker format — one block marker per block (title EXACTLY from the assignment), the subblocks as a list below it. No code fences, no text outside the blocks:
|
|
||||||
|
|
||||||
<!-- block: Exact block title -->
|
|
||||||
- First subblock
|
|
||||||
- Second subblock
|
|
||||||
{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