diff --git a/.env.example b/.env.example index 3b4e0eb..ccbbc4b 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,14 @@ MINIMAX_API_KEY= #ROLE_JUDGE= #ROLE_GUIDE= #ROLE_FAST= + +# Parallelität (Defaults: siehe backend/config.py). Prozess-Tier = opencode-Prozesse +# (~310 MB RSS pro Agent), API-Tier = direkte MiniMax-Calls für tool-lose Agenten (~0 RAM). +# Auf 8-GB-Maschinen Prozess-Limit klein halten; das API-Tier darf hoch. +#MAX_CONCURRENT_AGENTS=8 +#MAX_CONCURRENT_AGENTS_PER_TOPIC=24 +#MAX_CONCURRENT_API_AGENTS=28 +# opencode-Starts warten, wenn weniger als dieser Anteil RAM frei ist (0 = aus). +#RAM_MIN_FREE_PCT=20 +# Kill-Switch: 0 = Text-Calls wieder über opencode-Prozesse statt direkter API. +#CREATOR_TEXT_API=0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fc5cdbc --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/backend/agents.py b/backend/agents.py index d1c75c0..d64135b 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -22,13 +22,16 @@ import urllib.request from contextlib import asynccontextmanager from pathlib import Path +import httpx + from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS, - MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_INTERACTIVE, - resolve_role) + MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_API_AGENTS, + MAX_CONCURRENT_INTERACTIVE, RAM_MIN_FREE_PCT, resolve_role) log = logging.getLogger("creator.agents") _active_processes: dict[str, asyncio.subprocess.Process] = {} +_active_api: set[str] = set() # agent_keys of running direct-API calls (no process to kill) _active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display) _active_labels: dict[str, str] = {} # agent_key → human-readable label (for display + events) @@ -39,7 +42,8 @@ def active_agents(scope_prefix: str | None = None) -> list[dict]: now = time.time() out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)} for k, t in list(_active_started.items()) - if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))] + if (k in _active_processes or k in _active_api) + and (not scope_prefix or k.startswith(scope_prefix))] return sorted(out, key=lambda a: -a["runtime"]) # Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose @@ -95,7 +99,8 @@ class _PrioritySemaphore: self._value += 1 -_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS) +_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS) # process tier (~310 MB RSS each) +_batch_sem_api = _PrioritySemaphore(MAX_CONCURRENT_API_AGENTS) # direct-API tier (~0 RAM) _interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE) # Per-topic caps (lazily created): each topic gets its own priority semaphore of size @@ -119,17 +124,19 @@ def _agent_priority(key: str) -> int: @asynccontextmanager -async def _batch_gate(scope: str | None, priority: int): +async def _batch_gate(scope: str | None, priority: int, api: bool = False): """Per-topic slot FIRST (fair), then the GLOBAL slot by priority (earlier columns win when - agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally.""" + agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally. + The per-topic cap is shared across both tiers; only the global cap is tiered (process vs API).""" topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None + global_sem = _batch_sem_api if api else _batch_sem if topic_sem is not None: await topic_sem.acquire(priority) - await _batch_sem.acquire(priority) + await global_sem.acquire(priority) try: yield finally: - _batch_sem.release() + global_sem.release() if topic_sem is not None: topic_sem.release() @@ -151,6 +158,55 @@ async def _opencode_slot() -> None: _opencode_next_start = start_at + _OPENCODE_START_DELAY await asyncio.sleep(max(0.0, start_at - now)) +# RAM-adaptive admission for opencode spawns (~310 MB RSS each): below RAM_MIN_FREE_PCT +# free memory new processes wait instead of starting. Gates admission only — running +# processes are never touched. +_RAM_GATE_FLOOR = 2 # below this many running: always admit (deadlock guard) +_RAM_PER_PROC_KB = 350 * 1024 # commit estimate: RSS ramps up slowly after spawn +_RAM_COMMIT_WINDOW_S = 10.0 # fresh admissions count as already-spent RAM +_RAM_POLL_S = 2.0 +_opencode_running = 0 # opencode spawns only (stagger path), not claude +_opencode_recent_starts: list[float] = [] # monotonic timestamps of admissions + + +def _meminfo() -> tuple[int, int] | None: + """(MemAvailable_kB, MemTotal_kB) from /proc/meminfo; None → gate fails open (non-Linux).""" + try: + text = Path("/proc/meminfo").read_text() + except OSError: + return None + m = {k: v for k, v in re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE)} + if "MemTotal" not in m or "MemAvailable" not in m: + return None + return int(m["MemAvailable"]), int(m["MemTotal"]) + + +async def _ram_gate(agent_key: str) -> bool: + """True = start admitted (commit registered), False = scope cancelled while waiting. + Check and commit-append happen in the same synchronous block (no await between) — + concurrent waiters on the loop cannot double-admit on the same free RAM.""" + if RAM_MIN_FREE_PCT <= 0: + return True + waited = False + while True: + if _scope_cancelled(agent_key): + return False + mem = _meminfo() + if mem is None or _opencode_running < _RAM_GATE_FLOOR: + break # fail open / floor + avail_kb, total_kb = mem + now = time.monotonic() + _opencode_recent_starts[:] = [t for t in _opencode_recent_starts + if now - t < _RAM_COMMIT_WINDOW_S] + if avail_kb - len(_opencode_recent_starts) * _RAM_PER_PROC_KB >= total_kb * RAM_MIN_FREE_PCT / 100: + break + if not waited: + log.info("agent %s: RAM gate waiting (%.0f%% free)", agent_key, avail_kb * 100 / total_kb) + waited = True + await asyncio.sleep(_RAM_POLL_S) + _opencode_recent_starts.append(time.monotonic()) + return True + _SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json" # Capability → Claude --allowedTools @@ -170,6 +226,17 @@ _OPENCODE_AGENTS = { } +def _use_text_api(provider: str, model: str, capabilities: str, on_line) -> bool: + """Direct API path only for tool-less, non-streaming MiniMax calls. Model-prefix check + instead of cli check: "lokal" (ollama) also runs via opencode. Missing key or + CREATOR_TEXT_API=0 (kill switch) falls back to the opencode process path.""" + return (capabilities == "none" and on_line is None + and PROVIDERS[provider]["cli"] == "opencode" + and model.split("/", 1)[0] in ("minimax", "minimax-kalt") + and bool(os.environ.get("MINIMAX_API_KEY")) + and os.getenv("CREATOR_TEXT_API", "1") != "0") + + def provider_available(provider: str) -> bool: cfg = PROVIDERS.get(provider) if not cfg: @@ -258,10 +325,11 @@ async def run_agent( provider, model = run_provider, PROVIDERS[run_provider].get(role, "") if not model: return 1, "", f"No model for role '{role}' (provider: {provider})" - if shutil.which(PROVIDERS[provider]["cli"]) is None: + use_api = _use_text_api(provider, model, capabilities, on_line) + if not use_api and shutil.which(PROVIDERS[provider]["cli"]) is None: return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})" queued = time.monotonic() - gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key)) + gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key), api=use_api) async with gate: if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn return 1, "", "cancelled" @@ -270,9 +338,13 @@ async def run_agent( status = "error" rc = None err_tail = "" + api_tokens = None try: log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role) - if PROVIDERS[provider]["cli"] == "opencode": + if use_api: + rc_, out_, err_, api_tokens = await _run_text_api(agent_key, prompt, timeout, model, label=label) + res = (rc_, out_, err_) + elif PROVIDERS[provider]["cli"] == "opencode": res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label) else: res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label) @@ -293,7 +365,9 @@ async def run_agent( meta = {"provider": provider, "model": model, "role": role, "rc": rc} if err_tail: meta["stderr"] = err_tail - if PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent + if api_tokens: # direct-API path: usage from the response, even on rc!=0 + meta["tokens"] = api_tokens + elif PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent if (tok := await asyncio.to_thread(_session_tokens, agent_key)): meta["tokens"] = tok await on_event(topic=scope, kind="agent", key=agent_key, label=label, @@ -316,9 +390,14 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, env=env, ) + global _opencode_running if stagger: - await _opencode_slot() # spaced start slot; spawn itself is not serialized + if not await _ram_gate(agent_key): + return 1, "", "cancelled" # like the cancelled path in run_agent + await _opencode_slot() # gate BEFORE the start slot: an admission wave still gets spaced process = await spawn() + if stagger: + _opencode_running += 1 # Collision-safe tracking: identical keys (e.g. same chunk label from parallel cards) # get a ~n suffix — prefix-based kill/cancel still matches, nothing becomes an orphan. track_key = agent_key @@ -366,6 +445,8 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, ) return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") finally: + if stagger: + _opencode_running -= 1 # Pop only on identity: a slot restart under the same key must not evict # the NEW process from tracking. if _active_processes.get(track_key) is process: @@ -441,6 +522,67 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str prompt_path.unlink(missing_ok=True) +# Direct text-API path (MiniMax, Anthropic Messages format): saves the ~310 MB RSS opencode +# process for tool-less calls. The native "minimax" and "minimax-kalt" opencode providers both +# resolve to this base URL — only the per-model options differ. Options source of truth: +# dev-ops/opencode.json (+ -slim); keep in sync on changes there. +_API_URL = "https://api.minimax.io/anthropic/v1/messages" +_API_VERSION = "2023-06-01" +_API_MAX_TOKENS = 32_000 # required Messages field; generate calls are long +_API_MODEL_OPTS = { # prefix "minimax" (native) → endpoint defaults (no entry) + "minimax-kalt/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}}, + "minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3}, +} + + +async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str, + label: str = "") -> tuple[int, str, str, dict | None]: + """→ (rc, text, err, tokens). Tokens come straight from the response usage (same key set + as _session_tokens) and are returned even on rc!=0 — waste stays visible.""" + body = { + "model": model.split("/", 1)[1], # without the opencode provider prefix + "max_tokens": _API_MAX_TOKENS, + "messages": [{"role": "user", "content": prompt}], + **_API_MODEL_OPTS.get(model, {}), + } + headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION} + track_key = agent_key + n = 2 + while track_key in _active_api: + track_key = f"{agent_key}~{n}" + n += 1 + _active_api.add(track_key) + _active_started[track_key] = time.time() + _active_labels[track_key] = label + start = time.monotonic() + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client: + resp = await asyncio.wait_for( # belt: hard wall-clock cap like the process path + client.post(_API_URL, json=body, headers=headers), timeout=timeout) + except httpx.TimeoutException: + raise asyncio.TimeoutError # contract: run_agent/_race handle TimeoutError + except httpx.HTTPError as e: + return 1, "", f"{type(e).__name__}: {e}", None + finally: + _active_api.discard(track_key) + _active_started.pop(track_key, None) + _active_labels.pop(track_key, None) + log.info("agent %s: api done after %.1fs", agent_key, time.monotonic() - start) + if resp.status_code != 200: + return 1, "", f"HTTP {resp.status_code}: {resp.text[:300]}", None + data = resp.json() + # Only text blocks count — thinking blocks (native route) are skipped. + text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text") + u = data.get("usage") or {} + tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0), + "reasoning": 0, "cache_read": int(u.get("cache_read_input_tokens") or 0), + "cache_write": int(u.get("cache_creation_input_tokens") or 0)} + if not text.strip(): + return 1, "", f"empty response (stop_reason={data.get('stop_reason')})", tokens + err = "stop_reason=max_tokens (truncated)" if data.get("stop_reason") == "max_tokens" else "" + return 0, text, err, tokens + + _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") diff --git a/backend/block_calls.py b/backend/block_calls.py new file mode 100644 index 0000000..2339595 --- /dev/null +++ b/backend/block_calls.py @@ -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]}} diff --git a/backend/blocks.py b/backend/blocks.py index c6e82e8..1ec885d 100644 --- a/backend/blocks.py +++ b/backend/blocks.py @@ -25,7 +25,7 @@ from pathlib import Path import database as db import embedding from agents import kill_process, cancel_scope, clear_scope, run_agent -from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR, SUB_VARIANT_COS, SEED_COVER_COS, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES +from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR, SUB_VARIANT_COS, SEED_COVER_COS, SUBBLOCK_MAX, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES from fsutil import atomic_write_text, atomic_write_json from jsonio import parse_json_text, read_json_file as _json_file from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder @@ -41,12 +41,10 @@ from textkit import ( # Pipeline-Tuning-Konstanten liegen zentral in config.py (tunebar via CREATOR_PARAMS). from config import ( # noqa: E402 - ARTEFACT_CHUNK_SUBS, CONSOLIDATION_CHUNK, CONSOLIDATION_PANEL, DEDUP_GLOBAL_FLOOR, - DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FACTS_CHECK_PANEL, FACTS_CHUNK_SUBS, - FILTER_CHUNK, FILTER_RECHECK_PANEL, LEVEL_CHUNK, QUESTION_CHUNK_SUBS, QUESTION_MAX_ROUNDS, - RESEARCH_BATCH, RESEARCH_READERS, RESEARCH_SECTION_CHARS, RESEARCH_THEMA_AGENTS, - SUBBLOCK_CAP, SUBBLOCK_CHUNK, SUBBLOCK_EXTRA_ROUNDS, SUBBLOCK_MAX, SUBBLOCK_MAX_ROUNDS, - SUBBLOCK_MIN, SUBBLOCK_PANEL) + CONSOLIDATION_CHUNK, CONSOLIDATION_PANEL, DEDUP_GLOBAL_FLOOR, + DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, + FILTER_CHUNK, FILTER_RECHECK_PANEL, + RESEARCH_BATCH, RESEARCH_READERS, RESEARCH_SECTION_CHARS, RESEARCH_THEMA_AGENTS) log = logging.getLogger("creator.blocks") @@ -184,13 +182,9 @@ def _blocks_steps(topic: str) -> tuple: q = load_source(topic) base = ("Research", "Consolidation", "Clarification", "Dedup", "Blocks-Filter", "Blocks-Gruppierung") rest = ( - "Subblocks find", "Subblocks select", "Subblocks clarify", - "Facts find", "Facts check", "Facts fix", - "Levels find", "Levels select", "Levels clarify", - "Relevance find", "Relevance select", "Relevance clarify", + "Generate", "Verify", "Fix", "Outline", - "Questions find", "Questions select", "Questions clarify", "Questions check", - "Flashcards", "Examples", + "Artefakte gen", "Artefakte check", ) middle = base + (("Supplement",) if q["type"] == "projekt" else ()) + rest return (("Source prep",) if q["type"] == "link" else ()) + middle @@ -213,13 +207,10 @@ def _report_p(set_p, topic: str, step: str): PHASEN = ( ("Source", ("Source prep",)), ("Inventory", ("Research", "Consolidation", "Clarification", "Dedup", "Blocks-Filter", "Blocks-Gruppierung", "Supplement")), - ("Subblocks", ("Subblocks find", "Subblocks select", "Subblocks clarify")), - ("Facts", ("Facts find", "Facts check", "Facts fix")), - ("Levels", ("Levels find", "Levels select", "Levels clarify")), - ("Relevance", ("Relevance find", "Relevance select", "Relevance clarify")), + ("Generate", ("Generate",)), + ("Verify", ("Verify", "Fix")), ("Outline", ("Outline",)), - ("Questions", ("Questions find", "Questions select", "Questions clarify", "Questions check")), - ("Artefacts", ("Flashcards", "Examples")), + ("Artefakte", ("Artefakte gen", "Artefakte check")), ) @@ -597,65 +588,6 @@ def _sink_json(result, path: Path, schema): return val -async def _panel_2of3(tasks: dict, sink, outs_now, norm) -> None: - """Panel-Welle „first 2 agree": kehrt zurück, sobald zwei vorliegende Verdicts - übereinstimmen — die dritte Stimme kann die Mehrheit dann nicht mehr kippen. Sonst - (Dissens) wird weiter gewartet. Der Langsamste bestimmte jede Welle (gemessen: 98 s - bei ok-p50 ~50 s). Nachzügler laufen detached weiter; ihr File dient nur dem Resume. - tasks: {Task: judge_nr} · sink(j, result) persistiert · outs_now() liest Verdicts · - norm(verdict) macht sie vergleichbar.""" - offen = dict(tasks) - while offen: - done, _rest = await asyncio.wait(list(offen), return_when=asyncio.FIRST_COMPLETED) - for t in done: - j = offen.pop(t) - try: - r = t.result() - except Exception: # noqa: BLE001 — Panel ist fail-open, Ausfall = fehlende Stimme - continue - if isinstance(r, tuple): - sink(j, r) - outs = [norm(s) for s in outs_now()] - if len(outs) >= 2 and any(outs[a] == outs[b] - for a in range(len(outs)) for b in range(a + 1, len(outs))): - break - for t, j in offen.items(): # Dritter läuft weiter — sein File zählt fürs Resume - async def _warte(t=t, j=j): - try: - r = await t - if isinstance(r, tuple): - sink(j, r) - except (asyncio.CancelledError, Exception): # noqa: BLE001 - pass - _detached(asyncio.create_task(_warte())) - - -def _sink_subs(result, path: Path): - """Finder reply as TEXT (marker format), persisted to `path` for audit/diagnosis. - File fallback: a tool-capable agent (thema web mode) that wrote the file despite the - text instruction still counts — same tolerance as _sink_or_file.""" - text = _reply_text(result).strip() - d = _parse_subblocks(text) - if d: - atomic_write_text(path, text) - return d - return _parse_subblocks(_read(path)) or None - - -def _finder_material(folder, sources: list[str] | None, queries: list[str]) -> tuple[str, str, str]: - """→ (material, backing, caps) for the Subblock-Research prompt. uni/projekt: corpus - excerpts INLINE — the finder had neither path nor excerpts and hunted the material per - call via glob/grep/bash (measured: 3–13 tool rounds, 52–141 s vs 15–35 s single-shot). - thema (or excerpt miss): web research as before, fail-open.""" - ev = _evidence_pack(folder, sources, queries) if folder else "" - if ev: - material = "\n" + _prompt("Blocks-Source-Inline", excerpts=ev) + "\n" - backing = ("Backed by the SOURCE EXCERPTS above, not invented — leave out any " - "sub-point the excerpts do not support.") - return material, backing, "none" - return "", "Backed, not invented. Verify uncertain points via web search.", ("files" if folder else "full") - - def material_folder(topic: str) -> Path | None: """Korpus fürs Inline-Material: echte Quelle (uni/projekt/link) oder bei thema die Fundstellen der Research-Reader (arbeit/material/*.txt). Nur für Evidence-Packs — @@ -698,26 +630,6 @@ def _file_payload(path: Path): -def _question_pattern_chunk_schema(data) -> list[dict] | None: - """{"pattern": [{block, subblock, question}, …]} → list of valid entries · otherwise None. - - One pattern per subblock (no type cross-product — the difficulty only comes at - exam time from the learner's tier). Invalid individual entries are skipped.""" - if not isinstance(data, dict) or not isinstance(data.get("pattern"), list): - return None - out = [] - for e in data["pattern"]: - if not isinstance(e, dict): - continue - blk = str(e.get("block", "")).strip() - sub = str(e.get("subblock", "")).strip() - question = str(e.get("question", "")).strip() - if not blk or not sub or not question: - continue - out.append({"block": blk, "subblock": sub, "question": question}) - return out or None - - def _read(p: Path) -> str: return p.read_text(encoding="utf-8") if p.exists() else "" @@ -729,33 +641,10 @@ def _chunk_nums(items: list, n: int) -> list[list]: return [items[i:i + size] for i in range(0, len(items), size)] -def _n_chunks(count: int, size: int = SUBBLOCK_CHUNK) -> int: +def _n_chunks(count: int, size: int) -> int: return min(SUBBLOCK_MAX, max(1, math.ceil(count / size))) -def _lpt_chunks(weights: list[int], target: int) -> list[list[int]]: - """Distribute indices across chunks load-balanced (LPT, makespan-minimal). Weight = cost per index. - K = ceil(total weight/target); heaviest first into the currently lightest bin. → index lists.""" - if not weights: - return [] - K = max(1, math.ceil(sum(weights) / max(1, target))) - bins: list[list[int]] = [[] for _ in range(K)] - last = [0] * K - for i in sorted(range(len(weights)), key=lambda x: weights[x], reverse=True): - j = min(range(K), key=lambda b: last[b]) - bins[j].append(i) - last[j] += weights[i] - return [b for b in bins if b] - - - - - - - - -# lemmatized: 'kein Syntaxfehler' vs 'keine Syntax-Fehlermeldung' are the SAME statement — -# unlemmatized token sets ({kein} ≠ {keine}) blocked that fold at cos 0.974 (measured). _NEG_LEMMA = {"nicht": "nicht", "ohne": "ohne", "nie": "nie", "niemals": "nie", "kein": "kein", "keine": "kein", "keinen": "kein", "keiner": "kein", "keinem": "kein", "keines": "kein"} @@ -796,437 +685,6 @@ def _variant_clusters(titles: list[str], mentions: list[int], sims) -> list[dict "mentions": sum(mentions[k] for k in g)} for g in groups.values()] -async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str, - wipe: bool = True, ns: str = "", seeds: list[str] | None = None, - lbl: str = "", sources: list[str] | None = None) -> dict | None: - """Block B (DB + loop): per package, find subblocks in rounds (3 finders, until 0 new/cap), - collect in the DB (variant-clustered mentions ≥2 = consensus), a judge panel cleans up per - package; blocks below SUBBLOCK_MIN get focused catch-up rounds; `seeds` (demoted fragment - titles, single-block kanban calls) are guaranteed to reach the facts evidence gate. - → {block title: [subblock, …]} (consensus) or None. Fills DB table `subblocks`. - wipe=False (kanban board: one call per block) keeps the other blocks' rows.""" - topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled - work_dir = files["arbeit"] - folder = source_folder(topic) - mat = material_folder(topic) # thema: Research-Fundstellen als Inline-Korpus - caps = "files" if folder else "full" - # Source for the evidence exam in the clarify step (discards invented/unsupportable subs). - _type = load_source(topic).get("type", "thema") - source = _prompt(_SOURCE_TEMPLATE[_type], project=folder) if _type in _SOURCE_TEMPLATE else _prompt("Blocks-Source-Thema", topic=topic) - nums = list(entries) - chunks = _chunk_nums(nums, _n_chunks(len(nums))) - n = len(chunks) - title_by_num = {num: _title(entries[num]) for num in nums} - norm_by_num = {num: _norm_title(title_by_num[num]) for num in nums} - emb_on = EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available) - if wipe: - await db.delete_subblocks(topic) # fresh start of the block (idempotent counter) - else: - for num in nums: # per-block wipe: a re-spawned card must not accumulate mentions - await db.delete_subblocks(topic, norm_by_num[num]) - - async def _known_block(chunk): - known = [] - for num in chunk: - subs = [s["sub_title"] for s in await db.list_subblocks(topic, norm_by_num[num])] - if subs: - known.append(f"\n" + "\n".join(f"- {s}" for s in subs)) - if not known: - return "" - # Do NOT list known items again (otherwise re-confirmation inflates the mention count, - # self-bias/echo) — only add what's missing. This keeps the counter an honest consensus signal. - return ("\n\nBEREITS ERFASST — liste diese NICHT erneut. Finde nur, was FEHLT:\n" + "\n".join(known)) - - # ONE finder round (3 slots, quorum 2) → count of NEW sub norms; None = no result/cancel. - async def _one_round(label, subset, assignment, paths, keys, known, extra_instr, material, backing, round_caps): - chunk_idx = _title_index({num: title_by_num[num] for num in subset}) - for p in paths: - p.unlink(missing_ok=True) - slots = [{ - "key": k, - "prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=known, material=material, backing=backing, extra=_extra(extra_instr)), - "role": "quick", "capabilities": round_caps, - "payload": (lambda result, p=p: _sink_subs(result, p)), - } for k, p in zip(keys, paths)] - - async def _fold_late(d: dict) -> None: - """Dritte Stimme nachbuchen statt warten (ersetzte grace=300, gemessen 73 s/Runde): - Mentions sind additiv; ein Fund, den nur der Nachzügler hat, bleibt Einzelfund - und läuft durchs Clarify-Quellen-Gate — verfälscht wird nichts.""" - for marker, subs in d.items(): - num = _resolve_title(chunk_idx, marker) - if num is None: - continue - seen_late = set() - for sub in subs: - sn = _norm_title(sub) - if sn and sn not in seen_late: - seen_late.add(sn) - await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub) - - agent_texts = await _race(topic, label, slots, 2, _timeout("subblock", len(subset)), provider, cancelled=is_cancelled, late=_fold_late) - if is_cancelled() or not agent_texts: - return None - rows_before = {num: await db.list_subblocks(topic, norm_by_num[num]) for num in subset} - existing = {num: {s["sub_norm"] for s in rows_before[num]} for num in subset} - fresh: dict[int, list[str]] = {} - for d in agent_texts: - for marker, subs in d.items(): - num = _resolve_title(chunk_idx, marker) - if num is None: - continue - seen_set = set() - for sub in subs: - sn = _norm_title(sub) - if not sn or sn in seen_set: - continue - seen_set.add(sn) - if sn not in existing[num]: - existing[num].add(sn) - fresh.setdefault(num, []).append(sub) - await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub) - # "New" is variant-robust: a paraphrase of an existing sub (or of another fresh find) - # still gets stored above (its mention feeds the cluster consensus), but it must not - # keep the saturation loop spinning — finders rephrase every round (measured: 5–9 - # rounds without this fold). Model off → exact counting (status quo). - new = 0 - for num, cands in fresh.items(): - sims = None - base = [s["sub_title"] for s in rows_before[num]] - if emb_on and base + cands: - sims = await asyncio.to_thread(embedding.embed_sims, base + cands) - if sims is None: - new += len(cands) - continue - negs = [_neg_set(t) for t in base + cands] - nb = len(base) - kept: list[int] = [] - for i in range(nb, nb + len(cands)): - dup = any(float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j] - for j in [*range(nb), *kept]) - if not dup: - kept.append(i) - new += len(kept) - return new - - # Phase "Subblocks find": per package loop until 0 new subs / time cap. - async def _find(c, chunk): - assignment = "\n".join(f"- {entries[num]}" for num in chunk) - # Material once per package (excerpts are round-invariant; one query per block - # keeps the coverage guarantee of _evidence_pack). - material, backing, round_caps = await asyncio.to_thread( - _finder_material, mat, sources, [str(entries[num]) for num in chunk]) - start = time.monotonic() - round_n = 0 - while not is_cancelled(): - round_n += 1 - bekannt = await _known_block(chunk) if round_n > 1 else "" - paths = [work_dir / f"subblock-c{c}-r{round_n}-{i}.md" for i in (1, 2, 3)] - keys = [f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}" for i in (1, 2, 3)] - new = await _one_round(f"{lbl}Subblocks package {c} R{round_n}", chunk, assignment, paths, keys, bekannt, instructions, material, backing, round_caps) - if new is None: - if is_cancelled(): - return False - return round_n > 1 # round 1 without result = error; later = simply the end - if new == 0: - break - if round_n >= SUBBLOCK_MAX_ROUNDS: - _log(topic, f"Subblocks package {c}: round cap reached ({round_n})") - break - if time.monotonic() - start > SUBBLOCK_CAP: - _log(topic, f"Subblocks package {c}: time cap reached (round {round_n})") - break - return True - - oks = await _gather_progress([_find(c, chunk) for c, chunk in enumerate(chunks, 1)], n, _report_p(set_p, topic, "Subblocks find")) - if is_cancelled(): - return None - if not all(ok is True for ok in oks): - _blocks_errors[topic] = "Subblocks failed (research)" - return None - - # Phase "Subblocks select": variant-clustered mentions ≥2 = consensus — the cluster - # representative carries the status, folded members become `variant` (NOT discarded: - # the clarify panel's uncertain group must not re-list them). Model off → exact counter. - async def _select(subset, keep_consensus=False): - for num in subset: - rows = await db.list_subblocks(topic, norm_by_num[num]) - clusters = None - if emb_on and len(rows) >= 2: - sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows]) - if sims is not None: - clusters = _variant_clusters([r["sub_title"] for r in rows], - [r["mentions"] for r in rows], sims) - if clusters is None: - for r in rows: - if keep_consensus and r["status"] == "consensus": - continue - await db.set_subblock_fields(topic, norm_by_num[num], r["sub_norm"], - status=("consensus" if r["mentions"] >= 2 else "discarded")) - continue - for cl in clusters: - # a re-select (catch-up) never demotes panel-confirmed subs — an existing - # consensus member stays the representative, new variants fold under it. - kept = [k for k in cl["members"] if keep_consensus and rows[k]["status"] == "consensus"] - for k in cl["members"]: - if kept: - st = "consensus" if k in kept else "variant" - elif cl["mentions"] >= 2: - st = "consensus" if k == cl["rep"] else "variant" - else: - st = "discarded" - if keep_consensus and rows[k]["status"] == "consensus" and st != "consensus": - continue - await db.set_subblock_fields(topic, norm_by_num[num], rows[k]["sub_norm"], status=st) - - set_p(f"Subblocks select ({n} packages)…", step=_step_idx(topic, "Subblocks select")) - await _select(nums) - - # Judge formulation → shown candidate (best cos ≥ SUB_VARIANT_COS, negation-guarded). - # Judges demonstrably paraphrase; without canonicalizing, the exact-norm majority vote - # splinters across formulations (measured: 0.993-duplicates in a final list). - async def _canon_map(shown: list[str], judge_titles: list[str]) -> dict[str, tuple[str, str]]: - if not emb_on or not shown or not judge_titles: - return {} - texts = shown + list(judge_titles) - sims = await asyncio.to_thread(embedding.embed_sims, texts) - if sims is None: - return {} - negs = [_neg_set(t) for t in texts] - m: dict[str, tuple[str, str]] = {} - for a in range(len(shown), len(texts)): - best, bv = None, 0.0 - for b in range(len(shown)): - v = float(sims[a][b]) - if v >= SUB_VARIANT_COS and v > bv and negs[a] == negs[b]: - best, bv = b, v - if best is not None: - m[_norm_title(texts[a])] = (_norm_title(shown[best]), shown[best]) - return m - - # Phase "Subblocks clarify": source panel (SUBBAUSTEIN_PANEL judges) checks consensus + uncertain (1×) - # against the source; code majority per sub. External, multi-voice gate against single-judge bias + echo. - async def _clarify(c, chunk, tag=""): - fp = work_dir / f"subblock-final-c{c}{tag}.md" - if _parse_subblocks(_read(fp)): - return - block_texts, has_any = [], False - consensus_by_num: dict[int, list[str]] = {} - shown_by_num: dict[int, list[str]] = {} - for num in chunk: - rows = await db.list_subblocks(topic, norm_by_num[num]) - consensus_subs = [s["sub_title"] for s in rows if s["status"] == "consensus"] - # folded variants (status `variant`) are already counted — only true singles are uncertain - uncertain = [s["sub_title"] for s in rows if s["status"] == "discarded" and s["mentions"] == 1] - consensus_by_num[num] = consensus_subs - shown_by_num[num] = consensus_subs + uncertain - if not consensus_subs and not uncertain: - continue - has_any = True - k_lines = "\n".join(f"- {s}" for s in consensus_subs) if consensus_subs else "- (keiner)" - u_lines = "\n".join(f"- {s}" for s in uncertain) if uncertain else "- (keiner)" - band = "" - shown = shown_by_num[num] - if emb_on and len(shown) >= 2: # near-dup pairs BELOW the fold threshold → explicit panel hint - sims = await asyncio.to_thread(embedding.embed_sims, shown) - if sims is not None: - pairs = [f"- „{shown[i]}“ ↔ „{shown[j]}“" - for i in range(len(shown)) for j in range(i + 1, len(shown)) - if 0.75 <= float(sims[i][j]) < SUB_VARIANT_COS] - if pairs: - band = ("\nMögliche Duplikate — prüfen und ggf. zu EINEM Eintrag zusammenführen:\n" - + "\n".join(pairs[:12])) - block_texts.append(f"BLOCK: {title_by_num[num]}\nKonsens (≥2 finders):\n{k_lines}\nUnsicher (1× — streng gegen Source check):\n{u_lines}{band}") - if not has_any: - return - - chunk_idx = _title_index({num: title_by_num[num] for num in chunk}) - paths = [work_dir / f"subblock-final-c{c}{tag}-j{j}.md" for j in range(1, SUBBLOCK_PANEL + 1)] - # truthiness, NOT `is None`: a missing file parses to {} — with `is None` the whole - # panel silently never ran (fallback adopted the raw consensus unchecked). - pending = [(j, p) for j, p in enumerate(paths, 1) if not _parse_subblocks(_read(p))] - for _, p in pending: - p.unlink(missing_ok=True) - if pending: - # Inline evidence: corpus excerpts in the prompt (no self-research); the judge - # answers as TEXT, the engine persists the j-file (resume + majority unchanged). - ev = _evidence_pack(mat, sources, - [title_by_num[num] for num in chunk] - + [s for num in chunk for s in shown_by_num.get(num, [])]) if mat else "" - j_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source - - def _sink(result, p): - text = _reply_text(result).strip() - d = _parse_subblocks(text) - if d: - atomic_write_text(p, text) - return d or None - - slots = [{ - "key": f"blocks-{topic}-{ns}subblock-final-c{c}{tag}-j{j}", - "prompt": _prompt("Subblock-Mapping", topic=topic, source=j_source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)), - "role": "judge", "capabilities": "none" if ev else caps, - "payload": (lambda result, p=p: _sink(result, p)), - } for j, p in pending] - existing = SUBBLOCK_PANEL - len(pending) - await _race(topic, f"{lbl}Subblock-Clarification {c}", slots, max(1, 2 - existing), - _timeout("subblock_check", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE) - if is_cancelled(): - return - outs = [d for p in paths if (d := _parse_subblocks(_read(p)))] - if not outs: # panel fully failed → adopt consensus (the fallback as before) - _log(topic, f"Subblock clarification package {c} failed — consensus adopted") - text = "\n\n".join(f"\n" + "\n".join(f"- {s}" for s in consensus_by_num[num]) - for num in chunk if consensus_by_num[num]) - atomic_write_text(fp, text) - return - - # code majority per block/sub-norm: keep if a majority of judges list it (tie → keep). - # Votes are canonicalized onto the shown candidates first (paraphrase-robust). - block_texts_out = [] - for num in chunk: - raw_votes: list[list[str]] = [] - for d in outs: - subs_of_num: list[str] = [] - for marker, subs in d.items(): - if _resolve_title(chunk_idx, marker) == num: - subs_of_num.extend(subs) - raw_votes.append(subs_of_num) - judge_titles = list(dict.fromkeys(s for subs in raw_votes for s in subs - if _norm_title(s) not in {_norm_title(t) for t in shown_by_num[num]})) - canon = await _canon_map(shown_by_num[num], judge_titles) - votes: dict[str, int] = {} - form: dict[str, str] = {} - for subs_of_num in raw_votes: - seen = set() - for sub in subs_of_num: - sn = _norm_title(sub) - if not sn: - continue - if sn in canon: - sn, sub = canon[sn] - if sn in seen: - continue - seen.add(sn) - form.setdefault(sn, sub) - votes[sn] = votes.get(sn, 0) + 1 - kept = [form[sn] for sn in form if votes[sn] * 2 >= len(outs)] - if kept: - block_texts_out.append(f"\n" + "\n".join(f"- {s}" for s in kept)) - atomic_write_text(fp, "\n\n".join(block_texts_out)) - - await _gather_progress([_clarify(c, chunk) for c, chunk in enumerate(chunks, 1)], n, _report_p(set_p, topic, "Subblocks clarify")) - if is_cancelled(): - return None - - # Final list per block: judge output, otherwise consensus fallback. Reconcile DB + build raw. - raw: dict[str, list[str]] = {} - - async def _align(c, chunk, tag=""): - final = _parse_subblocks(_read(work_dir / f"subblock-final-c{c}{tag}.md")) or {} - chunk_idx = _title_index({num: title_by_num[num] for num in chunk}) - final_by_num = {_resolve_title(chunk_idx, m): subs for m, subs in final.items() if _resolve_title(chunk_idx, m) is not None} - for num in chunk: - title = title_by_num[num] - consensus = [s["sub_title"] for s in await db.list_subblocks(topic, norm_by_num[num]) if s["status"] == "consensus"] - subs = final_by_num.get(num) or consensus - if not subs: - continue - raw[title] = subs - # align DB to the final list: final = consensus, rest discarded, add new ones. - final_norms = {_norm_title(s) for s in subs} - have = {s["sub_norm"] for s in await db.list_subblocks(topic, norm_by_num[num])} - for s in await db.list_subblocks(topic, norm_by_num[num]): - if s["sub_norm"] in final_norms: - st = "consensus" - elif s["status"] == "variant": - st = "variant" # folded members stay marked — a catch-up clarify must not - else: # re-list them as "uncertain singles" - st = "discarded" - await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"], status=st) - for s in subs: - sn = _norm_title(s) - if sn and sn not in have: - await db.upsert_subblock(topic, norm_by_num[num], sn, title, s) - await db.set_subblock_fields(topic, norm_by_num[num], sn, status="consensus") - - for c, chunk in enumerate(chunks, 1): - await _align(c, chunk) - - # Minimum catch-up: a block below SUBBLOCK_MIN gets up to SUBBLOCK_EXTRA_ROUNDS focused - # finder rounds. Saturation stop stays — a thin block REMAINS thin if nothing verifiable. - async def _catchup(c, chunk): - for k in range(1, SUBBLOCK_EXTRA_ROUNDS + 1): - lacking = [num for num in chunk if len(raw.get(title_by_num[num]) or []) < SUBBLOCK_MIN] - if not lacking or is_cancelled(): - return - assignment = "\n".join(f"- {entries[num]}" for num in lacking) - known = await _known_block(lacking) # ALL rows of the block, incl. variants/discarded - focus = (instructions + "\n\nDieser Block hat bisher nur sehr wenige belegte " - "Subbausteine. Suche gezielt nach WEITEREN belegbaren Kernaspekten, die " - "oben fehlen. Nimm NUR auf, was die Quellen wirklich hergeben — nicht aufblähen.") - material, backing, round_caps = await asyncio.to_thread( - _finder_material, mat, sources, [str(entries[num]) for num in lacking]) - paths = [work_dir / f"subblock-x{k}-c{c}-{i}.md" for i in (1, 2, 3)] - keys = [f"blocks-{topic}-{ns}subblock-x{k}-c{c}-{i}" for i in (1, 2, 3)] - new = await _one_round(f"{lbl}Subblocks catch-up {c} X{k}", lacking, assignment, paths, keys, known, focus, material, backing, round_caps) - if not new: - return - await _select(lacking, keep_consensus=True) - await _clarify(c, chunk, tag=f"-x{k}") - if is_cancelled(): - return - await _align(c, chunk, tag=f"-x{k}") - - # progress reuses the clarify step label — catch-up has no own registry entry - await _gather_progress([_catchup(c, chunk) for c, chunk in enumerate(chunks, 1)], n, - _report_p(set_p, topic, "Subblocks clarify")) - if is_cancelled(): - return None - - # Seed guarantee (single-block kanban calls): every demoted-fragment seed must reach the - # facts evidence gate — covered by a consensus sub, promoted from a single find, or - # inserted as its own sub. Unverifiable seeds die at the facts discard, not silently here. - for num in (nums if seeds else []): - title = title_by_num[num] - for seed in dict.fromkeys(s for s in seeds if s): - st = _sub_tokens(seed) - have = raw.get(title) or [] - if not st or any(st <= _sub_tokens(s) for s in have): - continue - if emb_on and have: # embedding backup for rephrased covers (lexical is primary) - sims = await asyncio.to_thread(embedding.embed_sims, [seed] + have) - if sims is not None and max(float(sims[0][j]) for j in range(1, len(have) + 1)) >= SEED_COVER_COS: - continue - rows = [r for r in await db.list_subblocks(topic, norm_by_num[num]) if r["status"] != "consensus"] - cand = next((r for r in rows if st <= _sub_tokens(r["sub_title"])), None) - if cand is None and emb_on and rows: - sims = await asyncio.to_thread(embedding.embed_sims, [seed] + [r["sub_title"] for r in rows]) - if sims is not None: - j = max(range(1, len(rows) + 1), key=lambda x: float(sims[0][x])) - if float(sims[0][j]) >= SEED_COVER_COS and _neg_set(seed) == _neg_set(rows[j - 1]["sub_title"]): - cand = rows[j - 1] - if cand is not None: - await db.set_subblock_fields(topic, norm_by_num[num], cand["sub_norm"], status="consensus") - raw.setdefault(title, []).append(cand["sub_title"]) - _log(topic, f"Seed „{seed}“: Einzelfund „{cand['sub_title']}“ übernommen ({title})") - elif (sn := _norm_title(seed)): - await db.put_subblock(topic, norm_by_num[num], sn, title, seed, status="consensus") - raw.setdefault(title, []).append(seed) - _log(topic, f"Seed „{seed}“ als Subbaustein eingefügt ({title}) — Facts-Gate prüft") - - # AFTER the seed guarantee: promoted/inserted seeds must not bypass the near-dup filter - await _dedup_subblocks(topic, raw) # near-dup filter per block (deterministic, no LLM) - - if not raw: - # Finders ran but nothing survived the consensus/evidence gates: a legitimately - # thin block (e.g. a bare named reduction). {} = done-without-subs — the guide - # writes it from description+facts. Agent FAILURES return None elsewhere (retry). - _log(topic, "Subblocks: nichts Belegbares gefunden — Block bleibt ohne Subbausteine") - return {} - return raw - - async def _dedup_subblocks(topic: str, raw: dict[str, list[str]]) -> None: """Deterministic near-duplicate filter per block: subblocks with cosine ≥ EMBEDDING_SUB_DUP are the same statement (reliable in the narrow block context — no LLM @@ -1256,51 +714,6 @@ async def _dedup_subblocks(topic: str, raw: dict[str, list[str]]) -> None: raw[title] = [subs[i] for i in sorted(keepers)] # original order of the kept ones -_KONSOLIDIERUNG_PANEL = 2 # merge needs unanimity — single judges over-merge (blocks-dedup lesson) - - -def _kons_id(x, n: int) -> int | None: - """Judge id → int in 1..n, else None (bools are not 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 _konsolidierung_schema(data, n: int) -> dict | None: - """Judge output → normalized dict, else None. gruppen accepts the {"haupt": 1, - "weitere": [4]} form AND the legacy plain-list form [1, 4] (resume files of the - first template version). kataloge/fremd/luecken are optional.""" - if not isinstance(data, dict) or not isinstance(data.get("gruppen"), list): - return None - - def _ids(lst): - return sorted({i for x in (lst or []) if (i := _kons_id(x, n)) is not None}) - - gruppen = [] - for g in data["gruppen"]: - if isinstance(g, dict): - haupt = _kons_id(g.get("haupt"), n) - ids = _ids(([haupt] if haupt else []) + list(g.get("weitere") or [])) - elif isinstance(g, list): - haupt, ids = None, _ids(g) - else: - return None - 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}) - 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()]} - - def _facts_union(wf: dict, lf: dict) -> None: """Merge a folded sub's facts into the winner's: key_points/cited_facts union (exact-duplicate-free), scalar fields only fill gaps.""" @@ -1356,263 +769,18 @@ def _luecken_schnitt(l1: list[str], l2: list[str], cap: int = _LUECKEN_CAP) -> l return out[:cap] -async def _konsolidiere_subblocks(ctx: GenContext, files: dict, raw: dict, facts_map: dict, - instructions: str = "", ns: str = "", lbl: str = "") -> dict: - """In-block consolidation AFTER the facts stage: a two-judge panel sees the subs WITH - their key points and applies the 100%-decomposition test — the embedding paths only - catch cos ≥ 0.90, real paraphrase duplicates measure down to 0.61, and only the facts - reveal a subset. Every action needs UNANIMITY of both judges: - gruppen — same-statement/subset entries fold into the judge-named `haupt` (base - before detail; heuristic fallback), facts union, losers → `variant` - kataloge — pure enumeration entries of one kind bundle into a NEW named sub row - (members → `variant`); runs before levels/relevance, so the new row - gets classified normally - fremd — statements off-topic for the TOPIC → `discarded` (removal test) - Questions/artefacts do not exist yet — no orphans. Gaps are returned per block so the - caller can run the single follow-up finder round (`_luecken_runde`). - Judge replies persist as j-files keyed by a subs-list hash (resume-safe). - → {block title: [luecken]}""" - topic = ctx.topic - work_dir = files["arbeit"] - luecken_by_title: dict[str, list[str]] = {} - for title, subs in list(raw.items()): - if ctx.is_cancelled(): - return luecken_by_title - n = len(subs) - if n < 2: - continue - bnorm = _norm_title(title) - bfacts = facts_map.setdefault(title, {}) - - def _kp(s): - return (bfacts.get(_norm_title(s)) or {}).get("key_points") or [] - - # prompt shows max 3 key points per sub — full lists blew past the judge timeout - # (measured: 15 % timeouts at 585 s); the facts UNION on merge stays complete - lines = "\n".join(f"{k}. {s}" + "".join(f"\n - {p}" for p in _kp(s)[:3]) - for k, s in enumerate(subs, 1)) - h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8] - paths = [work_dir / f"sub-konsolidierung-{ns}{h}-j{j}.json" for j in (1, 2)] - - async def _judge(j, path): - if _konsolidierung_schema(_json_file(path), n) is not None: - return # resume - status, _v = await run_single_slot( - ctx, f"{lbl}Sub-Konsolidierung j{j}", - key=f"blocks-{topic}-{ns}sub-konsolidierung-{h}-j{j}", - prompt=_prompt("Subblock-Konsolidierung", topic=topic, block=title, subs=lines, extra=_extra(instructions)), - role="judge", capabilities="none", - payload=lambda result, p=path: _sink_json(result, p, lambda d: _konsolidierung_schema(d, n)), - timeout=_timeout("konsolidierung", n)) - if status == FAILED: - _log(topic, f"Sub-Konsolidierung {title} j{j} ohne Ergebnis — fail-open") - - await asyncio.gather(*[_judge(j, p) for j, p in zip((1, 2), paths)]) - if ctx.is_cancelled(): - return luecken_by_title - outs = [o for p in paths if (o := _konsolidierung_schema(_json_file(p), n)) is not None] - if len(outs) == 1: # Ersatz-Richter: EIN Timeout darf die gute Stimme nicht entwerten - ersatz = work_dir / f"sub-konsolidierung-{ns}{h}-j3.json" - await _judge(3, ersatz) - if ctx.is_cancelled(): - return luecken_by_title - outs = [o for p in [*paths, ersatz] - if (o := _konsolidierung_schema(_json_file(p), n)) is not None] - # gaps need UNANIMITY (token-overlap match) — the union of both judges was uncalibrated - luecken = (_luecken_schnitt(outs[0]["luecken"], outs[1]["luecken"]) - if len(outs) == _KONSOLIDIERUNG_PANEL else []) - journal = {"block": title, "richter": len(outs), "vorher": n, - "luecken_roh": [len(o["luecken"]) for o in outs], - "gruppen": [], "kataloge": [], "fremd": [], "luecken": luecken} - if len(outs) == _KONSOLIDIERUNG_PANEL: - negs = [_neg_set(s) for s in subs] - keep = list(subs) - gone: set[int] = set() - - async def _fold(k: int, wf: dict | None): - lose_title = subs[k - 1] - lf = bfacts.pop(_norm_title(lose_title), None) or {} - if wf is not None: - _facts_union(wf, lf) - await db.set_subblock_fields(topic, bnorm, _norm_title(lose_title), status="variant") - keep.remove(lose_title) - gone.add(k) - - # 1. Fremd (removal test): off-topic for the TOPIC → discarded, no heir. - for k in sorted(outs[0]["fremd"] & outs[1]["fremd"]): - ft = subs[k - 1] - bfacts.pop(_norm_title(ft), None) - await db.set_subblock_fields(topic, bnorm, _norm_title(ft), status="discarded") - keep.remove(ft) - gone.add(k) - journal["fremd"].append(ft) - - # 2. Gruppen: winner = judge-named haupt (majority), else key_points/length heuristic. - haupt_votes: dict[int, int] = {} - for o in outs: - for g in o["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 o["gruppen"]]) for o in outs], 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(subs[k - 1])), len(subs[k - 1]), -k)) - wf = bfacts.setdefault(_norm_title(subs[win - 1]), {}) - for k in g: - if k != win: - await _fold(k, wf) - journal["gruppen"].append({"behalten": subs[win - 1], - "gefaltet": [subs[k - 1] for k in g if k != win]}) - - # 3. Kataloge: bundle enumeration rows into ONE new named sub (facts union). - for g in _agreed_cliques([_pairs_of([x["ids"] for x in o["kataloge"]]) for o in outs], 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 outs[0]["kataloge"] + outs[1]["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 # no usable/colliding title → members stay - 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") - journal["kataloge"].append({"titel": titel, "gefaltet": [subs[k - 1] for k in g]}) - - if len(keep) != n: - raw[title] = keep - _log(topic, f"Sub-Konsolidierung {title}: {n} → {len(keep)}") - elif outs: - _log(topic, f"Sub-Konsolidierung {title}: nur {len(outs)}/{_KONSOLIDIERUNG_PANEL} Richter — fail-open") - if luecken: - luecken_by_title[title] = luecken - _log(topic, f"Sub-Konsolidierung {title}: mögliche Lücken: {', '.join(luecken[:5])}") - atomic_write_json(work_dir / f"sub-konsolidierung-{ns}{h}.json", journal, indent=1) - return luecken_by_title - - -async def _luecken_runde(ctx: GenContext, files: dict, title: str, luecken: list[str], - raw: dict, facts_map: dict, q: dict, folder, instructions: str = "", - ns: str = "", lbl: str = "", sources: list[str] | None = None) -> int: - """ONE targeted finder round for the consolidation judges' reported gaps — no loop. - Finds are deduped against the existing subs (token containment + embedding + - negation guard, seed-guarantee pattern) and must pass the facts evidence gate - (own work subdir `nf` — the block's facts resume files must not collide) before - they join raw/facts_map as consensus rows. They then flow through levels/relevance/ - questions/artefacts like any other sub. → count of adopted subs.""" - topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled - work_dir = files["arbeit"] - have = list(raw.get(title) or []) - focus = (instructions + "\n\nFinde NUR belegbare Subbausteine zu diesen bisher fehlenden " - "Aspekten des Blocks — nichts anderes:\n" + "\n".join(f"- {l}" for l in luecken)) - known = ("\n\nBEREITS ERFASST — liste diese NICHT erneut:\n" - + "\n".join(f"- {s}" for s in have)) if have else "" - material, backing, caps = await asyncio.to_thread( - _finder_material, material_folder(topic), sources, [title] + list(luecken)) - paths = [work_dir / f"luecken-{ns}r1-{i}.md" for i in (1, 2, 3)] - slots = [{ - "key": f"blocks-{topic}-{ns}luecken-r1-{i}", - "prompt": _prompt("Subblock-Research", topic=topic, assignment=f"- {title}", known=known, material=material, backing=backing, extra=_extra(focus)), - "role": "quick", "capabilities": caps, - "payload": (lambda result, p=p: _sink_subs(result, p)), - } for i, p in zip((1, 2, 3), paths)] - agent_texts = await _race(topic, f"{lbl}Lücken-Nachfass", slots, 2, - _timeout("subblock", 1), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE) - if is_cancelled() or not agent_texts: - return 0 - cands: list[str] = [] - seen = {_norm_title(s) for s in have} - for d in agent_texts: - for subs in d.values(): # single-block call — every marker means this block - for s in subs: - sn = _norm_title(s) - if sn and sn not in seen: - seen.add(sn) - cands.append(s) - if not cands: - return 0 - emb_on = EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available) - fresh: list[str] = [] - for s in cands: - st = _sub_tokens(s) - base = have + fresh - if any(st <= _sub_tokens(b) or _sub_tokens(b) <= st for b in base): - continue - if emb_on and base: - sims = await asyncio.to_thread(embedding.embed_sims, [s] + base) - if sims is not None: - negs = [_neg_set(t) for t in [s] + base] - if any(float(sims[0][j]) >= SEED_COVER_COS and negs[0] == negs[j] - for j in range(1, len(base) + 1)): - continue - fresh.append(s) - if not fresh: - return 0 - nf_dir = work_dir / "nf" - nf_dir.mkdir(parents=True, exist_ok=True) - res = await _facts_block(ctx, lambda *a, **k: None, {**files, "arbeit": nf_dir}, - {title: list(fresh)}, q, folder, instructions, - ns=f"{ns}nf-", lbl=lbl, sources=sources, slim=True) - if is_cancelled() or res is None: - return 0 - nf_facts, discarded = res - dropped = (discarded or {}).get(title) or set() - nf_map = nf_facts.get(title) or {} - - def _belegt(s: str) -> bool: # HARD gate: no facts entry = no evidence = no adoption - fk = nf_map.get(_norm_title(s)) - return bool(fk and (fk.get("key_points") or fk.get("cited_facts"))) - - kept = [s for s in fresh if _norm_title(s) not in dropped and _belegt(s)] - if not kept: - return 0 - bnorm = _norm_title(title) - bfacts = facts_map.setdefault(title, {}) - for s in kept: - sn = _norm_title(s) - await db.upsert_subblock(topic, bnorm, sn, title, s) - await db.set_subblock_fields(topic, bnorm, sn, status="consensus") - bfacts[sn] = nf_map[sn] - raw.setdefault(title, []).extend(kept) - _log(topic, f"Lücken-Nachfass {title}: {len(kept)}/{len(fresh)} Funde übernommen") - return len(kept) - - -async def _facts_nachfass(ctx: GenContext, files: dict, raw: dict, facts_map: dict, q: dict, - folder, instructions: str = "", ns: str = "", lbl: str = "", - sources: list[str] | None = None) -> int: - """ONE slim facts round for consensus subs WITHOUT a facts entry — renames during - consolidation and catalog rows left 35 % of the subs ungrounded; the fact gate then - flagged their (correct) guide statements wholesale. The subs themselves stay either - way: they exist by consensus, only the grounding is fetched. → count of filled subs.""" - fehlend = {bt: [s for s in subs if _norm_title(s) not in (facts_map.get(bt) or {})] - for bt, subs in raw.items()} - fehlend = {bt: subs for bt, subs in fehlend.items() if subs} - if not fehlend: - return 0 - nf_dir = files["arbeit"] / "nf2" - nf_dir.mkdir(parents=True, exist_ok=True) - res = await _facts_block(ctx, lambda *a, **k: None, {**files, "arbeit": nf_dir}, - fehlend, q, folder, instructions, - ns=f"{ns}nf2-", lbl=lbl, sources=sources, slim=True) - if ctx.is_cancelled() or res is None: - return 0 - nf_facts, _discarded = res # discard verdicts ignored — consensus subs are not removed here - filled = 0 - for bt, fm in nf_facts.items(): - bfacts = facts_map.setdefault(bt, {}) - for sn, fk in fm.items(): - if sn not in bfacts and (fk.get("key_points") or fk.get("cited_facts")): - bfacts[sn] = fk - filled += 1 - if filled: - _log(ctx.topic, f"Facts-Nachfass{': ' + lbl if lbl else ''}{filled} Subs nachbelegt") - return filled +def _match_sub(agent_sub: str, rel: list[str]) -> str: + """Map the agent's subblock title to the matching relevant title — exact, + then normalized, then substring (the agent drops e.g. the prefix "Question: "). + No match → keep the agent title. This way NO pattern is lost to a title mismatch.""" + if agent_sub in rel: + return agent_sub + an = _norm_title(agent_sub) + for r in rel: + rn = _norm_title(r) + if an and rn and (an == rn or an in rn or rn in an): + return r + return agent_sub def _subs_hash(sidecar_or_raw: dict) -> str: @@ -1627,158 +795,6 @@ def _subs_hash(sidecar_or_raw: dict) -> str: return hashlib.md5("\n".join(parts).encode()).hexdigest()[:8] -def _code_vote(rater: list[dict], n: int) -> tuple[dict, dict]: - """Majority vote over rater dicts on local ids 1..n → (outcome, disputed). A clear winner - needs ≥2 votes and no tie; otherwise the id is disputed (kept with its vote list).""" - outcome: dict[int, str] = {} - disputed: dict[int, list[str]] = {} - for k in range(1, n + 1): - vote_list = [d[k] for d in rater if k in d] - counter: dict[str, int] = {} - for s in vote_list: - counter[s] = counter.get(s, 0) + 1 - best = max(counter.values(), default=0) - winners = [s for s, v in counter.items() if v == best] - if len(winners) == 1 and best >= 2: - outcome[k] = winners[0] - else: - disputed[k] = vote_list - return outcome, disputed - - -def _disputed_lines(items, item_idxs, disputed: dict) -> str: - """Render disputed items as `k. [block] sub — Stimmen: a, b` lines for the judge prompt.""" - return "\n".join( - f"{k}. [{items[item_idxs[k - 1]][0]}] {items[item_idxs[k - 1]][1]} — Stimmen: {', '.join(vote_list) or 'none'}" - for k, vote_list in disputed.items() - ) - - -async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None: - """Block C: three phases with a barrier — find (classify), select (vote), clarify. - Local IDs 1..n per package, mapped to global gid afterwards. - → {block title: [{title, level}, …]} or None.""" - topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled - work_dir = files["arbeit"] - # key points per sub as concise context (better-grounded classification; classification needs little). - facts_map = _json_file(files["facts"]) - facts_map = facts_map if isinstance(facts_map, dict) else {} - items = [(title, sub) for title, subs in raw.items() for sub in subs] # global id = index+1 - if not items: - return {title: [] for title in raw} - # pack chunks from WHOLE blocks (don't split a block) → each rater sees per block - # all subs and can classify relatively. Item indices per block in raw order. - chunks, cur, i = [], [], 0 - for _title_b, subs in raw.items(): - g = list(range(i, i + len(subs))) - i += len(subs) - if cur and len(cur) + len(g) > LEVEL_CHUNK: - chunks.append(cur) - cur = [] - cur.extend(g) - if cur: - chunks.append(cur) - n = len(chunks) - sh = _subs_hash(raw) # resume must invalidate when the sub set changed - - def rater_paths(c): - return [work_dir / f"level-{sh}-c{c}-{i}.json" for i in (1, 2, 3)] - - def lset(item_idxs): - return set(range(1, len(item_idxs) + 1)) - - # Phase "Levels find": 3 raters per package (min. 2), local IDs. - async def _rate(c, item_idxs): - local_set = lset(item_idxs) - paths = rater_paths(c) - existing = sum(1 for p in paths if _levels_schema(_json_file(p), local_set)) - if existing >= 2: - return True - enum_lines, cur_b = [], None - for k, j in enumerate(item_idxs, 1): - b, sub = items[j] - if b != cur_b: - enum_lines.append(f"\nBAUSTEIN: {b}") - cur_b = b - enum_lines.append(f"{k}. {sub}") - if (kz := _core_line(facts_map.get(b, {}).get(_norm_title(sub)))): - enum_lines.append(f" {kz}") - enum = "\n".join(enum_lines).strip() - pending = [(i, p) for i, p in enumerate(paths, 1) if not _levels_schema(_json_file(p), local_set)] - slots = [{ - "key": f"blocks-{topic}-{ns}level-c{c}-{i}", - "prompt": _prompt("Levels-Research", topic=topic, subblocks=enum, out_path=p, extra=_extra(instructions)), - "role": "quick", "capabilities": "none", # pure mapping, everything inline → text reply - "payload": (lambda result, p=p, ids=local_set: _sink_json(result, p, lambda d: _levels_schema(d, ids))), - } for i, p in pending] - new = await _race(topic, f"{lbl}Levels package {c}", slots, 2 - existing, _timeout("level", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE) - return not is_cancelled() and new is not None - - oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Levels find")) - if is_cancelled(): - return None - if not all(ok is True for ok in oks): - _blocks_errors[topic] = "Classification failed (research)" - return None - - # Phase "Levels select": code vote per package → (outcome, disputed). - set_p(f"Levels select ({n} packages)…", step=_step_idx(topic, "Levels select")) - vote_by_c = {} - for c, item_idxs in enumerate(chunks, 1): - local_set = lset(item_idxs) - rater = [d for p in rater_paths(c) if (d := _levels_schema(_json_file(p), local_set))] - vote_by_c[c] = _code_vote(rater, len(item_idxs)) - - # Phase "Levels clarify": one judge per package on the disputed items, all in parallel. - async def _clarify(c, item_idxs): - outcome, strittig = vote_by_c[c] - if strittig: - judge_path = work_dir / f"level-final-{sh}-c{c}.json" - decision = _levels_schema(_json_file(judge_path), set(strittig)) - if decision is None: - disputed_block = _disputed_lines(items, item_idxs, strittig) - status, decision = await run_single_slot( - ctx, f"{lbl}Levels-Clarification {c}", - key=f"blocks-{topic}-{ns}level-final-c{c}", - prompt=_prompt("Levels-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)), - role="judge", capabilities="none", - payload=lambda result, p=judge_path, ids=set(strittig): _sink_json(result, p, lambda d: _levels_schema(d, ids)), - timeout=_timeout("level_check", len(strittig)), - ) - if status == FAILED: - _log(topic, f"Levels clarification package {c} failed — default 'advanced'") - decision = decision if isinstance(decision, dict) else {} - # disputed without a decision → 'advanced'; vote winners stay; judge overrides. - outcome = {**{k: "advanced" for k in strittig}, **outcome, **decision} - return {item_idxs[k - 1] + 1: level for k, level in outcome.items()} - - parts = await _gather_progress([_clarify(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Levels clarify")) - if is_cancelled(): - return None - level_by_id: dict[int, str] = {} - for c, part in enumerate(parts, 1): - if not isinstance(part, dict): - # clarification is not fatal: vote outcome + default 'advanced' for disputed. - if isinstance(part, BaseException): - _log(topic, f"Levels clarification package {c}: {type(part).__name__}: {part}") - outcome, strittig = vote_by_c[c] - item_idxs = chunks[c - 1] - merged = {**{k: "advanced" for k in strittig}, **outcome} - part = {item_idxs[k - 1] + 1: s for k, s in merged.items()} - level_by_id.update(part) - - # assemble the sidecar — same order as items → gid matches - sidecar: dict[str, list[dict]] = {} - gid = 0 - for title, subs in raw.items(): - lst = [] - for sub in subs: - gid += 1 - lst.append({"title": sub, "level": level_by_id.get(gid, "advanced")}) - sidecar[title] = lst - return sidecar - - _FACTS_FIELDS = ("key_points", "prerequisites", "hurdles", "cited_facts", "example_idea") @@ -1808,28 +824,6 @@ def _facts_schema(data) -> list[dict] | None: return out or None -def _facts_check_schema(data) -> list[tuple[str, bool]] | None: - """Facts check → [(sub_norm, verwerfen)] per objection · {ok:true}→[] · None if invalid. - verwerfen=True: sub not supportable in substance (remove). verwerfen=False: only correct the fact.""" - if not isinstance(data, dict): - return None - if data.get("ok") is True: - return [] - pr = data.get("problems") - if not isinstance(pr, list): - return None - return [(sn, bool(p.get("discard"))) - for p in pr if isinstance(p, dict) and (sn := _norm_title(str(p.get("subblock", ""))))] - - -def _core_line(fk) -> str: - """Concise key-point line for classification (level/relevance) — less context suffices there. - Empty if no facts/key points (legacy).""" - if not isinstance(fk, dict) or not fk.get("key_points"): - return "" - return "Kern: " + " · ".join(str(k) for k in fk["key_points"]) - - def _facts_lines(fk: dict) -> str: z = [] if fk.get("key_points"): @@ -1845,588 +839,6 @@ def _facts_lines(fk: dict) -> str: return "\n".join(z) -async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "", sources: list[str] | None = None, slim: bool = False) -> tuple | None: - """Block: per sub extract source facts (find) → verify (check) → correct/discard (fix). - Extract-once grounding: the result feeds level/relevance/questions/guide. - slim=True (gap follow-up): no supplement pass, ONE check judge — the full program cost - 230 agent-minutes per run for a handful of finds; the hard adoption gate stays. - → (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map - {block: {sub_norm}} (unsupportable subs to remove) — or None on cancel/error.""" - topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled - work_dir = files["arbeit"] - caps = "files" if folder else "full" - type = q.get("type", "thema") - source = _prompt(_SOURCE_TEMPLATE[type], project=folder) if type in _SOURCE_TEMPLATE else _prompt("Blocks-Source-Thema", topic=topic) - blocks = [(title, [str(s).strip() for s in subs if str(s).strip()]) for title, subs in raw.items() if subs] - if not blocks: - return {}, {} - chunks = _lpt_chunks([len(subs) for _, subs in blocks], FACTS_CHUNK_SUBS) - sh = _subs_hash(raw) # resume must invalidate when the sub set changed - - def raw_path(ci): return work_dir / f"facts-{sh}-c{ci}.json" - def supp_path(ci): return work_dir / f"facts-erg-{sh}-c{ci}.json" - def chk_path(ci, j): return work_dir / f"facts-check-{sh}-c{ci}-j{j}.json" - def fix_path(ci): return work_dir / f"facts-fix-{sh}-c{ci}.json" - def ctitle(idxs): return [blocks[i][0] for i in idxs] - def block_text(idxs): - return "\n\n".join( - f"BLOCK: {blocks[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in blocks[i][1]) - for i in idxs) - - # raw facts of a chunk → {block: {sub_norm: {sub, …fields}}}, matched to chunk titles. - def raw_map(ci, path): - idxs = chunks[ci] - rel_by = {blocks[i][0]: blocks[i][1] for i in idxs} - ct = ctitle(idxs) - out: dict[str, dict] = {} - for e in _facts_schema(_json_file(path)) or []: - bt = _match_sub(e["block"], ct) - if bt not in rel_by: - continue - sub = _match_sub(e["subblock"], rel_by[bt]) - out.setdefault(bt, {})[_norm_title(sub)] = {"sub": sub, **{k: e[k] for k in _FACTS_FIELDS}} - return out - - # union raw facts + completeness supplements (recall): only subs that exist in raw. - def _chunk_facts(ci): - raw = raw_map(ci, raw_path(ci)) - erg = raw_map(ci, supp_path(ci)) if supp_path(ci).exists() else {} - if not erg: - return raw - for bt, fm in raw.items(): - ebt = erg.get(bt, {}) - for sn, fk in fm.items(): - ek = ebt.get(sn) - if not ek: - continue - seen = {str(k).strip().casefold() for k in fk.get("key_points", [])} - for k in ek.get("key_points", []): - if str(k).strip().casefold() not in seen: - seen.add(str(k).strip().casefold()) - fk["key_points"].append(k) - seent = {bf["text"].strip().casefold() for bf in fk.get("cited_facts", [])} - for bf in ek.get("cited_facts", []): - if bf["text"].strip().casefold() not in seent: - seent.add(bf["text"].strip().casefold()) - fk["cited_facts"].append(bf) - for f in ("prerequisites", "hurdles", "example_idea"): - if not fk.get(f) and ek.get(f): - fk[f] = ek[f] - return raw - - def _inline_source(queries: list[str]) -> tuple[str, str]: - """Korpus-Auszüge INLINE statt Datei-Recherche → (source, capabilities). Die - Tool-Agenten (bash/read) verloren sich messbar in Reasoning-Schleifen (bis 39k - Zeichen) und endeten mit leerem Turn — Retry-Wellen à 60–90 s seriell pro Block. - No-Tool-Calls mit Inline-Material hatten 0 solcher Fälle (Muster: Facts-Check). - Fail-open: ohne Treffer bleibt 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" - return source, caps - - # Phase "Facts find": 1 generator per chunk. - async def _find(ci, idxs): - fp = raw_path(ci) - if _facts_schema(_json_file(fp)): - return True - subs_total = sum(len(blocks[i][1]) for i in idxs) - f_source, f_caps = await asyncio.to_thread( - _inline_source, [blocks[i][0] for i in idxs] + [s for i in idxs for s in blocks[i][1]]) - status, _r = await run_single_slot( - ctx, f"{lbl}Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}", - prompt=_prompt("Facts-Research", topic=topic, source=f_source, blocks=block_text(idxs), out_path=fp, extra=_extra(instructions)), - role="quick", capabilities=f_caps, - payload=lambda result, p=fp: _sink_or_file(result, p, _facts_schema), - timeout=_timeout("content", subs_total)) - return status != FAILED and _facts_schema(_json_file(fp)) is not None - - oks = await _gather_progress([_find(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _report_p(set_p, topic, "Facts find")) - if is_cancelled(): - return None - if not any(ok is True for ok in oks): - _blocks_errors[topic] = "Facts extraction failed" - return None - - # Phase "Facts ergänzen" (recall): a targeted gap hunt per chunk looks for source-backed facts that - # the single pass missed. Best-effort — never fails (no erg file → merge uses only raw). - async def _supplement(ci, idxs): - ep = supp_path(ci) - if _facts_schema(_json_file(ep)): - return - per = raw_map(ci, raw_path(ci)) - if not per: - return - block = "\n\n".join( - f"BLOCK: {bt}\nSUBBAUSTEINE (mit bereits erfassten Facts):\n" + "\n".join( - f"- {fk['sub']}\n Erfasst: " + ("; ".join( - list(fk.get("key_points", [])) + [bf["text"] for bf in fk.get("cited_facts", [])]) or "(nichts)") - for fk in fm.values()) - for bt, fm in per.items()) - subs_total = sum(len(blocks[i][1]) for i in idxs) - e_source, e_caps = await asyncio.to_thread( - _inline_source, [blocks[i][0] for i in idxs] + [s for i in idxs for s in blocks[i][1]]) - await run_single_slot( - ctx, f"{lbl}Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}", - prompt=_prompt("Facts-Supplement", topic=topic, source=e_source, blocks=block, out_path=ep, extra=_extra(instructions)), - role="quick", capabilities=e_caps, - payload=lambda result, p=ep: _sink_or_file(result, p, _facts_schema), - timeout=_timeout("content", subs_total)) - - if not slim: - set_p("Facts supplement…", step=_step_idx(topic, "Facts find")) - await _gather_progress([_supplement(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _report_p(set_p, topic, "Facts find")) - if is_cancelled(): - return None - panel = (1,) if slim else (1, 2, 3)[:FACTS_CHECK_PANEL] - min_discard = 1 if slim else 2 - - # Phase "Facts check": FACTS_CHECK_PANEL judges per chunk. Two majority sets: - # flagged (fact inaccurate → correct) and discard (sub not supportable → remove). - async def _check(ci, idxs): - per = _chunk_facts(ci) # raw + supplements → panel verifies the union - if not per: - return ci, set(), set() - facts_text = "\n\n".join(f"SUBBAUSTEIN: {fk['sub']}\n{_facts_lines(fk)}" for fm in per.values() for fk in fm.values()) - # Inline evidence: the EXACT cited regions (falls back to keyword excerpts) go into - # the prompt; the judge answers as TEXT, the engine persists the check file. - cites = [bf.get("source", "") for fm in per.values() for fk in fm.values() - for bf in fk.get("cited_facts", [])] - fallback = [blocks[i][0] for i in idxs] + [fk["sub"] for fm in per.values() for fk in fm.values()] - mat = material_folder(topic) - ev = _cited_evidence(mat, sources, cites, fallback) if mat else "" - c_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source - pending = [j for j in panel if _facts_check_schema(_json_file(chk_path(ci, j))) is None] - tmap = {asyncio.create_task( - run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}", - _prompt("Facts-Check", topic=topic, source=c_source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)), - _timeout("content_check", len(per)), provider=provider, role="judge", - capabilities="none" if ev else caps, - scope=topic, label=f"{lbl}Facts check {ci}/{j}")): j - for j in pending} - await _panel_2of3(tmap, lambda j, r: _sink_json(r, chk_path(ci, j), _facts_check_schema), - lambda: [s for j in panel - if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None], - lambda s: {tuple(x) for x in s}) - outs = [s for j in panel if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None] - bvotes: dict[str, int] = {} - vvotes: dict[str, int] = {} - for s in outs: # s = [(sub_norm, verwerfen)] of one judge - gb, gv = set(), set() - for sn, disc in s: - if sn not in gb: - gb.add(sn); bvotes[sn] = bvotes.get(sn, 0) + 1 - if disc and sn not in gv: - gv.add(sn); vvotes[sn] = vvotes.get(sn, 0) + 1 - threshold = len(outs) / 2 if outs else 99 - flagged = {sn for sn, v in bvotes.items() if v > threshold} - # Discarding is irreversible → stricter than flagging: majority AND ≥2 agreeing judges - # (prevents deletion by a single vote when the panel is degraded). slim runs ONE judge - # by design — there its single vote must be allowed to discard. - to_discard = {sn for sn, v in vvotes.items() if v > threshold and v >= min_discard} - return ci, flagged, to_discard - - check = await _gather_progress([_check(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _report_p(set_p, topic, "Facts check")) - if is_cancelled(): - return None - flagged: dict[int, set] = {} - to_discard: dict[int, set] = {} - for r in check: - if isinstance(r, tuple) and len(r) == 3: - ci, b, v = r - flagged[ci] = b - to_discard[ci] = v - - # Phase "Facts fix": re-extract only CORRECTABLE ones (flagged without discard). - correctable = {ci: (flagged.get(ci, set()) - to_discard.get(ci, set())) for ci in flagged} - n_problem = sum(len(s) for s in correctable.values()) - if n_problem: - set_p(f"Correcting facts ({n_problem})…", step=_step_idx(topic, "Facts fix")) - async def _fix(ci): - subs_norm = correctable.get(ci, set()) - if not subs_norm or _facts_schema(_json_file(fix_path(ci))): - return - idxs = chunks[ci] - rel_by = {blocks[i][0]: blocks[i][1] for i in idxs} - goal = [] - for bt, subs in rel_by.items(): - affected_subs = [s for s in subs if _norm_title(s) in subs_norm] - if affected_subs: - goal.append(f"BLOCK: {bt}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in affected_subs)) - if not goal: - return - x_source, x_caps = await asyncio.to_thread( - _inline_source, [bt for bt in rel_by] + [s for subs in rel_by.values() - for s in subs if _norm_title(s) in subs_norm]) - await run_single_slot( - ctx, f"{lbl}Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}", - prompt=_prompt("Facts-Research", topic=topic, source=x_source, blocks="\n\n".join(goal), out_path=fix_path(ci), extra=_extra(instructions)), - role="quick", capabilities=x_caps, - payload=lambda result, p=fix_path(ci): _sink_or_file(result, p, _facts_schema), - timeout=_timeout("content", len(subs_norm))) - await _gather_progress([_fix(ci) for ci in correctable], len(correctable), _report_p(set_p, topic, "Facts fix")) - if is_cancelled(): - return None - - # assemble: raw + fix overrides for corrected. Discarded subs out (+ report per block). - outcome: dict[str, dict] = {} - discarded_map: dict[str, set] = {} - for ci in range(len(chunks)): - per = _chunk_facts(ci) # raw + supplements (recall); fix overrides only corrected - fix = raw_map(ci, fix_path(ci)) if fix_path(ci).exists() else {} - disc = to_discard.get(ci, set()) - for bt, fm in per.items(): - for sn, fk in fm.items(): - if sn in disc: - discarded_map.setdefault(bt, set()).add(sn) - continue - winners = fix.get(bt, {}).get(sn, fk) if sn in correctable.get(ci, set()) else fk - outcome.setdefault(bt, {})[sn] = {k: winners[k] for k in _FACTS_FIELDS} - if discarded_map: - _log(topic, f"Facts check discards {sum(len(s) for s in discarded_map.values())} unsupportable subblocks") - return outcome, discarded_map - - -async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None: - """Block D: three phases with a barrier — find (relevant/peripheral), select (vote), clarify. - Items from the sidecar; local IDs 1..n per package → global gid. - → {gid: relevance} or None on cancel/research error. Default on gap/dispute: 'relevant'.""" - topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled - work_dir = files["arbeit"] - items = [(title, sub["title"], sub.get("facts")) for title, subs in sidecar.items() for sub in subs] # global id = index+1 - if not items: - return {} - chunks = _chunk_nums(list(range(len(items))), _n_chunks(len(items), LEVEL_CHUNK)) - n = len(chunks) - sh = _subs_hash(sidecar) # resume must invalidate when the sub set changed - - def rater_paths(c): - return [work_dir / f"relevance-{sh}-c{c}-{i}.json" for i in (1, 2, 3)] - - def lset(item_idxs): - return set(range(1, len(item_idxs) + 1)) - - # Phase "Relevance find": 3 raters per package (min. 2), local IDs. - async def _rate(c, item_idxs): - local_set = lset(item_idxs) - paths = rater_paths(c) - existing = sum(1 for p in paths if _relevance_schema(_json_file(p), local_set)) - if existing >= 2: - return True - enum_lines = [] - for k, j in enumerate(item_idxs, 1): - enum_lines.append(f"{k}. [{items[j][0]}] {items[j][1]}") - if (kz := _core_line(items[j][2])): - enum_lines.append(f" {kz}") - enum = "\n".join(enum_lines) - pending = [(i, p) for i, p in enumerate(paths, 1) if not _relevance_schema(_json_file(p), local_set)] - slots = [{ - "key": f"blocks-{topic}-{ns}relevance-c{c}-{i}", - "prompt": _prompt("Relevance-Research", topic=topic, subblocks=enum, out_path=p, extra=_extra(instructions)), - "role": "quick", "capabilities": "none", # pure mapping, everything inline → text reply - "payload": (lambda result, p=p, ids=local_set: _sink_json(result, p, lambda d: _relevance_schema(d, ids))), - } for i, p in pending] - new = await _race(topic, f"{lbl}Relevance package {c}", slots, 2 - existing, _timeout("relevance", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE) - return not is_cancelled() and new is not None - - oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Relevance find")) - if is_cancelled(): - return None - if not all(ok is True for ok in oks): - _blocks_errors[topic] = "Relevance failed (research)" - return None - - # Phase "Relevance select": code vote per package → (outcome, disputed). - set_p(f"Relevance select ({n} packages)…", step=_step_idx(topic, "Relevance select")) - vote_by_c = {} - for c, item_idxs in enumerate(chunks, 1): - local_set = lset(item_idxs) - rater = [d for p in rater_paths(c) if (d := _relevance_schema(_json_file(p), local_set))] - vote_by_c[c] = _code_vote(rater, len(item_idxs)) - - # Phase "Relevance clarify": one judge per package on the disputed items, all in parallel. - async def _clarify(c, item_idxs): - outcome, strittig = vote_by_c[c] - if strittig: - judge_path = work_dir / f"relevance-final-{sh}-c{c}.json" - decision = _relevance_schema(_json_file(judge_path), set(strittig)) - if decision is None: - disputed_block = _disputed_lines(items, item_idxs, strittig) - status, decision = await run_single_slot( - ctx, f"{lbl}Relevance-Clarification {c}", - key=f"blocks-{topic}-{ns}relevance-final-c{c}", - prompt=_prompt("Relevance-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)), - role="judge", capabilities="none", - payload=lambda result, p=judge_path, ids=set(strittig): _sink_json(result, p, lambda d: _relevance_schema(d, ids)), - timeout=_timeout("relevance_check", len(strittig)), - ) - if status == FAILED: - _log(topic, f"Relevance clarification package {c} failed — default 'relevant'") - decision = decision if isinstance(decision, dict) else {} - # disputed without a decision → 'relevant' (never accidentally exclude). - outcome = {**{k: "relevant" for k in strittig}, **outcome, **decision} - return {item_idxs[k - 1] + 1: rel for k, rel in outcome.items()} - - parts = await _gather_progress([_clarify(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Relevance clarify")) - if is_cancelled(): - return None - relevance_by_id: dict[int, str] = {} - for c, part in enumerate(parts, 1): - if not isinstance(part, dict): - # clarification is not fatal: vote outcome + default 'relevant' for disputed. - if isinstance(part, BaseException): - _log(topic, f"Relevance clarification package {c}: {type(part).__name__}: {part}") - outcome, strittig = vote_by_c[c] - item_idxs = chunks[c - 1] - merged = {**{k: "relevant" for k in strittig}, **outcome} - part = {item_idxs[k - 1] + 1: s for k, s in merged.items()} - relevance_by_id.update(part) - return relevance_by_id - - -def _match_sub(agent_sub: str, rel: list[str]) -> str: - """Map the agent's subblock title to the matching relevant title — exact, - then normalized, then substring (the agent drops e.g. the prefix "Question: "). - No match → keep the agent title. This way NO pattern is lost to a title mismatch.""" - if agent_sub in rel: - return agent_sub - an = _norm_title(agent_sub) - for r in rel: - rn = _norm_title(r) - if an and rn and (an == rn or an in rn or rn in an): - return r - return agent_sub - - -async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None: - """Block E (chunks of 10): find (1 generator per ~10 blocks, parallel), select (code: - group per block + dedup), clarify (1 critic per chunk), check (catch-up round). - Assignment per entry via the `block` field (a chunk file carries several blocks). - → {block title: [{subblock, question}, …]} or None on cancel.""" - topic, is_cancelled = ctx.topic, ctx.is_cancelled - work_dir = files["arbeit"] - # ALL subblocks (including peripheral) get a pattern — peripheral is testable in the FGuide level. - # facts_by: full facts context per sub (generation benefits — better questions). - blocks = [] - facts_by: dict[tuple, dict] = {} - for title, subs in sidecar.items(): - all_titles = [] - for s in subs: - if isinstance(s, dict) and (st := str(s.get("title", "")).strip()): - all_titles.append(st) - if isinstance(s.get("facts"), dict): - facts_by[(title, _norm_title(st))] = s["facts"] - if all_titles: - blocks.append((title, all_titles)) - if not blocks: - return {} - chunks = _lpt_chunks([len(rel) for _, rel in blocks], QUESTION_CHUNK_SUBS) # load-balanced by sub count - sh = _subs_hash(sidecar) # resume must invalidate when the sub set changed - - def raw_path(ci): - return work_dir / f"question-pattern-{sh}-c{ci}.json" - - def final_path(ci): - return work_dir / f"question-pattern-final-{sh}-c{ci}.json" - - def _chunk_title(idxs): - return [blocks[i][0] for i in idxs] - - # Phase "Questions find": 1 generator per chunk, all in parallel. - async def _find(ci, idxs): - fp = raw_path(ci) - if _question_pattern_chunk_schema(_json_file(fp)): - return # Resume - def _sub_line(bi, s): - line = f"- {s}" - fk = facts_by.get((blocks[bi][0], _norm_title(s))) - if fk and (ft := _facts_lines(fk)): - line += "\n" + "\n".join(" " + l for l in ft.split("\n")) - return line - block = "\n\n".join( - f"BLOCK: {blocks[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(_sub_line(i, s) for s in blocks[i][1]) - for i in idxs - ) - subs_total = sum(len(blocks[i][1]) for i in idxs) - status, _ = await run_single_slot( - ctx, f"{lbl}Question-Pattern {ci}", - key=f"blocks-{topic}-{ns}question-pattern-c{ci}", - prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=block, - out_path=fp, extra=_extra(instructions)), - role="quick", capabilities="files", - payload=lambda result, p=fp: _sink_or_file(result, p, _question_pattern_chunk_schema), - timeout=_timeout("question_pattern", subs_total), - ) - if status == FAILED: - _log(topic, f"Question pattern chunk {ci} failed — blocks in fallback (catch-up round/live)") - - async def find_all(ci_list): - ci_list = list(ci_list) - await _gather_progress([_find(ci, chunks[ci]) for ci in ci_list], len(ci_list), _report_p(set_p, topic, "Questions find")) - - await find_all(range(len(chunks))) - if is_cancelled(): - return None - - # Phase "Questions select": code — group chunk files per block, drop duplicates, - # loosely map block/subblock titles to the targets (discard nothing for a mismatch). - def _select_chunk(ci): - idxs = chunks[ci] - ctitle = _chunk_title(idxs) - rel_by = {blocks[i][0]: blocks[i][1] for i in idxs} - out, seen_set = {}, {} - for e in _question_pattern_chunk_schema(_json_file(raw_path(ci))) or []: - title = _match_sub(e["block"], ctitle) - if title not in rel_by: - continue # not assignable → discard - sub = _match_sub(e["subblock"], rel_by[title]) - seen = seen_set.setdefault(title, set()) - if sub in seen: - continue # exactly one pattern per subblock - seen.add(sub) - out.setdefault(title, []).append({"subblock": sub, "question": e["question"]}) - return out - - def _select_all(ci_list): - raw = {} - for ci in ci_list: - for title, eintraege in _select_chunk(ci).items(): - raw.setdefault(title, []).extend(eintraege) - return raw - - set_p("Questions select…", step=_step_idx(topic, "Questions select")) - raw_by_title = _select_all(range(len(chunks))) - - # Phase "Questions clarify": 1 critic per chunk cleans up the tables (grouped by block). - async def _clarify(ci, idxs): - fp = final_path(ci) - if _question_pattern_chunk_schema(_json_file(fp)): - return # resume - block_texts = [] - for i in idxs: - t = blocks[i][0] - eintraege = raw_by_title.get(t) or [] - if not eintraege: - continue - lines = "\n".join(f"- ({e['subblock']}) {e['question']}" for e in eintraege) - block_texts.append(f"BLOCK: {t}\n{lines}") - if not block_texts: - return # nothing to clarify in this chunk - subs_total = sum(len(blocks[i][1]) for i in idxs) - status, _ = await run_single_slot( - ctx, f"{lbl}Question-Pattern-Clarification {ci}", - key=f"blocks-{topic}-{ns}question-pattern-final-c{ci}", - prompt=_prompt("Question-Pattern-Critique", topic=topic, table="\n\n".join(block_texts), out_path=fp, extra=_extra(instructions)), - role="judge", capabilities="none", # pure review, everything inline → text reply - payload=lambda result, p=fp: _sink_json(result, p, _question_pattern_chunk_schema), - timeout=_timeout("question_pattern_check", subs_total), - ) - if status == FAILED: - _log(topic, f"Question pattern clarification chunk {ci} failed — raw pattern adopted") - - async def clarify_all(ci_list): - ci_list = list(ci_list) - await _gather_progress([_clarify(ci, chunks[ci]) for ci in ci_list], len(ci_list), _report_p(set_p, topic, "Questions clarify")) - - await clarify_all(range(len(chunks))) - if is_cancelled(): - return None - - # Clarified chunk table per block, fallback to raw pattern. Map titles loosely. - def _final_by_title(ci_list): - out = {} - for ci in ci_list: - idxs = chunks[ci] - ctitle = _chunk_title(idxs) - rel_by = {blocks[i][0]: blocks[i][1] for i in idxs} - for e in _question_pattern_chunk_schema(_json_file(final_path(ci))) or []: - title = _match_sub(e["block"], ctitle) - if title not in rel_by: - continue - out.setdefault(title, []).append( - {"subblock": _match_sub(e["subblock"], rel_by[title]), "question": e["question"]}) - return out - - final_by_title = _final_by_title(range(len(chunks))) - outcome = {t: (final_by_title.get(t) or raw_by_title.get(t) or []) for t, _ in blocks} - - # Phase "Questions check": per-sub completeness. Generators crash randomly (~15 %), - # 1 agent per chunk without retry → subs (whole blocks) fall through silently. Hence several - # rounds that re-request ONLY the missing subs (short packages, Question-Pattern-Research). - set_p("Questions check…", step=_step_idx(topic, "Questions check")) - - def _missing_subs() -> list[tuple[str, list[str]]]: - out = [] - for t, subs in blocks: - have_set = {_norm_title(e["subblock"]) for e in outcome.get(t) or []} - miss = [s for s in subs if _norm_title(s) not in have_set] - if miss: - out.append((t, miss)) - return out - - def _followup_block(items): # items: [(block_title, [missing sub_title])] - block_texts = [] - for title, subs in items: - lines = [] - for s in subs: - z = f"- {s}" - fk = facts_by.get((title, _norm_title(s))) - if fk and (ft := _facts_lines(fk)): - z += "\n" + "\n".join(" " + l for l in ft.split("\n")) - lines.append(z) - block_texts.append(f"BLOCK: {title}\nSUBBAUSTEINE:\n" + "\n".join(lines)) - return "\n\n".join(block_texts) - - async def _request_more(round_n, pi, items): - fp = work_dir / f"question-pattern-nach{round_n}-{sh}-c{pi}.json" - if _question_pattern_chunk_schema(_json_file(fp)): - return # resume - subs_total = sum(len(s) for _, s in items) - await run_single_slot( - ctx, f"{lbl}Question pattern catch-up R{round_n}/{pi}", - key=f"blocks-{topic}-{ns}question-pattern-nach{round_n}-c{pi}", - prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=_followup_block(items), - out_path=fp, extra=_extra(instructions)), - role="quick", capabilities="files", - payload=lambda result, p=fp: _sink_or_file(result, p, _question_pattern_chunk_schema), - timeout=_timeout("question_pattern", subs_total), - ) - - for round_n in range(1, QUESTION_MAX_ROUNDS + 1): - missing_subs = _missing_subs() - if not missing_subs: - break - n_subs = sum(len(s) for _, s in missing_subs) - _log(topic, f"Question pattern round {round_n}: {n_subs} sub(s) in {len(missing_subs)} block(s) without a pattern — re-request") - packages = _lpt_chunks([len(s) for _, s in missing_subs], QUESTION_CHUNK_SUBS) - package_items = [[missing_subs[i] for i in idxs] for idxs in packages] - await _gather_progress( - [_request_more(round_n, pi, items) for pi, items in enumerate(package_items)], - len(package_items), _report_p(set_p, topic, "Questions check")) - if is_cancelled(): - return None - # parse output per package + merge newly gained subs (don't overwrite existing ones). - for pi, items in enumerate(package_items): - title_subs = {t: subs for t, subs in items} - ctitle = list(title_subs.keys()) - for e in _question_pattern_chunk_schema(_json_file(work_dir / f"question-pattern-nach{round_n}-{sh}-c{pi}.json")) or []: - title = _match_sub(e["block"], ctitle) - if title not in title_subs: - continue - sub = _match_sub(e["subblock"], title_subs[title]) - have_set = {_norm_title(x["subblock"]) for x in outcome.get(title) or []} - if _norm_title(sub) in have_set: - continue - outcome.setdefault(title, []).append({"subblock": sub, "question": e["question"]}) - - rest = _missing_subs() - if rest: - n = sum(len(s) for _, s in rest) - _log(topic, f"Question pattern: {n} sub(s) in {len(rest)} block(s) remain empty after {QUESTION_MAX_ROUNDS} rounds: {[t for t, _ in rest][:5]}") - return outcome - - # ── Inventory in the DB: research loop · consolidation · clarification ──────────── @@ -3256,166 +1668,6 @@ async def _outline_block(ctx: GenContext, set_p, files: dict, entries: dict, ins # --- Learning artefacts (flashcards/examples from the facts) --- -def _cards_schema(data): - """{"cards":[{block,subblock,question,answer}]} → list (also empty) · None if broken.""" - if not isinstance(data, dict) or not isinstance(data.get("cards"), list): - return None - out = [] - for e in data["cards"]: - if isinstance(e, dict) and (f := str(e.get("question", "")).strip()) and (a := str(e.get("answer", "")).strip()): - out.append({"block": str(e.get("block", "")).strip(), "subblock": str(e.get("subblock", "")).strip(), - "question": f, "answer": a}) - return out - - -def _example_schema(data): - """{"examples":[{block,subblock,problem,steps,result}]} → list (also empty) · None if broken.""" - if not isinstance(data, dict) or not isinstance(data.get("examples"), list): - return None - out = [] - for e in data["examples"]: - if not isinstance(e, dict): - continue - problem = str(e.get("problem", "")).strip() - steps = [s for x in (e.get("steps") or []) if (s := str(x).strip())] - if problem and steps: - out.append({"block": str(e.get("block", "")).strip(), "subblock": str(e.get("subblock", "")).strip(), - "problem": problem, "steps": steps, "result": str(e.get("result", "")).strip()}) - return out - - -def _example_check_schema(data): - """Worked-example check → {"ok": true} → set() (all correct); {"problems":[{"index":N}]} → - {N, …} (1-based flagged indices); None if broken.""" - if not isinstance(data, dict): - return None - if data.get("ok") is True: - return set() - pr = data.get("problems") - if not isinstance(pr, list): - return None - out: set[int] = set() - for p in pr: - if isinstance(p, dict): - try: - out.add(int(p.get("index"))) - except (ValueError, TypeError): - continue - return out - - -_ARTEFACT_SCHEMA = {"flashcard": _cards_schema, "example": _example_schema} -_ARTEFACT_PROMPT = {"flashcard": "Artifact-Flashcard", "example": "Artifact-Example"} -_ARTEFACT_STEP = {"flashcard": "Flashcards", "example": "Examples"} - - -async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None: - """Generate learning artefacts per type from the stored facts — one generation pass - per type over chunks. Worked examples are verified against the facts (wrong ones discarded); - flashcards are low-risk and stay unchecked. → {type: [entries]} (also in files).""" - topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled - work_dir = files["arbeit"] - caps = "files" - sh = _subs_hash(sidecar) # resume must invalidate when the sub set changed - # Blocks with subs + facts lines as input block (extract-once from the facts). - blocks = [] - for btitle, subs in sidecar.items(): - if not isinstance(subs, list): - continue - lines = [] - for s in subs: - if not isinstance(s, dict) or not (st := str(s.get("title", "")).strip()): - continue - fk = s.get("facts") if isinstance(s.get("facts"), dict) else {} - line = f"- {st}" - if fk and (fk_text := _facts_lines(fk)): - line += "\n" + "\n".join(" " + l for l in fk_text.split("\n")) - lines.append(line) - if lines: - blocks.append((btitle, lines)) - if not blocks: - empty_map = {t: [] for t in ARTEFACT_TYPES} - atomic_write_json(files["artefakte"], empty_map, indent=1) - return empty_map - - chunks = _lpt_chunks([len(z) for _, z in blocks], ARTEFACT_CHUNK_SUBS) - def block_text(idxs): - return "\n\n".join(f"BLOCK: {blocks[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(blocks[i][1]) for i in idxs) - - # Check worked examples against the facts (panel majority) — discard wrong ones. CoT steps are - # error-prone; a wrong example imprints a faulty schema → no example > a wrong one. - async def _check_examples(ci, idxs, items): - if is_cancelled() or not items: - return items - def cpath(j): return work_dir / f"artifact-example-check-{sh}-c{ci}-j{j}.json" - examples_txt = "\n\n".join( - f"{k}. PROBLEM: {e['problem']}\n SCHRITTE: " + " | ".join(e.get("steps", [])) - + (f"\n ERGEBNIS: {e['result']}" if e.get("result") else "") - for k, e in enumerate(items, 1)) - pending = [j for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if _example_check_schema(_json_file(cpath(j))) is None] - if pending: - # ground truth (facts) is fully inline → no tools, text reply, engine persists - tmap = {asyncio.create_task( - run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}", - _prompt("Artifact-Example-Check", topic=topic, facts=block_text(idxs), examples=examples_txt, out_path=cpath(j), extra=_extra(instructions)), - _timeout("content_check", len(items)), provider=provider, role="judge", capabilities="none", - scope=topic, label=f"{lbl}Beispiel-Check {ci}/{j}")): j - for j in pending} - await _panel_2of3(tmap, lambda j, r: _sink_json(r, cpath(j), _example_check_schema), - lambda: [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] - if (s := _example_check_schema(_json_file(cpath(j)))) is not None], - lambda s: frozenset(s)) - outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _example_check_schema(_json_file(cpath(j)))) is not None] - if not outs: - return items # no exam possible → keep (best-effort) - votes: dict[int, int] = {} - for s in outs: - for idx in s: - votes[idx] = votes.get(idx, 0) + 1 - threshold = len(outs) / 2 - dropped = {idx for idx, v in votes.items() if v > threshold} # majority (≥2 of 3) flagged → out - if dropped: - _log(topic, f"Worked-example check chunk {ci}: {len(dropped)}/{len(items)} discarded") - return [e for k, e in enumerate(items, 1) if k not in dropped] - - async def _one_type(typ: str) -> list | None: - """Full pipeline of ONE artefact type — the types are independent (own schemas, - own files) and run in parallel.""" - schema = _ARTEFACT_SCHEMA[typ] - - def apath(ci): return work_dir / f"artifact-{typ}-{sh}-c{ci}.json" - - async def _gen(ci, idxs): - p = apath(ci) - if schema(_json_file(p)) is not None: - return True - await run_single_slot( - ctx, f"{lbl}{_ARTEFACT_STEP[typ]} {ci}", key=f"blocks-{topic}-{ns}artifact-{typ}-c{ci}", - prompt=_prompt(_ARTEFACT_PROMPT[typ], topic=topic, blocks=block_text(idxs), out_path=p, extra=_extra(instructions)), - role="guide", capabilities="files", - payload=lambda result, p=p: _sink_or_file(result, p, schema), - timeout=_timeout("content", sum(len(blocks[i][1]) for i in idxs))) - return schema(_json_file(p)) is not None - - await _gather_progress([_gen(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _report_p(set_p, topic, _ARTEFACT_STEP[typ])) - if is_cancelled(): - return None - eintraege: list = [] - for ci in range(len(chunks)): - chunk_items = schema(_json_file(apath(ci))) or [] - if typ == "example" and chunk_items: - chunk_items = await _check_examples(ci, chunks[ci], chunk_items) - eintraege += chunk_items - return eintraege - - results = await asyncio.gather(*[_one_type(t) for t in ARTEFACT_TYPES]) - if is_cancelled() or any(r is None for r in results): - return None - outcome: dict[str, list] = dict(zip(ARTEFACT_TYPES, results)) - atomic_write_json(files["artefakte"], outcome, indent=1) - return outcome - - async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None: """Mirror the sidecar {block title: [{title, level, relevance}]} into the DB table subblocks.""" for btitle, subs in sidecar.items(): diff --git a/backend/board_artefacts.py b/backend/board_artefacts.py index 13e8d8b..2b9d495 100644 --- a/backend/board_artefacts.py +++ b/backend/board_artefacts.py @@ -1,14 +1,12 @@ """Board 2 „Artefakte": per finished block, prepare the learning artefacts the guide presents. A card is spawned by board 1's `done` column per mirrored block and runs through: - subblocks → facts → levels → relevance → question_pattern (+artefacts 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 files + the DB tables. Danach zwei topic-weite BARRIEREN: `konsolidierung` (cross-block sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order), -re-run once per generation run. - -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.""" +re-run once per generation run — outline läuft parallel zur Dedup-Barriere.""" import asyncio import hashlib @@ -19,11 +17,8 @@ import re import database as db import blocks import embedding -from blocks import ( - ARTEFACT_TYPES, _artefacts_block, _facts_block, _facts_nachfass, _konsolidiere_subblocks, - _levels_block, _luecken_runde, _match_sub, _neg_set, _question_pattern_block, _relevance_block, - _sink_json, _subblocks_block, _outline_block, -) +from block_calls import _artefakte_block, _generate_block, _verify_block +from blocks import ARTEFACT_TYPES, _match_sub, _neg_set, _sink_json, _outline_block from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS from fsutil import atomic_write_json from jsonio import read_json_file as _json_file @@ -100,7 +95,7 @@ def make_spawner(topic: str, files: dict): norm = payload.get("mirrored_norm") if not norm: return - await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "subblocks", { + await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "generate", { "title": payload.get("title", ""), "description": payload.get("description", ""), "n_size": payload.get("n_size", 0), # LPT estimate until subs_n exists @@ -172,88 +167,76 @@ async def _seed_map(topic: str) -> dict[str, list[str]]: return seeds -async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards): +async def _proc_generate(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards): + """Verschmolzener Erzeuger: 2 unabhängige Generatoren liefern Subs+Facts+Einstufung + in EINEM Call, Konsens im Code (block_calls._generate_block).""" topic = flow.topic - # Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks. + # fragments demoted to a parent become seed candidates of the parent's subblocks seeds = await _seed_map(topic) async def one(c): p = c["payload"] norm = c["card_id"] - instr = instructions - sd = [s for s in seeds.get(norm, []) if s] - if sd: - instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses " - "Blocks (prüfen; wenn belegt UND noch nicht durch einen anderen Eintrag " - "abgedeckt, aufnehmen — nicht wörtlich übernehmen, sondern als eigenständige " - "Aussage formulieren):\n" - + "\n".join(f"- {s}" for s in sd)) - raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), - {1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-", - seeds=sd or None, lbl=f"{p.get('title', norm)} · ", - sources=p.get("sources")) - if raw is None: - return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}") - p["raw"] = raw - p["subs_n"] = sum(len(v) for v in raw.values()) # LPT: bigger blocks pull first + gen = await _generate_block(ctx, _pfiles(files, norm), p.get("title", ""), + p.get("description", ""), instructions, + ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ", + sources=p.get("sources"), + seeds=[s for s in seeds.get(norm, []) if s] or None) + if gen is None: + return _fail_or_cancel(ctx, f"Generate {p.get('title', norm)}") + p["raw"], p["facts"] = gen["raw"], gen["facts"] + p["unsicher"], p["votes"] = gen["unsicher"], gen["votes"] + p["subs_n"] = sum(len(v) for v in gen["raw"].values()) + len(gen["unsicher"]) await db.kanban_set_payload(topic, BOARD, norm, p) - await db.kanban_advance(topic, BOARD, norm, "facts") + await db.kanban_advance(topic, BOARD, norm, "verify") await _gather_cards(ctx, flow, cards, one) -async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder, - instructions: str, cards): +async def _proc_verify(ctx: GenContext, flow: Flow, files: dict, q: dict, instructions: str, cards): + """Verschmolzener Prüfer: MECE + Facts + Einstufung in einem Panel-Call, Fix-Tail + inklusive (block_calls._verify_block) — ersetzt facts-check/konsolidierung/levels/ + relevance als eigene Stages.""" topic = flow.topic async def one(c): p = c["payload"] norm = c["card_id"] - raw = p.get("raw") or {} - res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q, - folder, instructions, ns=f"{_safe(norm)}-", - lbl=f"{p.get('title', norm)} · ", sources=p.get("sources")) + title = p.get("title", "") + gen = {"raw": p.get("raw") or {}, "facts": p.get("facts") or {}, + "unsicher": p.get("unsicher") or [], "votes": p.get("votes") or {}} + res = await _verify_block(ctx, _pfiles(files, norm), title, gen, q, instructions, + ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ", + sources=p.get("sources")) if res is None: - return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}") - facts_map, discarded = res - if discarded: # unsupportable subs vanish from raw too (guide never sees them) - for bt, sns in discarded.items(): - if bt in raw: - raw[bt] = [s for s in raw[bt] if _norm_title(s) not in sns] - raw = {bt: subs for bt, subs in raw.items() if subs} - # In-block consolidation: two-judge panel folds same-statement/subset subs, bundles - # catalogs, drops off-topic ones — the facts are in hand (key points as evidence), - # questions/artefacts not yet built. Reported gaps get ONE follow-up finder round. - luecken = await _konsolidiere_subblocks(ctx, _pfiles(files, norm), raw, facts_map, - instructions, ns=f"{_safe(norm)}-", - lbl=f"{p.get('title', norm)} · ") - if ctx.is_cancelled(): - return None - nachgefasst = 0 - for bt, lk in (luecken or {}).items(): - nachgefasst += await _luecken_runde(ctx, _pfiles(files, norm), bt, lk, raw, facts_map, - q, folder, instructions, ns=f"{_safe(norm)}-", - lbl=f"{p.get('title', norm)} · ", sources=p.get("sources")) - if ctx.is_cancelled(): - return None - if nachgefasst: # close the loop: follow-up finds get the SAME duplicate test as the - # rest (new subs-hash → fresh judge files); their gap report is deliberately ignored - await _konsolidiere_subblocks(ctx, _pfiles(files, norm), raw, facts_map, - instructions, ns=f"{_safe(norm)}-", - lbl=f"{p.get('title', norm)} · ") - if ctx.is_cancelled(): - return None - raw = {bt: subs for bt, subs in raw.items() if subs} - # consolidation renames/catalogs can leave consensus subs without grounding — the - # guide fact gate then flags their correct statements wholesale (measured: 74/210) - await _facts_nachfass(ctx, _pfiles(files, norm), raw, facts_map, q, folder, - instructions, ns=f"{_safe(norm)}-", - lbl=f"{p.get('title', norm)} · ", sources=p.get("sources")) - if ctx.is_cancelled(): - return None - p["raw"], p["facts"] = raw, facts_map + return None # nur Cancel — Karte bleibt liegen + p["raw"], p["facts"], p["sidecar"] = res["raw"], res["facts"], res["sidecar"] + p.pop("unsicher", None) + p.pop("votes", None) await db.kanban_set_payload(topic, BOARD, norm, p) - await db.kanban_advance(topic, BOARD, norm, "levels") + await db.kanban_advance(topic, BOARD, norm, "artefakte") + + await _gather_cards(ctx, flow, cards, one) + + +async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards): + """Fragen + Flashcards + Beispiele in einem Generator-Call, ein Prüfer-Call dahinter + (block_calls._artefakte_block).""" + topic = flow.topic + + async def one(c): + p = c["payload"] + norm = c["card_id"] + title = p.get("title", "") + res = await _artefakte_block(ctx, _pfiles(files, norm), title, + (p.get("sidecar") or {}).get(title) or [], + instructions, ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ") + 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) @@ -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 # generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen # ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln. - nachzuegler = [(c["card_id"], "question_pattern") for c in cards - if "pattern" not in c["payload"]] + nachzuegler = [(c["card_id"], "artefakte" if "sidecar" in c["payload"] else "generate") + for c in cards if "pattern" not in c["payload"]] if nachzuegler: await db.kanban_advance_many(topic, BOARD, nachzuegler) flow.wake.set() @@ -417,101 +400,6 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc await _advance_all() -async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards): - topic = flow.topic - - async def one(c): - p = c["payload"] - norm = c["card_id"] - sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), - p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-", - lbl=f"{p.get('title', norm)} · ") - if sidecar is None: - return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}") - facts_map = p.get("facts") or {} - for btitle, subs in sidecar.items(): # merge facts into the subs (mirror + guide) - fm = facts_map.get(btitle, {}) - for sub in subs: - # level agents paraphrase titles — exact miss falls back to the unique - # prefix/containment match, else the sub silently loses its grounding - sn = _sub_key(set(fm), _norm_title(sub["title"])) - if (fk := fm.get(sn)): - sub["facts"] = fk - p["sidecar"] = sidecar - await db.kanban_set_payload(topic, BOARD, norm, p) - await db.kanban_advance(topic, BOARD, norm, "relevance") - - await _gather_cards(ctx, flow, cards, one) - - -async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards): - topic = flow.topic - - async def one(c): - p = c["payload"] - norm = c["card_id"] - sidecar = p.get("sidecar") or {} - rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), - sidecar, instructions, ns=f"{_safe(norm)}-", - lbl=f"{p.get('title', norm)} · ") - if rel is None: - return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}") - gid = 0 - for subs in sidecar.values(): - for sub in subs: - gid += 1 - sub["relevance"] = rel.get(gid, "relevant") - p["sidecar"] = sidecar - await db.kanban_set_payload(topic, BOARD, norm, p) - await db.kanban_advance(topic, BOARD, norm, "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 ───────────────────── def _merge_json(path, block_keys: dict) -> None: 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) ───────────────────────────── +_ALT_STAGES = ("subblocks", "facts", "levels", "relevance", "question_pattern", "artefacts") + + +async def migriere_alt_karten(topic: str) -> int: + """Harter Schnitt: Karten der alten Stage-Treppe beim Flow-Start auf `generate` + zurücksetzen (Payload auf die Spawn-Felder reduziert — Zwischenstände der alten + Struktur sind für die verschmolzenen Calls wertlos). → Anzahl migrierter Karten.""" + moves = [] + for c in await db.kanban_cards(topic, board=BOARD, kind="ablock"): + if c["stage"] in _ALT_STAGES: + p = c["payload"] + await db.kanban_set_payload(topic, BOARD, c["card_id"], { + "title": p.get("title", ""), "description": p.get("description", ""), + "n_size": p.get("n_size", 0), "sources": p.get("sources")}) + moves.append((c["card_id"], "generate")) + if moves: + await db.kanban_advance_many(topic, BOARD, moves) + return len(moves) + + def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder, instructions: str) -> list[Stage]: research_done = lambda: flow.research_done # noqa: E731 return [ - Stage(BOARD, "subblocks", lambda cs: _proc_subblocks(ctx, flow, files, instructions, cs)), - Stage(BOARD, "facts", lambda cs: _proc_facts(ctx, flow, files, q, folder, instructions, cs)), - Stage(BOARD, "levels", lambda cs: _proc_levels(ctx, flow, files, instructions, cs)), - Stage(BOARD, "relevance", lambda cs: _proc_relevance(ctx, flow, files, instructions, cs)), - 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, "generate", lambda cs: _proc_generate(ctx, flow, files, instructions, cs)), + Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)), + Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)), Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True), # Cross-Block-Dedup als END-Barriere: als Mittel-Barriere idelte jede fertige Karte # auf die langsamste (8:46 min/Block gemessen); jetzt faltet sie nach finalize diff --git a/backend/board_inventory.py b/backend/board_inventory.py index 3cef658..a5e41eb 100644 --- a/backend/board_inventory.py +++ b/backend/board_inventory.py @@ -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 flow.state["spawn_artefact"] = board_artefacts.make_spawner(topic, files) await board_artefacts.ensure_outline_card(topic) + migriert = await board_artefacts.migriere_alt_karten(topic) + if migriert: + _log(topic, f"Board 2: {migriert} Karte(n) der alten Stage-Struktur → generate") stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions) if QA_GATE_NOTE > 0: # QA gate: board 2 waits until the inventory QA passed (or the user forces). # Costs pipelining (board 2 no longer starts per finished block) but saves # tokens on a bad foundation — the watcher below runs the QA and decides. - sub = next(st for st in stages if st.stage == "subblocks") + sub = next(st for st in stages if st.stage == "generate") sub.gate = lambda: bool(flow.state.get("qa_ok") or flow.state.get("qa_force")) stages = chain_stages(stages) if artefacts: - # Outline needs every block's TITLE + FACTS, nothing later: cut the post-facts - # artefact stages from its barrier so it runs parallel to levels…finalize of the - # slowest block (makespan tail). Inventory stages all stay — no late blocks. + # Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the + # post-generate stages from its barrier so it runs parallel to verify…finalize AND + # zur Cross-Dedup-Barriere des langsamsten Blocks (makespan tail). outline = next(s for s in stages if s.stage == "outline") outline.upstream = [u for u in outline.upstream if u not in - ("levels", "relevance", "question_pattern", "artefacts", "finalize")] + ("verify", "artefakte", "finalize", "konsolidierung")] producers = _build_producers(ctx, flow, q, folder, instructions) if research else [] async def _as_producer(coro): @@ -1712,7 +1715,7 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr if ctx.is_cancelled(): return False if flow.state.get("qa_paused"): - return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in subblocks + return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in generate await _write_final(topic, files) await _write_run_summary(topic, flow) return True @@ -1834,14 +1837,11 @@ COLUMNS = [ ("inventory", "done_block", "Fertig", "block"), ("inventory", "rejected", "Verworfen", None), ("inventory", "grouped", "Zusammengelegt", "block"), - ("artefacts", "subblocks", "Subbausteine", "ablock"), - ("artefacts", "facts", "Fakten", "ablock"), - ("artefacts", "levels", "Stufen", "ablock"), - ("artefacts", "relevance", "Relevanz", "ablock"), - ("artefacts", "konsolidierung", "Konsolidierung", "ablock"), - ("artefacts", "question_pattern", "Fragen", "ablock"), - ("artefacts", "artefacts", "Lernkarten", "ablock"), + ("artefacts", "generate", "Erzeugen", "ablock"), + ("artefacts", "verify", "Prüfen", "ablock"), + ("artefacts", "artefakte", "Lernmittel", "ablock"), ("artefacts", "finalize", "Zusammenführen", "ablock"), + ("artefacts", "konsolidierung", "Konsolidierung", "ablock"), ("artefacts", "outline", "Gliederung", "outline"), ("artefacts", "done_artefact", "Fertig", "ablock"), ] @@ -1849,18 +1849,16 @@ COLUMNS = [ _TITLE_STAGES = ["ingest", "cluster"] _CLUSTER_STAGES = ["pair_check", "consensus_gate", "clarify", "naming", "naming_check"] _BLOCK_STAGES = ["fragment_filter", "dedup", "grouping", "gap_check", "done"] -_ART_STAGES = ["subblocks", "facts", "levels", "relevance", "konsolidierung", "question_pattern", - "artefacts", "finalize", "outline"] +_ART_STAGES = ["generate", "verify", "artefakte", "finalize", "konsolidierung", "outline"] # where a requeued dead card restarts, by kind _DEAD_RESTART = {"title": "cluster", "cluster": "pair_check", "block": "fragment_filter", - "ablock": "subblocks", "outline": "outline"} + "ablock": "generate", "outline": "outline"} _VERDICT_KEYS = ("reason", "votes", "judges", "merged_into", "parent_norm", "mirrored_norm") DONE_ART = "done_artefact" # Board-2 stage → its fine-step group in blocks.PHASEN (drives the per-card stepper). -_STAGE_PHASE = {"subblocks": "Subblocks", "facts": "Facts", "levels": "Levels", - "relevance": "Relevance", "question_pattern": "Questions", "artefacts": "Artefacts"} +_STAGE_PHASE = {"generate": "Generate", "verify": "Verify", "artefakte": "Artefakte"} _PHASE_STEPS = {name: steps for name, steps in blocks.PHASEN} @@ -1928,7 +1926,7 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None: note = r.get("note") if note is None: return None - wartend = counts.get("artefacts", {}).get("subblocks", 0) + wartend = counts.get("artefacts", {}).get("generate", 0) pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None) return {"note": note, "note_artefakte": r.get("note_artefakte"), "schwelle": QA_GATE_NOTE, "pausiert": pausiert, @@ -1997,7 +1995,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict await _requeue(r, "outline") elif r["stage"] in later: await _requeue(r, stage) - if stage == "subblocks": # full artefact re-derive → also the DB mirrors + if stage == "generate": # full artefact re-derive → also the DB mirrors await db.delete_subblocks(topic) await db.delete_question_pattern(topic) await db.delete_sub_artefakte(topic) @@ -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: - """Restart ONE artefacts card from `subblocks` — wipes only ITS derived DB rows + """Restart ONE artefacts card from `generate` — wipes only ITS derived DB rows (per-block work-dir slots overwrite themselves; finalize re-upserts later). Only call while nothing is generating (the route guards). → False if unknown.""" card = await db.kanban_get_card(topic, "artefacts", card_id) @@ -2024,7 +2022,7 @@ async def restart_artefact_card(topic: str, card_id: str) -> bool: await db.delete_sub_artefakte(topic, card_id) p = {k: v for k, v in card["payload"].items() if k in ("title", "description")} await db.kanban_set_payload(topic, "artefacts", card_id, p) - await db.kanban_advance(topic, "artefacts", card_id, "subblocks") + await db.kanban_advance(topic, "artefacts", card_id, "generate") await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart") return True diff --git a/backend/config.py b/backend/config.py index 3f51166..5642775 100644 --- a/backend/config.py +++ b/backend/config.py @@ -114,9 +114,17 @@ FRAGMENT_MIN_COS = 0.15 # dominates). Locally raise the global cap to actually parallelize across topics (per-topic stays 10). # Own lane for interactive calls (chat, elements) so they don't hang behind running writers. MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16")) # global, all topics -MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "12")) # per topic +# Per-topic higher than the process cap: it is SHARED between process and API tier — at 12 a +# single-topic run (the normal case) would never benefit from the cheap API tier. +MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "24")) # per topic +# Direct-API text calls (agents._run_text_api): ~0 RAM, only network — own, higher global cap. +MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "28")) MAX_CONCURRENT_INTERACTIVE = 8 +# RAM guard for opencode spawns: below this free share (MemAvailable/MemTotal in %) new +# processes wait instead of starting (agents._ram_gate). 0 = off. +RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20")) + # Grace window of the consensus races (blocks, guide, OnePager): after the first # valid result the remaining agents may still become done for this many seconds # (kill only once the minimum is already in). @@ -186,6 +194,12 @@ ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples bulk chunk FACTS_CHECK_PANEL = 3 # judges per facts-check chunk (majority) CONSOLIDATION_PANEL = 3 # mapping judges per chunk SUBBLOCK_PANEL = 3 # judges in the subblock clarification +# Board 2, verschmolzene Calls (block_calls.py): Panel-Größen der neuen Struktur. +# Konsens braucht ≥2 unabhängige Nennungen bzw. Einstimmigkeit — 2 ist das Minimum, +# 3 kauft Robustheit für +50 % Tokens auf dem jeweiligen Segment. +GEN_PANEL = 2 # unabhängige Generator-Calls pro Block +VERIFY_PANEL = 2 # unabhängige Prüfer-Calls pro Block (+ Ersatz bei 1 Ausfall) +ART_SPLIT_SUBS = 20 # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck MAX_WRITER_ROUNDS = 2 # guide coverage→writer loop cap GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below this → log only (falsch fixt immer) @@ -225,6 +239,12 @@ TIMEOUTS = { "relevance_check": (150, 8), # judge decides contested relevance in the chunk "question_pattern": (300, 15), # question patterns per block (subblocks × types) "question_pattern_check": (150, 8), # critic cleans up the pattern table per block + # Board 2, verschmolzene Calls: größere Outputs pro Call, dafür wenige Segmente + "generate": (450, 0), # Subs+Facts+Level in einem (Sub-Zahl vorab unbekannt) + "verify": (300, 10), # Audit über alle Subs (n = Subs), key points gekappt + "fix": (300, 15), # Korrekturen + Lücken (n = Aufträge) + "artefakt": (450, 15), # Fragen+Karten+Beispiele (n = Subs) + "artefakt_check": (200, 8), # Beispiel-Verifikation + Fragen-Kritik (n = Subs) "writer": (450, 60), # per section — split keeps sections ≤30 subs "lese_check": (300, 10), # per section in the package # guide board (per card = one block) diff --git a/backend/fake_agents.py b/backend/fake_agents.py index 0b06166..b114b3e 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -140,16 +140,17 @@ class Welt: nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}}) - # Board 2 / Artefakte - if "-luecken-" in key: - return "\n" # Lücken-Nachfass findet nichts Neues - if "-subblock-final-" in key or "-subblock-" in key: # Finder + Judge, gleiches Format - teile = [] + # Board 2 / Artefakte (verschmolzene Calls, block_calls.py) + if "-sb-gen-" in key: + subs = [] for t in self._bloecke_im_prompt(prompt): - subs = "\n".join(f"- {s}" for s in self.bloecke[t]["subs"]) - teile.append(f"\n{subs}") - return "\n".join(teile) or "\n" - if "-sub-konsolidierung-" in key: + for s in self.bloecke[t]["subs"]: + f = self._fakt(t, s) + subs.append({"title": s, "level": "beginner", "relevance": "relevant", + **{k: f[k] for k in ("key_points", "prerequisites", "hurdles", + "cited_facts", "example_idea")}}) + return j({"subs": subs}) + if "-sb-verify-" in key: nummern = {_norm(m.group(2)): m.group(1) for m in _NUM_RE.finditer(prompt)} gruppen = [] for haupt, weitere in self.gruppen: @@ -162,50 +163,29 @@ class Welt: m = [int(nummern[_norm(x)]) for x in mitglieder if _norm(x) in nummern] if len(m) >= 2: kataloge.append({"titel": kt, "mitglieder": m}) - return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": []}) + unsicher = {} + if (u := prompt.find("UNSICHER")) != -1: # alle Unsicher-Nummern übernehmen + import re as _re + for m in _re.finditer(r"entries (\d+)–(\d+)", prompt[u:u + 200]): + unsicher = {str(k): "ja" for k in range(int(m.group(1)), int(m.group(2)) + 1)} + return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": [], + "uebernehmen": unsicher, "facts_probleme": [], "levels": {}, "relevanz": {}}) + if "-sb-fix-" in key: + return j({"subs": []}) if "-sub-crossblock-" in key: urteile = {} for m in _PAIR_RE.finditer(prompt): urteile[m.group(1)] = "a" # identischer Text (nur so wird gepaart) → A behält return j({"pairs": urteile or {"1": "nein"}}) - if "-facts-check-" in key: + if "-art-gen-" in key: + subs = self._subs_im_prompt(prompt) + t = (self._bloecke_im_prompt(prompt) or ["?"])[0] + return j({"pattern": [{"block": t, "subblock": s, "question": f"Was ist {s}?"} for s in subs], + "cards": [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"} for s in subs], + "examples": [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}", + "steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"} for s in subs]}) + if "-art-check-" in key: return j({"ok": True}) - if "-facts-" in key: # facts / facts-fix / facts-erg: gleiches Format - eintraege = [] - for t in self._bloecke_im_prompt(prompt): - for s in self.bloecke[t]["subs"]: - if s in prompt: - eintraege.append(self._fakt(t, s)) - for kt, _m in self.kataloge: - if kt in prompt: - eintraege.append(self._fakt(t, kt)) - if not eintraege: # Nachfass-Fälle: Subs ohne Blockkontext im Prompt - eintraege = [self._fakt(bt, s) for bt, b in self.bloecke.items() - for s in b["subs"] if s in prompt] - return j({"facts": eintraege}) - if "-level-" in key: # Rater + final: alle geforderten Nummern - nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} - return j({"levels": {k: "beginner" for k in sorted(nums, key=int)} or {"1": "beginner"}}) - if "-relevance-" in key: - nums = {m.group(1) for m in _NUM_RE.finditer(prompt)} - return j({"relevance": {k: "relevant" for k in sorted(nums, key=int)} or {"1": "relevant"}}) - if "-question-pattern-" in key: - eintraege = [] - for t in self._bloecke_im_prompt(prompt): - for s in self._subs_im_prompt(prompt): - eintraege.append({"block": t, "subblock": s, "question": f"Was ist {s}?"}) - return j({"pattern": eintraege}) - if "-artifact-example-check-" in key: - return j({"ok": True}) - if "-artifact-flashcard-" in key: - karten = [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"} - for t in self._bloecke_im_prompt(prompt) for s in self._subs_im_prompt(prompt)] - return j({"cards": karten}) - if "-artifact-example-" in key: - bsp = [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}", - "steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"} - for t in self._bloecke_im_prompt(prompt) for s in self._subs_im_prompt(prompt)] - return j({"examples": bsp}) if "-outline-prereqs" in key: return j({"prereqs": {}}) if "-outline-review" in key: diff --git a/backend/requirements.txt b/backend/requirements.txt index 8026a1d..e3daef7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,7 @@ fastapi uvicorn[standard] aiosqlite +httpx playwright trafilatura pymupdf4llm diff --git a/backend/tests/test_agents_api.py b/backend/tests/test_agents_api.py new file mode 100644 index 0000000..11859ea --- /dev/null +++ b/backend/tests/test_agents_api.py @@ -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 diff --git a/backend/tests/test_block_calls.py b/backend/tests/test_block_calls.py new file mode 100644 index 0000000..75f82df --- /dev/null +++ b/backend/tests/test_block_calls.py @@ -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"]} diff --git a/backend/tests/test_board_inventory.py b/backend/tests/test_board_inventory.py index e85c192..34e4b21 100644 --- a/backend/tests/test_board_inventory.py +++ b/backend/tests/test_board_inventory.py @@ -70,39 +70,36 @@ async def board_env(testdb, tmp_path, monkeypatch): return False monkeypatch.setattr(bi, "_emb_ok", no_emb) - async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None): - title = list(entries.values())[0].split(" — ")[0] - return {title: ["Sub Eins", "Sub Zwei"]} + async def fake_generate(ctx, files, title, description, instructions="", ns="", lbl="", + sources=None, seeds=None): + subs = ["Sub Eins", "Sub Zwei"] + return {"raw": {title: list(subs)}, + "facts": {title: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []} + for s in subs}}, + "unsicher": [], "votes": {}} - async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None): - facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []} - for s in subs} for t, subs in raw.items()} - return facts, {} + async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None): + subs = gen["raw"].get(title) or [] + bfacts = gen["facts"].get(title) or {} + sidecar = [{"title": s, "level": "beginner", + "relevance": "relevant" if i == 0 else "peripheral", + "facts": bfacts.get(_norm_title(s)) or {}} + for i, s in enumerate(subs)] + return {"raw": {title: list(subs)}, "facts": {title: bfacts}, "sidecar": {title: sidecar}} - async def fake_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""): - return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()} - - async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns="", lbl=""): - return {1: "relevant", 2: "peripheral"} - - async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns="", lbl=""): - return {t: [{"subblock": subs[0]["title"], "question": f"Was ist {t}?"}] - for t, subs in sidecar.items()} - - async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns="", lbl=""): - return {"flashcard": [{"block": t, "subblock": subs[0]["title"], "front": "F", "back": "B"} - for t, subs in sidecar.items()], "example": []} + async def fake_artefakte(ctx, files, title, sidecar_subs, instructions="", ns="", lbl=""): + if not sidecar_subs: + return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}} + first = sidecar_subs[0]["title"] + return {"pattern": {title: [{"subblock": first, "question": f"Was ist {title}?"}]}, + "artefacts": {"flashcard": [{"block": title, "subblock": first, "front": "F", "back": "B"}], + "example": []}} async def fake_outline(ctx, set_p, files, entries, instructions): return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]} - async def fake_konsolidierung(ctx, files, raw, facts_map, instructions="", ns="", lbl=""): - return None - - for name, fn in [("_subblocks_block", fake_subblocks), ("_facts_block", fake_facts), - ("_levels_block", fake_levels), ("_relevance_block", fake_relevance), - ("_question_pattern_block", fake_pattern), ("_artefacts_block", fake_artefacts), - ("_outline_block", fake_outline), ("_konsolidiere_subblocks", fake_konsolidierung)]: + for name, fn in [("_generate_block", fake_generate), ("_verify_block", fake_verify), + ("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]: monkeypatch.setattr(ba, name, fn) class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch @@ -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): - """Legitim leere Subbausteine ({} statt None) → Karte läuft bis done_artefact durch.""" + """Legitim leere Subbausteine (leere raw-Liste statt None) → Karte läuft bis done_artefact durch.""" import asyncio import board_artefacts as ba import blocks as blx db, ctx, files = board_env - async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None): - return {} - monkeypatch.setattr(ba, "_subblocks_block", empty_subs) - await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks", + async def empty_gen(ctx, files, title, description, instructions="", ns="", lbl="", + sources=None, seeds=None): + return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}} + monkeypatch.setattr(ba, "_generate_block", empty_gen) + await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "generate", {"title": "Leerer Block", "description": "d"}) ok = await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False), @@ -712,18 +710,18 @@ def test_agent_priority_order(): async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch): - """Gliederung startet, sobald alle Karten die facts-Stage passiert haben — + """Gliederung startet, sobald alle Karten die generate-Stage passiert haben — parallel zu den restlichen Artefakt-Stages des langsamsten Blocks.""" import asyncio import board_artefacts as ba db, ctx, files = board_env await _seed(db) - base_levels = ba._levels_block + base_verify = ba._verify_block snapshot = {} - async def slow_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""): - await asyncio.sleep(0.8) # keeps one card in `levels` while the outline fires - return await base_levels(ctx, set_p, files, raw, instructions, ns=ns, lbl=lbl) + async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None): + await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires + return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl, sources=sources) base_outline = ba._outline_block @@ -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") return await base_outline(ctx, set_p, files, entries, instructions) - monkeypatch.setattr(ba, "_levels_block", slow_levels) + monkeypatch.setattr(ba, "_verify_block", slow_verify) monkeypatch.setattr(ba, "_outline_block", spy_outline) ok = await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False), @@ -750,7 +748,7 @@ async def test_outline_facts_from_payloads(board_env, tmp_path): db, ctx, files = board_env await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block", {"title": "Alpha", "description": "d"}) - await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels", + await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "verify", {"title": "Alpha", "facts": {"Alpha": {"sub eins": { "sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}}) await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline", @@ -764,16 +762,16 @@ async def test_outline_facts_from_payloads(board_env, tmp_path): async def test_card_view_stepper(testdb): """Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar.""" - r = {"board": "artefacts", "card_id": "alpha", "stage": "facts", "retries": 0, + r = {"board": "artefacts", "card_id": "alpha", "stage": "verify", "retries": 0, "payload": {"title": "Alpha"}} - v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Facts check 1/2…", "step": "Facts check"}}) - assert v["status"] == "active" and v["info"] == "Facts check 1/2…" - assert v["step_i"] == 2 and v["step_n"] == 3 and v["steps"][0] == "Facts find" + v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Fix 1/2…", "step": "Fix"}}) + assert v["status"] == "active" and v["info"] == "Fix 1/2…" + assert v["step_i"] == 2 and v["step_n"] == 2 and v["steps"][0] == "Verify" # legacy plain-string live info → no stepper, no crash - v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"}) - assert v2["info"] == "Facts find 0/1…" and "step_n" not in v2 + v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Verify 0/1…"}) + assert v2["info"] == "Verify 0/1…" and "step_n" not in v2 # step outside the card's stage group (e.g. supplement note) → no stepper - v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Subblocks find"}}) + v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Generate"}}) assert "step_n" not in v3 @@ -808,17 +806,17 @@ async def test_seed_map_resolves_cascade(testdb): def test_per_block_functions_accept_wrapper_kwargs(): - """Die board_artefacts-Wrapper übergeben ns/lbl (subblocks auch seeds) — ein fehlender - Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur).""" + """Die board_artefacts-Prozessoren übergeben ns/lbl (generate auch seeds/sources) — + ein fehlender Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken + die Signatur).""" import inspect - import blocks as blx - for fn in ("_subblocks_block", "_facts_block", "_levels_block", "_relevance_block", - "_question_pattern_block", "_artefacts_block"): - params = inspect.signature(getattr(blx, fn)).parameters + import block_calls as bc + for fn in ("_generate_block", "_verify_block", "_artefakte_block"): + params = inspect.signature(getattr(bc, fn)).parameters assert "ns" in params and "lbl" in params, fn - assert "seeds" in inspect.signature(blx._subblocks_block).parameters - for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch - assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn + assert "seeds" in inspect.signature(bc._generate_block).parameters + for fn in ("_generate_block", "_verify_block"): # Board 2 reicht die Block-Quellen durch + assert "sources" in inspect.signature(getattr(bc, fn)).parameters, fn # ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ───────────────────── @@ -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): - """Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in subblocks.""" + """Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate.""" import qa as qa_mod db, ctx, files = board_env @@ -918,7 +916,7 @@ async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch): await _seed(db) ok = await _run_flow(ctx, files) assert ok - warten = await db.kanban_cards(TOPIC, board="artefacts", stage="subblocks") + warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate") assert len(warten) == 4 # alle Blöcke gespawnt, keiner verarbeitet assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 @@ -981,7 +979,7 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch): (tmp_path / TOPIC).mkdir() (tmp_path / TOPIC / "r1.json").write_text(_json.dumps( {"note": 5.0, "quoten": {"fremd": 0.2}, "fremd": ["X"], "unecht": ["Y"]}), encoding="utf-8") - counts = {"inventory": {"done_block": 3}, "artefacts": {"subblocks": 4}} + counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}} v = bi._qa_view(TOPIC, counts, None) assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"] from types import SimpleNamespace @@ -1115,11 +1113,11 @@ def test_hat_anker_ziffern_suffix(): assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker -async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path): - """Reset auf Spalte subblocks: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots +async def test_reset_generate_loescht_globale_dateien(testdb, tmp_path): + """Reset auf Spalte generate: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen.""" db = testdb - await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefacts", {"title": "Alpha"}) + await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"}) await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus") arbeit = tmp_path / "arbeit" (arbeit / "ab-alpha").mkdir(parents=True) @@ -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"): files[k] = tmp_path / f"{k}.json" files[k].write_text("{}", encoding="utf-8") - moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "subblocks", files) + moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files) assert moved == 1 assert not await db.list_subblocks(TOPIC) assert not (arbeit / "ab-alpha").exists() diff --git a/backend/tests/test_e2e_fake.py b/backend/tests/test_e2e_fake.py index f8a4f8b..c7853b9 100644 --- a/backend/tests/test_e2e_fake.py +++ b/backend/tests/test_e2e_fake.py @@ -84,8 +84,9 @@ async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path): @pytest.mark.parametrize("stoerung", [ {"muster": r"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE - {"muster": r"-sub-konsolidierung-.*-j1$", "modus": "garbage", "mal": 1}, # Retry heilt - {"muster": r"-facts-c\d+$", "modus": "fehler", "mal": 1}, # Slot-Restart + {"muster": r"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE + {"muster": r"-sb-gen-.*-g1$", "modus": "fehler", "mal": 1}, # degraded: 1 Generator + {"muster": r"-art-gen-.*-t1$", "modus": "fehler", "mal": 1}, # Slot-Restart {"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot ]) async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung): diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index 1db40a1..169f216 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -156,7 +156,7 @@ async def test_restart_artefact_card_wipes_only_that_block(testdb): await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?") await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}") assert await bi.restart_artefact_card(TOPIC, "alpha") is True - assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "subblocks" + assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate" assert await db.list_subblocks(TOPIC, "alpha") == [] assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False diff --git a/backend/tests/test_konsolidierung.py b/backend/tests/test_konsolidierung.py index 080ca9f..ea4169e 100644 --- a/backend/tests/test_konsolidierung.py +++ b/backend/tests/test_konsolidierung.py @@ -1,10 +1,10 @@ -"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und -Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB.""" +"""Cross-Block-Konsolidierung (board_artefacts._proc_konsolidierung) und Finalize — +Judges gefaked, gegen Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs- +Umbau in block_calls._verify_block und wird in tests/test_block_calls.py getestet.""" import json import numpy as np -import pytest import blocks import board_artefacts as ba @@ -39,218 +39,6 @@ async def _seed_block(db, bnorm, subs): await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus") -# ── In-Block ──────────────────────────────────────────────────────────────────────── - -async def test_merge_on_unanimity(testdb, tmp_path, monkeypatch): - """Beide Judges gruppieren 1+2 → Gewinner (mehr key_points) bleibt, facts-Union, - Verlierer wird DB-variant und fliegt aus raw/facts_map.""" - db = testdb - subs = ["Durchstreichung: ~~text~~", "Durchstreichung: ~~text~~ streicht Text durch", "Fett: **text**"] - await _seed_block(db, "betonung", subs) - raw = {"Betonung": list(subs)} - facts = {"Betonung": { - blocks._norm_title(subs[0]): {"key_points": ["kp-a"], "cited_facts": [{"text": "z1"}]}, - blocks._norm_title(subs[1]): {"key_points": ["kp-b", "kp-c"], "cited_facts": [{"text": "z1"}, {"text": "z2"}]}, - }} - fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": ["Marker-Escaping fehlt"]}, - "j2": {"gruppen": [[2, 1]], "luecken": ["Escaping von Markern"]}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts) - assert raw["Betonung"] == [subs[1], "Fett: **text**"] # Gewinner: 2 key_points > 1 - wf = facts["Betonung"][blocks._norm_title(subs[1])] - assert wf["key_points"] == ["kp-b", "kp-c", "kp-a"] - assert wf["cited_facts"] == [{"text": "z1"}, {"text": "z2"}] # Union ohne Doppel - assert blocks._norm_title(subs[0]) not in facts["Betonung"] - rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "betonung")} - assert rows[blocks._norm_title(subs[0])] == "variant" - assert rows[blocks._norm_title(subs[1])] == "consensus" - journale = list(tmp_path.glob("sub-konsolidierung-*.json")) - j = json.loads([p for p in journale if "-j" not in p.stem][0].read_text()) - # Lücken-Schnitt: Token-Überlappung beider Judges, Formulierung von j1 gewinnt - assert j["gruppen"][0]["behalten"] == subs[1] and j["luecken"] == ["Marker-Escaping fehlt"] - - -async def test_dissent_keeps_everything(testdb, tmp_path, monkeypatch): - """Nur ein Judge gruppiert → keine Einstimmigkeit → kein Merge.""" - db = testdb - subs = ["Eintrag eins", "Eintrag zwei"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}, - "j2": {"gruppen": [], "luecken": []}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) - assert raw["Block"] == subs - assert all(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "block")) - - -async def test_judge_failure_fail_open(testdb, tmp_path, monkeypatch): - """Ein Judge UND der Ersatz ohne Ergebnis → fail-open, nichts ändert sich.""" - db = testdb - subs = ["Eintrag eins", "Eintrag zwei"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}}) # j2 UND j3 → FAILED - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) - assert raw["Block"] == subs - assert len(fake.calls) == 3 # j1, j2, Ersatz j3 - - -async def test_ersatzrichter_bei_ausfall(testdb, tmp_path, monkeypatch): - """j1 fällt aus → Ersatz j3 springt ein; Einstimmigkeit j2+j3 faltet. - Vorher entwertete EIN Timeout die gute Stimme (13 Links-Dubletten überlebten).""" - db = testdb - subs = ["Kurz", "Deutlich längerer Eintrag"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - fake = _fake_slot({"j2": {"gruppen": [[1, 2]], "luecken": []}, - "j3": {"gruppen": [[2, 1]], "luecken": []}}) # j1 → FAILED - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) - assert raw["Block"] == ["Deutlich längerer Eintrag"] - - -async def test_negation_guard_blocks_merge(testdb, tmp_path, monkeypatch): - """Gegensätzliche Aussagen werden selbst bei einstimmigen Judges nicht gefaltet.""" - db = testdb - subs = ["Tabs werden expandiert", "Tabs werden nicht expandiert"] - await _seed_block(db, "tabs", subs) - raw = {"Tabs": list(subs)} - fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}, - "j2": {"gruppen": [[1, 2]], "luecken": []}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) - assert raw["Tabs"] == subs - - -async def test_resume_skips_judges(testdb, tmp_path, monkeypatch): - """Vorhandene j-Dateien → kein neuer Agenten-Call, Ergebnis wird übernommen.""" - db = testdb - subs = ["Eintrag eins", "Eintrag zwei lang"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - import hashlib - h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8] - for j in (1, 2): - (tmp_path / f"sub-konsolidierung-{h}-j{j}.json").write_text( - json.dumps({"gruppen": [[1, 2]], "luecken": []}), encoding="utf-8") - - async def kein_agent(*a, **kw): - raise AssertionError("Resume darf keinen Agenten starten") - - monkeypatch.setattr(blocks, "run_single_slot", kein_agent) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) - assert raw["Block"] == ["Eintrag zwei lang"] - - -def test_schema_accepts_both_group_forms(): - """Alte Listenform [1,4] und neue {haupt, weitere}-Form parsen beide; kataloge/fremd optional.""" - alt = blocks._konsolidierung_schema({"gruppen": [[1, 4]], "luecken": []}, 5) - assert alt["gruppen"] == [{"haupt": None, "ids": [1, 4]}] and alt["fremd"] == set() - neu = blocks._konsolidierung_schema( - {"gruppen": [{"haupt": 4, "weitere": [1]}], - "kataloge": [{"titel": "Katalog: Symbole", "mitglieder": [2, 3]}], - "fremd": [5], "luecken": ["x"]}, 5) - assert neu["gruppen"] == [{"haupt": 4, "ids": [1, 4]}] - assert neu["kataloge"] == [{"titel": "Katalog: Symbole", "ids": [2, 3]}] - assert neu["fremd"] == {5} and neu["luecken"] == ["x"] - assert blocks._konsolidierung_schema({"gruppen": [{"haupt": 9, "weitere": [1]}]}, 5) == \ - {"gruppen": [], "kataloge": [], "fremd": set(), "luecken": []} # id out of range - - -async def test_haupt_beats_heuristic(testdb, tmp_path, monkeypatch): - """Judges nennen den kürzeren Eintrag als haupt → er gewinnt trotz weniger key_points.""" - db = testdb - subs = ["Basis", "Detailregel mit sehr langem Titel und Facts"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - facts = {"Block": {blocks._norm_title(subs[1]): {"key_points": ["a", "b", "c"]}}} - fake = _fake_slot({"j1": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}, - "j2": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts) - assert raw["Block"] == ["Basis"] - assert facts["Block"][blocks._norm_title("Basis")]["key_points"] == ["a", "b", "c"] # Union geerbt - - -async def test_katalog_bundles_to_new_row(testdb, tmp_path, monkeypatch): - """Einstimmige Katalog-Mitglieder → neue consensus-Zeile mit Facts-Union, Mitglieder variant.""" - db = testdb - subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f", "Eigene Regel"] - await _seed_block(db, "mathe", subs) - raw = {"Mathe": list(subs)} - facts = {"Mathe": {blocks._norm_title(subs[0]): {"key_points": ["kp1"]}, - blocks._norm_title(subs[1]): {"key_points": ["kp2"]}}} - kat = {"titel": "Symbolkatalog: Pfeile und Mengen", "mitglieder": [1, 2]} - fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [kat], "luecken": []}, - "j2": {"gruppen": [], "kataloge": [kat], "luecken": []}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts) - assert raw["Mathe"] == ["Eigene Regel", "Symbolkatalog: Pfeile und Mengen"] - kn = blocks._norm_title("Symbolkatalog: Pfeile und Mengen") - assert sorted(facts["Mathe"][kn]["key_points"]) == ["kp1", "kp2"] - rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "mathe")} - assert rows[kn] == "consensus" - assert rows[blocks._norm_title(subs[0])] == "variant" - - -async def test_katalog_dissent_keeps_members(testdb, tmp_path, monkeypatch): - db = testdb - subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f"] - await _seed_block(db, "mathe", subs) - raw = {"Mathe": list(subs)} - fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [{"titel": "K", "mitglieder": [1, 2]}], "luecken": []}, - "j2": {"gruppen": [], "kataloge": [], "luecken": []}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) - assert raw["Mathe"] == subs - - -async def test_fremd_unanimous_discards(testdb, tmp_path, monkeypatch): - """Einstimmig fremd → discarded + raus; einseitig fremd → bleibt.""" - db = testdb - subs = ["CSS display überschreibt Verhalten", "Echte Markdown-Regel", "Nur einer hält es für fremd"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - fake = _fake_slot({"j1": {"gruppen": [], "fremd": [1, 3], "luecken": []}, - "j2": {"gruppen": [], "fremd": [1], "luecken": []}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) - assert raw["Block"] == [subs[1], subs[2]] - rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")} - assert rows[blocks._norm_title(subs[0])] == "discarded" - assert rows[blocks._norm_title(subs[2])] == "consensus" - assert luecken == {} - - -async def test_luecken_nur_bei_einstimmigkeit(testdb, tmp_path, monkeypatch): - """Nur Lücken mit Token-Überlappung BEIDER Judges überleben; einseitige fallen weg.""" - db = testdb - subs = ["Eintrag eins", "Eintrag zwei"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - fake = _fake_slot({"j1": {"gruppen": [], "luecken": ["Inline-HTML fehlt", "Front-Matter"]}, - "j2": {"gruppen": [], "luecken": ["nichts zu Inline-HTML"]}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) - assert luecken == {"Block": ["Inline-HTML fehlt"]} - - -async def test_kp_deckel_im_judge_prompt(testdb, tmp_path, monkeypatch): - """Prompt zeigt max. 3 key_points je Sub (Timeout-Schutz); die Union bleibt voll.""" - db = testdb - subs = ["Eintrag eins", "Eintrag zwei"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - facts = {"Block": {blocks._norm_title(subs[0]): {"key_points": [f"kp{i}" for i in range(1, 6)]}}} - fake = _fake_slot({"j1": {"gruppen": [], "luecken": []}, "j2": {"gruppen": [], "luecken": []}}) - monkeypatch.setattr(blocks, "run_single_slot", fake) - await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts) - prompt = fake.calls[0]["prompt"] - assert "kp3" in prompt and "kp4" not in prompt - - def test_luecken_schnitt_cap(): l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")] assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3 @@ -266,91 +54,6 @@ def test_neg_set_lemmatisiert(): assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert") -# ── Lücken-Nachfass ───────────────────────────────────────────────────────────────── - -async def _nachfass_env(db, monkeypatch, facts_result): - subs = ["Eintrag eins"] - await _seed_block(db, "block", subs) - raw = {"Block": list(subs)} - facts_map = {"Block": {}} - - async def fake_race(topic, label, slots, quorum, timeout, provider, cancelled=None, grace=0): - return [{"Block": ["Eintrag eins", "Neuer Aspekt"]}] - - async def fake_facts(ctx, set_p, files, fraw, q, folder, instructions, ns="", lbl="", sources=None, slim=False): - assert slim is True # Nachfass nutzt die schlanke Facts-Variante - assert list(fraw["Block"]) == ["Neuer Aspekt"] # nur der frische Fund geht ins Gate - return facts_result - - monkeypatch.setattr(blocks, "_race", fake_race) - monkeypatch.setattr(blocks, "_facts_block", fake_facts) - monkeypatch.setattr(blocks, "EMBEDDING_AKTIV", False) - return raw, facts_map - - -async def test_nachfass_adopts_backed_find(testdb, tmp_path, monkeypatch): - db = testdb - nn = blocks._norm_title("Neuer Aspekt") - raw, facts_map = await _nachfass_env(db, monkeypatch, - ({"Block": {nn: {"key_points": ["kp"]}}}, {})) - n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"], - raw, facts_map, {"type": "thema"}, None) - assert n == 1 and raw["Block"] == ["Eintrag eins", "Neuer Aspekt"] - assert facts_map["Block"][nn]["key_points"] == ["kp"] - rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")} - assert rows[nn] == "consensus" - - -async def test_nachfass_drops_unbacked_find(testdb, tmp_path, monkeypatch): - db = testdb - nn = blocks._norm_title("Neuer Aspekt") - raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {"Block": {nn}})) - n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"], - raw, facts_map, {"type": "thema"}, None) - assert n == 0 and raw["Block"] == ["Eintrag eins"] - assert not any(r["sub_norm"] == nn for r in await db.list_subblocks(TOPIC, "block")) - - -async def test_nachfass_drops_find_without_facts(testdb, tmp_path, monkeypatch): - """HARTES Gate: kein Facts-Eintrag = kein Beleg = keine Übernahme — nicht nur - aktiv Verworfenes fliegt (Bilder-Lauf: 13 von 18 kamen ohne Beleg durch).""" - db = testdb - raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {})) # Facts fand NICHTS - n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"], - raw, facts_map, {"type": "thema"}, None) - assert n == 0 and raw["Block"] == ["Eintrag eins"] - - -async def test_facts_stage_konsolidiert_nachfass_funde_erneut(testdb, tmp_path, monkeypatch): - """Kreis geschlossen: nach Übernahmen läuft die Konsolidierung ein zweites Mal; - deren Lücken lösen KEINEN weiteren Nachfass aus.""" - db = testdb - payload = {"title": "Alpha", "raw": {"Alpha": ["s1"]}} - await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts", payload) - calls = {"kons": 0, "nf": 0} - - async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False): - return {"Alpha": {}}, {} - - async def fake_kons(ctx, files, raw, facts_map, instructions="", ns="", lbl=""): - calls["kons"] += 1 - return {"Alpha": ["Lücke X"]} # meldet auch in Runde 2 — darf nicht erneut nachfassen - - async def fake_nf(ctx, files, title, luecken, raw, facts_map, q, folder, - instructions="", ns="", lbl="", sources=None): - calls["nf"] += 1 - return 2 - - monkeypatch.setattr(ba, "_facts_block", fake_facts) - monkeypatch.setattr(ba, "_konsolidiere_subblocks", fake_kons) - monkeypatch.setattr(ba, "_luecken_runde", fake_nf) - flow = Flow(TOPIC, work_dir=tmp_path) - await ba._proc_facts(_ctx(), flow, {"arbeit": tmp_path}, {"type": "thema"}, None, "", - [{"card_id": "alpha", "payload": payload}]) - assert calls == {"kons": 2, "nf": 1} - assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "levels" - - async def test_finalize_purges_stale_rows(testdb, tmp_path): """Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert.""" db = testdb @@ -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 -async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkeypatch): - """Resume-Karte aus der alten Stage-Position (kein pattern im Payload) → zurück nach - question_pattern, KEIN Dedup — finalize würde den Fold sonst re-spiegeln.""" +async def test_crossblock_nachzuegler_zurueck_zum_erzeugen(testdb, tmp_path, monkeypatch): + """Resume-Karte ohne pattern im Payload → zurück nach generate (bzw. artefakte bei + vorhandenem sidecar), KEIN Dedup — finalize würde den Fold sonst re-spiegeln.""" db = testdb flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False) + cards[1]["payload"]["sidecar"] = {"Beta": []} # hat Verify schon hinter sich + await db.kanban_set_payload(TOPIC, "artefacts", "beta", cards[1]["payload"]) async def kein_agent(*a, **kw): raise AssertionError("Nachzügler dürfen keinen Dedup auslösen") @@ -498,8 +203,8 @@ async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkey monkeypatch.setattr(ba, "embedding", _FakeEmb) monkeypatch.setattr(ba, "run_single_slot", kein_agent) await ba._proc_konsolidierung(_ctx(), flow, files, "", cards) - for cid in ("alpha", "beta"): - assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern" + assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate" + assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "artefakte" rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")} assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus" @@ -558,47 +263,3 @@ async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch): assert len(fake.calls) == 4 # 2 Chunks × j1/j2 rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")} 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"]} diff --git a/backend/tests/test_subblocks.py b/backend/tests/test_subblocks.py index 94678df..09b11b5 100644 --- a/backend/tests/test_subblocks.py +++ b/backend/tests/test_subblocks.py @@ -1,16 +1,14 @@ -"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review.""" +"""Subbaustein-Helfer: Varianten-Cluster, Evidence-Packs, Outline-Review, Hash/Key-Auflösung. +Die verschmolzene Block-Pipeline (Generate/Verify/Artefakte) testet tests/test_block_calls.py.""" import json -import re import numpy as np -import pytest import blocks as blx from pipeline import GenContext TOPIC = "t" -_MD_PATH = re.compile(r"(/\S+\.md)") # ── _variant_clusters (pure) ───────────────────────────────────────────────────────── @@ -37,157 +35,6 @@ def test_variant_clusters_negation_guard(): assert len(cl) == 2 # antonyms never merge, no matter the cosine -# ── _subblocks_block integration (fake race + fake embeddings) ────────────────────── - -def _fake_sims(texts): - """Markertoken matrix: same first word → 0.95, else 0.""" - n = len(texts) - m = np.eye(n) - key = lambda t: t.split()[0].casefold() - for i in range(n): - for j in range(n): - if i != j and key(texts[i]) == key(texts[j]): - m[i][j] = 0.95 - return m - - -def _mk_race(finder_by_agent): - """Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and - catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt.""" - prompts = [] - - async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None, - cancelled=None, *, grace=None, min_runtime=None, max_runtime=None, 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 = "\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 = "\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 = "\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 ─────────────────────────────────────────────────────────────────── 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, "") assert plan2["chapters"][0]["numbers"] == [1, 2, 6] -async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch): - """Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3. - Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting).""" - db, ctx, files = sub_env - base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]}) - async def with_r2(topic, label, slots, *a, **k): - if any("-r2-" in s["key"] for s in slots): - text = "\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"\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) ────────────────────────────────────────── +# ── Inline-Evidenz (Evidence-Packs für die verschmolzenen Calls) ───────────────────── def _corpus(tmp_path): 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() -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): """Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None.""" 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 -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(): """Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'. Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert.""" diff --git a/backend/tests/test_train.py b/backend/tests/test_train.py index 0434018..1613c30 100644 --- a/backend/tests/test_train.py +++ b/backend/tests/test_train.py @@ -65,17 +65,17 @@ def _trainer(tmp_path, runner, f0, **kw): async def test_aco_konvergiert_auf_optimum(tmp_path): - """Gepflanztes Optimum (FACTS_CHUNK_SUBS=6) wird gefunden und bestätigt übernommen; + """Gepflanztes Optimum (GEN_PANEL=3) wird gefunden und bestätigt übernommen; die Pheromon-Spur konzentriert sich dort.""" def bewertung(params): - return _metrics(note=9.5, dauer=7.0) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics() + return _metrics(note=9.5, dauer=7.0) if params.get("GEN_PANEL") == 3 else _metrics() runner, f0 = _stub(bewertung) t = _trainer(tmp_path, runner, f0, max_trials=120) best = await t.run() - assert best.get("FACTS_CHUNK_SUBS") == 6 - taus = t.pheromon["FACTS_CHUNK_SUBS"] - assert max(taus, key=lambda k: taus[k]) == "6" + assert best.get("GEN_PANEL") == 3 + taus = t.pheromon["GEN_PANEL"] + assert max(taus, key=lambda k: taus[k]) == "3" async def test_uebernahme_braucht_bestaetigung(tmp_path): @@ -83,7 +83,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path): zustand = {"mal": 0} def bewertung(params): - if params.get("FACTS_CHUNK_SUBS") == 6: + if params.get("GEN_PANEL") == 3: zustand["mal"] += 1 return _metrics(note=9.5) if zustand["mal"] == 1 else _metrics(note=8.0) return _metrics() @@ -91,7 +91,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path): runner, f0 = _stub(bewertung) t = _trainer(tmp_path, runner, f0, max_trials=40) best = await t.run() - assert best.get("FACTS_CHUNK_SUBS") != 6 + assert best.get("GEN_PANEL") != 3 async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path): @@ -111,16 +111,16 @@ async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path): async def test_resume_laedt_pheromon_und_cache(tmp_path): def bewertung(params): - return _metrics(note=9.5) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics() + return _metrics(note=9.5) if params.get("GEN_PANEL") == 3 else _metrics() runner, f0 = _stub(bewertung) t = _trainer(tmp_path, runner, f0, max_trials=60) await t.run() - best, tau = t.best_params, dict(t.pheromon["FACTS_CHUNK_SUBS"]) + best, tau = t.best_params, dict(t.pheromon["GEN_PANEL"]) runner2, f02 = _stub(bewertung) t2 = _trainer(tmp_path, runner2, f02, max_trials=0) # kein Budget: alles aus Persistenz assert t2.best_params == best - assert t2.pheromon["FACTS_CHUNK_SUBS"] == tau + assert t2.pheromon["GEN_PANEL"] == tau async def test_budget_stoppt(tmp_path): diff --git a/backend/train_lauf.py b/backend/train_lauf.py index 25282f4..7172139 100644 --- a/backend/train_lauf.py +++ b/backend/train_lauf.py @@ -105,7 +105,7 @@ async def _frozen_inventar(topic: str, vorlage: str) -> None: shutil.copy(src, dst) files = _blocks_files(topic) files["arbeit"].mkdir(parents=True, exist_ok=True) - await board_inventory.reset_board_from_stage(topic, "artefacts", "subblocks", files) + await board_inventory.reset_board_from_stage(topic, "artefacts", "generate", files) async def _aufraeumen(topic: str) -> None: diff --git a/backend/train_params.py b/backend/train_params.py index 2449b82..7c87246 100644 --- a/backend/train_params.py +++ b/backend/train_params.py @@ -18,18 +18,10 @@ PARAMS: dict[str, dict] = { "FILTER_CHUNK": {"default": 35, "min": 15, "max": 60, "step": 10, "kategorie": "laufzeit", "fidelity": "voll"}, "FILTER_RECHECK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"}, "CONSOLIDATION_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"}, - # Subbausteine - "SUBBLOCK_CHUNK": {"default": 10, "min": 4, "max": 20, "step": 2, "kategorie": "laufzeit", "fidelity": "board2"}, - "SUBBLOCK_MIN": {"default": 5, "min": 2, "max": 10, "step": 1, "kategorie": "auswahl", "fidelity": "board2"}, - "SUBBLOCK_MAX_ROUNDS": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "auswahl", "fidelity": "board2"}, - "SUBBLOCK_EXTRA_ROUNDS": {"default": 2, "min": 0, "max": 4, "step": 1, "kategorie": "auswahl", "fidelity": "board2"}, - "SUBBLOCK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"}, - # Facts / Artefakte / Fragen - "FACTS_CHUNK_SUBS": {"default": 10, "min": 4, "max": 20, "step": 2, "kategorie": "laufzeit", "fidelity": "board2"}, - "FACTS_CHECK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"}, - "QUESTION_CHUNK_SUBS": {"default": 25, "min": 10, "max": 50, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"}, - "QUESTION_MAX_ROUNDS": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"}, - "ARTEFACT_CHUNK_SUBS": {"default": 25, "min": 10, "max": 50, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"}, + # Board 2, verschmolzene Calls (block_calls.py) + "GEN_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"}, + "VERIFY_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"}, + "ART_SPLIT_SUBS": {"default": 20, "min": 10, "max": 40, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"}, # Embedding-Schwellen (Auswahl-Kern) "SUB_VARIANT_COS": {"default": 0.90, "min": 0.85, "max": 0.96, "step": 0.01, "kategorie": "auswahl", "fidelity": "board2"}, "SEED_COVER_COS": {"default": 0.80, "min": 0.7, "max": 0.9, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"}, diff --git a/templates/Prompt/Artefakt-Check.md b/templates/Prompt/Artefakt-Check.md new file mode 100644 index 0000000..e9b51a9 --- /dev/null +++ b/templates/Prompt/Artefakt-Check.md @@ -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} diff --git a/templates/Prompt/Artefakt-Generate.md b/templates/Prompt/Artefakt-Generate.md new file mode 100644 index 0000000..43dbbe7 --- /dev/null +++ b/templates/Prompt/Artefakt-Generate.md @@ -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} diff --git a/templates/Prompt/Artifact-Example-Check.md b/templates/Prompt/Artifact-Example-Check.md deleted file mode 100644 index ee222bf..0000000 --- a/templates/Prompt/Artifact-Example-Check.md +++ /dev/null @@ -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} diff --git a/templates/Prompt/Artifact-Example.md b/templates/Prompt/Artifact-Example.md deleted file mode 100644 index e6e6dc2..0000000 --- a/templates/Prompt/Artifact-Example.md +++ /dev/null @@ -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": "", "subblock": "", - "problem": "…", "steps": ["…", "…"], "result": "…"}} -]}} - -Output no other text. -{extra} diff --git a/templates/Prompt/Artifact-Flashcard.md b/templates/Prompt/Artifact-Flashcard.md deleted file mode 100644 index a708b27..0000000 --- a/templates/Prompt/Artifact-Flashcard.md +++ /dev/null @@ -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": "", "subblock": "", - "question": "…", "answer": "…"}} -]}} - -Output no other text. -{extra} diff --git a/templates/Prompt/Facts-Check.md b/templates/Prompt/Facts-Check.md deleted file mode 100644 index 4c6e497..0000000 --- a/templates/Prompt/Facts-Check.md +++ /dev/null @@ -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": "", "problem": "…", "discard": true}}]}} -{extra} diff --git a/templates/Prompt/Facts-Research.md b/templates/Prompt/Facts-Research.md deleted file mode 100644 index ceec12f..0000000 --- a/templates/Prompt/Facts-Research.md +++ /dev/null @@ -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": "", "subblock": "", - "key_points": ["…"], "prerequisites": "…", "hurdles": "…", - "cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}} -]}} - -Output no other text. -{extra} diff --git a/templates/Prompt/Facts-Supplement.md b/templates/Prompt/Facts-Supplement.md deleted file mode 100644 index 0be965c..0000000 --- a/templates/Prompt/Facts-Supplement.md +++ /dev/null @@ -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": "", "subblock": "", - "key_points": ["…"], "prerequisites": "…", "hurdles": "…", - "cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}} -]}} - -Output no other text. -{extra} diff --git a/templates/Prompt/Levels-Mapping.md b/templates/Prompt/Levels-Mapping.md deleted file mode 100644 index b8155c1..0000000 --- a/templates/Prompt/Levels-Mapping.md +++ /dev/null @@ -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} diff --git a/templates/Prompt/Levels-Research.md b/templates/Prompt/Levels-Research.md deleted file mode 100644 index 3ee9010..0000000 --- a/templates/Prompt/Levels-Research.md +++ /dev/null @@ -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} diff --git a/templates/Prompt/Question-Pattern-Critique.md b/templates/Prompt/Question-Pattern-Critique.md deleted file mode 100644 index 151bed6..0000000 --- a/templates/Prompt/Question-Pattern-Critique.md +++ /dev/null @@ -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": "", "subblock": "", "question": ""}} -]}} - -Output no other text. -{extra} diff --git a/templates/Prompt/Question-Pattern-Research.md b/templates/Prompt/Question-Pattern-Research.md deleted file mode 100644 index 9867ecc..0000000 --- a/templates/Prompt/Question-Pattern-Research.md +++ /dev/null @@ -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": "", "subblock": "", "question": ""}} -]}} - -Output no other text. diff --git a/templates/Prompt/Relevance-Mapping.md b/templates/Prompt/Relevance-Mapping.md deleted file mode 100644 index ff34a3a..0000000 --- a/templates/Prompt/Relevance-Mapping.md +++ /dev/null @@ -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} diff --git a/templates/Prompt/Relevance-Research.md b/templates/Prompt/Relevance-Research.md deleted file mode 100644 index 32a27ec..0000000 --- a/templates/Prompt/Relevance-Research.md +++ /dev/null @@ -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} diff --git a/templates/Prompt/Subblock-Fix.md b/templates/Prompt/Subblock-Fix.md new file mode 100644 index 0000000..42d79ac --- /dev/null +++ b/templates/Prompt/Subblock-Fix.md @@ -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} diff --git a/templates/Prompt/Subblock-Generate.md b/templates/Prompt/Subblock-Generate.md new file mode 100644 index 0000000..cda4df0 --- /dev/null +++ b/templates/Prompt/Subblock-Generate.md @@ -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 `` → `src` (Bildquelle), `alt` (Alternativtext), empty `alt` for decorative images. Block `

