refactor
This commit is contained in:
@@ -13,7 +13,6 @@ relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashca
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
import database as db
|
||||
|
||||
@@ -18,33 +18,25 @@ import math
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import unicodedata
|
||||
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, SUBBLOCK_MAX, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES
|
||||
from fsutil import atomic_write_text, atomic_write_json
|
||||
from agents import kill_process, cancel_scope, clear_scope
|
||||
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, SUB_VARIANT_COS, SUBBLOCK_MAX, EVIDENCE_BUDGET_CHARS, EVIDENCE_CTX_LINES
|
||||
from fsutil import 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
|
||||
from crawl import crawl
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, OK, GenContext, _detached, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race,
|
||||
_relevance_schema, _runde_schema, _semaphore, _str_list, _levels_schema, _timeout, run_single_slot,
|
||||
)
|
||||
from textkit import (
|
||||
_unique_title, _load_blocks, _norm_title, _parse_selection, _parse_subblocks, _title,
|
||||
_resolve_title, _title_index, clean_title,
|
||||
GenContext, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race,
|
||||
_semaphore, _timeout, run_single_slot,
|
||||
)
|
||||
from textkit import _load_blocks, _norm_title, _parse_selection, _title
|
||||
|
||||
# Pipeline-Tuning-Konstanten liegen zentral in config.py (tunebar via CREATOR_PARAMS).
|
||||
from config import ( # noqa: E402
|
||||
CONSOLIDATION_CHUNK, CONSOLIDATION_PANEL, DEDUP_GLOBAL_FLOOR,
|
||||
DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO,
|
||||
FILTER_CHUNK, FILTER_RECHECK_PANEL,
|
||||
RESEARCH_BATCH, RESEARCH_READERS, RESEARCH_SECTION_CHARS, RESEARCH_THEMA_AGENTS)
|
||||
from config import RESEARCH_SECTION_CHARS # noqa: E402
|
||||
|
||||
log = logging.getLogger("creator.blocks")
|
||||
|
||||
@@ -269,8 +261,6 @@ async def blocks_status(topic: str) -> dict:
|
||||
"progress": _blocks_progress.get(topic),
|
||||
"error": _blocks_errors.get(topic),
|
||||
"partial": not generating and open_cards > 0,
|
||||
"steps": [], # legacy phase pills — replaced by the live board
|
||||
"feine_steps": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from kanban import Flow, Stage
|
||||
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot
|
||||
from textkit import _norm_title, _title
|
||||
from textkit import _norm_title, _title, parse_facts
|
||||
|
||||
log = logging.getLogger("creator.board_artefacts")
|
||||
|
||||
@@ -107,6 +107,8 @@ async def _gather_cards(ctx: GenContext, flow: Flow, cards, one):
|
||||
results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
|
||||
errs = [r for r in results if isinstance(r, Exception)]
|
||||
if errs:
|
||||
for e in errs[1:]: # nur errs[0] wird re-raised — der Rest darf nicht stumm verschwinden
|
||||
log.error("weitere Karten-Exception im Batch: %r", e)
|
||||
raise errs[0]
|
||||
flow.wake.set()
|
||||
|
||||
@@ -316,10 +318,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
||||
return
|
||||
|
||||
def _kp(r: dict) -> list:
|
||||
try:
|
||||
return (json.loads(r.get("facts") or "{}")).get("key_points") or []
|
||||
except ValueError:
|
||||
return []
|
||||
return parse_facts(r.get("facts")).get("key_points") or []
|
||||
|
||||
def _side(tag: str, r: dict) -> str:
|
||||
return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r))
|
||||
|
||||
@@ -39,9 +39,7 @@ import kanban
|
||||
from kanban import Flow, Stage, chain_stages
|
||||
import blocks
|
||||
from blocks import (
|
||||
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
|
||||
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
|
||||
RESEARCH_THEMA_AGENTS, _FILTER_NOTATION, _GROUP_STANDALONE,
|
||||
_FILTER_NOTATION, _GROUP_STANDALONE,
|
||||
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
|
||||
_completion_schema, _containment_parent, _crawl_index, _file_payload,
|
||||
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement,
|
||||
@@ -50,7 +48,9 @@ from blocks import (
|
||||
_aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder,
|
||||
)
|
||||
from config import (QA_GATE_NOTE, QA_GATE_LLM,
|
||||
|
||||
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
|
||||
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
|
||||
RESEARCH_THEMA_AGENTS,
|
||||
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
||||
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
|
||||
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
|
||||
@@ -1915,14 +1915,9 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None:
|
||||
flow and flow.state.get("qa_note") is not None):
|
||||
return None
|
||||
import qa
|
||||
tdir = qa.QA_DIR / topic
|
||||
# by mtime: a re-run overwrites the run-id-named file, which sorts before timestamp names.
|
||||
# guide-* reports are the guide_qa series — they must not shadow the inventory badge.
|
||||
reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
if not reports:
|
||||
r = qa.latest_report(topic)
|
||||
if r is None:
|
||||
return None
|
||||
r = _json_file(reports[-1]) or {}
|
||||
note = r.get("note")
|
||||
if note is None:
|
||||
return None
|
||||
@@ -1965,9 +1960,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
await _requeue(r, stage)
|
||||
await db.kanban_delete_cards(topic, "inventory", "cluster")
|
||||
await db.kanban_delete_cards(topic, "inventory", "block")
|
||||
dbc = await db.get_db()
|
||||
await dbc.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,))
|
||||
await dbc.commit()
|
||||
await db.kanban_delete_members(topic)
|
||||
await db.delete_blocks(topic)
|
||||
await _clean_artefact_state(topic, files)
|
||||
elif board == "inventory" and stage in _CLUSTER_STAGES:
|
||||
|
||||
@@ -164,27 +164,18 @@ EVIDENCE_CTX_LINES = 15 # context lines around a cited source position
|
||||
# ── Pipeline tuning (zentral, tunebar via CREATOR_PARAMS — siehe Override-Hook am Datei-Ende;
|
||||
# Registry mit Suchraum: backend/train_params.py). QA-/Detektor-Konstanten bleiben bewusst in
|
||||
# qa.py/guide_qa.py — die Messlatte darf nie Teil des Suchraums sein. ─────────────────────────
|
||||
SUBBLOCK_CHUNK = 10 # subblock finder: 1 agent per ~10 blocks, capped
|
||||
SUBBLOCK_MAX = 40 # chunk cap
|
||||
LEVEL_CHUNK = 100 # classifying is cheap → large packages
|
||||
RESEARCH_BATCH = 20 # crawl pages per batch
|
||||
RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2)
|
||||
RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema")
|
||||
RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard)
|
||||
RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live)
|
||||
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (fallback path)
|
||||
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair
|
||||
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package
|
||||
DEDUP_TITLE_AUTO = 0.95 # near-identical TITLE cosine → merge without judge
|
||||
DEDUP_GLOBAL_FLOOR = 0.65 # global post-naming dedup candidate floor
|
||||
FILTER_CHUNK = 35 # blocks per judge in the degrade pass
|
||||
QUESTION_CHUNK_SUBS = 25 # target relevant subs per question chunk (LPT)
|
||||
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern
|
||||
FACTS_CHUNK_SUBS = 10 # facts extraction chunk (chunk count = parallelism)
|
||||
ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples bulk chunk
|
||||
FACTS_CHECK_PANEL = 3 # judges per facts-check chunk (majority)
|
||||
CONSOLIDATION_PANEL = 3 # mapping judges per chunk
|
||||
SUBBLOCK_PANEL = 3 # judges in the subblock clarification
|
||||
# Board 2, verschmolzene Calls (block_calls.py): Panel-Größen der neuen Struktur.
|
||||
# Konsens braucht ≥2 unabhängige Nennungen bzw. Einstimmigkeit — 2 ist das Minimum,
|
||||
# 3 kauft Robustheit für +50 % Tokens auf dem jeweiligen Segment.
|
||||
@@ -193,6 +184,10 @@ VERIFY_PANEL = 2 # unabhängige Prüfer-Calls pro Block (+ Ersatz bei
|
||||
ART_SPLIT_SUBS = 20 # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls
|
||||
FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck
|
||||
GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below min(this, relevante Subs) → log only (falsch fixt immer)
|
||||
ZIELE_MAX = 12 # Lernziele-Cap pro Block (mehr verwässert Coverage-Prüfung und Writer-Fokus)
|
||||
# Prüfer-Längenband um guide_qa.block_budget: enger als das QA-Band (0.35–1.5), damit der
|
||||
# Fix VOR der QA-Grenze greift. Die QA-Messlatte selbst bleibt bewusst in guide_qa.py.
|
||||
FIX_LAENGE_BAND = (0.5, 1.2)
|
||||
WRITER_SPLIT_SUBS = 30 # guide writer splits sections above this sub count
|
||||
KANBAN_BATCH = 5 # cards a worker pulls per micro-batch
|
||||
MAX_CARD_RETRIES = 3 # failures per card → dead-letter
|
||||
@@ -205,31 +200,22 @@ MAX_RESTARTS = 2 # agent restart cap per race slot
|
||||
# Fix-/Gate-Call (die laufen normal 110–135 s). 0 = aus.
|
||||
HEDGE_NACH_S = 90
|
||||
JUDGE_CHUNK = 40 # repair: findings per judge call
|
||||
EVENTS_RETENTION_TAGE = 60 # events älter als das werden beim Start gelöscht (Tabelle wuchs unbegrenzt)
|
||||
EVIDENCE_PER_BLOCK = 6000 # repair: excerpt chars per fremd candidate
|
||||
ABSCHLUSS_QA_LLM = 1 # 0 = Abschluss-QA ohne LLM-Judges (Training misst selbst; spart Minuten)
|
||||
|
||||
# Timeouts per agent step: (base seconds, seconds per block/section).
|
||||
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
|
||||
TIMEOUTS = {
|
||||
"research": (900, 0), # p95 measured 125 s (web mode); uni/link sections need headroom
|
||||
"research_mapping": (600, 3), # n = pre-merged entries
|
||||
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
|
||||
"ergaenzung": (600, 0), # subject-field extension for projects (web research)
|
||||
"plan": (300, 5),
|
||||
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
|
||||
"content": (450, 30), # facts find/erg/fix — p95 measured 241 s (was 600+90n)
|
||||
# Judge caps tightened 2026-07-04: judge p50 is 6–72 s; a stalled call burns the whole
|
||||
# cap and its retry heals in seconds — the old 300 s base tripled the stall cost.
|
||||
"content_check": (150, 8), # content exam per block in the package
|
||||
"subblock": (400, 15), # finder round — p95 measured 124 s (was 900+45n)
|
||||
"subblock_check": (150, 10), # judge decides contested subblocks in the chunk
|
||||
"konsolidierung": (300, 20), # consolidation judge sees ALL subs with key points
|
||||
"level": (300, 10), # classify subblocks per chunk
|
||||
"level_check": (150, 8), # judge decides contested levels in the chunk
|
||||
"relevance": (300, 10), # subblocks relevant/peripheral per chunk
|
||||
"relevance_check": (150, 8), # judge decides contested relevance in the chunk
|
||||
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
||||
"question_pattern_check": (150, 8), # critic cleans up the pattern table per block
|
||||
# Board 2, verschmolzene Calls: größere Outputs pro Call, dafür wenige Segmente
|
||||
"generate": (450, 0), # Subs+Facts+Level in einem (Sub-Zahl vorab unbekannt)
|
||||
"verify": (300, 10), # Audit über alle Subs (n = Subs), key points gekappt
|
||||
@@ -237,19 +223,11 @@ TIMEOUTS = {
|
||||
"artefakt": (450, 15), # Fragen+Karten+Beispiele (n = Subs)
|
||||
"artefakt_check": (200, 8), # Beispiel-Verifikation + Fragen-Kritik (n = Subs)
|
||||
"writer": (450, 60), # per section — split keeps sections ≤30 subs
|
||||
"lese_check": (300, 10), # per section in the package
|
||||
# guide board (per card = one block)
|
||||
"lernziele": (300, 5), # backward-design objectives per block
|
||||
"fakten_gate": (600, 5), # CoVe claim check per block
|
||||
"coverage": (300, 5), # objective↔section mapping per block
|
||||
}
|
||||
|
||||
# Purpose per format — flows into the outline judge (what the guide should achieve).
|
||||
# German strings: these are inserted verbatim into the judge prompt → kept German on purpose.
|
||||
FORMAT_PURPOSE = {
|
||||
"Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen",
|
||||
"FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen",
|
||||
"Rest": "einen Ergänzungs-Guide — nur die Randthemen",
|
||||
"pruefer": (600, 5), # verschmolzener Qualitäts-Pass (Gate+Coverage+Lese) per block
|
||||
# QA/Repair-Judge-Wellen (qa.judge_wave) — außerhalb der Boards, keine n-Skalierung
|
||||
"qa_judge": (600, 0),
|
||||
}
|
||||
|
||||
# Provider stacks: completely independent, any one can be removed at any time.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -288,13 +288,12 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
|
||||
import kanban
|
||||
import pipeline
|
||||
import qa
|
||||
import repair
|
||||
|
||||
async def fake_run_agent(agent_key, prompt, timeout, provider="", role="fast",
|
||||
capabilities="none", lane="batch", scope=None, on_line=None, label=""):
|
||||
return welt.respond(agent_key, prompt, capabilities)
|
||||
|
||||
for mod in (agents, pipeline, blocks, guide, repair):
|
||||
for mod in (agents, pipeline, guide):
|
||||
setattr_fn(mod, "run_agent", fake_run_agent)
|
||||
setattr_fn(blocks, "CONSENSUS_GRACE", 0)
|
||||
setattr_fn(bi, "_QA_GATE_POLL", 0.05)
|
||||
|
||||
@@ -11,7 +11,6 @@ Step files are kept → an abort preserves progress, ▶ resumes at the open ste
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,26 +18,14 @@ import uuid
|
||||
|
||||
from agents import run_agent
|
||||
from blocks import _convert_pdfs, source_folder
|
||||
from config import (
|
||||
DEFAULT_PROVIDER, FORMAT_PURPOSE, CONSENSUS_GRACE,
|
||||
READABILITY_ACTIVE, TEMPLATES_DIR,
|
||||
)
|
||||
import readability
|
||||
from config import DEFAULT_PROVIDER, TEMPLATES_DIR
|
||||
from database import (list_guides, update_guide, list_blocks, list_subblocks, set_guide_content,
|
||||
get_guide_content, get_outline, guide_stage_counts, delete_guide_board)
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_file, parse_json_text as _parse_json_text
|
||||
from paths import blocks_path, guide_content_path, project_dir, subblocks_path
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, GenContext, _claude_error, _extra,
|
||||
_fail, _gather_error, _gather_progress, _log, _prompt, _race,
|
||||
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
|
||||
is_guide_cancelled, run_single_slot,
|
||||
)
|
||||
from textkit import (
|
||||
_unique_title, _load_blocks, _norm_title, _parse_fragment, _split_chunks,
|
||||
_title, _resolve_title, _title_index,
|
||||
)
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from paths import blocks_path, guide_content_path, subblocks_path
|
||||
from pipeline import _fail, _prompt, _semaphore, clear_guide_cancelled, is_guide_cancelled
|
||||
from textkit import _unique_title, _load_blocks, _norm_title, _title, parse_facts
|
||||
|
||||
log = logging.getLogger("creator.guide")
|
||||
|
||||
@@ -66,10 +53,7 @@ async def _load_subblocks(topic: str) -> dict[str, list[dict]]:
|
||||
out: dict[str, list[dict]] = {}
|
||||
for r in await list_subblocks(topic):
|
||||
if r["status"] == "consensus" and r["sub_title"]:
|
||||
try:
|
||||
facts = json.loads(r["facts"]) if r.get("facts") else {}
|
||||
except (ValueError, TypeError):
|
||||
facts = {}
|
||||
facts = parse_facts(r.get("facts"))
|
||||
level = r["level"] if r["level"] in _LEVELS_OK else "advanced"
|
||||
out.setdefault(r["block"], []).append(
|
||||
{"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts})
|
||||
@@ -234,10 +218,16 @@ async def reconcile_guides() -> None:
|
||||
file write and status update.
|
||||
"""
|
||||
for g in await list_guides():
|
||||
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
|
||||
log.warning("[%s] Guide %s: done without content file — set to error", g["topic"], g["id"])
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now)
|
||||
if g["status"] != "done":
|
||||
continue
|
||||
# DB-first wie die Content-Route — die Datei ist nur Legacy-Fallback
|
||||
if await get_guide_content(g["topic"], g["format"]) is not None:
|
||||
continue
|
||||
if guide_content_path(g["topic"], g["format"]).exists():
|
||||
continue
|
||||
log.warning("[%s] Guide %s: done without content — set to error", g["topic"], g["id"])
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now)
|
||||
|
||||
|
||||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None:
|
||||
|
||||
@@ -22,10 +22,9 @@ import re
|
||||
import database as db
|
||||
import readability
|
||||
from blocks import _sink_json
|
||||
from config import (FORMAT_PURPOSE, READABILITY_ACTIVE,
|
||||
TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC)
|
||||
from config import (FIX_LAENGE_BAND, READABILITY_ACTIVE, TEMPLATES_DIR,
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC, ZIELE_MAX)
|
||||
from guide_qa import block_budget
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||
_timeout, is_guide_cancelled, run_single_slot)
|
||||
@@ -57,7 +56,7 @@ def _ziele_schema(data):
|
||||
return None
|
||||
zid = str(z.get("id", "")).strip()
|
||||
text = str(z.get("text", "")).strip()
|
||||
if not zid or not text or zid in seen or len(out) >= 12:
|
||||
if not zid or not text or zid in seen or len(out) >= ZIELE_MAX:
|
||||
continue
|
||||
seen.add(zid)
|
||||
out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()})
|
||||
@@ -140,10 +139,35 @@ class _Env:
|
||||
return self.content_path.parent / f"{self.content_path.stem}.{name}"
|
||||
|
||||
|
||||
def _memo(env, attr: str) -> dict:
|
||||
"""Lazy per-Karte-Cache auf dem env-Objekt (funktioniert auch für Test-Mocks). Lernziele,
|
||||
Beispiel-Rows und Facts-Grounding sind während EINES Laufs immutabel, wurden aber je Karte
|
||||
2–3× neu geholt (writer, pruefer, re-pruefer). Karten haben disjunkte block_norm-Keys und
|
||||
laufen ihre Stages seriell → kein Race."""
|
||||
d = env.__dict__.get(attr)
|
||||
if d is None:
|
||||
d = env.__dict__[attr] = {}
|
||||
return d
|
||||
|
||||
|
||||
async def _ziele(env: _Env, block_norm: str) -> list[dict]:
|
||||
cache = _memo(env, "_ziele_cache")
|
||||
if block_norm not in cache:
|
||||
cache[block_norm] = await db.list_lernziele(env.topic, block_norm)
|
||||
return cache[block_norm]
|
||||
|
||||
|
||||
def _ziele_text(ziele: list[dict]) -> str:
|
||||
return "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
|
||||
|
||||
|
||||
def _card_facts(env: _Env, block_title: str) -> str:
|
||||
from guide import _facts_grounding # lazy: guide imports this module
|
||||
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
|
||||
return grounding or env.fallback_facts
|
||||
cache = _memo(env, "_facts_cache")
|
||||
if block_title not in cache:
|
||||
from guide import _facts_grounding # lazy: guide imports this module
|
||||
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
|
||||
cache[block_title] = grounding or env.fallback_facts
|
||||
return cache[block_title]
|
||||
|
||||
|
||||
async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
|
||||
@@ -151,7 +175,10 @@ async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
|
||||
"""Verified worked examples of the block as writer input, matched to `subs` via
|
||||
sub_norm (a split half gets only its own). Rows whose sub does not match (generation
|
||||
mismatch) go to the full writer / split part 1 so they never vanish silently."""
|
||||
rows = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
|
||||
cache = _memo(env, "_example_rows")
|
||||
if block_norm not in cache:
|
||||
cache[block_norm] = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
|
||||
rows = cache[block_norm]
|
||||
if not rows:
|
||||
return ""
|
||||
wanted = {_norm_title(s["title"]) for s in subs}
|
||||
@@ -326,8 +353,7 @@ async def _write_split(env: _Env, card: dict, ziele_text: str):
|
||||
|
||||
async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
ziele = await db.list_lernziele(env.topic, norm)
|
||||
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
|
||||
ziele_text = _ziele_text(await _ziele(env, norm))
|
||||
# oversized first drafts: two halves, merged into one canonical section
|
||||
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
|
||||
text = await _write_split(env, card, ziele_text)
|
||||
@@ -385,9 +411,10 @@ def _det_hinweise(env: _Env, card: dict, sec: dict) -> list[str]:
|
||||
budget = block_budget(subs_all)
|
||||
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
|
||||
zeichen = len(aus[1] if len(aus) == 2 else sec["md"])
|
||||
if not (0.5 * budget <= zeichen <= 1.2 * budget):
|
||||
lo, hi = FIX_LAENGE_BAND
|
||||
if not (lo * budget <= zeichen <= hi * budget):
|
||||
out.append(
|
||||
f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(0.5 * budget)}–{round(1.2 * budget)}): "
|
||||
f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(lo * budget)}–{round(hi * budget)}): "
|
||||
f"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und "
|
||||
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
|
||||
return out
|
||||
@@ -424,8 +451,8 @@ async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[st
|
||||
Section-Text in drei seriellen Calls). Text-Antwort + Engine-Sink (Datei-schreibende
|
||||
Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED)."""
|
||||
norm = card["block_norm"]
|
||||
ziele = await db.list_lernziele(env.topic, norm)
|
||||
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
|
||||
ziele = await _ziele(env, norm)
|
||||
ziele_text = _ziele_text(ziele)
|
||||
ids = {z["ziel_id"] for z in ziele}
|
||||
facts = _card_facts(env, card["block"])
|
||||
ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
|
||||
@@ -441,7 +468,7 @@ async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[st
|
||||
hinweise=hinweise, extra=_extra(env.instructions)),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result: _sink_json(result, path, lambda d: _pruefer_schema(d, ids)),
|
||||
timeout=_timeout("fakten_gate", 1))
|
||||
timeout=_timeout("pruefer", 1))
|
||||
if status != OK or verdict is None:
|
||||
return None
|
||||
for zid, ok in verdict["ziele"].items():
|
||||
@@ -520,12 +547,19 @@ async def _stage_fix(env: _Env, card: dict) -> bool:
|
||||
card["md"] = fixed
|
||||
angewandt = True
|
||||
rest = ""
|
||||
if not angewandt:
|
||||
# Fix ohne Ergebnis: Befunde nicht stumm löschen — sie bleiben im gate_info sichtbar
|
||||
rest = "Fix ohne Ergebnis — offene Befunde:\n" + auftraege
|
||||
_log(env.topic, f"Fix {card['block']}: nicht angewandt — Befunde bleiben sichtbar")
|
||||
if kritisch and angewandt:
|
||||
sec2 = _first_section(card["md"])
|
||||
verdict = await _pruefer_call(env, card, sec2, "re", [])
|
||||
if verdict is None and is_guide_cancelled(env.guide_id):
|
||||
return False
|
||||
if verdict:
|
||||
if verdict is None:
|
||||
rest = "Re-Prüfer ohne Ergebnis — Fix ungeprüft übernommen"
|
||||
_log(env.topic, f"Re-Prüfer {card['block']}: kein Ergebnis — Fix ungeprüft übernommen")
|
||||
else:
|
||||
zeilen, _k = _auftraege(verdict, [], _n_rel(env, card))
|
||||
if zeilen:
|
||||
rest = "Rest-Befunde nach Fix:\n" + "\n".join(zeilen)
|
||||
@@ -568,6 +602,22 @@ async def _run_card_inner(env: _Env, card: dict) -> None:
|
||||
|
||||
|
||||
# ── Orchestration ──────────────────────────────────────────────────────────────────
|
||||
async def _progress_reporter(guide_id: str, topic: str, format_name: str, takt: float = 2.0) -> None:
|
||||
"""Live-Fortschritt fürs Frontend; ein DB-Fehler darf den Reporter nie beenden
|
||||
(der Fortschritt fror sonst still ein), unveränderter Stand wird nicht geschrieben."""
|
||||
zuletzt = None
|
||||
while True:
|
||||
try:
|
||||
counts = await db.guide_stage_counts(topic, format_name)
|
||||
stand = (counts.get("done", 0), sum(counts.values()))
|
||||
if stand != zuletzt:
|
||||
zuletzt = stand
|
||||
await db.update_guide(guide_id, progress=f"Board: {stand[0]}/{stand[1]} Karten fertig")
|
||||
except Exception:
|
||||
log.exception("[%s] guide progress reporter", topic)
|
||||
await asyncio.sleep(takt)
|
||||
|
||||
|
||||
async def _chapter_map(topic: str, entries: dict[int, str]) -> dict[str, tuple[str, int]]:
|
||||
"""block_norm → (chapter title, global order) from the outline artefact."""
|
||||
from guide import _outline_from_db, _fallback_outline, _with_remainder
|
||||
@@ -602,25 +652,20 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
|
||||
else _prompt("Guide-Facts-Thema"))
|
||||
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
|
||||
subs_raw, await _chapter_map(topic, entries), fallback, spec)
|
||||
for num, line in entries.items():
|
||||
title = _title(line)
|
||||
await db.upsert_guide_card(topic, format_name, _norm_title(title), title)
|
||||
await db.upsert_guide_cards_many(
|
||||
topic, format_name,
|
||||
[(_norm_title(_title(line)), _title(line)) for line in entries.values()])
|
||||
cards = await db.list_guide_cards(topic, format_name)
|
||||
open_cards = [c for c in cards if c["stage"] != "done"]
|
||||
if open_cards:
|
||||
sem = asyncio.Semaphore(CARD_CONCURRENCY)
|
||||
|
||||
async def _progress():
|
||||
while True:
|
||||
counts = await db.guide_stage_counts(topic, format_name)
|
||||
done = counts.get("done", 0)
|
||||
total = sum(counts.values())
|
||||
await db.update_guide(guide_id, progress=f"Board: {done}/{total} Karten fertig")
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
reporter = asyncio.create_task(_progress())
|
||||
reporter = asyncio.create_task(_progress_reporter(guide_id, topic, format_name))
|
||||
try:
|
||||
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
|
||||
ergebnisse = await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards],
|
||||
return_exceptions=True)
|
||||
for c, r in zip(open_cards, ergebnisse):
|
||||
if isinstance(r, BaseException):
|
||||
log.error("[%s] guide card task %s: %r", topic, c["block"], r)
|
||||
finally:
|
||||
reporter.cancel()
|
||||
if is_guide_cancelled(guide_id):
|
||||
@@ -701,9 +746,7 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
|
||||
columns.append({"key": stage, "label": STAGE_LABELS[stage],
|
||||
"total": len(in_stage), "cards": views})
|
||||
import qa as qa_mod # lazy wie in board_inventory
|
||||
tdir = qa_mod.QA_DIR / topic
|
||||
greports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
note_guide = (_json_file(greports[-1]) or {}).get("note_guide") if greports else None
|
||||
note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide")
|
||||
return {"columns": columns, "qa_guide": note_guide}
|
||||
|
||||
|
||||
@@ -713,8 +756,7 @@ async def repair_karten(topic: str, format_name: str) -> list[str]:
|
||||
generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum
|
||||
Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel."""
|
||||
import qa as qa_mod
|
||||
tdir = qa_mod.QA_DIR / topic
|
||||
reports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
reports = qa_mod.report_paths(topic, guide=True)
|
||||
rep = _json_file(reports[-1]) if reports else None
|
||||
if not rep:
|
||||
return []
|
||||
@@ -762,8 +804,7 @@ async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int:
|
||||
target = GUIDE_STAGES[ab_stage]
|
||||
stages = list(GUIDE_STAGES[ab_stage:]) + ["done"]
|
||||
if ab_stage == 0:
|
||||
for c in await db.list_guide_cards(topic, format_name):
|
||||
await db.delete_lernziele(topic, c["block_norm"])
|
||||
await db.delete_lernziele_all(topic)
|
||||
moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target,
|
||||
clear_md=ab_stage <= 2)
|
||||
return moved
|
||||
|
||||
@@ -10,7 +10,7 @@ Report: storage/qa/<topic>/guide-<ts>.json + Konsolen-Digest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
@@ -19,7 +19,9 @@ import database as db
|
||||
import qa
|
||||
import readability
|
||||
from fsutil import atomic_write_json
|
||||
from textkit import _norm_title
|
||||
from textkit import _norm_title, parse_facts
|
||||
|
||||
log = logging.getLogger("creator.guide_qa")
|
||||
|
||||
JACCARD_ABSATZ = 0.6 # Wort-Jaccard, ab dem zwei Absätze als Doppel gelten
|
||||
ABSATZ_MIN_CHARS = 200 # kürzere Absätze sind Übergänge — kein Dubletten-Signal
|
||||
@@ -137,22 +139,11 @@ async def _fachlich_falsch(topic: str, cards: list[dict]) -> list[str]:
|
||||
"""LLM-Stichprobe: Section enthält eine fachlich falsche Aussage? Zwei unabhängige
|
||||
Durchgänge, nur DOPPELT bestätigte zählen — ein Einzel-Judge schwankte zwischen
|
||||
0 und 5 Befunden am selben Guide und kippte die Note (Gewicht 3.0) auf 0."""
|
||||
from agents import run_agent
|
||||
from jsonio import parse_json_text
|
||||
from pipeline import _yesno_schema
|
||||
|
||||
async def _pass(kandidaten: list[dict], tag: str) -> list[str]:
|
||||
out = []
|
||||
for lo in range(0, len(kandidaten), 5):
|
||||
chunk = kandidaten[lo:lo + 5]
|
||||
listing = "\n\n".join(f"{k}. SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}"
|
||||
for k, c in enumerate(chunk, 1))
|
||||
rc, txt, _err = await run_agent(
|
||||
f"qa-guide-{topic}-fakten{tag}-{lo}", qa._qa_prompt("QA-Guide-Fakten", topic=topic, extra="", sections=listing),
|
||||
600, role="judge", capabilities="none", scope=topic, label=f"Guide-QA Fakten{tag} {lo}")
|
||||
v = (_yesno_schema(parse_json_text(txt)) or {}) if rc == 0 else {}
|
||||
out += [c["block"] for k, c in enumerate(chunk, 1) if v.get(k) == "ja"]
|
||||
return out
|
||||
items = [f"SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}" for c in kandidaten]
|
||||
v = await qa.judge_wave("QA-Guide-Fakten", topic, f"fakten{tag}", "sections", items,
|
||||
chunk=5, prefix="qa-guide", label="Guide-QA")
|
||||
return [c["block"] for k, c in enumerate(kandidaten, 1) if v.get(k) == "ja"]
|
||||
|
||||
verdacht = await _pass(cards, "")
|
||||
if not verdacht:
|
||||
@@ -178,12 +169,8 @@ async def guide_qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
for r in await db.list_subblocks(topic):
|
||||
if r["status"] != "consensus":
|
||||
continue
|
||||
try:
|
||||
facts = json.loads(r["facts"]) if r["facts"] else {}
|
||||
except (ValueError, TypeError):
|
||||
facts = {}
|
||||
subs_by_norm.setdefault(r["block_norm"], []).append(
|
||||
{"relevance": r["relevance"], "facts": facts if isinstance(facts, dict) else {}})
|
||||
{"relevance": r["relevance"], "facts": parse_facts(r["facts"])})
|
||||
if r["relevance"] != "peripheral":
|
||||
subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"])
|
||||
ziele = [dict(r) for r in await db.list_lernziele(topic)]
|
||||
|
||||
@@ -81,25 +81,12 @@ class BlocksResetStageRequest(BaseModel):
|
||||
stage: str = Field(min_length=1, max_length=40) # kanban column to reset back to
|
||||
|
||||
|
||||
class BlocksStep(BaseModel):
|
||||
label: str
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BlocksFineStep(BaseModel):
|
||||
label: str
|
||||
phase: str = ""
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BlocksStatusResponse(BaseModel):
|
||||
ready: bool
|
||||
generating: bool
|
||||
progress: str | None = None
|
||||
error: str | None = None
|
||||
partial: bool = False
|
||||
steps: list[BlocksStep] = []
|
||||
feine_steps: list[BlocksFineStep] = []
|
||||
|
||||
|
||||
class FolderResponse(BaseModel):
|
||||
|
||||
@@ -9,14 +9,11 @@ import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agents import run_agent, kill_process, cancel_scope, clear_scope
|
||||
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
|
||||
from database import update_guide
|
||||
from jsonio import read_json_file as _json_file
|
||||
from textkit import _STUFEN
|
||||
|
||||
log = logging.getLogger("creator.pipeline")
|
||||
|
||||
@@ -132,7 +129,6 @@ def _runde_schema(data, final: bool = False):
|
||||
return include, rest
|
||||
|
||||
|
||||
_RELEVANCE = ("relevant", "peripheral")
|
||||
_YESNO = ("ja", "nein")
|
||||
|
||||
|
||||
@@ -159,23 +155,13 @@ def _enum_map_schema(key: str, allowed):
|
||||
return parse
|
||||
|
||||
|
||||
_levels_schema = _enum_map_schema("levels", _STUFEN) # level ∈ beginner/advanced/expert
|
||||
_relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈ relevant/peripheral
|
||||
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
|
||||
|
||||
|
||||
from config import MAX_RESTARTS as _MAX_RESTARTS, HEDGE_NACH_S as _HEDGE_NACH_S # noqa: E402 — zentral tunebar
|
||||
|
||||
# Detached Nachzügler-Tasks (late-Fold): Referenz gegen GC, Aufräumen via done-callback.
|
||||
_NACHZUEGLER: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _detached(task: asyncio.Task) -> None:
|
||||
_NACHZUEGLER.add(task)
|
||||
task.add_done_callback(_NACHZUEGLER.discard)
|
||||
|
||||
|
||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None, late=None) -> list | None:
|
||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, cancelled=None, *, grace: int | None = None) -> list | None:
|
||||
"""Starts all slots in parallel and collects `quorum` valid results.
|
||||
|
||||
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||
@@ -188,16 +174,6 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
a timer of `grace` seconds. After it expires, running agents are only
|
||||
killed if the minimum stands — otherwise the race, including restarts,
|
||||
keeps running until it stands. Returns: `quorum` to `len(slots)` results.
|
||||
|
||||
`min_runtime` (wall-clock from start): the race does not return before it
|
||||
elapses while agents are still running — gives them time to search thoroughly.
|
||||
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
|
||||
(or None if nothing), killing the rest. Both default off; only Research sets them.
|
||||
|
||||
`late(value)` (async): Nachzügler werden beim Quorum-Return NICHT gekillt, sondern
|
||||
laufen detached weiter; jedes noch eintreffende valide Ergebnis geht an `late`.
|
||||
Ersetzt den grace-Timer der Finder-Runden — der hielt die Runde bis 300 s offen,
|
||||
nur damit die dritte Stimme zählt (gemessen: 73 s Warten pro Runde).
|
||||
"""
|
||||
attempts = {i: 0 for i in range(len(slots))}
|
||||
tasks: dict[asyncio.Task, int] = {}
|
||||
@@ -209,9 +185,6 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
# hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110–135 s laufen.
|
||||
hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
min_deadline = start + min_runtime if min_runtime else None
|
||||
max_deadline = start + max_runtime if max_runtime else None
|
||||
deadline: float | None = None
|
||||
|
||||
def spawn(i: int, suffix: str = "") -> None:
|
||||
@@ -227,29 +200,6 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
keys[task] = key
|
||||
born[task] = loop.time()
|
||||
|
||||
spaet: set[int] = set() # je Slot zählt nur EIN spätes Ergebnis (Hedge-Zwilling = Echo)
|
||||
|
||||
def _detach_rest() -> None:
|
||||
"""Quorum steht: Nachzügler an `late` übergeben statt killen (nur Erfolgs-Return)."""
|
||||
if late is None:
|
||||
return
|
||||
for t, i in list(tasks.items()):
|
||||
tasks.pop(t)
|
||||
keys.pop(t, None)
|
||||
born.pop(t, None)
|
||||
|
||||
async def _warte(t=t, i=i):
|
||||
try:
|
||||
r = await t
|
||||
if i in spaet:
|
||||
return
|
||||
if r and r[0] == 0 and (val := slots[i]["payload"](r)) is not None:
|
||||
spaet.add(i)
|
||||
await late(val)
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001 — Nachzügler sind best-effort
|
||||
pass
|
||||
_detached(asyncio.create_task(_warte()))
|
||||
|
||||
for i in range(len(slots)):
|
||||
spawn(i)
|
||||
|
||||
@@ -258,13 +208,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
while tasks:
|
||||
if cancelled and cancelled():
|
||||
return None
|
||||
# Hard wall-clock cap: return whatever we have (None if empty), kill the rest.
|
||||
if max_deadline is not None and loop.time() >= max_deadline:
|
||||
_log(topic, f"{label}: max runtime {max_runtime}s reached ({len(results)} valid)")
|
||||
return results or None
|
||||
min_ok = min_deadline is None or loop.time() >= min_deadline
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
|
||||
_detach_rest()
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
|
||||
return results
|
||||
# Hedge: a slot running HEDGE_NACH_S without result gets ONE parallel twin
|
||||
# (key -h) — first valid result wins. Stalled provider calls burned the full
|
||||
@@ -277,14 +221,10 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
hedged.add(i)
|
||||
spawn(i, suffix="-h")
|
||||
_log(topic, f"{label} {i + 1}: {round(hedge_s)}s ohne Ergebnis — Hedge-Zwilling gestartet")
|
||||
# Wake up for the earliest relevant deadline (grace, min, max, or next hedge).
|
||||
# Wake up for the earliest relevant deadline (grace or next hedge).
|
||||
waits = []
|
||||
if deadline is not None and len(results) >= quorum:
|
||||
waits.append(deadline - loop.time())
|
||||
if min_deadline is not None:
|
||||
waits.append(min_deadline - loop.time())
|
||||
if max_deadline is not None:
|
||||
waits.append(max_deadline - loop.time())
|
||||
if hedge_s:
|
||||
naechste = [born[t] + hedge_s - loop.time() for t in tasks
|
||||
if tasks[t] not in hedged | fertig]
|
||||
@@ -323,11 +263,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
if grace is not None and deadline is None:
|
||||
deadline = loop.time() + grace
|
||||
_log(topic, f"{label}: first result — grace {grace}s running")
|
||||
if on_update:
|
||||
on_update(len(results))
|
||||
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
|
||||
and (min_deadline is None or loop.time() >= min_deadline)):
|
||||
_detach_rest()
|
||||
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
|
||||
return results
|
||||
continue
|
||||
|
||||
@@ -340,7 +276,6 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
if attempts[i] <= _MAX_RESTARTS and not enough and not zwilling and not (cancelled and cancelled()):
|
||||
spawn(i)
|
||||
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
|
||||
_detach_rest()
|
||||
return results
|
||||
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
|
||||
return None
|
||||
|
||||
108
backend/qa.py
108
backend/qa.py
@@ -11,7 +11,7 @@ previous report of the same topic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
@@ -19,12 +19,14 @@ from pathlib import Path
|
||||
|
||||
import database as db
|
||||
import embedding
|
||||
from config import STORAGE_DIR, SUB_DUP_KANDIDAT_COS
|
||||
from config import JUDGE_CHUNK, STORAGE_DIR, SUB_DUP_KANDIDAT_COS
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from paths import arbeit_dir
|
||||
from textkit import _norm_title
|
||||
|
||||
log = logging.getLogger("creator.qa")
|
||||
|
||||
QA_DIR = STORAGE_DIR / "qa"
|
||||
JACCARD_FLOOR = 0.5 # title token overlap that makes a pair suspicious
|
||||
EMB_FLOOR = 0.82 # casefolded title cosine (own threshold, NOT the pipeline's 0.65)
|
||||
@@ -281,19 +283,70 @@ def _qa_prompt(name: str, **kwargs) -> str:
|
||||
return (TEMPLATES_DIR / "QA" / f"{name}.md").read_text(encoding="utf-8").format(**kwargs)
|
||||
|
||||
|
||||
async def _llm_verdicts(template: str, topic: str, key: str, items: list[str]) -> dict[int, str]:
|
||||
async def judge_wave(template: str, topic: str, key: str, slot: str, items: list[str],
|
||||
*, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA") -> dict[int, str]:
|
||||
"""Gechunkte Ja/Nein-Judge-Welle über ALLE Items, Chunks parallel (die Semaphoren in
|
||||
agents.py begrenzen); Ergebnis mit globalen 1-basierten Indizes. Fail-open pro Chunk
|
||||
(Items bleiben ohne Urteil), aber nie stumm. Ersetzt die drei strukturgleichen
|
||||
Handkopien in repair/qa/guide_qa."""
|
||||
from agents import run_agent
|
||||
from pipeline import _yesno_schema
|
||||
from pipeline import _timeout, _yesno_schema
|
||||
from jsonio import parse_json_text
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(items, 1))
|
||||
slot = {"Dubletten": "pairs", "Luecken": "sections", "Bausteine": "blocks", "Sub": "pairs"}[template.split("-")[1]]
|
||||
rc, out, _err = await run_agent(f"qa-{topic}-{key}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}),
|
||||
600, role="judge", capabilities="none", scope=topic, label=f"QA {key}")
|
||||
return (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {}
|
||||
|
||||
async def _chunk(lo: int) -> dict[int, str]:
|
||||
teil = items[lo:lo + chunk]
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1))
|
||||
try:
|
||||
rc, out, _err = await run_agent(
|
||||
f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}),
|
||||
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}")
|
||||
except Exception:
|
||||
log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo)
|
||||
return {}
|
||||
if rc != 0:
|
||||
log.warning("[%s] %s-Judge %s+%d fehlgeschlagen (rc=%s) — %d Items ohne Urteil",
|
||||
topic, label, key, lo, rc, len(teil))
|
||||
return {}
|
||||
return _yesno_schema(parse_json_text(out)) or {}
|
||||
|
||||
offsets = range(0, len(items), chunk)
|
||||
results = await asyncio.gather(*[_chunk(lo) for lo in offsets])
|
||||
return {lo + k: urteil for lo, v in zip(offsets, results) for k, urteil in v.items()}
|
||||
|
||||
|
||||
# ── Report ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def report_paths(topic: str, guide: bool = False) -> list[Path]:
|
||||
"""QA-Reports eines Topics, mtime-aufsteigend (Run-ID- und Timestamp-Namen sortieren
|
||||
lexikographisch nicht). guide=True → die separate guide-*-Serie (guide_qa.py).
|
||||
freispruch.json teilt den Ordner, ist aber kein Report — immer außen vor."""
|
||||
tdir = QA_DIR / topic
|
||||
if not tdir.is_dir():
|
||||
return []
|
||||
return sorted((p for p in tdir.glob("*.json")
|
||||
if p.name.startswith("guide-") == guide and p.name != "freispruch.json"),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
_latest_cache: dict[tuple[str, bool], tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def latest_report(topic: str, guide: bool = False) -> dict | None:
|
||||
"""Jüngster Report als geparstes dict, mtime-gecacht — die Board-Snapshots lesen das
|
||||
im 1,2-s-Frontend-Takt, ein JSON-Read je Poll war unnötiges Datei-I/O. glob+stat
|
||||
bleiben (billig), der Read passiert nur bei geänderter mtime."""
|
||||
reports = report_paths(topic, guide)
|
||||
if not reports:
|
||||
return None
|
||||
p = reports[-1]
|
||||
mtime = p.stat().st_mtime
|
||||
key = (topic, guide)
|
||||
cached = _latest_cache.get(key)
|
||||
if cached is None or cached[0] != mtime:
|
||||
_latest_cache[key] = (mtime, _json_file(p) or {})
|
||||
return _latest_cache[key][1]
|
||||
|
||||
|
||||
def freispruch_pfad(topic: str) -> Path:
|
||||
return QA_DIR / topic / "freispruch.json"
|
||||
|
||||
@@ -341,41 +394,37 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
fr = [t for t in fr if _norm_title(t) not in frei_fremd]
|
||||
|
||||
if llm and d:
|
||||
v = await _llm_verdicts("QA-Dubletten", topic, "dubletten",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]])
|
||||
v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]])
|
||||
for k, p in enumerate(d[:LLM_SAMPLE], 1):
|
||||
p["llm"] = v.get(k, "?")
|
||||
if llm and lk:
|
||||
v = await _llm_verdicts("QA-Luecken", topic, "luecken",
|
||||
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]])
|
||||
v = await judge_wave("QA-Luecken", topic, "luecken", "sections",
|
||||
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]])
|
||||
for k, x in enumerate(lk[:LLM_SAMPLE], 1):
|
||||
x["llm"] = v.get(k, "?")
|
||||
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note
|
||||
for lo in range(0, len(sd), 40):
|
||||
chunk = sd[lo:lo + 40]
|
||||
v = await _llm_verdicts("QA-Sub-Dubletten", topic, f"sub-dubletten-{lo}",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in chunk])
|
||||
for k, p in enumerate(chunk, 1):
|
||||
p["llm"] = v.get(k, "?")
|
||||
v = await judge_wave("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in sd])
|
||||
for k, p in enumerate(sd, 1):
|
||||
p["llm"] = v.get(k, "?")
|
||||
frei_sub = set(frei.get("sub_dubletten") or [])
|
||||
for p in sd:
|
||||
if p.get("llm") == "ja" and _paar_key(p["a"], p["b"]) in frei_sub:
|
||||
p["freispruch"] = True # 2:1-Urteil „behalten" — sichtbar, aber notenfrei
|
||||
unecht: list[str] | None = None
|
||||
if llm and blocks:
|
||||
verdacht = []
|
||||
for lo in range(0, len(blocks), 80): # ein Call je 80 Titel
|
||||
chunk = blocks[lo:lo + 80]
|
||||
v = await _llm_verdicts("QA-Bausteine", topic, f"bausteine-{lo}",
|
||||
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in chunk])
|
||||
verdacht += [b for k, b in enumerate(chunk, 1) if v.get(k) == "nein"]
|
||||
v = await judge_wave("QA-Bausteine", topic, "bausteine", "blocks",
|
||||
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in blocks],
|
||||
chunk=80)
|
||||
verdacht = [b for k, b in enumerate(blocks, 1) if v.get(k) == "nein"]
|
||||
# Bestätiger-Pass nur über die Geflaggten: der Einzel-Judge flaggte pro Lauf ANDERE
|
||||
# Blöcke (gemessen aak: Note pendelte 9.3↔10.0 bei identischem Bestand) — nur
|
||||
# doppelt-„nein" zählt; Repair hat als dritte Sicherung die eigene Zweitmeinung
|
||||
unecht = []
|
||||
if verdacht:
|
||||
v2 = await _llm_verdicts("QA-Bausteine", topic, "bausteine-b2",
|
||||
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in verdacht])
|
||||
v2 = await judge_wave("QA-Bausteine", topic, "bausteine-b2", "blocks",
|
||||
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in verdacht])
|
||||
unecht = [b["title"] for k, b in enumerate(verdacht, 1) if v2.get(k) == "nein"]
|
||||
frei_unecht = set(frei.get("unecht") or [])
|
||||
unecht = [t for t in unecht if _norm_title(t) not in frei_unecht]
|
||||
@@ -432,10 +481,7 @@ def _diff(prev: dict | None, cur: dict) -> dict:
|
||||
def _write_report(report: dict) -> Path:
|
||||
tdir = QA_DIR / report["topic"]
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
# by mtime: run-id names (…-1311-5e5c) and timestamp names don't sort lexicographically.
|
||||
# guide-* reports share the directory but are a SEPARATE series (guide_qa.py).
|
||||
older = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
older = report_paths(report["topic"])
|
||||
prev = _json_file(older[-1]) if older else None
|
||||
report["diff_zum_vorlauf"] = _diff(prev, report)
|
||||
name = report["run_id"] or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
@@ -6,29 +6,22 @@ deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur n
|
||||
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
|
||||
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import database as db
|
||||
import qa
|
||||
from agents import run_agent
|
||||
from blocks import _blocks_files, _evidence_pack, source_folder
|
||||
from config import EVIDENCE_PER_BLOCK
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import parse_json_text, read_json_file as _json_file
|
||||
from pipeline import _yesno_schema
|
||||
from textkit import _norm_title, _title, clean_title
|
||||
from jsonio import read_json_file as _json_file
|
||||
from textkit import _norm_title, _title, clean_title, parse_facts
|
||||
|
||||
log = logging.getLogger("creator.repair")
|
||||
|
||||
JUDGE_TIMEOUT = 600
|
||||
from config import EVIDENCE_PER_BLOCK, JUDGE_CHUNK # noqa: E402 — zentral tunebar
|
||||
|
||||
|
||||
async def repair_befunde(topic: str) -> dict:
|
||||
tdir = qa.QA_DIR / topic
|
||||
reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
reports = qa.report_paths(topic)
|
||||
report = _json_file(reports[-1]) if reports else None
|
||||
if not report:
|
||||
return {"fehler": "kein QA-Report — erst QA laufen lassen"}
|
||||
@@ -53,21 +46,8 @@ async def repair_befunde(topic: str) -> dict:
|
||||
|
||||
|
||||
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
|
||||
"""No-Tool-Judge-Wellen über alle Items (fail-open: Fehler → leeres Verdikt = behalten)."""
|
||||
verdicts: dict[int, str] = {}
|
||||
for lo in range(0, len(items), JUDGE_CHUNK):
|
||||
chunk = items[lo:lo + JUDGE_CHUNK]
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(chunk, 1))
|
||||
try:
|
||||
rc, out, _err = await run_agent(
|
||||
f"repair-{topic}-{key}-{lo}", qa._qa_prompt(template, topic=topic, extra="", **{slot: listing}),
|
||||
JUDGE_TIMEOUT, role="judge", capabilities="none", scope=topic, label=f"Repair {key}")
|
||||
v = (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {}
|
||||
except Exception:
|
||||
log.exception("[%s] Repair-Judge %s fehlgeschlagen — Befunde bleiben", topic, key)
|
||||
v = {}
|
||||
verdicts.update({lo + k: urteil for k, urteil in v.items()})
|
||||
return verdicts
|
||||
"""No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten)."""
|
||||
return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair")
|
||||
|
||||
|
||||
def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
|
||||
@@ -165,10 +145,7 @@ _SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S)
|
||||
def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]:
|
||||
"""Gewinner = mehr key_points im facts-Feld, dann längerer Titel (Muster Konsolidierung)."""
|
||||
def score(r):
|
||||
try:
|
||||
kp = len((json.loads(r.get("facts") or "{}")).get("key_points") or [])
|
||||
except ValueError:
|
||||
kp = 0
|
||||
kp = len(parse_facts(r.get("facts")).get("key_points") or [])
|
||||
return (kp, len(r.get("sub_title") or ""))
|
||||
return (a, b) if score(a) >= score(b) else (b, a)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -19,6 +20,7 @@ from database import (
|
||||
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
|
||||
get_sub_artefakte, kanban_reset, delete_guide_board,
|
||||
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
|
||||
list_runs, get_db,
|
||||
)
|
||||
from textkit import _norm_title
|
||||
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
|
||||
@@ -72,6 +74,18 @@ async def get_stats():
|
||||
return {"topics": len(topics), "formats": formats_stats(guides, levels)}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
await (await get_db()).execute("SELECT 1")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def get_runs(topic: str, limit: int = 10):
|
||||
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id."""
|
||||
return {"runs": await list_runs(topic, limit)}
|
||||
|
||||
|
||||
@router.get("/topics/progress")
|
||||
async def topic_progress(topic: str):
|
||||
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
|
||||
@@ -89,14 +103,28 @@ async def add_topic(req: TopicCreateRequest):
|
||||
|
||||
@router.delete("/topics")
|
||||
async def remove_topic(topic: str):
|
||||
guides = [g for g in await list_guides() if g["topic"] == topic]
|
||||
status = await blocks_status(topic)
|
||||
if status["generating"] or any(g["status"] == "generating" for g in guides):
|
||||
raise HTTPException(409, "Generierung läuft — erst abbrechen")
|
||||
await delete_topic(topic)
|
||||
await delete_block_data(topic)
|
||||
await delete_topic_pipeline(topic)
|
||||
await delete_source(topic) # topic config (DB) — removed together with the topic
|
||||
await delete_guide_content(topic)
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
# guides/Board/Kanban mitlöschen — GET /topics leitet Topics aus guides ab,
|
||||
# sonst taucht das gelöschte Topic sofort wieder auf
|
||||
for g in guides:
|
||||
await delete_guide(g["id"])
|
||||
await delete_guide_board(topic)
|
||||
await kanban_reset(topic)
|
||||
import qa
|
||||
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
|
||||
# rmtree (potenziell große Topic-Ordner) in den Threadpool — der Event-Loop bedient
|
||||
# parallel laufende Flows/Polls, blockierendes Datei-I/O friert die alle ein
|
||||
def _wipe():
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
|
||||
await asyncio.to_thread(_wipe)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -325,9 +353,10 @@ async def blocks_completeness(topic: str):
|
||||
counts = await kanban_stage_counts(topic)
|
||||
inv = counts.get("inventory", {})
|
||||
blocks = await list_blocks(topic, status="consensus")
|
||||
subs = 0
|
||||
for b in blocks:
|
||||
subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus")
|
||||
# ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen
|
||||
consensus_blocks = {b["title_norm"] for b in blocks}
|
||||
subs = sum(1 for s in await list_subblocks(topic)
|
||||
if s["status"] == "consensus" and s["block_norm"] in consensus_blocks)
|
||||
ziele = await list_lernziele(topic)
|
||||
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
|
||||
degradiert = ueberstimmt = 0
|
||||
@@ -466,15 +495,26 @@ async def block_chat_route(req: BlockChatRequest):
|
||||
|
||||
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
|
||||
# overwrite the absolute score with a stale base (race).
|
||||
_check_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {}
|
||||
|
||||
|
||||
def _check_lock(topic: str, block: str) -> asyncio.Lock:
|
||||
@asynccontextmanager
|
||||
async def _check_lock(topic: str, block: str):
|
||||
"""Per-(topic,block)-Lock mit Refcount, das den Eintrag nach dem letzten Nutzer
|
||||
entfernt — die Map wuchs sonst unbegrenzt (ein Lock pro je geprüftem Block)."""
|
||||
key = (topic, block)
|
||||
lock = _check_locks.get(key)
|
||||
if lock is None:
|
||||
lock = _check_locks[key] = asyncio.Lock()
|
||||
return lock
|
||||
entry = _check_locks.get(key)
|
||||
if entry is None:
|
||||
entry = _check_locks[key] = (asyncio.Lock(), [0])
|
||||
lock, ref = entry
|
||||
ref[0] += 1
|
||||
try:
|
||||
async with lock:
|
||||
yield
|
||||
finally:
|
||||
ref[0] -= 1
|
||||
if ref[0] == 0 and _check_locks.get(key) is entry:
|
||||
del _check_locks[key]
|
||||
|
||||
|
||||
def _basis(state: dict, question: str) -> tuple[int, bool]:
|
||||
|
||||
@@ -50,14 +50,26 @@ async def load_learnstate() -> tuple[list[dict], dict[str, dict[str, set[str]]]]
|
||||
return await list_guides(), levels
|
||||
|
||||
|
||||
_content_cache: dict[str, tuple[float, dict | None]] = {}
|
||||
|
||||
|
||||
def _content_json(topic: str, fmt: str) -> dict | None:
|
||||
"""Guide-Content-JSON (kann MB groß sein), mtime-gecacht — /stats und /topics/progress
|
||||
lasen die Datei bei JEDEM Frontend-Poll neu und synchron im Event-Loop."""
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
_content_cache.pop(str(path), None)
|
||||
return None
|
||||
cached = _content_cache.get(str(path))
|
||||
if cached is None or cached[0] != mtime:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
data = None
|
||||
_content_cache[str(path)] = (mtime, data)
|
||||
return _content_cache[str(path)][1]
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
|
||||
import board_inventory as bi
|
||||
from pipeline import GenContext
|
||||
from tests.invarianten import pruefe_invarianten, pruefe_guide_invarianten
|
||||
from invarianten import pruefe_invarianten, pruefe_guide_invarianten
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
|
||||
@@ -131,8 +131,8 @@ async def test_guide_error_event(testdb):
|
||||
|
||||
def test_timeout_calibration_smoke():
|
||||
from pipeline import _timeout
|
||||
assert _timeout("subblock", 10) == 400 + 150
|
||||
assert _timeout("content", 10) == 450 + 300
|
||||
assert _timeout("subblock_check", 10) == 150 + 100
|
||||
assert _timeout("writer", 10) == 450 + 600
|
||||
|
||||
|
||||
def test_env_file_wins(tmp_path, monkeypatch):
|
||||
@@ -317,3 +317,71 @@ async def test_events_run_summary_aggregates(testdb):
|
||||
assert s["agents"]["gesamt"] == 2 and s["agents"]["ok"] == 1 and s["agents"]["timeout"] == 1
|
||||
assert s["agents"]["verlorene_min"] == 2
|
||||
assert s["tokens"] == {"input": 15, "output": 2, "cache_read": 80, "cache_write": 1}
|
||||
|
||||
|
||||
async def test_topic_delete_entfernt_guides_und_kanban(testdb, tmp_path, monkeypatch):
|
||||
"""DELETE /topics: guides/guide_cards/kanban_cards mitlöschen — GET /topics leitet
|
||||
Topics aus guides ab, sonst taucht das gelöschte Topic sofort wieder auf."""
|
||||
import routes, qa
|
||||
db = testdb
|
||||
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
|
||||
monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa")
|
||||
await db.create_topic(TOPIC)
|
||||
await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "",
|
||||
"status": "done", "progress": None,
|
||||
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
|
||||
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "b-1", "block", "done_block", {"title": "Alpha"})
|
||||
res = await routes.remove_topic(TOPIC)
|
||||
assert res["ok"]
|
||||
assert all(g["topic"] != TOPIC for g in await db.list_guides())
|
||||
assert await db.list_guide_cards(TOPIC, "Guide") == []
|
||||
assert await db.kanban_cards(TOPIC, "inventory") == []
|
||||
assert TOPIC not in await routes.get_topics()
|
||||
|
||||
|
||||
async def test_topic_delete_409_bei_laufendem_guide(testdb, tmp_path, monkeypatch):
|
||||
"""Läuft eine Generierung, wird nicht gelöscht (409) — ein laufender Flow schrieb
|
||||
sonst nach dem Löschen munter neue Rows/Dateien."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
import routes
|
||||
db = testdb
|
||||
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
|
||||
await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "",
|
||||
"status": "generating", "progress": None,
|
||||
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await routes.remove_topic(TOPIC)
|
||||
assert e.value.status_code == 409
|
||||
assert any(g["topic"] == TOPIC for g in await db.list_guides())
|
||||
|
||||
|
||||
async def test_runs_endpoint_liefert_bilanz(testdb):
|
||||
"""GET /api/runs: pro run_id Zeitspanne + Agent-/Token-Bilanz + Fails, jüngster zuerst,
|
||||
aktiv-Flag aus dem Run-Registry."""
|
||||
import routes
|
||||
db = testdb
|
||||
db.set_current_run(TOPIC, "r1")
|
||||
await db.add_event(TOPIC, "agent", key="a", status="ok", dur_ms=1000,
|
||||
meta={"tokens": {"input": 10, "output": 20, "cache_read": 0, "cache_write": 0}})
|
||||
await db.add_event(TOPIC, "agent", key="b", status="timeout", dur_ms=120000)
|
||||
await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"})
|
||||
res = await routes.get_runs(TOPIC)
|
||||
runs = res["runs"]
|
||||
assert len(runs) == 1 and runs[0]["run_id"] == "r1" and runs[0]["aktiv"] is True
|
||||
assert runs[0]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1
|
||||
assert runs[0]["tokens"]["output"] == 20
|
||||
assert runs[0]["fails"][0]["error"] == "kaputt" and runs[0]["fails"][0]["status"] == "dead"
|
||||
assert runs[0]["start"] <= runs[0]["ende"]
|
||||
db.set_current_run(TOPIC, "r2")
|
||||
await db.add_event(TOPIC, "agent", key="c", status="ok")
|
||||
db.set_current_run(TOPIC, None)
|
||||
runs = (await routes.get_runs(TOPIC))["runs"]
|
||||
assert [r["run_id"] for r in runs] == ["r2", "r1"]
|
||||
assert runs[0]["aktiv"] is False # Registry geräumt → Lauf beendet
|
||||
|
||||
|
||||
async def test_health(testdb):
|
||||
import routes
|
||||
assert (await routes.health())["ok"] is True
|
||||
|
||||
@@ -444,3 +444,78 @@ async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, mo
|
||||
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "pruefer"
|
||||
assert cards["alpha"]["md"] # Text bleibt — der Prüfer arbeitet auf dem Bestand
|
||||
assert cards["gamma"]["stage"] == "done"
|
||||
|
||||
|
||||
async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch):
|
||||
"""Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie
|
||||
bleiben im gate_info sichtbar (vorher wurde gate_info geleert)."""
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
env = gb._Env(None, "g-ff", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
|
||||
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\nText."
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md,
|
||||
"stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
assert "-gfix-" in key
|
||||
return gb.FAILED, None
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
assert await gb._stage_fix(env, card)
|
||||
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
|
||||
assert karte["stage"] == "done"
|
||||
assert "offene Befunde" in karte["gate_info"] and "Claim c1" in karte["gate_info"]
|
||||
|
||||
|
||||
async def test_repruefer_ausfall_wird_vermerkt(testdb, tmp_path, monkeypatch):
|
||||
"""Fällt der Re-Prüfer aus (kein Cancel), wird das im gate_info vermerkt statt die
|
||||
Karte stumm als geprüft durchzuwinken."""
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
env = gb._Env(None, "g-ra", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
|
||||
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md,
|
||||
"stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
if "-gfix-" in key:
|
||||
import re as _re
|
||||
m = _re.search(r"(/\S+\.md)", prompt)
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(md.replace("Text im Rahmen.", "Korrigiert."))
|
||||
return gb.OK, payload(None)
|
||||
return gb.FAILED, None # Re-Prüfer fällt aus
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
assert await gb._stage_fix(env, card)
|
||||
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
|
||||
assert karte["stage"] == "done" and "Korrigiert." in karte["md"]
|
||||
assert "Re-Prüfer ohne Ergebnis" in karte["gate_info"]
|
||||
|
||||
|
||||
async def test_progress_reporter_ueberlebt_db_fehler(testdb, monkeypatch):
|
||||
"""Ein DB-Fehler beendet den Reporter nicht (der Fortschritt fror sonst still ein);
|
||||
unveränderter Stand wird nicht erneut geschrieben."""
|
||||
import asyncio
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
calls = {"n": 0}
|
||||
writes = []
|
||||
orig = db.guide_stage_counts
|
||||
|
||||
async def flaky(topic, fmt):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise RuntimeError("kaputt")
|
||||
return await orig(topic, fmt)
|
||||
|
||||
async def fake_update(guide_id, **kw):
|
||||
writes.append(kw["progress"])
|
||||
|
||||
monkeypatch.setattr(gb.db, "guide_stage_counts", flaky)
|
||||
monkeypatch.setattr(gb.db, "update_guide", fake_update)
|
||||
task = asyncio.create_task(gb._progress_reporter("g-pr", TOPIC, FMT, takt=0.01))
|
||||
await asyncio.sleep(0.08)
|
||||
task.cancel()
|
||||
assert calls["n"] >= 3 # lief nach dem Fehler weiter
|
||||
assert writes.count(writes[0]) == 1 # gleicher Stand nur einmal geschrieben
|
||||
|
||||
@@ -227,7 +227,7 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
|
||||
{"title": titel, "description": "d"})
|
||||
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
|
||||
|
||||
async def fake_verdicts(template, topic, key, items):
|
||||
async def fake_wave(template, topic, key, slot, items, **kw):
|
||||
if template != "QA-Bausteine":
|
||||
return {}
|
||||
if key.startswith("bausteine-b2"): # Bestätiger sieht nur die Geflaggten
|
||||
@@ -235,7 +235,7 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
|
||||
return {1: "nein", 2: "ja"} # nur der erste wird bestätigt
|
||||
return {1: "nein", 2: "nein", 3: "ja"} # Pass 1 flaggt zwei
|
||||
|
||||
monkeypatch.setattr(qa, "_llm_verdicts", fake_verdicts)
|
||||
monkeypatch.setattr(qa, "judge_wave", fake_wave)
|
||||
report = await qa.qa_report("t", llm=True)
|
||||
assert report["unecht"] == ["Wackelkandidat"]
|
||||
|
||||
|
||||
@@ -82,59 +82,6 @@ async def test_hedge_schwelle_skaliert_mit_timeout(monkeypatch):
|
||||
assert calls == ["k1"]
|
||||
|
||||
|
||||
async def test_late_fold_nachzuegler_zaehlt_nach(monkeypatch):
|
||||
"""Quorum 2 kehrt sofort zurück; der dritte Slot wird nicht gekillt, sein Ergebnis
|
||||
geht an `late` (ersetzt den grace-Timer der Finder-Runden)."""
|
||||
import time
|
||||
killed, spaet = [], []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
if key == "k3":
|
||||
await asyncio.sleep(0.2)
|
||||
return (0, "dritter", "")
|
||||
return (0, key, "")
|
||||
|
||||
async def late(val):
|
||||
spaet.append(val)
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
|
||||
slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none",
|
||||
"payload": lambda r: r[1]} for i in (1, 2, 3)]
|
||||
t0 = time.monotonic()
|
||||
res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late)
|
||||
assert time.monotonic() - t0 < 0.15 # kein Warten auf k3
|
||||
assert sorted(res) == ["k1", "k2"]
|
||||
assert "k3" not in killed
|
||||
await asyncio.sleep(0.3)
|
||||
assert spaet == ["dritter"]
|
||||
|
||||
|
||||
async def test_late_fold_invalider_nachzuegler_ignoriert(monkeypatch):
|
||||
"""Nachzügler mit invalidem Payload löst late NICHT aus (best-effort)."""
|
||||
spaet = []
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
if key == "k3":
|
||||
await asyncio.sleep(0.1)
|
||||
return (1, "", "kaputt")
|
||||
return (0, key, "")
|
||||
|
||||
async def late(val):
|
||||
spaet.append(val)
|
||||
|
||||
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
|
||||
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
|
||||
slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none",
|
||||
"payload": lambda r: r[1]} for i in (1, 2, 3)]
|
||||
res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late)
|
||||
assert res is not None
|
||||
await asyncio.sleep(0.25)
|
||||
assert spaet == []
|
||||
|
||||
|
||||
async def test_hedge_zwilling_ersetzt_restart(monkeypatch):
|
||||
"""Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN
|
||||
zusätzlichen Restart — der Zwilling ist der Retry."""
|
||||
|
||||
@@ -67,7 +67,7 @@ async def test_merge_confirmed_duplicate(env, monkeypatch):
|
||||
calls.append(prompt)
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["merges"] == ["Alpha → Alpha Problem"]
|
||||
assert len(calls) == 1 and "Beta" not in calls[0] # nur das llm=ja-Paar zum Judge
|
||||
@@ -90,7 +90,7 @@ async def test_fremd_removed_only_on_nein(env, monkeypatch):
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
monkeypatch.setattr(repair, "source_folder", lambda t: None)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["entfernt"] == ["Fremdling"]
|
||||
@@ -108,7 +108,7 @@ async def test_judge_failure_keeps_everything(env, monkeypatch):
|
||||
async def broken_agent(key, prompt, timeout, **kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", broken_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", broken_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["entfernt"] == []
|
||||
card = await db.kanban_get_card(TOPIC, "inventory", cid)
|
||||
@@ -124,7 +124,7 @@ async def test_hygiene_cleans_title_norm_invariant(env, monkeypatch):
|
||||
async def no_agent(*a, **kw):
|
||||
raise AssertionError("Hygiene braucht keinen Agenten")
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", no_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["hygiene"] == ["**Fetter Titel** → Fetter Titel"]
|
||||
card = await db.kanban_get_card(TOPIC, "inventory", cid)
|
||||
@@ -177,7 +177,7 @@ async def test_sub_dubletten_merge(env, monkeypatch):
|
||||
assert "Gibtsnicht" not in prompt
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["sub_merges"] == ["Verlierer Sub → Gewinner Sub"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
|
||||
@@ -199,7 +199,7 @@ async def test_sub_dubletten_zweitmeinung_nein(env, monkeypatch):
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "nein"}}', ""
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["sub_merges"] == []
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
|
||||
@@ -223,7 +223,7 @@ async def test_sub_dubletten_stichentscheid_faltet(env, monkeypatch):
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
return 0, '{"relevant": {"1": "nein"}}', "" # Zweitmeinung widerspricht
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["sub_merges"] == ["Sub B → Sub A"]
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
|
||||
@@ -245,7 +245,7 @@ async def test_stichentscheid_behalten_persistiert_freispruch(env, monkeypatch):
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "nein"}}', "" # beide Repair-Judges: behalten
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["sub_merges"] == [] and len(res["freigesprochen"]) == 1
|
||||
frei = qa_mod.lade_freispruch(TOPIC)
|
||||
@@ -269,7 +269,7 @@ async def test_fremd_stichentscheid_behalten(env, monkeypatch):
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "ja"}}', "" # beide: belegt/behalten
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", fake_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["entfernt"] == []
|
||||
|
||||
@@ -292,7 +292,7 @@ async def test_waisen_cleanup(env, monkeypatch):
|
||||
async def no_agent(*a, **kw):
|
||||
raise AssertionError("Aufräumen braucht keinen Agenten")
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", no_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["aufgeraeumt"] == 3
|
||||
rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
|
||||
|
||||
@@ -143,8 +143,10 @@ def test_cited_evidence_lines_and_fallback(tmp_path):
|
||||
|
||||
def test_sink_json_writes_only_valid(tmp_path):
|
||||
p = tmp_path / "level-final-c1.json"
|
||||
from pipeline import _enum_map_schema
|
||||
levels = _enum_map_schema("levels", ("beginner", "advanced", "expert"))
|
||||
ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
|
||||
lambda d: blx._levels_schema(d, {1}))
|
||||
lambda d: levels(d, {1}))
|
||||
assert ok == {1: "beginner"}
|
||||
assert json.loads(p.read_text(encoding="utf-8"))["levels"]["1"] == "beginner"
|
||||
bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d)
|
||||
|
||||
@@ -25,9 +25,9 @@ def test_registry_spiegelt_config():
|
||||
|
||||
def test_creator_params_override_wirkt_im_subprozess():
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-c", "import config; print(config.FACTS_CHUNK_SUBS, config.TIMEOUTS['subblock_check'][0])"],
|
||||
[sys.executable, "-c", "import config; print(config.GATE_FIX_MIN, config.TIMEOUTS['subblock_check'][0])"],
|
||||
capture_output=True, text=True, cwd=BACKEND,
|
||||
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"FACTS_CHUNK_SUBS": 6, "TIMEOUT_subblock_check_base": 77}'})
|
||||
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"GATE_FIX_MIN": 6, "TIMEOUT_subblock_check_base": 77}'})
|
||||
assert out.stdout.split() == ["6", "77"], out.stderr
|
||||
|
||||
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
No state, no IO — safe to import anywhere.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now
|
||||
|
||||
|
||||
def parse_facts(raw) -> dict:
|
||||
"""subblocks.facts ist ein JSON-Blob aus LLM-Hand — leer/kaputt/kein dict → {}."""
|
||||
try:
|
||||
d = json.loads(raw) if raw else {}
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return d if isinstance(d, dict) else {}
|
||||
|
||||
|
||||
def _norm_title(s: str) -> str:
|
||||
"""Normalize a title for key comparison.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async def f0(out: str) -> None:
|
||||
ok = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
|
||||
research=True, qa_force=True), timeout=180)
|
||||
from tests.invarianten import pruefe_invarianten
|
||||
from invarianten import pruefe_invarianten
|
||||
fehler = await pruefe_invarianten("f0", files)
|
||||
atomic_write_json(Path(out), {
|
||||
"ok": bool(ok), "invarianten_fehler": fehler, "calls": len(welt.calls),
|
||||
|
||||
Reference in New Issue
Block a user