` → 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} diff --git a/templates/Prompt/Subblock-Konsolidierung.md b/templates/Prompt/Subblock-Konsolidierung.md deleted file mode 100644 index d854177..0000000 --- a/templates/Prompt/Subblock-Konsolidierung.md +++ /dev/null @@ -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} diff --git a/templates/Prompt/Subblock-Mapping.md b/templates/Prompt/Subblock-Mapping.md deleted file mode 100644 index e4d5e19..0000000 --- a/templates/Prompt/Subblock-Mapping.md +++ /dev/null @@ -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: - - -- Subblock -- Subblock - -Output the marker line exactly like this. Every block must appear. No text outside the blocks. -{extra} diff --git a/templates/Prompt/Subblock-Research.md b/templates/Prompt/Subblock-Research.md deleted file mode 100644 index 5b5487a..0000000 --- a/templates/Prompt/Subblock-Research.md +++ /dev/null @@ -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 `` → `src` (Bildquelle), `alt` (Alternativtext), empty `alt` for decorative images, `width`/`height` against layout shifts, void element without a closing tag. Block `

` → 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. `` must have noticeably more subblocks than `

`. - -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: - - -- First subblock -- Second subblock -{known} -{extra} diff --git a/templates/Prompt/Subblock-Verify.md b/templates/Prompt/Subblock-Verify.md new file mode 100644 index 0000000..0d6e1a3 --- /dev/null +++ b/templates/Prompt/Subblock-Verify.md @@ -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}