Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2178c6faf4 | ||
|
|
f9d77a113b | ||
|
|
9a6ab0937b | ||
|
|
224ba1ed4f | ||
|
|
27364d0789 | ||
|
|
f2bc6bcedb | ||
|
|
41f96330fd | ||
|
|
f0c1bdacfa | ||
|
|
f2feb1dbd0 | ||
|
|
1441c01f2c | ||
|
|
9c55b05373 | ||
|
|
72a82ed9dd | ||
|
|
73ac3e532f | ||
|
|
cc27e53b9e | ||
|
|
f4c5116abb | ||
|
|
ed5cf146c3 | ||
|
|
250ea0b764 |
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -47,10 +47,11 @@ stop:
|
||||
logs:
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
remove: stop
|
||||
remove:
|
||||
@read -p "storage/ (DB + alle Nutzdaten) wirklich löschen? [y/N] " a && [ "$$a" = "y" ]
|
||||
@echo "Lösche Datenbank und generierte Dateien..."
|
||||
rm -rf storage/*
|
||||
@echo "Fertig."
|
||||
@echo "Fertig. (Server ggf. separat stoppen: make stop)"
|
||||
|
||||
searxng:
|
||||
docker run -d --name searxng --restart unless-stopped -p 8888:8080 searxng/searxng
|
||||
|
||||
+36
-18
@@ -46,6 +46,16 @@ def active_agents(scope_prefix: str | None = None) -> list[dict]:
|
||||
and (not scope_prefix or k.startswith(scope_prefix))]
|
||||
return sorted(out, key=lambda a: -a["runtime"])
|
||||
|
||||
|
||||
# Board-2-Calls tragen diese Marker im agent_key; alles andere unter blocks-{topic}- ist Board 1.
|
||||
_ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix",
|
||||
"-outline")
|
||||
|
||||
|
||||
def agent_ebene(key: str) -> str:
|
||||
"""Generierungs-Ebene eines Blocks-Agenten (für Board-Anzeige/Accounting): inventory|artefacts."""
|
||||
return "artefacts" if any(m in key for m in _ARTEFAKT_MARKER) else "inventory"
|
||||
|
||||
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
|
||||
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
|
||||
# in the semaphore queue are also stopped immediately on abort instead of still starting.
|
||||
@@ -162,11 +172,12 @@ async def _opencode_slot() -> None:
|
||||
# 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_PER_PROC_KB = 350 * 1024 # commit estimate for a batch agent (opencode/claude, no MCP)
|
||||
_RAM_PER_PROC_FULL_KB = 1250 * 1024 # `full` agent: opencode + 3 MCP servers (~300 MB each)
|
||||
_RAM_COMMIT_WINDOW_S = 10.0 # fresh admissions count as already-spent RAM
|
||||
_RAM_POLL_S = 2.0
|
||||
_opencode_running = 0 # opencode spawns only (stagger path), not claude
|
||||
_opencode_recent_starts: list[float] = [] # monotonic timestamps of admissions
|
||||
_cli_running = 0 # CLI spawns (opencode AND claude) — the deadlock-floor counter
|
||||
_cli_recent_starts: list[tuple[float, int]] = [] # (monotonic ts, est_kb) of recent admissions
|
||||
|
||||
|
||||
def _meminfo() -> tuple[int, int] | None:
|
||||
@@ -181,10 +192,11 @@ def _meminfo() -> tuple[int, int] | None:
|
||||
return int(m["MemAvailable"]), int(m["MemTotal"])
|
||||
|
||||
|
||||
async def _ram_gate(agent_key: str) -> bool:
|
||||
async def _ram_gate(agent_key: str, est_kb: int) -> 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."""
|
||||
Gates ALL CLI spawns (opencode + claude). `est_kb` = this spawn's RAM estimate (a `full`
|
||||
agent drags 3 MCP servers). Check and commit-append happen in the same synchronous block
|
||||
(no await between) — concurrent waiters cannot double-admit on the same free RAM."""
|
||||
if RAM_MIN_FREE_PCT <= 0:
|
||||
return True
|
||||
waited = False
|
||||
@@ -192,19 +204,20 @@ async def _ram_gate(agent_key: str) -> bool:
|
||||
if _scope_cancelled(agent_key):
|
||||
return False
|
||||
mem = _meminfo()
|
||||
if mem is None or _opencode_running < _RAM_GATE_FLOOR:
|
||||
if mem is None or _cli_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:
|
||||
_cli_recent_starts[:] = [(t, kb) for t, kb in _cli_recent_starts
|
||||
if now - t < _RAM_COMMIT_WINDOW_S]
|
||||
committed_kb = sum(kb for _, kb in _cli_recent_starts)
|
||||
if avail_kb - committed_kb >= total_kb * RAM_MIN_FREE_PCT / 100:
|
||||
break
|
||||
if not waited:
|
||||
log.info("agent %s: RAM gate waiting (%.0f%% free)", agent_key, avail_kb * 100 / total_kb)
|
||||
waited = True
|
||||
await asyncio.sleep(_RAM_POLL_S)
|
||||
_opencode_recent_starts.append(time.monotonic())
|
||||
_cli_recent_starts.append((time.monotonic(), est_kb))
|
||||
return True
|
||||
|
||||
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
|
||||
@@ -363,6 +376,8 @@ async def run_agent(
|
||||
if on_event is not None and scope is not None: # batch pipeline only, never fatal
|
||||
try:
|
||||
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
|
||||
if agent_key.startswith("blocks-"): # Ebene fürs Per-Board-Accounting
|
||||
meta["board"] = agent_ebene(agent_key)
|
||||
if err_tail:
|
||||
meta["stderr"] = err_tail
|
||||
if api_tokens: # direct-API path: usage from the response, even on rc!=0
|
||||
@@ -377,7 +392,7 @@ async def run_agent(
|
||||
log.debug("on_event failed", exc_info=True)
|
||||
|
||||
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None) -> tuple[int, str, str]:
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None, est_kb: int = _RAM_PER_PROC_KB) -> tuple[int, str, str]:
|
||||
start = time.monotonic()
|
||||
|
||||
async def spawn():
|
||||
@@ -390,14 +405,14 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
env=env,
|
||||
)
|
||||
|
||||
global _opencode_running
|
||||
global _cli_running
|
||||
if stagger:
|
||||
if not await _ram_gate(agent_key):
|
||||
if not await _ram_gate(agent_key, est_kb):
|
||||
return 1, "", "cancelled" # like the cancelled path in run_agent
|
||||
await _opencode_slot() # gate BEFORE the start slot: an admission wave still gets spaced
|
||||
process = await spawn()
|
||||
if stagger:
|
||||
_opencode_running += 1
|
||||
_cli_running += 1
|
||||
# Collision-safe tracking: identical keys (e.g. same chunk label from parallel cards)
|
||||
# get a ~n suffix — prefix-based kill/cancel still matches, nothing becomes an orphan.
|
||||
track_key = agent_key
|
||||
@@ -446,7 +461,7 @@ 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
|
||||
_cli_running -= 1
|
||||
# Pop only on identity: a slot restart under the same key must not evict
|
||||
# the NEW process from tracking.
|
||||
if _active_processes.get(track_key) is process:
|
||||
@@ -462,7 +477,8 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str,
|
||||
if tools:
|
||||
cmd += ["--allowedTools", tools]
|
||||
cmd += ["--dangerously-skip-permissions"]
|
||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, label=label)
|
||||
# stagger=True: claude-CLI (~310 MB) also passes the RAM gate — previously ungated.
|
||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, stagger=True, label=label)
|
||||
|
||||
|
||||
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
|
||||
@@ -516,7 +532,9 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
||||
# at the mcp-free config copy; only `full` (research/supplement) keeps the servers.
|
||||
env = {**os.environ, "OPENCODE_CONFIG": str(_SLIM_CONFIG)}
|
||||
try:
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env)
|
||||
# `full` keeps the 3 MCP servers (~300 MB each) → charge the gate accordingly.
|
||||
est_kb = _RAM_PER_PROC_FULL_KB if capabilities == "full" else _RAM_PER_PROC_KB
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env, est_kb=est_kb)
|
||||
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
|
||||
finally:
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Auto-Repair-Loop einer Ebene: QA → Befunde beheben, bis 100 % (10.0/10),
|
||||
Stillstand (Repair verbessert die Note nicht mehr) oder max_iter erreicht ist.
|
||||
|
||||
Reine Primitive — keine Kanban-/DB-Kopplung. Der Aufrufer übergibt zwei Coroutinen:
|
||||
`messen()` liefert die aktuelle QA-Note (0–10), `reparieren()` behebt die Befunde.
|
||||
So bleibt der Loop pro Ebene (Inventar/Artefakte/Guide) identisch testbar.
|
||||
"""
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("creator.auto_loop")
|
||||
|
||||
VOLL = 10.0 # 100 % = 10.0/10
|
||||
MAX_ITER = 10 # Sicherheitslimit: mehr Runden ⇒ etwas im System stimmt nicht
|
||||
|
||||
|
||||
async def auto_repair_loop(ebene: str, erst_note: float, reparieren, max_iter: int = MAX_ITER) -> dict:
|
||||
"""Loop QA→Repair für eine Ebene.
|
||||
|
||||
erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (0–10).
|
||||
reparieren() -> (float, bool): behebt die Befunde UND gibt die neu gemessene Note
|
||||
zurück (die Repair-Bausteine messen ohnehin am Ende — keine Doppel-
|
||||
messung) plus „bewegt": ob die Runde etwas getan hat (Fixes, Merges,
|
||||
Freisprüche, Nach-Recherche). Eine bewegte Runde darf die Note auch
|
||||
mal transient senken (neue Blöcke → neue Verdachte) — Stillstand ist
|
||||
erst, wenn NICHTS mehr passiert UND die Note nicht steigt.
|
||||
|
||||
Stopp bei: Note == VOLL (fertig), unbewegt ohne Verbesserung (stillstand),
|
||||
oder max_iter Runden (limit — begrenzt „bewegt ohne Notengewinn"-Livelocks).
|
||||
Returns {ebene, note, runden, grund} mit grund ∈ {"fertig","stillstand","limit"}.
|
||||
"""
|
||||
if erst_note >= VOLL:
|
||||
return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"}
|
||||
note = erst_note
|
||||
for runde in range(1, max_iter + 1):
|
||||
neu, bewegt = await reparieren()
|
||||
log.info("[%s] Auto-Loop Runde %d: %.1f → %.1f (%s)",
|
||||
ebene, runde, note, neu, "bewegt" if bewegt else "unbewegt")
|
||||
if neu >= VOLL:
|
||||
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"}
|
||||
if not bewegt and neu <= note: # nichts getan UND keine Verbesserung ⇒ kein Repair-Pfad
|
||||
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "stillstand"}
|
||||
note = max(note, neu)
|
||||
return {"ebene": ebene, "note": note, "runden": max_iter, "grund": "limit"}
|
||||
+65
-93
@@ -13,7 +13,6 @@ relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashca
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
import database as db
|
||||
@@ -221,104 +220,60 @@ async def _generate_block(ctx: GenContext, files: dict, title: str, description:
|
||||
instructions: str = "", ns: str = "", lbl: str = "",
|
||||
sources: list[str] | None = None,
|
||||
seeds: list[str] | None = None, melde=None) -> dict | None:
|
||||
"""GEN_PANEL unabhängige Generatoren liefern je Subs+Facts+Level/Relevanz in EINEM Call;
|
||||
Konsens im Code (Variant-Cluster über beide Ausgaben, ≥2 unabhängige Generatoren =
|
||||
consensus). Einzelnennungen und ungedeckte Seeds werden „unsicher" — der Prüfer
|
||||
entscheidet mit Material (ersetzt Sättigungsrunden + Clarify-Panel). Degraded: liefert
|
||||
nur EIN Generator, wird alles unsicher. → {raw, facts, unsicher, votes} | None."""
|
||||
topic, provider = ctx.topic, ctx.provider
|
||||
"""Bottom-up-Anreicherung: die Subs stehen FEST — Board 1 hat die Atome geclustert und
|
||||
als Subs persistiert. EIN Call liefert pro vorgegebenem Sub die Lern-Facts + Level +
|
||||
Relevanz aus dem Material. Er entdeckt und entfernt KEINE Subs (Discovery/Konsens/Seeds
|
||||
entfallen; die Vollständigkeit kommt aus Board 1). → {raw, facts, unsicher, votes} | None."""
|
||||
topic = ctx.topic
|
||||
work_dir = files["arbeit"]
|
||||
bnorm = _norm_title(title)
|
||||
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")
|
||||
# feste Subs aus Board 1 (Reihenfolge erhalten, dublettenfrei)
|
||||
subs: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for r in await db.list_subblocks(topic, bnorm):
|
||||
st = str(r.get("sub_title") or "").strip()
|
||||
sn = _norm_title(st)
|
||||
if st and sn and sn not in seen and r.get("status") in ("consensus", "candidate"):
|
||||
seen.add(sn)
|
||||
subs.append(st)
|
||||
if not subs:
|
||||
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
|
||||
source, caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + subs)
|
||||
if melde:
|
||||
melde("Generate")
|
||||
h = _h8(title, description, "gen")
|
||||
paths = [work_dir / f"gen-{h}-g{g}.json" for g in range(1, GEN_PANEL + 1)]
|
||||
prompt = _prompt("Subblock-Generate", topic=topic,
|
||||
melde("Anreichern")
|
||||
h = _h8(title, description, "enrich")
|
||||
pfad = work_dir / f"enrich-{h}.json"
|
||||
sub_liste = "\n".join(f"{i}. {t}" for i, t in enumerate(subs, 1))
|
||||
prompt = _prompt("Subblock-Anreichern", topic=topic,
|
||||
block=f"{title} — {description}" if description else title,
|
||||
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)
|
||||
subs=sub_liste, source=source, extra=_extra(instructions))
|
||||
if _gen_schema(_json_file(pfad)) is None:
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"{lbl}Anreichern", key=f"blocks-{topic}-{ns}sb-enrich-{h}",
|
||||
prompt=prompt, role="quick", capabilities=caps,
|
||||
payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema),
|
||||
timeout=_timeout("generate", len(subs)))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Anreichern {title} ohne Ergebnis — Facts bleiben leer, Prüfer misst")
|
||||
if ctx.is_cancelled():
|
||||
return None
|
||||
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] = []
|
||||
out = _gen_schema(_json_file(pfad)) or []
|
||||
by_norm = {_norm_title(e["title"]): e for e in out}
|
||||
facts: dict[str, dict] = {}
|
||||
votes: dict[str, dict] = {}
|
||||
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}
|
||||
for st in subs:
|
||||
sn = _norm_title(st)
|
||||
e = by_norm.get(sn)
|
||||
if e:
|
||||
fk = _fk_of(e)
|
||||
votes[sn] = {"level": [e["level"]] if e.get("level") else [],
|
||||
"relevance": [e["relevance"]] if e.get("relevance") else []}
|
||||
else: # der Call ließ diesen Sub aus — leere Facts, der Prüfer/QA sieht die Lücke
|
||||
fk = {k: ([] if k in ("key_points", "cited_facts") else "") for k in _FACTS_FIELDS}
|
||||
votes[sn] = {"level": [], "relevance": []}
|
||||
facts[sn] = fk
|
||||
await db.put_subblock(topic, bnorm, sn, title, st, status="consensus")
|
||||
return {"raw": {title: subs}, "facts": {title: facts}, "unsicher": [], "votes": votes}
|
||||
|
||||
|
||||
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
|
||||
@@ -337,11 +292,14 @@ def _default_vote(stimmen: list[str], default: str) -> str:
|
||||
|
||||
async def _verify_block(ctx: GenContext, files: dict, title: str, gen: dict, q: dict,
|
||||
instructions: str = "", ns: str = "", lbl: str = "",
|
||||
sources: list[str] | None = None, melde=None) -> dict | None:
|
||||
sources: list[str] | None = None, melde=None,
|
||||
keep_all: bool = False) -> dict | None:
|
||||
"""VERIFY_PANEL unabhängige Prüfer auditieren den Block in EINEM Call (MECE-Faltung,
|
||||
Fremd, Lücken, Unsicher-Übernahme, Facts-Korrektheit, Level/Relevanz). Auswertung mit
|
||||
Schnittmengen-Semantik pro Befundklasse (Faltung/Fremd/Übernahme einstimmig, Discard
|
||||
2/2, Korrektur ≥1 Stimme); Fix-Tail ist EIN Call für Korrekturen + belegte Lücken.
|
||||
keep_all=True (Bottom-up): kein Atom wird entfernt/gefaltet/als neu erfunden — nur
|
||||
Facts-Korrektur + Level/Relevanz bleiben (Board 1 hat schon MECE geclustert).
|
||||
→ {raw, facts, sidecar} | None (nur bei Cancel)."""
|
||||
topic = ctx.topic
|
||||
work_dir = files["arbeit"]
|
||||
@@ -428,6 +386,18 @@ async def _verify_block(ctx: GenContext, files: dict, title: str, gen: dict, q:
|
||||
|
||||
if einstimmig:
|
||||
v1, v2 = verdicts[0], verdicts[1]
|
||||
if keep_all:
|
||||
# Bottom-up: jedes Board-1-Atom bleibt. Destruktive/erzeugende Befunde
|
||||
# neutralisieren — die Loops unten laufen dann leer. Nur Facts-Korrektur
|
||||
# (ohne discard) + Level/Relevanz überleben.
|
||||
for v in (v1, v2):
|
||||
v["fremd"] = set()
|
||||
v["gruppen"] = []
|
||||
v["kataloge"] = []
|
||||
v["uebernehmen"] = {}
|
||||
v["luecken"] = []
|
||||
# discard abschalten (kein Atom entfernen), aber den Korrektur-Hinweis behalten
|
||||
v["facts_probleme"] = [{**p, "discard": False} for p in v["facts_probleme"]]
|
||||
# 1. Fremd (einstimmig): fürs THEMA fremde Aussagen → discarded
|
||||
for k in sorted(v1["fremd"] & v2["fremd"]):
|
||||
t = _titel(k)
|
||||
@@ -538,7 +508,9 @@ async def _verify_block(ctx: GenContext, files: dict, title: str, gen: dict, q:
|
||||
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
|
||||
elif not keep_all: # Lücken-Fund: neuer consensus-Sub
|
||||
# (Bottom-up unterdrückt das: die Subs stehen fest, der Fix-Call darf keine
|
||||
# neuen einschleusen — sonst wächst die Sub-Zahl über die Board-1-Atome hinaus)
|
||||
bfacts[sn] = _fk_of(e)
|
||||
keep.append(e["title"])
|
||||
sidecar_subs.append({"title": e["title"],
|
||||
|
||||
+177
-178
@@ -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, 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 crawl import load_pages
|
||||
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": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -380,27 +370,65 @@ def _pick_conversion(md: str | None, plain: str | None) -> tuple[str, str] | Non
|
||||
return plain, "pdftotext"
|
||||
|
||||
|
||||
# Small-Caps-/Math-Italic-Artefakte der PDF-Extraktion: LaTeX-\textsc/Kerning liest sich als
|
||||
# Binnen-Leerzeichen („H ITTING S ET", „N P") und erzeugt in der QA Phantom-Konzepte, die kein
|
||||
# Block je ankern kann. Regel A: Einzelgroßbuchstabe + GROSSLAUF(≥2) mergen — außer der Lauf
|
||||
# wird klein fortgesetzt („L NP-vollständig": L ist Variable, NP gehört zum Kompositum).
|
||||
_PDF_CAPS_SPLIT = re.compile(r"(?<![A-Za-zÄÖÜäöüß])([A-ZÄÖÜ]) ([A-ZÄÖÜ]{2,})(?!-?[a-zäöüß])")
|
||||
# Regel B: Einzelbuchstaben-PAAR („N P") — nur mergen, wenn das Ergebnis im selben Dokument
|
||||
# mehrfach ungespalten vorkommt (Frequenz-Beleg statt Domänenliste: „NP" ja, „C Y" nein).
|
||||
_PDF_LETTER_PAIR = re.compile(r"(?<![A-Za-zÄÖÜäöüß])([A-ZÄÖÜ]) ([A-ZÄÖÜ])(?![A-Za-zÄÖÜäöüß-])")
|
||||
# Regel C: GROSSLAUF + Leerzeichen VOR einem Bindestrich-Kompositum („NP -vollständig",
|
||||
# Makro-Spacing) → Leerzeichen weg. Einseitig: „X - y" (echter Gedankenstrich, beidseitig
|
||||
# Leerzeichen) bleibt unberührt.
|
||||
_PDF_HYPHEN_SPACE = re.compile(r"([A-ZÄÖÜ]{2,}) (-[a-zäöüß])")
|
||||
_PDF_NORM_VERSION = 2 # bump → nächster Lauf re-konvertiert alle PDFs (Marker .pdf-txt-norm)
|
||||
|
||||
|
||||
def _entzerre_pdf_woerter(text: str) -> str:
|
||||
"""Gespaltene Wörter aus der PDF-Konvertierung zusammenfügen (nur Merges, kein Umbau).
|
||||
Bewusste Restlücken: Zeilenumbruch-Splits und Mehrfach-Splits ohne Großlauf — dafür
|
||||
sind Lücken-Research + Freispruch das Netz."""
|
||||
prev = None
|
||||
while prev != text: # Ketten: „V ERTEX C OVER" braucht zwei Durchgänge pro Segment
|
||||
prev = text
|
||||
text = _PDF_CAPS_SPLIT.sub(r"\1\2", text)
|
||||
|
||||
def _belegt(m: re.Match) -> str:
|
||||
merged = m.group(1) + m.group(2)
|
||||
n = len(re.findall(rf"(?<![A-Za-zÄÖÜäöüß]){merged}(?![A-Za-zÄÖÜäöüß])", text))
|
||||
return merged if n >= 3 else m.group(0)
|
||||
|
||||
text = _PDF_LETTER_PAIR.sub(_belegt, text)
|
||||
return _PDF_HYPHEN_SPACE.sub(r"\1\2", text)
|
||||
|
||||
|
||||
def _convert_pdfs(project: Path) -> None:
|
||||
"""Convert PDFs in the project to .txt — agents read text instead of page images.
|
||||
|
||||
Called before every project generation; converts only if the .txt is missing or
|
||||
older than the PDF. Both converters run; the fidelity guard picks the better result
|
||||
per file. Neither available → hard error instead of an unreliable direct-read mode
|
||||
(MiniMax image limit, vision cost)."""
|
||||
(MiniMax image limit, vision cost). Ein Versions-Marker (.pdf-txt-norm) erzwingt nach
|
||||
Änderungen an der Wort-Entzerrung einmalig die Re-Konvertierung trotz mtime-Cache."""
|
||||
pdfs = list(project.rglob("*.pdf"))
|
||||
if not pdfs:
|
||||
return
|
||||
marker = project / ".pdf-txt-norm"
|
||||
aktuell = marker.exists() and marker.read_text(encoding="utf-8").strip() == str(_PDF_NORM_VERSION)
|
||||
for pdf in pdfs:
|
||||
txt = pdf.with_suffix(".txt")
|
||||
if txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
|
||||
if aktuell and txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
|
||||
continue
|
||||
picked = _pick_conversion(_pdf_markdown(pdf), _pdf_plaintext(pdf))
|
||||
if picked is None:
|
||||
raise RuntimeError(f"PDF conversion failed ({pdf.name}): weder pymupdf4llm noch "
|
||||
"pdftotext verfügbar/erfolgreich (pip install pymupdf4llm oder poppler-utils)")
|
||||
text, tool = picked
|
||||
txt.write_text(text, encoding="utf-8")
|
||||
txt.write_text(_entzerre_pdf_woerter(text), encoding="utf-8")
|
||||
_log(project.name, f"PDF konvertiert ({tool}): {pdf.name} → {txt.name}")
|
||||
if not aktuell:
|
||||
marker.write_text(str(_PDF_NORM_VERSION), encoding="utf-8")
|
||||
|
||||
|
||||
_SOURCE_TEMPLATE = {"projekt": "Blocks-Source-Projekt", "uni": "Blocks-Source-Uni", "link": "Blocks-Source-Link"}
|
||||
@@ -863,117 +891,10 @@ def _crawl_index(folder) -> dict[str, str]:
|
||||
return idx
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _triage_rules(folder, pages: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Deterministic content/noise filter (config.CRAWL_*). Substring match (lowercase) against
|
||||
URL + filename. Order: keep > noise > min_chars > keep. → (content, noise)."""
|
||||
folder = Path(folder)
|
||||
content, noise = [], []
|
||||
for fn in pages:
|
||||
lines = _read(folder / fn).splitlines()
|
||||
url = lines[0][len("QUELLE:"):].strip() if lines and lines[0].startswith("QUELLE:") else ""
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
hay = f"{url}\n{fn}".lower()
|
||||
if any(p in hay for p in CRAWL_KEEP_PATTERNS):
|
||||
content.append(fn)
|
||||
elif any(p in hay for p in CRAWL_NOISE_PATTERNS):
|
||||
noise.append(fn)
|
||||
elif len(body) < CRAWL_MIN_CHARS:
|
||||
noise.append(fn)
|
||||
else:
|
||||
content.append(fn) # default: keep — everything with content stays
|
||||
return content, noise
|
||||
|
||||
|
||||
def _page_snippet(folder, fn: str) -> tuple[str, str]:
|
||||
"""(url, snippet) of a crawl page for the relevance gate. url from the QUELLE: line;
|
||||
snippet = body excerpt (navigation boilerplate is up front — the prompt ignores it).
|
||||
The URL is the primary signal (meaningful slug), the snippet only supports it."""
|
||||
lines = _read(Path(folder) / fn).splitlines()
|
||||
url = lines[0][len("QUELLE:"):].strip() if lines and lines[0].startswith("QUELLE:") else ""
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
snippet = " ".join(body.split())[:QUELLE_RELEVANZ_SNIPPET]
|
||||
return (url or fn), snippet
|
||||
|
||||
|
||||
async def _relevance_triage(ctx: GenContext, set_p, files: dict, folder, content: list[str], spec: str, instructions: str) -> tuple[list[str], list[str]]:
|
||||
"""LLM topic gate after the rule filter: each content page ja/nein against the spec.
|
||||
Off-topic (different field) → out. Pattern like `_relevance_block`: small packages, 3 raters
|
||||
(`fast`), 2-of-3 consensus. CONSERVATIVE: drop only on a clear "nein" majority; dispute/gap/
|
||||
race error → keep. SAFETY: if the gate would drop ≥80 % (or all), everything stays
|
||||
(a spec mismatch/bug must not empty the source). → (kept, out) as filenames."""
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
work_dir = files["arbeit"]
|
||||
pages = sorted(content)
|
||||
if not pages:
|
||||
return content, []
|
||||
items = [_page_snippet(folder, fn) for fn in pages] # index aligns with `pages`
|
||||
chunks = _chunk_nums(list(range(len(pages))), _n_chunks(len(pages), QUELLE_RELEVANZ_CHUNK))
|
||||
n = len(chunks)
|
||||
|
||||
def rater_paths(c):
|
||||
return [work_dir / f"source-relevance-c{c}-{i}.json" for i in (1, 2, 3)]
|
||||
|
||||
def lset(idxs):
|
||||
return set(range(1, len(idxs) + 1))
|
||||
|
||||
async def _rate(c, idxs):
|
||||
local_set = lset(idxs)
|
||||
paths = rater_paths(c)
|
||||
existing = sum(1 for p in paths if _yesno_schema(_json_file(p), local_set))
|
||||
if existing >= 2:
|
||||
return True
|
||||
enum_lines = []
|
||||
for k, j in enumerate(idxs, 1):
|
||||
url, snip = items[j]
|
||||
enum_lines.append(f"{k}. {url}")
|
||||
if snip:
|
||||
enum_lines.append(f" {snip}")
|
||||
enum = "\n".join(enum_lines)
|
||||
pending = [(i, p) for i, p in enumerate(paths, 1) if not _yesno_schema(_json_file(p), local_set)]
|
||||
slots = [{
|
||||
"key": f"blocks-{topic}-source-relevance-c{c}-{i}",
|
||||
"prompt": _prompt("Source-Relevance", topic=topic, spec=spec, pages=enum, out_path=p, extra=_extra(instructions)),
|
||||
"role": "fast", "capabilities": "files",
|
||||
"payload": (lambda result, p=p, ids=local_set: _yesno_schema(_json_file(p), ids)),
|
||||
} for i, p in pending]
|
||||
new = await _race(topic, f"Relevance triage package {c}", slots, 2 - existing, _timeout("relevance", len(idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
return not is_cancelled() and new is not None
|
||||
|
||||
_qidx = _step_idx(topic, "Source prep") # gate runs in the source step (no own step)
|
||||
set_p(f"Check relevance against spec ({n} packages)…", step=_qidx)
|
||||
async def _report_triage(d, t):
|
||||
set_p(f"Check relevance against spec {d}/{t}…", step=_qidx)
|
||||
await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], n, _report_triage)
|
||||
if is_cancelled():
|
||||
return content, [] # cancel → drop nothing (caller aborts)
|
||||
|
||||
# Vote per page: only a clear "nein" majority (≥2 and more than "ja") throws it out.
|
||||
dropped: list[str] = []
|
||||
for c, idxs in enumerate(chunks, 1):
|
||||
local_set = lset(idxs)
|
||||
rater = [d for p in rater_paths(c) if (d := _yesno_schema(_json_file(p), local_set))]
|
||||
for k in range(1, len(idxs) + 1):
|
||||
vote_list = [d[k] for d in rater if k in d]
|
||||
nein, ja = vote_list.count("nein"), vote_list.count("ja")
|
||||
if nein >= 2 and nein > ja:
|
||||
dropped.append(pages[idxs[k - 1]])
|
||||
|
||||
if dropped and len(dropped) >= max(1, int(len(pages) * 0.8)):
|
||||
_log(topic, f"Relevance triage: would drop {len(dropped)}/{len(pages)} — discarded (spec mismatch?), keeping all")
|
||||
return content, []
|
||||
dropped_set = set(dropped)
|
||||
keepers = [fn for fn in pages if fn not in dropped_set]
|
||||
return keepers, dropped
|
||||
|
||||
|
||||
async def _prepare_source(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str) -> bool:
|
||||
"""Step "Source prep": crawl (link) + PDF convert + content/noise triage.
|
||||
Persists the triage in the coverage table (content). → True (ok) / False (cancel/error).
|
||||
thema: nothing. projekt/uni: only PDFs (curated folder, no triage)."""
|
||||
"""Step "Source prep": load link pages + PDF convert. → True (ok) / False (cancel/error).
|
||||
link: load each user-supplied URL (one page, no following); ALL pages count as content
|
||||
(curated — no triage). thema: nothing. projekt/uni: only PDFs (curated folder)."""
|
||||
topic, is_cancelled = ctx.topic, ctx.is_cancelled
|
||||
if not folder:
|
||||
return True # thema → no source to prepare
|
||||
@@ -983,28 +904,21 @@ async def _prepare_source(ctx: GenContext, set_p, files: dict, q: dict, folder,
|
||||
if await db.get_step_status(topic, "Source prep") == "done":
|
||||
return True
|
||||
if not _crawl_done(topic):
|
||||
set_p("Loading source (crawl)…", step=_step_idx(topic, "Source prep"))
|
||||
n = await asyncio.to_thread(crawl, q["location"], folder, cancelled=is_cancelled)
|
||||
urls = [ln.strip() for ln in q["location"].splitlines() if ln.strip()]
|
||||
set_p("Loading pages…", step=_step_idx(topic, "Source prep"))
|
||||
n = await asyncio.to_thread(load_pages, urls, folder, cancelled=is_cancelled)
|
||||
if is_cancelled():
|
||||
return False
|
||||
if not n:
|
||||
_blocks_errors[topic] = "Crawl yielded no content — check link/domain"
|
||||
_blocks_errors[topic] = "Links yielded no content — check the URLs"
|
||||
return False
|
||||
await asyncio.to_thread(_convert_pdfs, folder)
|
||||
pages = sorted(set(_crawl_index(folder).values()))
|
||||
if pages:
|
||||
set_p("Triaging pages…", step=_step_idx(topic, "Source prep"))
|
||||
# Curated links: the user picked them — keep every page, no rule/LLM triage.
|
||||
await db.delete_coverage(topic)
|
||||
content, noise = _triage_rules(folder, pages) # deterministic rule filter
|
||||
if q.get("spec") and content: # topic gate: separates the field (rules can't)
|
||||
content, dropped = await _relevance_triage(ctx, set_p, files, folder, content, q["spec"], instructions)
|
||||
if is_cancelled():
|
||||
return False
|
||||
if dropped:
|
||||
noise = sorted(set(noise) | set(dropped))
|
||||
_log(topic, f"LLM relevance: {len(dropped)} pages off-topic → noise")
|
||||
await db.mark_content(topic, sorted(content), sorted(noise))
|
||||
_log(topic, f"Triage: {len(content)} content / {len(noise)} noise of {len(pages)} (rules + LLM gate)")
|
||||
await db.mark_content(topic, pages, [])
|
||||
_log(topic, f"Links: {len(pages)} pages as content (curated, no triage)")
|
||||
await db.set_step_status(topic, "Source prep", "done")
|
||||
return True
|
||||
|
||||
@@ -1031,17 +945,45 @@ def _aspect_marker(title: str) -> int:
|
||||
|
||||
_REFERENCE_RE = re.compile(r'^(Satz|Lemma|Korollar|Bemerkung|Definition)\s*[\d.]+\s*(\([a-z]\)|[a-z])?\s*$', re.I)
|
||||
|
||||
# Catalogue scaffolding around a concept name: a leading label+number ("Satz 7.13", "Satz 7.6:")
|
||||
# or a trailing "(Definition 6.19)". _reference_strip peels it off and returns the concept remainder;
|
||||
# _is_reference is then just "nothing survives the strip". Generic — no author/domain whitelist. The
|
||||
# remainder feeds the rename step (a card must be titled by its concept, not its catalogue number).
|
||||
_REF_WORDS = (r'(?:Satz|Lemma|Korollar|Corollary|Bemerkung|Beobachtung|Definition|Def'
|
||||
r'|Theorem|Proposition|Folgerung|Kapitel|Abschnitt)')
|
||||
_REF_PREFIX_RE = re.compile(rf'^\s*{_REF_WORDS}\s*\d+(?:\.\d+)*[a-z]?\s*[:.\-–—]?\s*', re.I)
|
||||
_REF_SUFFIX_RE = re.compile(rf'\s*[\(\[]\s*{_REF_WORDS}\s*\d+(?:\.\d+)*[a-z]?\s*[\)\]]\s*$', re.I)
|
||||
_WHOLE_PAREN_RE = re.compile(r'^\s*[\(\[]\s*(.{2,}?)\s*[\)\]]\s*$')
|
||||
|
||||
|
||||
def _reference_strip(title: str) -> str:
|
||||
"""Concept remainder of a catalogue-referenced title, scaffolding removed. '' for a pure reference
|
||||
without a concept ('Bemerkung 7.22' → ''); the concept otherwise ('Satz 7.13 (Christofides)' →
|
||||
'Christofides', 'Satz 7.6: Kriterium für X' → 'Kriterium für X', 'N P … (Definition 6.19)' →
|
||||
'N P …'). A title without any catalogue reference comes back unchanged."""
|
||||
t = (title or "").strip()
|
||||
t = _REF_SUFFIX_RE.sub("", t).strip()
|
||||
m = _REF_PREFIX_RE.match(t)
|
||||
if m:
|
||||
t = t[m.end():].strip()
|
||||
if (w := _WHOLE_PAREN_RE.match(t)): # leading label left a lone '(Christofides)'
|
||||
t = w.group(1).strip()
|
||||
else:
|
||||
# 'Satz 7.12 (Lawler 1976): Konzept' — die Zuschreibungs-Klammer vor dem ':'
|
||||
# gehört zum Katalog-Gerüst. Nur MIT ':' peelen — '(a1=1)-SubsetSum' bleibt.
|
||||
t = re.sub(r'^[\(\[][^()\[\]]{2,40}[\)\]]\s*:\s*(?=\S)', '', t).strip() or t
|
||||
return t
|
||||
|
||||
|
||||
def _is_reference(title: str) -> bool:
|
||||
"""True for pure reference/placeholder titles WITHOUT meaningful content: "Satz 7.18", "Lemma 6.2",
|
||||
"Korollar 6.18" (number without a name) as well as marked spots "Bedingung (**)". NOT "Satz 6.24:
|
||||
Cook/Levin" (has a name) and NOT short technical symbols like "P⊆NP"/"Σ*" (real concepts)."""
|
||||
"""True for a pure reference/placeholder title WITHOUT concept content: "Satz 7.18", "Lemma 6.2",
|
||||
"Korollar 6.18" (number, no name) and marked spots "Bedingung (**)". False once a concept survives
|
||||
the catalogue strip ("Satz 7.13 (Christofides)", "Satz 6.24: Cook/Levin") and for short technical
|
||||
symbols like "P⊆NP"/"Σ*" (real concepts, no reference scaffolding)."""
|
||||
t = title.strip()
|
||||
if _REFERENCE_RE.match(t):
|
||||
return True
|
||||
if re.search(r'\(\*+\)', t): # marked spot "(**)" / "(*)"
|
||||
return True
|
||||
return False
|
||||
return bool(_REFERENCE_RE.match(t)) or not _reference_strip(t)
|
||||
|
||||
|
||||
def _canonical(candidates: list[dict], idxs: list[int], seen_norm: set[str]) -> dict:
|
||||
@@ -1128,6 +1070,15 @@ _CANON_STOP = re.compile(
|
||||
r'von|of|für|for|und|and|zum|zur|im)\b', re.I)
|
||||
|
||||
|
||||
# German compounds are head-final: "Cliquenproblem" = stem + optional Fuge + head noun "problem".
|
||||
# The standalone wrapper "problem" is already in _CANON_STOP, but a GLUED head hides the stem from ER
|
||||
# blocking ("Cliquenproblem" would never share a key with "Clique"). Strip the glued "problem" head so
|
||||
# the stem surfaces — a generic suffix rule, not a term list. The Fuge is only a consonant n/s
|
||||
# ("Cliquen-", "Entscheidungs-"): matching a vowel "e" would eat the stem's own "e" ("clique" → "cliqu").
|
||||
# The ≥3-char stem lookbehind keeps a card literally titled "Problem"/"Probleme" (→ _CANON_STOP) intact.
|
||||
_CANON_GLUED_HEAD = re.compile(r'(?<=\w{3})[ns]?problem(?:en|e|s)?\b', re.I)
|
||||
|
||||
|
||||
# Catalogue references ("Definition 6.19", "Satz 7.8") are scaffolding INCLUDING their number —
|
||||
# stripped as a phrase, so the digits don't pollute the key. Variant digits ("3-SAT") have no
|
||||
# scaffolding word in front and survive.
|
||||
@@ -1162,6 +1113,7 @@ def _canonical_key(title: str) -> str:
|
||||
s = _CANON_CATALOGUE.sub(' ', s)
|
||||
s = re.sub(r'≟|\bversus\b|\bvs\.?\b|=', ' opeq ', s) # equality / "vs" → one token
|
||||
s = re.sub(r'≤|⪯|→|⇒|⟹|\breduces?\s+to\b|\breduziert\b', ' opred ', s) # reduction → one token
|
||||
s = _CANON_GLUED_HEAD.sub(' ', s) # glued "…problem" head → stem
|
||||
s = _CANON_STOP.sub(' ', s)
|
||||
s = re.sub(r'[^\w ]', ' ', s) # drop punctuation/symbols
|
||||
return " ".join(sorted(t for t in s.split() if t))
|
||||
@@ -1202,6 +1154,15 @@ def _relation_conflict(title_a: str, title_b: str) -> bool:
|
||||
return a is not None and b is not None and a != b
|
||||
|
||||
|
||||
def _direction_conflict(title_a: str, title_b: str) -> bool:
|
||||
"""Nur der DETERMINISTISCH sichere Teil des Relation-Guards: dieselben Operanden in
|
||||
GETAUSCHTER Richtung („A ≤ B" vs „B ≤ A") — immer eine andere Reduktion. Ein Operand-Unterschied
|
||||
(z. B. „CLIQUE" vs „k-CLIQUE" — Synonym? oder echt verschieden?) ist eine Bedeutungsfrage und
|
||||
bleibt dem (belegten) Judge überlassen, statt per String-Vergleich blind blockiert zu werden."""
|
||||
a, b = _relation_operands(title_a), _relation_operands(title_b)
|
||||
return a is not None and b is not None and a != b and set(a) == set(b)
|
||||
|
||||
|
||||
|
||||
|
||||
def _filter_schema(data) -> dict[int, int] | None:
|
||||
@@ -1341,16 +1302,21 @@ _REDUCTION_ONLY_OP = re.compile(r'[≤⪯]|→|⇒|⟹|->') # genuine reduction
|
||||
|
||||
def _is_reduction_statement(title: str) -> bool:
|
||||
"""True if the title is a reduction between two NAMED problems (both sides carry ≥3 letters and
|
||||
neither is a pure bound like "O(m)"). Rejects unary "X ∈ NP" and proof-size "|A| = O(m)"."""
|
||||
neither is a pure bound like "O(m)"). Rejects unary "X ∈ NP", proof-size "|A| = O(m)", and a
|
||||
construction/assignment suffix after the target ("3-SAT ≤ K-COLOR: G=(V,E) Konstruktion" is a
|
||||
construction fragment, not a standalone statement — a bare target carries no ':' or '=')."""
|
||||
t = _REL_STRIP.sub('', title, count=1)
|
||||
m = _REDUCTION_ONLY_OP.search(t)
|
||||
if not m:
|
||||
return False
|
||||
right = t[m.end():]
|
||||
if ':' in right or '=' in right: # construction/assignment detail after the target → fragment
|
||||
return False
|
||||
|
||||
def _named(s: str) -> bool:
|
||||
return len(re.findall(r'[a-zäöüß]', s, re.I)) >= 3 and not re.match(r'\s*[Oo]\s*\(', s)
|
||||
|
||||
return _named(t[:m.start()]) and _named(t[m.end():])
|
||||
return _named(t[:m.start()]) and _named(right)
|
||||
|
||||
|
||||
def _is_named_statement(title: str, desc: str = "") -> bool:
|
||||
@@ -1426,28 +1392,6 @@ def _completion_schema(data, n_umbrellas: int, ids: set[int]):
|
||||
return out
|
||||
|
||||
|
||||
# Deterministic backstop to the grouping judge's TEST 1 (type gate): an umbrella may bundle ONLY
|
||||
# constituent sub-definitions of ONE definition. If a member title carries a standalone-unit signal
|
||||
# (a named algorithm / problem / reduction / theorem), the umbrella is dissolved — those stay their own
|
||||
# blocks. Kept narrow so real definition-parts (Konfiguration, Übergangsfunktion δ, Literale, Makespan,
|
||||
# m Maschinen) never match; checked against the aak over-merge (member „Greedy-Algorithmus GA" hits).
|
||||
# Suffix-anchored head nouns (German compounds are head-final: „Approximations+algorithmus" has NO word
|
||||
# boundary before „algorithmus", so \bAlgorithmus\b misses it → the MAX-SAT over-merge). \w* absorbs the
|
||||
# modifier; the head noun stays the discriminator. FP-safe: no real TM/KNF definition-part ends in these
|
||||
# heads (Berechnung is deliberately NOT a head → „Akzeptierende Berechnung" stays a valid member).
|
||||
_GROUP_STANDALONE = re.compile(
|
||||
r'\w*algorithm(?:us|en)\b|\w*problem(?:e|s|en)?\b|\w*reduktion(?:en)?\b|\bscheduling\b|[≤⪯]'
|
||||
r'|^\s*(?:Satz|Lemma|Korollar|Theorem|Bemerkung|Beobachtung)\s*\d'
|
||||
# atomicity: a named COMPLEXITY CLASS / a "…-Vollständigkeit(completeness)" / a "…Transformation" is a
|
||||
# self-contained concept (learning-object / atomic-KC), never a sub-definition — a bundle of ≥1 such
|
||||
# member is siblings, not one model → dissolve (catches the P/NP/NP-Vollständigkeit over-merge that NO
|
||||
# cosine floor separates). Head-final compounds (\w*klasse absorbs "Komplexitäts+klasse"); FP-safe —
|
||||
# no real TM/KNF/TSP/Scheduling definition-part carries these heads.
|
||||
r'|\w*vollständigkeit\b|\w*completeness\b|\w*transformation(?:en)?\b|\w*klasse[nr]?\b', re.I)
|
||||
|
||||
|
||||
|
||||
|
||||
# --- Outline (blocks artifact: chapter structure, only read by the guide) ---
|
||||
|
||||
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int):
|
||||
@@ -1689,11 +1633,60 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
|
||||
|
||||
|
||||
|
||||
async def _guide_ebene(topic: str, instructions: str, provider: str, is_cancelled, set_p) -> None:
|
||||
"""Guide-Ebene der Auto-Sequenz: generieren → QA → „Befunde beheben"-Loop bis
|
||||
100 %/Stillstand/10×. Fail-open: Fehler überspringen den Guide, blockieren nichts."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
import guide, guide_qa, guide_board
|
||||
from auto_loop import auto_repair_loop
|
||||
fmt = "Guide"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
guide_id = str(uuid.uuid4())
|
||||
await db.create_guide({"id": guide_id, "topic": topic, "format": fmt,
|
||||
"instructions": instructions, "status": "queued",
|
||||
"progress": None, "created_at": now, "updated_at": now})
|
||||
set_p("Guide wird generiert…")
|
||||
await guide.generate_guide(guide_id, topic, fmt, instructions, provider)
|
||||
if is_cancelled():
|
||||
return
|
||||
|
||||
async def _messen():
|
||||
rep = await guide_qa.guide_qa_report(topic, llm=True)
|
||||
return float(rep["note_guide"]) if rep else 10.0
|
||||
|
||||
erst = await _messen()
|
||||
|
||||
async def _reparieren():
|
||||
set_p("Befunde beheben (Guide)…")
|
||||
betroffen = await guide_board.repair_karten(topic, fmt) # Karten → pruefer/fix
|
||||
await guide.generate_guide(topic=topic, format_name=fmt, guide_id=guide_id,
|
||||
instructions=instructions, provider=provider) # resumt offene Karten
|
||||
return await _messen(), len(betroffen) > 0
|
||||
|
||||
res = await auto_repair_loop("Guide", erst, _reparieren)
|
||||
if res["grund"] != "fertig":
|
||||
msg = (f"Guide bleibt bei {round(res['note'] * 10)} % ({res['grund']}, "
|
||||
f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad (Systemfehler)")
|
||||
_blocks_errors[topic] = msg
|
||||
set_p(msg)
|
||||
else:
|
||||
set_p(f"Guide: 100 % nach {res['runden']} Runde(n)")
|
||||
except Exception as e:
|
||||
log.exception("[%s] Guide-Ebene fehlgeschlagen", topic)
|
||||
_blocks_errors[topic] = f"Guide-Ebene: {str(e)[:500]}"
|
||||
|
||||
|
||||
async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER,
|
||||
research: bool = True, qa_force: bool = False) -> None:
|
||||
research: bool = True, qa_force: bool = False,
|
||||
auto_inventory: bool = True, auto_artefacts: bool = True,
|
||||
auto_guide: bool = True, only_artefacts: bool = False) -> None:
|
||||
"""Kanban entry point: source prep, then both boards (inventory + artefacts) until
|
||||
quiescence. research=False = Continue (drain the existing queue, no new search).
|
||||
A run on a finished topic ADDS research (live extension) — full rebuild = DELETE /blocks."""
|
||||
A run on a finished topic ADDS research (live extension) — full rebuild = DELETE /blocks.
|
||||
auto_* per level: after each level's QA, loop „Befunde beheben" until 100 % / stall / 10×;
|
||||
if off, stop after that level's QA (control point)."""
|
||||
if topic in _blocks_progress:
|
||||
return
|
||||
_blocks_progress[topic] = "Warten…"
|
||||
@@ -1722,10 +1715,16 @@ async def generate_blocks(topic: str, instructions: str = "", provider: str = DE
|
||||
_blocks_errors[topic] = "Cancelled — progress is preserved"
|
||||
return
|
||||
import board_inventory # lazy: the boards import blocks
|
||||
status: dict = {}
|
||||
ok = await board_inventory.run_boards(ctx, set_p, files, q, folder, instructions,
|
||||
research=research, qa_force=qa_force)
|
||||
research=research, qa_force=qa_force,
|
||||
auto_inventory=auto_inventory, auto_artefacts=auto_artefacts,
|
||||
inventory=not only_artefacts, status_out=status)
|
||||
if not ok and is_cancelled():
|
||||
_blocks_errors[topic] = "Cancelled — progress is preserved"
|
||||
elif ok and auto_guide and status.get("weiter") and not is_cancelled():
|
||||
# Auto-Sequenz: Inventar + Artefakte liefen sauber durch → Guide-Ebene.
|
||||
await _guide_ebene(topic, instructions, provider, is_cancelled, set_p)
|
||||
except Exception as e:
|
||||
log.exception("[%s] Blocks generation failed", topic)
|
||||
_blocks_errors[topic] = str(e)[:2000]
|
||||
|
||||
+28
-176
@@ -4,26 +4,22 @@ A card is spawned by board 1's `done` column per mirrored block and runs through
|
||||
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 — outline läuft parallel zur Dedup-Barriere."""
|
||||
files + the DB tables. Danach eine topic-weite BARRIERE: `outline` (prerequisite graph →
|
||||
chapter order), re-run once per generation run."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import database as db
|
||||
import blocks
|
||||
import embedding
|
||||
from 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 blocks import ARTEFACT_TYPES, _match_sub, _outline_block
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from kanban import Flow, Stage
|
||||
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot
|
||||
from pipeline import GenContext, _log
|
||||
from textkit import _norm_title, _title
|
||||
|
||||
log = logging.getLogger("creator.board_artefacts")
|
||||
@@ -103,10 +99,27 @@ def make_spawner(topic: str, files: dict):
|
||||
return spawn
|
||||
|
||||
|
||||
async def seed_artefact_cards(topic: str, files: dict) -> int:
|
||||
"""Board-2-only (Inventar läuft NICHT mit): für jeden fertigen Block eine ablock-Karte
|
||||
erzeugen, falls noch keine existiert — sonst liefe ohne den Board-1-Spawn-Hook nichts
|
||||
durch Board 2. Vorhandene Karten bleiben (sie resümieren in ihrer Spalte). → neue Karten."""
|
||||
spawn = make_spawner(topic, files)
|
||||
existing = {c["card_id"] for c in await db.kanban_cards(topic, board=BOARD, kind="ablock")}
|
||||
n = 0
|
||||
for c in await db.kanban_cards(topic, board="inventory", kind="block", stage="done_block"):
|
||||
norm = c["payload"].get("mirrored_norm")
|
||||
if norm and norm not in existing:
|
||||
await spawn(c["card_id"], c["payload"])
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
async def _gather_cards(ctx: GenContext, flow: Flow, cards, one):
|
||||
results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
|
||||
errs = [r for r in results if isinstance(r, Exception)]
|
||||
if errs:
|
||||
for e in errs[1:]: # nur errs[0] wird re-raised — der Rest darf nicht stumm verschwinden
|
||||
log.error("weitere Karten-Exception im Batch: %r", e)
|
||||
raise errs[0]
|
||||
flow.wake.set()
|
||||
|
||||
@@ -221,7 +234,8 @@ async def _proc_verify(ctx: GenContext, flow: Flow, files: dict, q: dict, instru
|
||||
"unsicher": p.get("unsicher") or [], "votes": p.get("votes") or {}}
|
||||
res = await _verify_block(ctx, _pfiles(files, norm), title, gen, q, instructions,
|
||||
ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
|
||||
sources=p.get("sources"), melde=_melder(flow, norm))
|
||||
sources=p.get("sources"), melde=_melder(flow, norm),
|
||||
keep_all=True)
|
||||
if res is None:
|
||||
return None # nur Cancel — Karte bleibt liegen
|
||||
p["raw"], p["facts"], p["sidecar"] = res["raw"], res["facts"], res["sidecar"]
|
||||
@@ -256,165 +270,6 @@ async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions
|
||||
await _gather_cards(ctx, flow, cards, one)
|
||||
|
||||
|
||||
def _cross_schema(data) -> dict[int, str] | None:
|
||||
"""{"pairs": {"1": "a"|"b"|"nein"}} → {pair_nr: verdict} · otherwise None."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("pairs"), dict):
|
||||
return None
|
||||
out: dict[int, str] = {}
|
||||
for k, v in data["pairs"].items():
|
||||
try:
|
||||
nr = int(k)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
s = str(v).strip().casefold()
|
||||
if s in ("a", "b", "nein"):
|
||||
out[nr] = s
|
||||
return out or None
|
||||
|
||||
|
||||
async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
"""BARRIER/drain am RUN-ENDE — cross-block sub dedup: the SAME statement carried by two
|
||||
blocks (measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes —
|
||||
the in-block paths never see these). Embedding candidates (block≠block, cos ≥
|
||||
SUB_DUP_KANDIDAT_COS) go to a two-judge panel; UNANIMITY decides which block keeps the
|
||||
statement. Sitzt seit dem Umbau NACH finalize: als Mittel-Barriere wartete jede fertige
|
||||
Karte auf die langsamste (gemessen: 8:46 min Leerlauf pro Block, kanban-smoke). Der
|
||||
Verlierer wird per repair.falte_sub gefaltet (variant + Fragen/Artefakte umhängen) —
|
||||
die wenigen Cross-Dubletten kosten so ein paar umsonst generierte Artefakte statt
|
||||
Minuten Wandzeit für alle. Fail-open on judge failure/dissent."""
|
||||
from repair import falte_sub
|
||||
topic = flow.topic
|
||||
work_dir = flow.work_dir
|
||||
# Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig
|
||||
# generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen
|
||||
# ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln.
|
||||
nachzuegler = [(c["card_id"], "artefakte" if "sidecar" in c["payload"] else "generate")
|
||||
for c in cards if "pattern" not in c["payload"]]
|
||||
if nachzuegler:
|
||||
await db.kanban_advance_many(topic, BOARD, nachzuegler)
|
||||
flow.wake.set()
|
||||
return
|
||||
|
||||
async def _advance_all():
|
||||
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards])
|
||||
flow.wake.set()
|
||||
|
||||
rows = [r for r in await db.list_subblocks(topic) if r["status"] == "consensus"]
|
||||
if len(rows) < 2 or not EMBEDDING_AKTIV or not await asyncio.to_thread(embedding.available):
|
||||
await _advance_all()
|
||||
return
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
|
||||
if sims is None:
|
||||
await _advance_all()
|
||||
return
|
||||
negs = [_neg_set(r["sub_title"]) for r in rows]
|
||||
pairs = [(i, j) for i in range(len(rows)) for j in range(i + 1, len(rows))
|
||||
if rows[i]["block_norm"] != rows[j]["block_norm"] and negs[i] == negs[j]
|
||||
and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS]
|
||||
if not pairs:
|
||||
await _advance_all()
|
||||
return
|
||||
|
||||
def _kp(r: dict) -> list:
|
||||
try:
|
||||
return (json.loads(r.get("facts") or "{}")).get("key_points") or []
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
def _side(tag: str, r: dict) -> str:
|
||||
return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r))
|
||||
|
||||
# chunked judging: ONE call over all pairs scaled its timeout past 50 min, and a hung
|
||||
# call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min)
|
||||
chunks = [pairs[lo:lo + CROSS_CHUNK_PAARE] for lo in range(0, len(pairs), CROSS_CHUNK_PAARE)]
|
||||
|
||||
async def _urteile_chunk(chunk: list[tuple[int, int]]) -> dict[int, str]:
|
||||
"""Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict};
|
||||
empty dict = fail-open (pairs stay)."""
|
||||
lines = "\n\n".join(
|
||||
f"{k}.\n{_side('A', rows[i])}\n{_side('B', rows[j])}"
|
||||
for k, (i, j) in enumerate(chunk, 1))
|
||||
h = hashlib.md5(lines.encode()).hexdigest()[:8]
|
||||
paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)]
|
||||
|
||||
async def _judge(j, path, plines, n):
|
||||
if _cross_schema(_json_file(path)) is not None:
|
||||
return # resume
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"Sub-Crossblock j{j}", key=f"blocks-{topic}-sub-crossblock-{h}-j{j}",
|
||||
prompt=_prompt("Subblock-Crossblock", topic=topic, pairs=plines, extra=_extra(instructions)),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result, p=path: _sink_json(result, p, _cross_schema),
|
||||
timeout=_timeout("subblock_check", n))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Sub-Crossblock j{j} ohne Ergebnis — fail-open")
|
||||
|
||||
await asyncio.gather(*[_judge(j, p, lines, len(chunk)) for j, p in zip((1, 2), paths)])
|
||||
if ctx.is_cancelled():
|
||||
return {}
|
||||
outs = [o for p in paths if (o := _cross_schema(_json_file(p))) is not None]
|
||||
if len(outs) == 1: # Ersatz-Richter statt fail-open bei EINEM Ausfall
|
||||
ersatz = work_dir / f"sub-crossblock-{h}-jE.json"
|
||||
await _judge("E", ersatz, lines, len(chunk))
|
||||
if ctx.is_cancelled():
|
||||
return {}
|
||||
outs = [o for p in [*paths, ersatz] if (o := _cross_schema(_json_file(p))) is not None]
|
||||
if len(outs) != 2:
|
||||
if outs:
|
||||
_log(topic, "Sub-Crossblock: nur 1/2 Richter — fail-open")
|
||||
return {}
|
||||
final = {k: (outs[0].get(k, "nein") if outs[0].get(k, "nein") == outs[1].get(k, "nein")
|
||||
else "uneinig") for k in range(1, len(chunk) + 1)}
|
||||
disputed = [k for k, v in final.items() if v == "uneinig"]
|
||||
if disputed: # tie-breaker: a third judge sees ONLY the disputed pairs, majority 2/3
|
||||
d_lines = "\n\n".join(
|
||||
f"{x}.\n{_side('A', rows[chunk[k - 1][0]])}\n{_side('B', rows[chunk[k - 1][1]])}"
|
||||
for x, k in enumerate(disputed, 1))
|
||||
p3 = work_dir / f"sub-crossblock-{h}-j3.json"
|
||||
await _judge(3, p3, d_lines, len(disputed))
|
||||
if ctx.is_cancelled():
|
||||
return {}
|
||||
v3 = _cross_schema(_json_file(p3)) or {}
|
||||
if not v3:
|
||||
_log(topic, "Sub-Crossblock j3 ohne Ergebnis — strittige Paare bleiben")
|
||||
for x, k in enumerate(disputed, 1):
|
||||
t = v3.get(x, "nein")
|
||||
if t in (outs[0].get(k, "nein"), outs[1].get(k, "nein")):
|
||||
final[k] = t # majority 2/3; anything else stays disputed → no fold
|
||||
return final
|
||||
|
||||
chunk_finals = await asyncio.gather(*[_urteile_chunk(c) for c in chunks])
|
||||
if ctx.is_cancelled():
|
||||
return
|
||||
final_all: dict[int, str] = {} # global pair index (1-based over `pairs`) → verdict
|
||||
for cnr, fin in enumerate(chunk_finals):
|
||||
for k, v in fin.items():
|
||||
final_all[cnr * CROSS_CHUNK_PAARE + k] = v
|
||||
journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []}
|
||||
gone: set[tuple] = set()
|
||||
for k, (i, j) in enumerate(pairs, 1):
|
||||
verdict = final_all.get(k, "nein")
|
||||
journal["verdicts"].append({"a": f"{rows[i]['block']} · {rows[i]['sub_title']}",
|
||||
"b": f"{rows[j]['block']} · {rows[j]['sub_title']}",
|
||||
"verdict": verdict})
|
||||
if verdict not in ("a", "b"):
|
||||
continue
|
||||
win, lose = (rows[i], rows[j]) if verdict == "a" else (rows[j], rows[i])
|
||||
wk = (win["block_norm"], win["sub_norm"])
|
||||
lk = (lose["block_norm"], lose["sub_norm"])
|
||||
if lk in gone or wk in gone: # keeper already folded → don't chain away the content
|
||||
continue
|
||||
await falte_sub(topic, files, win, lose)
|
||||
gone.add(lk)
|
||||
journal["gefaltet"].append({"weg": f"{lose['block']} · {lose['sub_title']}",
|
||||
"bleibt": f"{win['block']} · {win['sub_title']}"})
|
||||
if journal["gefaltet"]:
|
||||
_log(topic, f"Sub-Crossblock: {len(journal['gefaltet'])} blockübergreifende Dublette(n) gefaltet")
|
||||
hg = hashlib.md5("\n".join(f"{i}:{j}" for i, j in pairs).encode()).hexdigest()[:8]
|
||||
atomic_write_json(work_dir / f"sub-crossblock-{hg}.json", journal, indent=1)
|
||||
await _advance_all()
|
||||
|
||||
|
||||
# ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
|
||||
def _merge_json(path, block_keys: dict) -> None:
|
||||
data = _json_file(path)
|
||||
@@ -485,7 +340,7 @@ async def _proc_finalize(ctx: GenContext, flow: Flow, files: dict, cards):
|
||||
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
|
||||
ensure_ascii=False)
|
||||
await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
|
||||
await db.kanban_advance(topic, BOARD, c["card_id"], "konsolidierung")
|
||||
await db.kanban_advance(topic, BOARD, c["card_id"], DONE)
|
||||
_log(topic, f"Artefakte fertig: {title}")
|
||||
flow.wake.set()
|
||||
|
||||
@@ -530,7 +385,8 @@ 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")
|
||||
_ALT_STAGES = ("subblocks", "facts", "levels", "relevance", "question_pattern", "artefacts",
|
||||
"konsolidierung") # abgeschaltete Stage: verwaiste Karten auf generate zurück
|
||||
|
||||
|
||||
async def migriere_alt_karten(topic: str) -> int:
|
||||
@@ -558,12 +414,8 @@ def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
||||
Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)),
|
||||
Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)),
|
||||
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
|
||||
# Cross-Block-Dedup als END-Barriere: als Mittel-Barriere idelte jede fertige Karte
|
||||
# auf die langsamste (8:46 min/Block gemessen); jetzt faltet sie nach finalize
|
||||
# per repair.falte_sub — spät gefundene Dubletten kosten Artefakt-Tokens, keine Wandzeit
|
||||
Stage(BOARD, "konsolidierung",
|
||||
lambda cs: _proc_konsolidierung(ctx, flow, files, instructions, cs),
|
||||
barrier=True, drain=True),
|
||||
# Cross-Block-Dedup (konsolidierung) entfällt im Bottom-up: Board 1 hat die Atome
|
||||
# global geclustert/dedupliziert, es gibt keine block-übergreifenden Sub-Dubletten mehr.
|
||||
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
|
||||
barrier=True, drain=True, gate=research_done),
|
||||
]
|
||||
+396
-110
@@ -39,27 +39,27 @@ import kanban
|
||||
from kanban import Flow, Stage, chain_stages
|
||||
import blocks
|
||||
from blocks import (
|
||||
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
|
||||
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
|
||||
RESEARCH_THEMA_AGENTS, _FILTER_NOTATION, _GROUP_STANDALONE,
|
||||
_FILTER_NOTATION,
|
||||
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
|
||||
_completion_schema, _containment_parent, _crawl_index, _file_payload,
|
||||
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement,
|
||||
_is_parentless_noise, _is_reference, _pairs_schema, _read,
|
||||
_relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
|
||||
_is_parentless_noise, _is_reference, _reference_strip, _pairs_schema, _read,
|
||||
_direction_conflict, _relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
|
||||
_aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder,
|
||||
)
|
||||
from config import (QA_GATE_NOTE, QA_GATE_LLM,
|
||||
|
||||
from config import (QA_GATE_NOTE, QA_GATE_LLM, LUECKEN_RESEARCH_MAX,
|
||||
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
|
||||
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
|
||||
RESEARCH_THEMA_AGENTS,
|
||||
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
||||
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
|
||||
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
|
||||
GROUP_RECONCILE_FLOOR, GROUP_THEMES_PER_SQRT,
|
||||
)
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_file
|
||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||
_runde_schema, _timeout, _yesno_schema, run_single_slot)
|
||||
from textkit import _norm_title, _parse_selection, _title, clean_title
|
||||
from textkit import _norm_title, _parse_selection, _title, _unclosed, clean_title
|
||||
|
||||
log = logging.getLogger("creator.board_inventory")
|
||||
|
||||
@@ -423,7 +423,8 @@ async def _pair_one(ctx: GenContext, flow: Flow, c):
|
||||
for ci, chunk in enumerate(chunks):
|
||||
verdict = _pairs_schema(_json_file(flow.work_dir / f"pair-{cid}-{h}-c{ci}.json")) or {}
|
||||
for j, (a, b) in enumerate(chunk):
|
||||
if verdict.get(j + 1) and not _relation_conflict(rows[a]["title"], rows[b]["title"]):
|
||||
# Judge entscheidet Operanden-Identität selbst; nur getauschte Richtung bleibt geblockt
|
||||
if verdict.get(j + 1) and not _direction_conflict(rows[a]["title"], rows[b]["title"]):
|
||||
edges.append((a, b))
|
||||
if sims_title is not None: # auto recall net: near-identical titles merge without the judge
|
||||
for a, b in ordered:
|
||||
@@ -490,12 +491,16 @@ def _hat_anker(title: str, ctoks: set[str]) -> bool:
|
||||
|
||||
def _sanierung_noetig(p: dict, ctoks: set[str] | None) -> bool:
|
||||
"""QA-messbare Befund-Formen am Entstehungsort: Titel ohne Korpus-Anker (QA: fremd —
|
||||
misst Token-Anker, nicht Semantik) oder leere Beschreibung (QA: hygiene). Gilt auch für
|
||||
Singleton-Cluster, die das Naming sonst überspringen — Reader-Rohtitel gingen wörtlich
|
||||
bis done_block durch (gemessen: 'k-Coloring' statt Korpus-Form 'k-Color')."""
|
||||
misst Token-Anker, nicht Semantik) oder leere Beschreibung (QA: hygiene). Zusätzlich ein
|
||||
Referenz-Titel (reine Katalog-Nummer), eine unbalancierte Klammer oder ein überlanger Titel
|
||||
(>80). Gilt auch für Singleton-Cluster, die das Naming sonst überspringen — Reader-Rohtitel
|
||||
gingen wörtlich bis done_block durch (gemessen: 'k-Coloring' statt Korpus-Form 'k-Color')."""
|
||||
title = p.get("title", "")
|
||||
if not (p.get("description") or "").strip():
|
||||
return True
|
||||
return bool(ctoks) and not _hat_anker(p.get("title", ""), ctoks)
|
||||
if _is_reference(title) or _unclosed(title) or len(title) > 80:
|
||||
return True
|
||||
return bool(ctoks) and not _hat_anker(title, ctoks)
|
||||
|
||||
|
||||
async def _anker_beleg(ctx: GenContext, flow: Flow, kandidaten: list[tuple[str, dict]]) -> set[str]:
|
||||
@@ -573,6 +578,9 @@ async def _proc_consensus_gate(ctx: GenContext, flow: Flow, cards):
|
||||
else:
|
||||
moves.append((cid, "naming"))
|
||||
else:
|
||||
# single-reader find: majority of the Klärung panel suffices (was unanimity — a single
|
||||
# "nein" discarded a real once-mentioned concept as failed-quorum, e.g. Cook-Levin)
|
||||
p["quorum"] = "majority"
|
||||
moves.append((cid, "clarify"))
|
||||
await db.kanban_set_payload(topic, BOARD, cid, p)
|
||||
if anker_kandidaten:
|
||||
@@ -592,8 +600,9 @@ async def _proc_consensus_gate(ctx: GenContext, flow: Flow, cards):
|
||||
|
||||
async def _proc_clarify(ctx: GenContext, flow: Flow, cards):
|
||||
"""Single-reader finds: deterministic pre-reject, then a 3-judge panel (Blocks-Klaerung).
|
||||
Quorum: UNANIMITY for single-reader clusters (origin-split C1), majority for reference-titled
|
||||
consensus clusters. Kept reference titles get the panel's rename."""
|
||||
Quorum: MAJORITY of the panel for single-reader clusters and for reference-titled consensus
|
||||
clusters (unanimity dropped real once-mentioned concepts on a single dissent). Kept reference
|
||||
titles get the panel's rename."""
|
||||
topic = flow.topic
|
||||
moves: list[tuple[str, str]] = []
|
||||
pending = []
|
||||
@@ -809,6 +818,12 @@ async def _namecheck_one(ctx: GenContext, flow: Flow, c):
|
||||
p["description"] = w.get("description") or p.get("description", "")
|
||||
readers = sorted(set().union(*[set(r.get("readers") or []) for r in rows])) if rows else []
|
||||
sources = sorted(set().union(*[set(r.get("sources") or []) for r in rows])) if rows else []
|
||||
# deterministic catalogue-number strip (final title): a source numbering is never canonical —
|
||||
# "Satz 7.13 (Christofides)" → "Christofides", "Satz 7.6: Kriterium …" → "Kriterium …". Runs AFTER
|
||||
# naming_check so a re-picked member title cannot re-introduce the number.
|
||||
stripped = _reference_strip(p.get("title", ""))
|
||||
if stripped and _norm_title(stripped) != _norm_title(p.get("title", "")) and not _is_reference(stripped):
|
||||
p["title"] = clean_title(stripped)
|
||||
await db.kanban_upsert_card(topic, BOARD, f"b-{cid}", "block", "fragment_filter", {
|
||||
"title": p.get("title", ""), "description": p.get("description", ""),
|
||||
"readers": readers, "sources": sources, "n_size": len(readers),
|
||||
@@ -1024,13 +1039,21 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
for nr in nums:
|
||||
if nr in fragments or nr in honored:
|
||||
continue
|
||||
votes = len(dem.get(nr, []))
|
||||
if drp.get(nr, 0) >= 2 and (_is_artifact(allrows[nr - 1]["title"])
|
||||
or _is_parentless_noise(allrows[nr - 1]["title"])):
|
||||
honored.add(nr)
|
||||
elif len(dem.get(nr, [])) >= 2:
|
||||
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
||||
elif nr in proposals:
|
||||
overruled.append(nr)
|
||||
# a pass-1 judge already proposed this demote — overturn it ONLY on a
|
||||
# UNANIMOUS recheck keep (zero demote votes). 15/17 majority-rescued cards
|
||||
# were later QA duplicates, so a single recheck demote reconfirms the fragment.
|
||||
if votes >= 1:
|
||||
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
||||
else:
|
||||
overruled.append(nr)
|
||||
elif votes >= 2:
|
||||
# pure ⚠ suspect without a pass-1 proposal: still needs a panel majority
|
||||
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
||||
# embedding backstop: veto confirmed non-containment demotes whose direct title pair is
|
||||
# literally structureless (see FRAGMENT_MIN_COS) — applied BEFORE _root resolution.
|
||||
floor_veto: list[int] = []
|
||||
@@ -1182,7 +1205,10 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
|
||||
detail: dict[tuple[int, int], str] = {}
|
||||
|
||||
def _edge(a, b, kanal):
|
||||
if _relation_conflict(allrows[a]["title"], allrows[b]["title"]):
|
||||
# Auto-Merges (kein Judge): voller String-Guard. Judge-Kanal ("ja"): nur der deterministische
|
||||
# Richtungs-Guard — Operanden-Identität (Synonym vs. echt verschieden) hat der Judge entschieden.
|
||||
guard = _direction_conflict if kanal == "ja" else _relation_conflict
|
||||
if guard(allrows[a]["title"], allrows[b]["title"]):
|
||||
detail[(a, b)] = "guard_veto"
|
||||
else:
|
||||
edges.append((a, b))
|
||||
@@ -1246,6 +1272,80 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _attach_nachzuegler(ctx: GenContext, flow: Flow, rows: list[dict]) -> set[str]:
|
||||
"""Nachzügler-Atome (Supplement-/Lücken-Welle) zuerst an BESTEHENDE fertige Blöcke
|
||||
anschließen: das Atom wird Sub des Blocks (Bottom-up-Persistenz) statt Zwergblock
|
||||
mit einem Sub; die Board-2-Karte des Blocks geht zurück auf generate (Re-Anreicherung,
|
||||
Resume-Hash ändert sich mit der Sub-Menge). Erstwelle hat keine done_block-Blöcke →
|
||||
no-op. Ohne Zuordnung bleibt das Atom standalone (echtes neues Konzept).
|
||||
→ card_ids der angeschlossenen rows."""
|
||||
topic = flow.topic
|
||||
done = [c for c in await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block")
|
||||
if c["payload"].get("mirrored_norm")]
|
||||
if not done or not rows:
|
||||
return set()
|
||||
work_dir = flow.work_dir
|
||||
h = _h(*[r["card_id"] for r in rows], "attach")
|
||||
path = work_dir / f"gruppierung-attach-{h}.json"
|
||||
ids = set(range(1, len(rows) + 1))
|
||||
add = _completion_schema(_json_file(path), len(done), ids)
|
||||
if add is None:
|
||||
anchors = "\n".join(
|
||||
f"UMBRELLA {k}: {c['payload'].get('title', '')} — {(c['payload'].get('description') or '')[:160]}"
|
||||
for k, c in enumerate(done))
|
||||
rest = "\n".join(f"{i}. {_t_text(r)}" for i, r in enumerate(rows, 1))
|
||||
status, add = await run_single_slot(
|
||||
ctx, "Gruppierung Anschluss", key=f"blocks-{topic}-gruppierung-attach-{h}",
|
||||
prompt=_prompt("Blocks-Gruppierung-Completion", topic=topic, umbrellas=anchors,
|
||||
rest=rest, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _completion_schema(_json_file(p), len(done), ids),
|
||||
timeout=_timeout("research_mapping", len(rows)))
|
||||
if status != OK or add is None:
|
||||
return set()
|
||||
attached: set[str] = set()
|
||||
moves = []
|
||||
for k, members in (add or []):
|
||||
c = done[k]
|
||||
bnorm = c["payload"]["mirrored_norm"]
|
||||
btitle = c["payload"].get("title", "")
|
||||
traf = False
|
||||
for m in members:
|
||||
if not (1 <= m <= len(rows)) or rows[m - 1]["card_id"] in attached:
|
||||
continue
|
||||
r = rows[m - 1]
|
||||
sn = _norm_title(r["title"])
|
||||
if not sn:
|
||||
continue
|
||||
await db.put_subblock(topic, bnorm, sn, btitle, r["title"], status="consensus")
|
||||
r["payload"].update(reason="attach", merged_into=btitle)
|
||||
await db.kanban_set_payload(topic, BOARD, r["card_id"], r["payload"])
|
||||
moves.append((r["card_id"], "grouped"))
|
||||
attached.add(r["card_id"])
|
||||
ch = c["payload"].setdefault("children", [])
|
||||
if r["title"] not in ch:
|
||||
ch.append(r["title"])
|
||||
traf = True
|
||||
if traf:
|
||||
await db.kanban_set_payload(topic, BOARD, c["card_id"], c["payload"])
|
||||
b2 = await db.kanban_get_card(topic, "artefacts", bnorm)
|
||||
if b2 and b2["stage"] != "generate": # neuen Sub anreichern lassen
|
||||
await db.kanban_advance(topic, "artefacts", bnorm, "generate")
|
||||
if moves:
|
||||
await db.kanban_advance_many(topic, BOARD, moves)
|
||||
_log(topic, f"Gruppierung: {len(moves)} Nachzügler an bestehende Blöcke angeschlossen")
|
||||
return attached
|
||||
|
||||
|
||||
def _themen_zielband(n: int) -> tuple[int, int]:
|
||||
"""Weiches Prompt-Band der Themenzahl: k = GROUP_THEMES_PER_SQRT·√n, ±30 %, min 2.
|
||||
n ist die Item-Zahl der GROUPING-WELLE — die Supplement-Welle bekommt bewusst ein
|
||||
kleines Band aus ihrem kleinen n (sie sortiert nur Neuzugänge nach)."""
|
||||
k = GROUP_THEMES_PER_SQRT * math.sqrt(max(n, 1))
|
||||
lo = max(2, math.floor(0.7 * k))
|
||||
return lo, max(lo + 1, math.ceil(1.3 * k))
|
||||
|
||||
|
||||
async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
"""BARRIER/drain — umbrella grouping over the filter survivors: embedding sibling clusters
|
||||
(low floor, high recall) + top-down pass, one judge per cluster, type gate + min-cos backstop,
|
||||
@@ -1256,6 +1356,13 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
"title": c["payload"].get("title", ""),
|
||||
"description": c["payload"].get("description") or "",
|
||||
"title_norm": _norm_title(c["payload"].get("title", ""))} for c in cards]
|
||||
# Nachzügler-Wellen: erst an bestehende fertige Blöcke anschließen (Sub statt Zwergblock)
|
||||
attached = await _attach_nachzuegler(ctx, flow, rows)
|
||||
if attached:
|
||||
rows = [r for r in rows if r["card_id"] not in attached]
|
||||
if not rows:
|
||||
flow.wake.set()
|
||||
return
|
||||
n = len(rows)
|
||||
if not (BLOCKS_GRUPPIERUNG_AKTIV and await _emb_ok(flow)) or n < 3:
|
||||
await db.kanban_advance_many(topic, BOARD, [(r["card_id"], "gap_check") for r in rows])
|
||||
@@ -1274,11 +1381,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
all_ids = set(range(1, n + 1))
|
||||
full_list = "\n".join(f"{i}. {texts[i - 1]}" for i in range(1, n + 1))
|
||||
h = _h(*[r["card_id"] for r in rows])
|
||||
|
||||
def _min_cos(idxs):
|
||||
if len(idxs) < 2:
|
||||
return 1.0
|
||||
return round(min(float(sims[i][j]) for a, i in enumerate(idxs) for j in idxs[a + 1:]), 3)
|
||||
theme_lo, theme_hi = _themen_zielband(n)
|
||||
|
||||
async def _assess(tag, cand_text, count):
|
||||
path = work_dir / f"gruppierung-{h}-c{tag}.json"
|
||||
@@ -1286,7 +1389,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"Gruppierung {tag}", key=f"blocks-{topic}-gruppierung-{h}-c{tag}",
|
||||
prompt=_prompt("Blocks-Gruppierung", topic=topic, candidates=cand_text,
|
||||
list=full_list, out_path=path),
|
||||
list=full_list, out_path=path,
|
||||
theme_lo=theme_lo, theme_hi=theme_hi, n_items=n),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _umbrella_schema(_json_file(p), all_ids),
|
||||
timeout=_timeout("research_mapping", count))
|
||||
@@ -1297,8 +1401,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
# ONE wave: TOP + all cluster judges in parallel. The used-ties precedence lives in the
|
||||
# ORDER of `sources` (TOP first), not in execution order — a failed judge simply leaves
|
||||
# no valid file and is skipped in the collection below (legacy semantics).
|
||||
jobs = [("TOP", "Scan the ENTIRE block list below and propose EVERY genuine umbrella "
|
||||
"you find — do not restrict yourself to any subset.", n)]
|
||||
jobs = [("TOP", "Card-sort the ENTIRE list below into themes: assign as MANY items as "
|
||||
"possible to a coherent theme, each item into exactly one.", n)]
|
||||
jobs += [(str(ci), "\n".join(f"{g + 1}. {texts[g]}" for g in cluster), len(cluster))
|
||||
for ci, cluster in enumerate(multi)]
|
||||
await asyncio.gather(*[_assess(tag, cand, count) for tag, cand, count in jobs],
|
||||
@@ -1320,16 +1424,9 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
members = [m for m in members if m not in used]
|
||||
if len(members) < 2:
|
||||
continue
|
||||
# Bottom-up card-sorting: keine type-gate/min-cos-Vetos mehr — jedes Item soll in
|
||||
# ein Thema; ein „Fehl-Merge" ist billig (Item bleibt als Sub erhalten, keine Lücke).
|
||||
mrows = [rows[m - 1] for m in members]
|
||||
if any(_GROUP_STANDALONE.search(r["title"]) for r in mrows):
|
||||
skipped.append({"umbrella": title, "grund": "type-gate",
|
||||
"mitglieder": [r["title"] for r in mrows]})
|
||||
continue
|
||||
mc = _min_cos([m - 1 for m in members])
|
||||
if mc < GROUP_MIN_COS_FLOOR:
|
||||
skipped.append({"umbrella": title, "grund": "min-cos", "min_cos": mc,
|
||||
"mitglieder": [r["title"] for r in mrows]})
|
||||
continue
|
||||
unorm = _norm_title(title)
|
||||
member_norms = {r["title_norm"] for r in mrows}
|
||||
if unorm not in member_norms and unorm in seen_norm:
|
||||
@@ -1338,7 +1435,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
continue
|
||||
used.update(members)
|
||||
seen_norm.add(unorm)
|
||||
chosen.append({"umbrella": title, "description": desc, "min_cos": mc, "members": members})
|
||||
chosen.append({"umbrella": title, "description": desc, "members": members})
|
||||
# reconcile: same parent proposed twice under different titles → union
|
||||
if len(chosen) >= 2:
|
||||
uv = await _vec_rows(flow, [f"{c['umbrella']} — {c['description']}" for c in chosen])
|
||||
@@ -1386,7 +1483,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
|
||||
add = []
|
||||
for k, new_members in (add or []):
|
||||
for m in new_members:
|
||||
if m in used or not (1 <= m <= n) or _GROUP_STANDALONE.search(rows[m - 1]["title"]):
|
||||
if m in used or not (1 <= m <= n):
|
||||
continue
|
||||
used.add(m)
|
||||
chosen[k]["members"].append(m)
|
||||
@@ -1442,24 +1539,25 @@ async def _proc_gap_check(ctx: GenContext, flow: Flow, cards):
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list) -> list:
|
||||
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list, tag: str = "") -> list:
|
||||
"""Evidence gate for supplement proposals: keyword excerpts per proposal, ONE no-tool
|
||||
judge marks material coverage (ja/nein). Proposals without any matching excerpt drop
|
||||
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round)."""
|
||||
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round).
|
||||
`tag` trennt Resume-Datei/Key je Aufrufer (Lücken-Recherche läuft mehrere Runden)."""
|
||||
topic = flow.topic
|
||||
folder = source_folder(topic)
|
||||
packs = [(t, d, _evidence_pack(folder, None, [t], budget=6000)) for t, d in supplements]
|
||||
cands = [(t, d, ev) for t, d, ev in packs if ev]
|
||||
kept: list = []
|
||||
if cands:
|
||||
path = flow.work_dir / "supplement-beleg.json"
|
||||
path = flow.work_dir / f"supplement-beleg{tag}.json"
|
||||
ids = set(range(1, len(cands) + 1))
|
||||
verdict = _yesno_schema(_json_file(path), ids)
|
||||
if verdict is None:
|
||||
lines = "\n\n".join(f"{k}. {t} — {d}\nAUSZÜGE:\n{ev}"
|
||||
for k, (t, d, ev) in enumerate(cands, 1))
|
||||
status, verdict = await run_single_slot(
|
||||
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg",
|
||||
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg{tag}",
|
||||
prompt=_prompt("Blocks-Supplement-Beleg", topic=topic, proposals=lines,
|
||||
extra=_extra(flow.state.get("instructions", ""))),
|
||||
role="judge", capabilities="none",
|
||||
@@ -1505,11 +1603,21 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
# treats). Only proposals the material itself covers may enter the inventory.
|
||||
if supplements and source_folder(topic):
|
||||
supplements = await _supplement_beleg(ctx, flow, supplements)
|
||||
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
|
||||
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
|
||||
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
|
||||
# in the dedup: those were rejected as non-blocks, not lost as content.
|
||||
new, reopened = await _ingest_proposals(topic, supplements or [])
|
||||
if new or reopened:
|
||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _ingest_proposals(topic: str, supplements: list) -> tuple[int, int]:
|
||||
"""Vorschläge (title, description) als Titel-Karten einspeisen — gemeinsamer Ingest-
|
||||
Schwanz von Supplement- und Lücken-Recherche. → (neu, wiedereröffnet).
|
||||
|
||||
Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||
must NOT dedup a proposal — their content is gone. A hit on a dead title REOPENS the
|
||||
lineage instead: the title card rejoins its cluster (live re-cluster) and the respawned
|
||||
block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay in the dedup:
|
||||
those were rejected as non-blocks, not lost as content."""
|
||||
dead_reasons = {"fragment", "drop-collateral", "drop"}
|
||||
cards = await db.kanban_cards(topic, board=BOARD)
|
||||
dead_clusters = {c["payload"].get("cluster") for c in cards
|
||||
@@ -1543,7 +1651,7 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
if (k := _canonical_key(tt)):
|
||||
known_keys.add(k)
|
||||
new = reopened = 0
|
||||
for t, d in (supplements or []):
|
||||
for t, d in supplements:
|
||||
t, d = clean_title(t), clean_title(d)
|
||||
norm = _norm_title(t)
|
||||
key = _canonical_key(t)
|
||||
@@ -1567,9 +1675,57 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
card["payload"]["supplement"] = True
|
||||
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
|
||||
new += 1
|
||||
if new or reopened:
|
||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
return new, reopened
|
||||
|
||||
|
||||
async def _luecken_producer(ctx: GenContext, flow: Flow, report: dict) -> int:
|
||||
"""Gezielte Nach-Recherche für offene QA-Lücken (Auto-Loop): EIN Agent sieht Bestand
|
||||
plus Lücken-Fundstellen (Abschnittstexte deterministisch re-extrahiert, gleicher
|
||||
Splitter wie die QA) und schlägt NUR dafür Blöcke vor — leere Liste erlaubt.
|
||||
Danach Evidence-Gate + Ingest wie beim Supplement; neue Titel durchlaufen alle
|
||||
Inventar-Gates bis done_block. → Zahl neuer/wiedereröffneter Karten."""
|
||||
import qa as _qa
|
||||
topic = flow.topic
|
||||
lk, kl = _qa.offene_luecken(report)
|
||||
if not (lk or kl):
|
||||
return 0
|
||||
flow.add_producer() # Engine am Leben halten, solange der Research-Call läuft
|
||||
try:
|
||||
texts = _qa._corpus_texts(topic)
|
||||
sections = {f: _qa._sections(t) for f, t in texts.items()}
|
||||
gaps = []
|
||||
for x in lk[:20]:
|
||||
sec = sections.get(x.get("datei"), [])
|
||||
k = int(x.get("abschnitt") or 0)
|
||||
volltext = sec[k - 1][:1500] if 0 < k <= len(sec) else x.get("vorschau", "")
|
||||
gaps.append(f"[{x.get('datei')} #{k}]\n{volltext}")
|
||||
gaps += [f"NAMED RESULT (no covering block): {n}" for n in kl[:20]]
|
||||
done = await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block")
|
||||
runde = flow.state.get("luecken_runde", 0)
|
||||
path = flow.work_dir / f"luecken-research-r{runde}.json"
|
||||
supplements = _supplement_schema(_json_file(path))
|
||||
if supplements is None:
|
||||
status, supplements = await run_single_slot(
|
||||
ctx, f"Lücken-Recherche r{runde}", key=f"blocks-{topic}-luecken-nachfass-r{runde}",
|
||||
prompt=_prompt("Blocks-Luecken-Research", topic=topic,
|
||||
blocks="\n".join(f"- {c['payload'].get('title', '')}" for c in done),
|
||||
gaps="\n\n".join(gaps), out_path=path,
|
||||
extra=_extra(flow.state.get("instructions", ""))),
|
||||
role="quick", capabilities="files",
|
||||
payload=lambda result, p=path: _supplement_schema(_json_file(p)),
|
||||
timeout=_timeout("ergaenzung"))
|
||||
if status != OK or supplements is None:
|
||||
_log(topic, "Lücken-Recherche ohne Ergebnis — übersprungen")
|
||||
return 0
|
||||
if supplements and source_folder(topic):
|
||||
supplements = await _supplement_beleg(ctx, flow, supplements, tag=f"-l{runde}")
|
||||
new, reopened = await _ingest_proposals(topic, supplements or [])
|
||||
if new or reopened:
|
||||
_log(topic, f"Lücken-Recherche: {new} Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
return new + reopened
|
||||
finally:
|
||||
flow.done_producer()
|
||||
|
||||
|
||||
async def _proc_done(ctx: GenContext, flow: Flow, cards):
|
||||
@@ -1596,6 +1752,19 @@ async def _proc_done(ctx: GenContext, flow: Flow, cards):
|
||||
await db.upsert_block(topic, norm, title, p.get("description", ""), p.get("sources") or [])
|
||||
await db.set_block_status(topic, norm, "consensus",
|
||||
title=title, description=p.get("description", ""))
|
||||
# Bottom-up-Persistenz: die geclusterten Atome bleiben als Subs (jedes Atom genau
|
||||
# ein Sub). Ein childless Standalone-Block ist sein eigener einziger Sub. Board 2
|
||||
# reichert diese an, entdeckt sie nie neu — so überlebt jedes Board-1-Atom.
|
||||
sub_titles = [(ch.get("title") if isinstance(ch, dict) else ch)
|
||||
for ch in (p.get("children") or [])]
|
||||
sub_titles = [s for s in sub_titles if s] or [title]
|
||||
seen_sub: set[str] = set()
|
||||
for st in sub_titles:
|
||||
sn = _norm_title(st)
|
||||
if not sn or sn in seen_sub:
|
||||
continue
|
||||
seen_sub.add(sn)
|
||||
await db.put_subblock(topic, norm, sn, title, st, status="consensus")
|
||||
mirrored[norm] = c["card_id"]
|
||||
p.update(mirrored_norm=norm, title=title)
|
||||
await db.kanban_set_payload(topic, BOARD, c["card_id"], p)
|
||||
@@ -1647,14 +1816,24 @@ async def _preload_state(flow: Flow):
|
||||
|
||||
|
||||
async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str,
|
||||
research: bool = True, artefacts: bool = True, qa_force: bool = False) -> bool:
|
||||
research: bool = True, artefacts: bool = True, qa_force: bool = False,
|
||||
auto_inventory: bool = True, auto_artefacts: bool = True,
|
||||
inventory: bool = True, status_out: dict | None = None) -> bool:
|
||||
"""Run the inventory board (plus board 2 „Artefakte") until quiescence.
|
||||
inventory=False = artefacts-only: board 1 is NOT run; board-2 cards are seeded from the
|
||||
finished blocks and the QA gate stays open (the inventory is already done).
|
||||
research=False = Continue: drain the existing queue, search nothing new.
|
||||
qa_force=True overrides a failed QA gate (user clicked „Trotzdem fortsetzen")."""
|
||||
qa_force=True overrides a failed QA gate (user clicked „Trotzdem fortsetzen").
|
||||
auto_inventory: after the inventory QA, loop repair until 100 %/stall/10×, then open the
|
||||
board-2 gate; off = pause after the QA. auto_artefacts: same loop after board 2.
|
||||
status_out (optional): filled with {"weiter": bool, "note_artefakte": float|None} — the
|
||||
orchestrator uses „weiter" to decide whether the guide level may start next."""
|
||||
topic = ctx.topic
|
||||
flow = Flow(topic, files["arbeit"])
|
||||
flow.state["instructions"] = instructions
|
||||
flow.state["qa_force"] = qa_force
|
||||
flow.state["auto_inventory"] = auto_inventory
|
||||
flow.state["auto_artefacts"] = auto_artefacts
|
||||
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-{uuid.uuid4().hex[:4]}"
|
||||
flow.state["run_id"] = run_id
|
||||
flow.state["run_started"] = datetime.now(timezone.utc).isoformat()
|
||||
@@ -1663,31 +1842,37 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
pages = await db.list_content(topic)
|
||||
flow.state["pages"] = pages or sorted(set(_crawl_index(folder).values()))
|
||||
await _preload_state(flow)
|
||||
stages = inventory_stages(ctx, flow)
|
||||
stages = inventory_stages(ctx, flow) if inventory else []
|
||||
inv_names = [st.stage for st in stages]
|
||||
if artefacts:
|
||||
import board_artefacts # lazy — board_artefacts imports blocks too
|
||||
flow.state["spawn_artefact"] = board_artefacts.make_spawner(topic, files)
|
||||
await board_artefacts.ensure_outline_card(topic)
|
||||
if not inventory: # board 1 does not run → seed the board-2 cards from the finished blocks
|
||||
geseedet = await board_artefacts.seed_artefact_cards(topic, files)
|
||||
if geseedet:
|
||||
_log(topic, f"Board 2 (nur Artefakte): {geseedet} Karte(n) aus fertigen Blocks erzeugt")
|
||||
migriert = await board_artefacts.migriere_alt_karten(topic)
|
||||
if migriert:
|
||||
_log(topic, f"Board 2: {migriert} Karte(n) der alten Stage-Struktur → generate")
|
||||
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
|
||||
if QA_GATE_NOTE > 0:
|
||||
if QA_GATE_NOTE > 0 and inventory:
|
||||
# QA gate: board 2 waits until the inventory QA passed (or the user forces).
|
||||
# Costs pipelining (board 2 no longer starts per finished block) but saves
|
||||
# tokens on a bad foundation — the watcher below runs the QA and decides.
|
||||
sub = next(st for st in stages if st.stage == "generate")
|
||||
sub.gate = lambda: bool(flow.state.get("qa_ok") or flow.state.get("qa_force"))
|
||||
elif not inventory:
|
||||
flow.state["qa_ok"] = True # artefacts-only: no inventory gate, board 2 runs immediately
|
||||
stages = chain_stages(stages)
|
||||
if artefacts:
|
||||
# Outline needs every block's TITLE + FACTS (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).
|
||||
# post-generate stages from its barrier so it runs parallel to verify…finalize
|
||||
# des langsamsten Blocks (makespan tail).
|
||||
outline = next(s for s in stages if s.stage == "outline")
|
||||
outline.upstream = [u for u in outline.upstream if u not in
|
||||
("verify", "artefakte", "finalize", "konsolidierung")]
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
|
||||
("verify", "artefakte", "finalize")]
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if (research and inventory) else []
|
||||
|
||||
async def _as_producer(coro):
|
||||
try:
|
||||
@@ -1704,7 +1889,7 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
# cancel hook: blocks.cancel_blocks flips is_cancelled; stop the flow with it
|
||||
stopper = asyncio.create_task(_stop_on_cancel(ctx, flow))
|
||||
watcher = (asyncio.create_task(_qa_gate_watch(ctx, flow, inv_names, set_p))
|
||||
if artefacts and QA_GATE_NOTE > 0 else None)
|
||||
if artefacts and QA_GATE_NOTE > 0 and inventory else None)
|
||||
try:
|
||||
try:
|
||||
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
|
||||
@@ -1714,10 +1899,25 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
watcher.cancel()
|
||||
if ctx.is_cancelled():
|
||||
return False
|
||||
if flow.state.get("infra_paused"):
|
||||
from blocks import _blocks_errors # lazy: Import-Zyklus vermeiden
|
||||
_blocks_errors[topic] = flow.state.get("infra_error") \
|
||||
or "Pausiert: API-Ratelimit (429) — «Fortsetzen», sobald Kontingent zurück"
|
||||
if status_out is not None:
|
||||
status_out["weiter"] = False
|
||||
return True # kein Fehler: Karten warten resümierbar in ihrer Spalte
|
||||
if flow.state.get("qa_paused"):
|
||||
if status_out is not None:
|
||||
status_out["weiter"] = False # Inventar pausiert → Board 2 lief nicht
|
||||
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in generate
|
||||
# Board 2 lief durch → Artefakt-Ebene: QA + (Auto) „Befunde beheben"-Loop.
|
||||
if artefacts and not ctx.is_cancelled():
|
||||
await _artefakt_auto_loop(ctx, flow, set_p)
|
||||
await _write_final(topic, files)
|
||||
await _write_run_summary(topic, flow)
|
||||
if status_out is not None:
|
||||
status_out["weiter"] = not (ctx.is_cancelled() or flow.state.get("artefakt_stopp"))
|
||||
status_out["note_artefakte"] = flow.state.get("note_artefakte")
|
||||
return True
|
||||
finally:
|
||||
# erst NACH der Abschluss-QA leeren: deren Judge-Events gehören zum Lauf —
|
||||
@@ -1728,17 +1928,24 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
_QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters
|
||||
|
||||
|
||||
async def _warte_inventar_ruhe(flow: Flow, inv_names: list[str]) -> None:
|
||||
"""Bis das Inventar quiescent ist (Research fertig, keine aktiven Karten in den
|
||||
Inventar-Stages) oder der Flow stoppt — Poll-Kopf des QA-Wächters, auch nach
|
||||
Lücken-Recherche-Runden gebraucht (neue Karten laufen erst durch alle Gates)."""
|
||||
while not flow.stop:
|
||||
if (flow.research_done and not flow.active_in(inv_names)
|
||||
and await db.kanban_count(flow.topic, inv_names) == 0):
|
||||
return
|
||||
await asyncio.sleep(_QA_GATE_POLL)
|
||||
|
||||
|
||||
async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_p):
|
||||
"""Companion task: once the inventory is quiescent, run the QA once and decide —
|
||||
open the board-2 gate or pause the flow. Fail-OPEN on errors (QA is a helper,
|
||||
not a jailer); qa_force short-circuits to open."""
|
||||
topic = flow.topic
|
||||
try:
|
||||
while not flow.stop:
|
||||
if (flow.research_done and not flow.active_in(inv_names)
|
||||
and await db.kanban_count(topic, inv_names) == 0):
|
||||
break
|
||||
await asyncio.sleep(_QA_GATE_POLL)
|
||||
await _warte_inventar_ruhe(flow, inv_names)
|
||||
if flow.stop or ctx.is_cancelled():
|
||||
return
|
||||
if flow.state.get("qa_force"):
|
||||
@@ -1750,17 +1957,66 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
|
||||
report = await qa.qa_report(topic, llm=QA_GATE_LLM)
|
||||
note = float(report["note"]) if report else 10.0
|
||||
flow.state["qa_note"] = note
|
||||
if report:
|
||||
try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen
|
||||
await qa.write_report(report)
|
||||
except Exception:
|
||||
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
|
||||
if note >= QA_GATE_NOTE:
|
||||
_log(topic, f"QA-Gate: Note {note} ≥ {QA_GATE_NOTE} — Board 2 startet")
|
||||
if not report:
|
||||
flow.state["qa_ok"] = True # nichts messbar → fail-open (QA ist Helfer, nicht Jailer)
|
||||
flow.wake.set()
|
||||
return
|
||||
try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen
|
||||
await qa.write_report(report)
|
||||
except Exception:
|
||||
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
|
||||
|
||||
if not flow.state.get("auto_inventory", True):
|
||||
# Auto aus: nach der QA anhalten (Kontrollpunkt), egal welche Note.
|
||||
_log(topic, f"Inventar-QA {round(note * 10)} % — Auto aus, pausiert")
|
||||
set_p(f"Inventar-QA {round(note * 10)} % — Auto aus, «Fortsetzen» startet Board 2")
|
||||
flow.state["qa_paused"] = True
|
||||
flow.stop = True
|
||||
flow.wake.set()
|
||||
return
|
||||
|
||||
# Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×. Offene Lücken
|
||||
# bekommen erst bis zu LUECKEN_RESEARCH_MAX gezielte Nach-Recherche-Runden;
|
||||
# das Lücken-Urteil (Freispruch/bestätigt) fällt erst, wenn die Recherche
|
||||
# nichts mehr bewegt — sonst würde eine füllbare Lücke vorschnell verurteilt.
|
||||
from auto_loop import auto_repair_loop
|
||||
import repair as _repair
|
||||
|
||||
async def _reparieren():
|
||||
rep = qa.latest_report(topic) or {}
|
||||
research_bewegt = False
|
||||
runde = flow.state.get("luecken_runde", 0)
|
||||
lk, kl = qa.offene_luecken(rep)
|
||||
if (lk or kl) and runde < LUECKEN_RESEARCH_MAX:
|
||||
flow.state["luecken_runde"] = runde + 1
|
||||
set_p(f"Lücken-Recherche {runde + 1}/{LUECKEN_RESEARCH_MAX}…")
|
||||
neue = await _luecken_producer(ctx, flow, rep)
|
||||
research_bewegt = neue > 0
|
||||
if neue: # neue Titel durchlaufen alle Gates — erst Ruhe, dann messen
|
||||
await _warte_inventar_ruhe(flow, inv_names)
|
||||
if flow.stop or ctx.is_cancelled():
|
||||
return float(flow.state.get("qa_note") or 0.0), False
|
||||
set_p("Befunde beheben (Inventar)…")
|
||||
r = await _repair.repair_befunde(topic, "inventory",
|
||||
luecken_urteil=not research_bewegt)
|
||||
n = float(r.get("note", 10.0))
|
||||
flow.state["qa_note"] = n
|
||||
return n, bool(r.get("aktionen", 0)) or research_bewegt
|
||||
|
||||
res = await auto_repair_loop("Inventar", note, _reparieren)
|
||||
end_note = res["note"]
|
||||
if res["grund"] == "fertig":
|
||||
_log(topic, f"Inventar: 100 % nach {res['runden']} Runde(n) — Board 2 startet")
|
||||
flow.state["qa_ok"] = True
|
||||
else:
|
||||
_log(topic, f"QA-Gate: Note {note} < {QA_GATE_NOTE} — pausiert (Continue erzwingt)")
|
||||
set_p(f"QA-Note {note} < {QA_GATE_NOTE} — pausiert")
|
||||
# Stillstand/Limit ohne 100 % → Abbruch: Repair hat keinen Pfad für die Restbefunde.
|
||||
msg = (f"Inventar bleibt bei {round(end_note * 10)} % ({res['grund']}, "
|
||||
f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad für die "
|
||||
f"Restbefunde (Systemfehler)")
|
||||
_log(topic, msg)
|
||||
set_p(msg)
|
||||
from blocks import _blocks_errors
|
||||
_blocks_errors[topic] = msg
|
||||
flow.state["qa_paused"] = True
|
||||
flow.stop = True
|
||||
flow.wake.set()
|
||||
@@ -1772,6 +2028,55 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _artefakt_auto_loop(ctx: GenContext, flow: Flow, set_p):
|
||||
"""Nach Board 2: Artefakt-QA → (Auto) „Befunde beheben"-Loop bis 100 %/Stillstand/10×.
|
||||
Auto aus = nur messen (Kontrollpunkt). Setzt flow.state[„note_artefakte"] und bei
|
||||
Stopp flow.state[„artefakt_stopp"] (der Guide startet dann nicht automatisch).
|
||||
Fail-open: ein QA-Fehler überspringt die Ebene (QA ist Helfer, nicht Jailer)."""
|
||||
topic = ctx.topic
|
||||
try:
|
||||
import qa
|
||||
rep = await qa.qa_report(topic, llm=QA_GATE_LLM)
|
||||
na = rep.get("note_artefakte") if rep else None
|
||||
if na is None: # Board 2 hat (noch) nichts geliefert → nichts zu messen
|
||||
return
|
||||
na = float(na)
|
||||
flow.state["note_artefakte"] = na
|
||||
try:
|
||||
await qa.write_report(rep)
|
||||
except Exception:
|
||||
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
|
||||
if na >= 10.0:
|
||||
return
|
||||
if not flow.state.get("auto_artefacts", True):
|
||||
_log(topic, f"Artefakt-QA {round(na * 10)} % — Auto aus, Guide startet nicht automatisch")
|
||||
set_p(f"Artefakt-QA {round(na * 10)} % — Auto aus")
|
||||
flow.state["artefakt_stopp"] = True
|
||||
return
|
||||
from auto_loop import auto_repair_loop
|
||||
import repair as _repair
|
||||
|
||||
async def _reparieren():
|
||||
set_p("Befunde beheben (Artefakte)…")
|
||||
r = await _repair.repair_befunde(topic, "artefacts")
|
||||
return float(r.get("note_artefakte") or 10.0), bool(r.get("aktionen", 0))
|
||||
|
||||
res = await auto_repair_loop("Artefakte", na, _reparieren)
|
||||
flow.state["note_artefakte"] = res["note"]
|
||||
if res["grund"] != "fertig":
|
||||
msg = (f"Artefakte bleiben bei {round(res['note'] * 10)} % ({res['grund']}, "
|
||||
f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad (Systemfehler)")
|
||||
_log(topic, msg)
|
||||
set_p(msg)
|
||||
from blocks import _blocks_errors
|
||||
_blocks_errors[topic] = msg
|
||||
flow.state["artefakt_stopp"] = True
|
||||
else:
|
||||
_log(topic, f"Artefakte: 100 % nach {res['runden']} Runde(n)")
|
||||
except Exception:
|
||||
log.exception("[%s] Artefakt-Auto-Loop fehlgeschlagen — übersprungen (fail-open)", topic)
|
||||
|
||||
|
||||
async def _write_run_summary(topic: str, flow: Flow):
|
||||
"""lauf-summary.json: the per-run numbers block QA diffs against. Never fatal."""
|
||||
try:
|
||||
@@ -1841,7 +2146,6 @@ COLUMNS = [
|
||||
("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,7 +2153,7 @@ 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 = ["generate", "verify", "artefakte", "finalize", "konsolidierung", "outline"]
|
||||
_ART_STAGES = ["generate", "verify", "artefakte", "finalize", "outline"]
|
||||
# where a requeued dead card restarts, by kind
|
||||
_DEAD_RESTART = {"title": "cluster", "cluster": "pair_check", "block": "fragment_filter",
|
||||
"ablock": "generate", "outline": "outline"}
|
||||
@@ -1908,28 +2212,25 @@ async def board_snapshot(topic: str, limit: int = 20) -> dict:
|
||||
|
||||
def _qa_view(topic: str, counts: dict, flow) -> dict | None:
|
||||
"""Latest QA report digest for the board header. `pausiert` = the gate stopped the
|
||||
flow (score below threshold, board-2 cards waiting, no flow running)."""
|
||||
flow (score below 100 %, board-2 cards waiting, no flow running) — das Auto-Gate
|
||||
öffnet nur bei VOLL; eine 9.6 pausierte den Loop, galt hier aber nicht als pausiert."""
|
||||
# no inventory (deleted/never built) → no badge; the report files stay on purpose,
|
||||
# so the first run after a rebuild diffs against the old state
|
||||
if not counts.get("inventory", {}).get("done_block", 0) and not (
|
||||
flow and flow.state.get("qa_note") is not None):
|
||||
return None
|
||||
import qa
|
||||
tdir = qa.QA_DIR / topic
|
||||
# by mtime: a re-run overwrites the run-id-named file, which sorts before timestamp names.
|
||||
# guide-* reports are the guide_qa series — they must not shadow the inventory badge.
|
||||
reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
if not reports:
|
||||
from auto_loop import VOLL
|
||||
r = qa.latest_report(topic)
|
||||
if r is None:
|
||||
return None
|
||||
r = _json_file(reports[-1]) or {}
|
||||
note = r.get("note")
|
||||
if note is None:
|
||||
return None
|
||||
wartend = counts.get("artefacts", {}).get("generate", 0)
|
||||
pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None)
|
||||
pausiert = bool(note < VOLL and wartend and flow is None)
|
||||
return {"note": note, "note_artefakte": r.get("note_artefakte"),
|
||||
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
|
||||
"schwelle": VOLL, "pausiert": pausiert,
|
||||
"quoten": r.get("quoten", {}),
|
||||
"befunde": (r.get("fremd", []) + r.get("unecht", []))[:6]}
|
||||
|
||||
@@ -1965,9 +2266,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
await _requeue(r, stage)
|
||||
await db.kanban_delete_cards(topic, "inventory", "cluster")
|
||||
await db.kanban_delete_cards(topic, "inventory", "block")
|
||||
dbc = await db.get_db()
|
||||
await dbc.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,))
|
||||
await dbc.commit()
|
||||
await db.kanban_delete_members(topic)
|
||||
await db.delete_blocks(topic)
|
||||
await _clean_artefact_state(topic, files)
|
||||
elif board == "inventory" and stage in _CLUSTER_STAGES:
|
||||
@@ -1995,13 +2294,17 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
await _requeue(r, "outline")
|
||||
elif r["stage"] in later:
|
||||
await _requeue(r, stage)
|
||||
if stage == "generate": # full artefact re-derive → also the DB mirrors
|
||||
await db.delete_subblocks(topic)
|
||||
if stage == "generate": # re-derive enrichment + artefacts; the Board-1 subs STAY
|
||||
# Bottom-up: subblocks belong to Board 1 (the clustered atoms). Deleting them would
|
||||
# leave Board 2 nothing to enrich → cards fall straight through to done. Only the
|
||||
# DERIVED artefacts/questions + sidecar files are wiped; the enrichment (facts/level)
|
||||
# gets overwritten by the fresh Board-2 run.
|
||||
await db.delete_question_pattern(topic)
|
||||
await db.delete_sub_artefakte(topic)
|
||||
await db.delete_outline(topic) # the outline is a blocks artifact too (set in board_artefacts)
|
||||
# global sidecar files and per-block resume slots: leftovers of the previous
|
||||
# derive would re-merge/resume into the fresh run — nothing here is reused
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"):
|
||||
files[k].unlink(missing_ok=True)
|
||||
for d in files["arbeit"].glob("ab-*"):
|
||||
if d.is_dir():
|
||||
@@ -2010,23 +2313,6 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
return moved
|
||||
|
||||
|
||||
async def restart_artefact_card(topic: str, card_id: str) -> bool:
|
||||
"""Restart ONE artefacts card from `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)
|
||||
if card is None or card["kind"] != "ablock":
|
||||
return False
|
||||
await db.delete_subblocks(topic, card_id)
|
||||
await db.delete_question_pattern(topic, card_id)
|
||||
await db.delete_sub_artefakte(topic, card_id)
|
||||
p = {k: v for k, v in card["payload"].items() if k in ("title", "description")}
|
||||
await db.kanban_set_payload(topic, "artefacts", card_id, p)
|
||||
await db.kanban_advance(topic, "artefacts", card_id, "generate")
|
||||
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
|
||||
return True
|
||||
|
||||
|
||||
async def requeue_dead(topic: str) -> int:
|
||||
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
|
||||
n = 0
|
||||
|
||||
+26
-62
@@ -75,9 +75,6 @@ SEED_COVER_COS = 0.80
|
||||
# (Markdown: 50 pairs in the band, 4 above) — below every auto-merge threshold, so an LLM
|
||||
# judge decides. Candidates only; a merge still needs judge unanimity.
|
||||
SUB_DUP_KANDIDAT_COS = 0.75
|
||||
# Cross-block judge pairs per call: ONE call over all pairs scaled its timeout to 54 min
|
||||
# and a hung call blocked the barrier that long (aak: 196 pairs) — chunks cap it at ~15 min.
|
||||
CROSS_CHUNK_PAARE = 40
|
||||
|
||||
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
|
||||
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
|
||||
@@ -94,16 +91,13 @@ EMBEDDING_SIBLING_CAP = 18 # a rich model (TM) can have many constituent p
|
||||
# titles (e.g. two „Turingmaschine"-umbrellas). Merge umbrella pairs whose title+description cosine is
|
||||
# ≥ this (conservative → only true same-parent duplicates, never two distinct umbrellas).
|
||||
GROUP_RECONCILE_FLOOR = 0.75
|
||||
# Over-merge backstop ONLY (no-structure floor). Research (meronymy ≠ similarity): parts of ONE model are
|
||||
# legitimately DISSIMILAR (TM: Alphabet/Konfiguration/δ ~0.22), while distinct same-type concepts (P/NP/…)
|
||||
# are SIMILAR (~0.85) — so member-vs-member cosine is the WRONG instrument for over-merge (empirically
|
||||
# inverted: TM 0.218 < the P/NP bundle 0.227). The real precision floor is the ATOMICITY type-guard
|
||||
# (_GROUP_STANDALONE: a member that is a named algorithm/problem/theorem/complexity-class dissolves the
|
||||
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless
|
||||
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
|
||||
GROUP_MIN_COS_FLOOR = 0.15
|
||||
# Fragment-demote backstop, same logic as GROUP_MIN_COS_FLOOR: fragment↔parent cosine is a BAD
|
||||
# fragment detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
|
||||
# Zielband der Themen-Blockzahl im Gruppierungs-Prompt: k = GROUP_THEMES_PER_SQRT·√n
|
||||
# (n = Items der Grouping-Welle), Band [0.7k, 1.3k] — weiche Vorgabe, kein Cap. √n hält
|
||||
# Blockzahl UND mittlere Blockgröße sublinear (n=30 → ~4–8, n=285 → ~12–22; das alte
|
||||
# statische „15–25" passte implizit nur zu n≈225–625).
|
||||
GROUP_THEMES_PER_SQRT = 1.0
|
||||
# Fragment-demote backstop (no-structure floor): fragment↔parent cosine is a BAD fragment
|
||||
# detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
|
||||
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
|
||||
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
|
||||
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
|
||||
@@ -135,25 +129,15 @@ CONSENSUS_GRACE = 300
|
||||
# check loops leave any remaining objections standing after that.
|
||||
CONSENSUS_MAX_ROUNDS = 3
|
||||
|
||||
# Crawler triage (content/noise) — deterministic rule filter instead of an LLM.
|
||||
# Match: substring (lowercase) against URL AND file name. Order: keep > noise > min_chars > keep.
|
||||
# Just add special rules here.
|
||||
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # always content
|
||||
CRAWL_NOISE_PATTERNS = [ # clearly off-topic → out
|
||||
"clubs", "events", "podcasts", "resources", "-u-",
|
||||
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login",
|
||||
]
|
||||
CRAWL_MIN_CHARS = 400 # too little text → out
|
||||
|
||||
# LLM topic relevance gate (after the rule filter): per content page yes/no against the spec.
|
||||
# Separates the subject area (e.g. backend vs frontend), which the global CRAWL_* rules can't.
|
||||
QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page)
|
||||
QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL is the primary signal)
|
||||
|
||||
# QA gate: after the inventory phase an automatic QA run scores the blocks; below the
|
||||
# threshold the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
|
||||
QA_GATE_NOTE = 9.5 # 0 = gate off; quota-based, so the tolerated finding count scales with topic size
|
||||
# QA gate: after the inventory phase an automatic QA run scores the blocks; below 100 %
|
||||
# the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
|
||||
# QA_GATE_NOTE ist nur noch der An/Aus-Schalter (> 0 = Gate aktiv) — das Gate selbst
|
||||
# öffnet ausschließlich bei 100 % (auto_loop.VOLL); Repair/Recherche arbeiten dahin.
|
||||
QA_GATE_NOTE = 9.5 # 0 = gate off
|
||||
QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run
|
||||
# Lücken-Recherche im Auto-Loop: max. gezielte Nach-Recherche-Runden pro Lauf, bevor die
|
||||
# Rest-Lücken dem Stichentscheid vorgelegt werden (Repair-Budget, keine Detektor-Konstante).
|
||||
LUECKEN_RESEARCH_MAX = 2
|
||||
|
||||
# Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting
|
||||
# every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the
|
||||
@@ -164,27 +148,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_BATCH = 1 # eine Seite pro Reader — er kann nichts übersehen (Vollständigkeit)
|
||||
RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2)
|
||||
RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema")
|
||||
RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard)
|
||||
RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live)
|
||||
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,11 +168,17 @@ 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
|
||||
RETRY_BACKOFF = 30.0 # base seconds; backoff = base · 2^(retries-1)
|
||||
MAX_RESTARTS = 2 # agent restart cap per race slot
|
||||
INFRA_MAX_RETRIES = 3 # 429/Timeout/Netz: Retries pro Slot, dann Lauf-Pause (kein fail-open)
|
||||
INFRA_BACKOFF_BASE = 8.0 # Pause = base · 2^(n-1) → 8/16/32 s
|
||||
# Stall-Hedge: läuft ein Race-Slot so lange ohne Ergebnis, startet parallel ein Zwilling
|
||||
# (key -h), der erste valide gewinnt. Gemessen (kanban-smoke): 4 Panel-Stalls à 160–230 s
|
||||
# verlängerten den kritischen Pfad um ~5 min. UNTERGRENZE: effektiv gilt
|
||||
@@ -205,31 +186,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 +209,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.
|
||||
|
||||
+23
-51
@@ -1,9 +1,9 @@
|
||||
"""Bounded domain crawler for link sources — renders JS via Playwright (Chromium).
|
||||
"""Page loader for link sources — renders JS via Playwright (Chromium).
|
||||
|
||||
Loads pages + PDFs starting from a start URL — ONLY the same domain, limited depth
|
||||
and page count. HTML pages are rendered in a headless browser (needed for SPAs), then
|
||||
links + text are pulled from the finished DOM. PDFs are loaded directly as bytes.
|
||||
Deterministic, bounded; runs via asyncio.to_thread (sync API, no event loop).
|
||||
Loads a FIXED list of URLs the user supplied — one page each, NO link following.
|
||||
HTML pages are rendered in a headless browser (needed for SPAs), then the main text
|
||||
is pulled from the finished DOM. PDFs are loaded directly as bytes. Deterministic,
|
||||
bounded; runs via asyncio.to_thread (sync API, no event loop).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -17,8 +17,6 @@ from fsutil import atomic_write_text
|
||||
|
||||
log = logging.getLogger("creator.crawl")
|
||||
|
||||
MAX_DEPTH = 3
|
||||
MAX_PAGES = 500
|
||||
PAGE_TIMEOUT = 30 # seconds per page (render or PDF download)
|
||||
CRAWL_SETTLE_MS = 3000 # capped settle after domcontentloaded (SPA render); no 30s networkidle hang
|
||||
MAX_BYTES = 10_000_000 # 10 MB cap per PDF
|
||||
@@ -33,7 +31,7 @@ def _fetch_bytes(url: str) -> bytes | None:
|
||||
data = resp.read(MAX_BYTES + 1)
|
||||
return None if len(data) > MAX_BYTES else data
|
||||
except Exception as e:
|
||||
log.debug("crawl: PDF fetch failed %s: %s", url, e)
|
||||
log.debug("load: PDF fetch failed %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -47,21 +45,6 @@ def _is_pdf(url: str) -> bool:
|
||||
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
|
||||
|
||||
|
||||
def _scope_prefix(start_url: str) -> str:
|
||||
"""First non-empty path segment of the start URL as the crawl scope, e.g.
|
||||
`/learn/path/x` → `/learn`. No path segment → `""` (whole domain, no narrowing)."""
|
||||
seg = [s for s in urlparse(start_url).path.split("/") if s]
|
||||
return f"/{seg[0]}" if seg else ""
|
||||
|
||||
|
||||
def _in_scope(url: str, prefix: str) -> bool:
|
||||
"""Segment-exact prefix match (no `/learn` ⊃ `/learning-x`). Empty prefix → everything allowed."""
|
||||
if not prefix:
|
||||
return True
|
||||
p = urlparse(url).path
|
||||
return p == prefix or p.startswith(prefix + "/")
|
||||
|
||||
|
||||
def _page_text(page) -> str:
|
||||
"""Main text of the rendered page — nav/footer/boilerplate removed via trafilatura.
|
||||
Falls back to the raw body text when extraction is empty/too short (non-article pages)."""
|
||||
@@ -78,34 +61,34 @@ def _page_text(page) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages: int = MAX_PAGES, cancelled=None) -> int:
|
||||
"""Crawl from start_url (same domain only), render JS and store pages/PDFs in `target`.
|
||||
def load_pages(urls: list[str], target: Path, *, cancelled=None) -> int:
|
||||
"""Load each URL in `urls` (one page each, NO link following), render JS and store
|
||||
pages/PDFs in `target`.
|
||||
|
||||
BFS up to `max_depth` / `max_pages`. Errors on individual pages are skipped.
|
||||
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it,
|
||||
so a restart crawls again. Returns the number of saved sources.
|
||||
Duplicate/fragment-only URLs collapse to one. Errors on individual pages are skipped.
|
||||
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it, so a
|
||||
restart loads again. Returns the number of saved sources.
|
||||
"""
|
||||
# Lazy: this way the backend starts even without Playwright installed; only crawling then fails.
|
||||
# Lazy: this way the backend starts even without Playwright installed; only loading then fails.
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
domain = urlparse(start_url).netloc
|
||||
prefix = _scope_prefix(start_url) # only follow links under this path segment
|
||||
seen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
todo: list[str] = []
|
||||
for u in urls:
|
||||
nu = urldefrag(u)[0].strip()
|
||||
if nu and nu not in seen:
|
||||
seen.add(nu)
|
||||
todo.append(nu)
|
||||
saved = 0
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
|
||||
page = browser.new_page(user_agent=_UA)
|
||||
try:
|
||||
while queue and saved < max_pages:
|
||||
for url in todo:
|
||||
if cancelled and cancelled():
|
||||
return saved # abort → NO .done marker → restart crawls again
|
||||
url, depth = queue.pop(0)
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
return saved # abort → NO .done marker → restart loads again
|
||||
|
||||
# PDFs need no rendering — load directly.
|
||||
if _is_pdf(url):
|
||||
@@ -120,7 +103,7 @@ def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto incomplete %s: %s", url, e) # still try to read the content
|
||||
log.debug("load: goto incomplete %s: %s", url, e) # still try to read the content
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
|
||||
except Exception:
|
||||
@@ -129,20 +112,9 @@ def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages
|
||||
if text:
|
||||
atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
saved += 1
|
||||
if depth < max_depth:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
hrefs = []
|
||||
for href in hrefs:
|
||||
nxt = urldefrag(href)[0]
|
||||
if (nxt.startswith(("http://", "https://"))
|
||||
and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
|
||||
and nxt not in seen):
|
||||
queue.append((nxt, depth + 1))
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
(target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
|
||||
log.info("crawl %s → %d sources in %s", start_url, saved, target)
|
||||
log.info("load_pages → %d sources in %s", saved, target)
|
||||
return saved
|
||||
+504
-408
File diff suppressed because it is too large.
Load diff
+59
-28
@@ -21,7 +21,6 @@ _PATH_RE = re.compile(r"(/\S+\.(?:json|md))")
|
||||
_NUM_RE = re.compile(r"^\s*(\d+)[.)]\s+(.*\S)", re.MULTILINE)
|
||||
_SUBLIST_RE = re.compile(r"^- (?:\[(\w+)\] )?(.+\S)\s*$", re.MULTILINE)
|
||||
_ZIEL_RE = re.compile(r"\(([a-z]\d+)\)")
|
||||
_PAIR_RE = re.compile(r"^(\d+)\.\s*\nA: \[Block: (.*?)\] (.*?)\n", re.MULTILINE)
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
@@ -30,7 +29,7 @@ def _norm(s: str) -> str:
|
||||
|
||||
class Welt:
|
||||
"""Deterministisches Themen-Modell. bloecke: {titel: {"beschreibung": str,
|
||||
"subs": [titel]}}; optionale Regeln steuern Konsolidierung/Cross-Block."""
|
||||
"subs": [titel]}}; optionale Regeln steuern das Störfall-Verhalten."""
|
||||
|
||||
def __init__(self, bloecke: dict | None = None, *, gruppen: list | None = None,
|
||||
kataloge: list | None = None, stoerungen: list | None = None):
|
||||
@@ -101,11 +100,14 @@ class Welt:
|
||||
j = json.dumps
|
||||
# Board 1 / Inventar
|
||||
if "-research-" in key:
|
||||
# Bottom-up: Research liefert die ATOME flach; Board 1 gruppiert sie zu den
|
||||
# Cluster-Bausteinen. Jedes Atom gehört zu genau einem Cluster.
|
||||
zeilen = []
|
||||
n = 1
|
||||
for t, b in self.bloecke.items():
|
||||
zeilen.append(f"{n}. {t} — {b['beschreibung']}")
|
||||
n += 1
|
||||
for cl, b in self.bloecke.items():
|
||||
for atom in b["subs"]:
|
||||
zeilen.append(f"{n}. {atom} — Atom aus {cl}")
|
||||
n += 1
|
||||
return "\n".join(zeilen)
|
||||
if "-pair-" in key:
|
||||
n = prompt.count("\nA: ") or 1
|
||||
@@ -127,20 +129,42 @@ class Welt:
|
||||
return j({"title": t.group(1).strip() if t else "", "description": beschr})
|
||||
if "-filter-" in key: # auch filter-recheck
|
||||
return j({"fragments": {}, "drop": []})
|
||||
if "-gruppierung-completion-" in key:
|
||||
return j({"additions": []})
|
||||
if "-gruppierung-completion-" in key or "-gruppierung-attach-" in key:
|
||||
return j({"additions": []}) # Anschluss-Judge: Fake-Nachzügler bleiben standalone
|
||||
if "-gruppierung-" in key:
|
||||
return j({"umbrellas": []})
|
||||
# jedes Atom seinem Cluster zuordnen (nur die FULL ITEM LIST, 1..n eindeutig)
|
||||
seg = prompt.split("FULL ITEM LIST", 1)[-1]
|
||||
atom_cluster = {_norm(a): cl for cl, b in self.bloecke.items() for a in b["subs"]}
|
||||
umb: dict[str, list[int]] = {}
|
||||
for m in _NUM_RE.finditer(seg):
|
||||
nr, zeile = int(m.group(1)), _norm(m.group(2))
|
||||
for an, cl in atom_cluster.items():
|
||||
if zeile.startswith(an):
|
||||
umb.setdefault(cl, []).append(nr)
|
||||
break
|
||||
umbrellas = [{"title": cl, "description": self.bloecke[cl]["beschreibung"],
|
||||
"members": ms} for cl, ms in umb.items() if ms]
|
||||
return j({"umbrellas": umbrellas})
|
||||
if "-supplement-beleg" in key or "-anker-beleg-" in key:
|
||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
|
||||
if "-supplement" in key:
|
||||
return j({"blocks": []})
|
||||
if "-supplement" in key or "-luecken-nachfass-" in key:
|
||||
return j({"blocks": []}) # Fake-Welt ist vollständig — Lücken-Research findet nichts
|
||||
if "-source-relevance-" in key:
|
||||
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
|
||||
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
|
||||
|
||||
# Board 2 / Artefakte (verschmolzene Calls, block_calls.py)
|
||||
if "-sb-enrich-" in key: # Bottom-up: feste Subs (aus dem Prompt) mit Facts anreichern
|
||||
seg = prompt.split("FIXED SUBBLOCKS", 1)[-1].split("\n\n", 1)[0]
|
||||
subs = []
|
||||
for m in re.finditer(r"^\s*\d+\.\s+(.+)$", seg, re.M):
|
||||
s = m.group(1).strip()
|
||||
f = self._fakt("", s)
|
||||
subs.append({"title": s, "level": "beginner", "relevance": "relevant",
|
||||
**{k: f[k] for k in ("key_points", "prerequisites", "hurdles",
|
||||
"cited_facts", "example_idea")}})
|
||||
return j({"subs": subs})
|
||||
if "-sb-gen-" in key:
|
||||
subs = []
|
||||
for t in self._bloecke_im_prompt(prompt):
|
||||
@@ -172,11 +196,6 @@ class Welt:
|
||||
"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 "-art-gen-" in key:
|
||||
subs = self._subs_im_prompt(prompt)
|
||||
t = (self._bloecke_im_prompt(prompt) or ["?"])[0]
|
||||
@@ -251,13 +270,15 @@ class Welt:
|
||||
|
||||
|
||||
def standard_bloecke() -> dict:
|
||||
"""3 Blöcke; „Gemeinsamer Grundbegriff" liegt in Alpha UND Beta (Cross-Block-Fall)."""
|
||||
"""3 Themen-Cluster; die subs sind die ATOME, die Board 1 bottom-up zu genau diesem
|
||||
Cluster gruppiert. Jedes Atom liegt in genau einem Cluster (kein geteiltes Atom mehr —
|
||||
das globale Clustern in Board 1 macht Cross-Block-Dubletten unmöglich)."""
|
||||
return {
|
||||
"Alpha-Konzept": {"beschreibung": "Das erste Grundkonzept",
|
||||
"subs": ["Definition Alpha", "Alpha Eigenschaften",
|
||||
"Gemeinsamer Grundbegriff"]},
|
||||
"Beta-Verfahren": {"beschreibung": "Das zentrale Verfahren",
|
||||
"subs": ["Beta Ablauf", "Beta Grenzen", "Gemeinsamer Grundbegriff"]},
|
||||
"subs": ["Beta Ablauf", "Beta Grenzen"]},
|
||||
"Gamma-Anwendung": {"beschreibung": "Praktische Anwendung",
|
||||
"subs": ["Gamma Praxisfall", "Gamma Werkzeuge"]},
|
||||
}
|
||||
@@ -281,20 +302,18 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
|
||||
|
||||
import agents
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
import board_inventory as bi
|
||||
import guide
|
||||
import guide_board
|
||||
import kanban
|
||||
import pipeline
|
||||
import qa
|
||||
import repair
|
||||
|
||||
async def fake_run_agent(agent_key, prompt, timeout, provider="", 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)
|
||||
@@ -309,11 +328,18 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
|
||||
|
||||
@staticmethod
|
||||
def embed(texts):
|
||||
import hashlib
|
||||
|
||||
import numpy as np
|
||||
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
|
||||
arr = np.zeros((len(texts), max(len(uniq), 1)))
|
||||
# FESTE Dimension (hash → One-hot): identischer Text = gleiche Spalte = cos 1.0,
|
||||
# verschiedener Text = andere Spalte = cos 0.0. Anders als eine pro-Aufruf
|
||||
# variable Breite lassen sich so Vektoren aus verschiedenen embed()-Aufrufen
|
||||
# concatenieren (Board-1-Cluster-Cache) ohne Dimensions-Mismatch.
|
||||
D = 4096
|
||||
arr = np.zeros((len(texts), D))
|
||||
for r, t in enumerate(texts):
|
||||
arr[r, uniq[t]] = 1.0
|
||||
h = int(hashlib.blake2b(t.encode("utf-8"), digest_size=8).hexdigest(), 16) % D
|
||||
arr[r, h] = 1.0
|
||||
return arr
|
||||
|
||||
@staticmethod
|
||||
@@ -321,9 +347,14 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
|
||||
arr = _FakeEmb.embed(texts)
|
||||
return arr @ arr.T
|
||||
|
||||
for mod in (blocks, ba, qa):
|
||||
setattr_fn(mod, "embedding", _FakeEmb)
|
||||
# reine Matrix-/Union-Find-Helfer (modell-unabhängig) ans echte Modul delegieren
|
||||
import embedding as _real_emb
|
||||
capped_blocks = staticmethod(_real_emb.capped_blocks)
|
||||
_find = staticmethod(_real_emb._find)
|
||||
_union = staticmethod(_real_emb._union)
|
||||
|
||||
async def emb_ok(flow): # Board-1-Vektorpfade aus — Judge-Wellen reichen
|
||||
return False
|
||||
setattr_fn(bi, "_emb_ok", emb_ok)
|
||||
for mod in (blocks, qa, bi):
|
||||
setattr_fn(mod, "embedding", _FakeEmb)
|
||||
# _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage.
|
||||
# _FakeEmb bildet nur bei identischem Text Nachbarn — verschiedene Atome erzeugen keine
|
||||
# falschen Vorab-Cluster; das eigentliche Clustern macht der TOP-Judge (Card-sort).
|
||||
+21
-34
@@ -11,7 +11,6 @@ Step files are kept → an abort preserves progress, ▶ resumes at the open ste
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,26 +18,14 @@ import uuid
|
||||
|
||||
from agents import run_agent
|
||||
from blocks import _convert_pdfs, source_folder
|
||||
from config import (
|
||||
DEFAULT_PROVIDER, FORMAT_PURPOSE, CONSENSUS_GRACE,
|
||||
READABILITY_ACTIVE, TEMPLATES_DIR,
|
||||
)
|
||||
import readability
|
||||
from config import DEFAULT_PROVIDER, TEMPLATES_DIR
|
||||
from database import (list_guides, update_guide, list_blocks, list_subblocks, set_guide_content,
|
||||
get_guide_content, get_outline, guide_stage_counts, delete_guide_board)
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_file, parse_json_text as _parse_json_text
|
||||
from paths import blocks_path, guide_content_path, project_dir, subblocks_path
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, GenContext, _claude_error, _extra,
|
||||
_fail, _gather_error, _gather_progress, _log, _prompt, _race,
|
||||
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
|
||||
is_guide_cancelled, run_single_slot,
|
||||
)
|
||||
from textkit import (
|
||||
_unique_title, _load_blocks, _norm_title, _parse_fragment, _split_chunks,
|
||||
_title, _resolve_title, _title_index,
|
||||
)
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from paths import blocks_path, guide_content_path, subblocks_path
|
||||
from pipeline import _fail, _prompt, _semaphore, clear_guide_cancelled, is_guide_cancelled
|
||||
from textkit import _unique_title, _load_blocks, _norm_title, _title, parse_facts
|
||||
|
||||
log = logging.getLogger("creator.guide")
|
||||
|
||||
@@ -66,10 +53,7 @@ async def _load_subblocks(topic: str) -> dict[str, list[dict]]:
|
||||
out: dict[str, list[dict]] = {}
|
||||
for r in await list_subblocks(topic):
|
||||
if r["status"] == "consensus" and r["sub_title"]:
|
||||
try:
|
||||
facts = json.loads(r["facts"]) if r.get("facts") else {}
|
||||
except (ValueError, TypeError):
|
||||
facts = {}
|
||||
facts = parse_facts(r.get("facts"))
|
||||
level = r["level"] if r["level"] in _LEVELS_OK else "advanced"
|
||||
out.setdefault(r["block"], []).append(
|
||||
{"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts})
|
||||
@@ -234,13 +218,19 @@ async def reconcile_guides() -> None:
|
||||
file write and status update.
|
||||
"""
|
||||
for g in await list_guides():
|
||||
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
|
||||
log.warning("[%s] Guide %s: done without content file — set to error", g["topic"], g["id"])
|
||||
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:
|
||||
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
|
||||
async with _semaphore:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_guide(guide_id, status="generating", progress="Starting…", updated_at=now)
|
||||
@@ -257,12 +247,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
||||
await asyncio.to_thread(_convert_pdfs, project)
|
||||
|
||||
import guide_board # lazy — guide_board imports helpers from this module
|
||||
# Re-run from stage: cards from `ab_step` onward back to that column.
|
||||
# A FINISHED guide without ab_step → complete fresh start (board + slots wiped).
|
||||
# Otherwise cards are leftovers of an abort/error → resume at their stored stage.
|
||||
if ab_step is not None:
|
||||
await guide_board.reset_from_stage(topic, format_name, ab_step)
|
||||
elif content_path.exists():
|
||||
# A FINISHED guide → complete fresh start (board + slots wiped). Otherwise the cards
|
||||
# are leftovers of an abort/error/repair → resume at their stored stage.
|
||||
if content_path.exists():
|
||||
counts = await guide_stage_counts(topic, format_name)
|
||||
if not counts or set(counts) == {"done"}:
|
||||
await delete_guide_board(topic, format_name)
|
||||
|
||||
+120
-78
@@ -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
|
||||
@@ -593,6 +643,8 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
|
||||
is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
# „-g"+hex4-Suffix markiert Guide-Läufe (Blocks-Suffixe sind reines Hex) —
|
||||
# database.latest_board_runs erkennt Guide-run_ids an genau diesem Muster.
|
||||
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
|
||||
try:
|
||||
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||||
@@ -602,25 +654,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,69 +748,64 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
|
||||
columns.append({"key": stage, "label": STAGE_LABELS[stage],
|
||||
"total": len(in_stage), "cards": views})
|
||||
import qa as qa_mod # lazy wie in board_inventory
|
||||
tdir = qa_mod.QA_DIR / topic
|
||||
greports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
note_guide = (_json_file(greports[-1]) or {}).get("note_guide") if greports else None
|
||||
note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide")
|
||||
return {"columns": columns, "qa_guide": note_guide}
|
||||
|
||||
|
||||
async def repair_karten(topic: str, format_name: str) -> list[str]:
|
||||
"""QA-Befund-getriebenes Guide-Repair: Karten, die im jüngsten Guide-QA-Report
|
||||
Befunde tragen, gehen zurück auf `pruefer` (md bleibt) — Prüfer+Fix beheben gezielt,
|
||||
generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum
|
||||
Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel."""
|
||||
"""QA-Befund-getriebenes Guide-Repair (Pendant zum Blocks-Repair: „Score unter 10
|
||||
muss einen Fix-Pfad haben"). Kritische Befunde (marker_fehlend/ziel_ohne_anker/
|
||||
fachlich_falsch) → zurück auf `pruefer` (brauchen Facts-/Ziele-Kontext + Re-Prüfer).
|
||||
Reine Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit expliziten
|
||||
Auftragszeilen: der Prüfer sieht pro Karte nur die EIGENE Section und kann
|
||||
Cross-Karten-Redundanz nie wiederfinden — der Fix bekommt den Kontext hier hinein
|
||||
(kein KRITISCH-Präfix → genau EIN Rewrite ohne Re-Prüfer). generate_guide resumt
|
||||
die offenen Karten und misst am Ende neu. → betroffene Blocktitel."""
|
||||
import qa as qa_mod
|
||||
tdir = qa_mod.QA_DIR / topic
|
||||
reports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
from guide_qa import block_budget, LAENGE_BAND
|
||||
reports = qa_mod.report_paths(topic, guide=True)
|
||||
rep = _json_file(reports[-1]) if reports else None
|
||||
if not rep:
|
||||
return []
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
|
||||
norms: set[str] = set()
|
||||
kritisch: set[str] = set()
|
||||
for e in rep.get("marker_fehlend", []): # "Block · sub"
|
||||
norms.add(_norm_title(str(e).split(" · ")[0]))
|
||||
kritisch.add(_norm_title(str(e).split(" · ")[0]))
|
||||
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
|
||||
norms.add(str(e).split(" · ")[0])
|
||||
for e in rep.get("laengen_ausreisser", []): # {"block": titel}
|
||||
norms.add(_norm_title(e.get("block", "") if isinstance(e, dict) else str(e)))
|
||||
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
|
||||
norms.add(_norm_title(str(e).split(":")[0]))
|
||||
kritisch.add(str(e).split(" · ")[0])
|
||||
for t in rep.get("fachlich_falsch", []) or []:
|
||||
norms.add(_norm_title(str(t)))
|
||||
kritisch.add(_norm_title(str(t)))
|
||||
stil: dict[str, list[str]] = {}
|
||||
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
|
||||
for seite in ("a", "b"):
|
||||
norms.add(_norm_title(str(e.get(seite, "")).split(":")[0]))
|
||||
a, b = str(e.get("a", "")), str(e.get("b", ""))
|
||||
bn = _norm_title(b.split(":")[0])
|
||||
auszug = b.split(":", 1)[1].strip() if ":" in b else b
|
||||
stil.setdefault(bn, []).append(
|
||||
f"- BALLAST (kürzen): Absatz doppelt zu Block «{a.split(':')[0]}» („{auszug}…\") — "
|
||||
"hier straffen, der Inhalt bleibt im anderen Block")
|
||||
for e in rep.get("laengen_ausreisser", []): # {"block", "zeichen", "budget"}
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
bn = _norm_title(e.get("block", ""))
|
||||
budget = e.get("budget") or block_budget([])
|
||||
lo, hi = LAENGE_BAND
|
||||
stil.setdefault(bn, []).append(
|
||||
f"- LÄNGE: {e.get('zeichen')} Zeichen (Budget {budget}, erlaubt "
|
||||
f"{round(lo * budget)}–{round(hi * budget)}): schreibe den ausführlich-Teil auf etwa "
|
||||
f"{budget} Zeichen GESAMT um — Sockel-Prosa und Wiederholungen streichen, "
|
||||
"alle Sub-Marker und Beispiele behalten")
|
||||
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
|
||||
bn = _norm_title(str(e).split(":")[0])
|
||||
hint = str(e).split(":", 1)[1].strip() if ":" in str(e) else str(e)
|
||||
stil.setdefault(bn, []).append(f"- LESBARKEIT: {hint}")
|
||||
betroffen = []
|
||||
for n in sorted(norms & set(cards)):
|
||||
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
|
||||
for n in sorted((kritisch | set(stil)) & set(cards)):
|
||||
if n in kritisch:
|
||||
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
|
||||
else:
|
||||
await db.set_guide_card(topic, format_name, n, stage="fix", status="open",
|
||||
gate_info="\n".join(stil[n]))
|
||||
betroffen.append(cards[n]["block"])
|
||||
return betroffen
|
||||
|
||||
|
||||
async def reset_card(topic: str, format_name: str, block_norm: str, ab_stage: int) -> bool:
|
||||
"""Reset ONE guide card to a stage (single-card variant of reset_from_stage):
|
||||
fields re-zeroed, md only wiped for writer(2) and earlier, lernziele only for 0."""
|
||||
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
|
||||
if block_norm not in cards:
|
||||
return False
|
||||
fields = dict(stage=GUIDE_STAGES[ab_stage], status="open", writer_rounds=0, gate_info="")
|
||||
if ab_stage <= 2:
|
||||
fields["md"] = ""
|
||||
if ab_stage == 0:
|
||||
await db.delete_lernziele(topic, block_norm)
|
||||
await db.set_guide_card(topic, format_name, block_norm, **fields)
|
||||
return True
|
||||
|
||||
|
||||
async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int:
|
||||
"""Cards in stages ≥ ab_stage (incl. done) back to GUIDE_STAGES[ab_stage]."""
|
||||
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
|
||||
target = GUIDE_STAGES[ab_stage]
|
||||
stages = list(GUIDE_STAGES[ab_stage:]) + ["done"]
|
||||
if ab_stage == 0:
|
||||
for c in await db.list_guide_cards(topic, format_name):
|
||||
await db.delete_lernziele(topic, c["block_norm"])
|
||||
moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target,
|
||||
clear_md=ab_stage <= 2)
|
||||
return moved
|
||||
+13
-26
@@ -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
|
||||
@@ -74,10 +76,10 @@ def ziel_ohne_anker(cards: list[dict], ziele: list[dict]) -> list[str]:
|
||||
# ein dichter Sub (viele key_points, Fakten, Beispiel) trägt mehr Text als ein Einzeiler.
|
||||
# Die Pipeline (Writer-Vorgabe, Prüfer-Trigger, Fix-Ziel) nutzt DIESELBE Formel mit engerem
|
||||
# Band — Messlatte und Fix-Auftrag müssen übereinstimmen, sonst sind Befunde unfixbar.
|
||||
BUDGET_BASIS = 200 # Einstieg/Übergang je Sub
|
||||
BUDGET_KEY_POINT = 160 # ~1–2 Sätze Erklärung je key_point
|
||||
BUDGET_FAKT = 60 # zitierter Fakt, in den Text eingewoben
|
||||
BUDGET_BEISPIEL = 250 # ausgearbeitetes Beispiel
|
||||
BUDGET_BASIS = 140 # Einstieg/Übergang je Sub
|
||||
BUDGET_KEY_POINT = 110 # ~1 Satz Erklärung je key_point
|
||||
BUDGET_FAKT = 45 # zitierter Fakt, in den Text eingewoben
|
||||
BUDGET_BEISPIEL = 170 # knappes Beispiel
|
||||
LAENGE_BAND = (0.35, 1.5) # QA-Toleranz um das Blockbudget
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
File renamed without changes.
@@ -19,6 +19,7 @@ import logging
|
||||
|
||||
import database as db
|
||||
from config import KANBAN_BATCH, MAX_CARD_RETRIES, MAX_CONCURRENT_AGENTS_PER_TOPIC, RETRY_BACKOFF
|
||||
from pipeline import AgentInfraError
|
||||
|
||||
log = logging.getLogger("creator.kanban")
|
||||
|
||||
@@ -165,6 +166,12 @@ async def _worker(flow: Flow, spec: Stage, inflight: int, all_stages: list[str])
|
||||
flow.active_cards.update(f"{spec.board}:{i}" for i in ids)
|
||||
try:
|
||||
await spec.process(cards)
|
||||
except AgentInfraError as e: # 429/Timeout erschöpft → Lauf pausieren, NICHT dead-letter
|
||||
log.warning("kanban %s/%s: infra-pause: %s", topic, spec.stage, e)
|
||||
flow.state["infra_paused"] = True
|
||||
flow.state["infra_error"] = str(e)
|
||||
flow.stop = True
|
||||
flow.wake.set()
|
||||
except Exception as e: # one bad package must not kill the worker → backoff/dead-letter
|
||||
log.info("kanban %s/%s: %s: %s", topic, spec.stage, type(e).__name__, e)
|
||||
try:
|
||||
|
||||
+29
-14
@@ -269,7 +269,13 @@ def _rating_text(rating: dict) -> str:
|
||||
|
||||
# Deterministic guard against double questions — the AI critic misses "…, and which…".
|
||||
_QUESTION_WORD = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)"
|
||||
_DOUBLE_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _QUESTION_WORD + r"\b", re.IGNORECASE)
|
||||
# Optional preposition between the conjunction and the question word — the second question
|
||||
# hides behind it: "und IN welcher Datei", "und UNTER welcher Bedingung".
|
||||
_PREP = r"(?:in|unter|auf|für|mit|bei|zu[rm]?|von|vom|über|durch|aus|nach|an|am|um|gegen|ohne|wobei)\s+"
|
||||
_DOUBLE_RE = re.compile(
|
||||
r"[,;]?\s+(?:und|sowie|außerdem|bzw\.?)\s+(?:" + _PREP + r")?" + _QUESTION_WORD + r"\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _double_question_flaw(question: str) -> str | None:
|
||||
@@ -368,9 +374,9 @@ def _avoid_block(avoid: list[str] | None) -> str:
|
||||
# the model takes the easy path (mere recall) — the cues lift higher tiers to apply/analyze/transfer.
|
||||
TIER_ROLE = {
|
||||
"beginner": "The learner is a BEGINNER. Cognitive: REMEMBER/UNDERSTAND. Ask about the basic understanding — the core concept, simple and direct.",
|
||||
"advanced": "The learner is ADVANCED. Cognitive: APPLY. Pose a small concrete situation and have the concept applied to it — don't just ask for the definition.",
|
||||
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Have them distinguish/compare, classify a special case or uncover a typical pitfall (hurdle) — don't quiz textbook knowledge.",
|
||||
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have the concept transferred to a NEW problem, justify a decision or weigh a trade-off.",
|
||||
"advanced": "The learner is ADVANCED. Cognitive: APPLY. Have the concept applied to ONE minimal case — name the case in a half-sentence (no story, no roles, no backstory), then ask ONE single thing.",
|
||||
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Ask for ONE distinction, comparison, or typical pitfall — a single question, no scenario prose.",
|
||||
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have them justify ONE decision or weigh ONE trade-off — one question, no build-up, no second sub-question.",
|
||||
}
|
||||
|
||||
|
||||
@@ -409,18 +415,27 @@ async def exam_question_variant(
|
||||
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
|
||||
) -> str | None:
|
||||
"""Action 'question' with a pattern: from a predefined pattern, phrase a concrete question in
|
||||
the addressee role of the tier. No critic (the pattern is build-checked).
|
||||
The style guard stays as a cheap protection against double questions · None on error."""
|
||||
the addressee role of the tier. No AI critic (the pattern is build-checked), but the
|
||||
deterministic double-question guard runs and forces a regenerate on a flaw · None on error."""
|
||||
try:
|
||||
section_block, compact_block = _section_blocks(section, compact)
|
||||
data = await _gen_call(
|
||||
"Block-Question-Variante", "guide", _question_schema, provider, lane="batch",
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
return data["question"]
|
||||
kritik_block = "(none)"
|
||||
question = None
|
||||
for _ in range(CRITIC_MAX_ROUNDS):
|
||||
data = await _gen_call(
|
||||
"Block-Question-Variante", "guide", _question_schema, provider, lane="batch",
|
||||
topic=topic, block=block, section_block=section_block,
|
||||
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
|
||||
kritik_block=kritik_block,
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
question = data["question"]
|
||||
flaw = _double_question_flaw(question)
|
||||
if not flaw:
|
||||
return question
|
||||
kritik_block = _critique_block(question, [flaw])
|
||||
return question # best-effort after the last round
|
||||
except Exception:
|
||||
log.warning("[%s] Question variant failed (%s)", topic, block, exc_info=True)
|
||||
return None
|
||||
|
||||
+10
-38
@@ -19,13 +19,6 @@ class GuideCreateRequest(BaseModel):
|
||||
format: FormatType
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
ab_step: int | None = Field(default=None, ge=0, le=5) # re-run from board stage (0 lernziele … 5 lesbarkeit); None = full/resume
|
||||
|
||||
|
||||
class GuideBoardResetRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
format: FormatType = "Guide"
|
||||
ab_stage: int = Field(ge=0, le=5) # reset cards back to this board stage (no generation)
|
||||
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
@@ -35,10 +28,12 @@ class TopicCreateRequest(BaseModel):
|
||||
class QaRunRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
llm: bool = True # wie das Gate: Echtheits-/Dubletten-Stichprobe inklusive
|
||||
ebene: str | None = None # None = alle Ebenen; "inventory"/"artefacts" = ohne Guide-QA-Overhead
|
||||
|
||||
|
||||
class RepairRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
ebene: str | None = None # None = alle Fixes, "inventory" oder "artefacts" filtert je Ebene
|
||||
|
||||
|
||||
class BlocksCreateRequest(BaseModel):
|
||||
@@ -46,14 +41,15 @@ class BlocksCreateRequest(BaseModel):
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
source_type: SourceType = "thema"
|
||||
source_location: str = Field(default="", max_length=2000)
|
||||
source_location: str = Field(default="", max_length=20000) # link mode: one URL per line
|
||||
research: bool = True # False = Continue: drain the existing kanban queue, no new search
|
||||
qa_force: bool = False # True = übersteuert ein pausierendes QA-Gate („Trotzdem fortsetzen")
|
||||
|
||||
|
||||
class BlocksCardRestartRequest(BaseModel):
|
||||
topic: str = Field(min_length=1)
|
||||
card_id: str = Field(min_length=1, max_length=200)
|
||||
# „Auto" pro Ebene: nach der QA im Loop „Befunde beheben", bis 100 % / Stillstand / 10×.
|
||||
# Aus = nach der QA stoppen (Kontrollpunkt). Default an.
|
||||
auto_inventory: bool = True
|
||||
auto_artefacts: bool = True
|
||||
auto_guide: bool = True
|
||||
only_artefacts: bool = False # True = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate)
|
||||
|
||||
|
||||
class GuideFormatRequest(BaseModel):
|
||||
@@ -68,28 +64,6 @@ class PracticeAnswerRequest(BaseModel):
|
||||
correct: bool
|
||||
|
||||
|
||||
class GuideCardResetRequest(BaseModel):
|
||||
topic: str = Field(min_length=1)
|
||||
format: str = Field(min_length=1)
|
||||
block_norm: str = Field(min_length=1, max_length=200)
|
||||
ab_stage: int = Field(ge=0, le=5)
|
||||
|
||||
|
||||
class BlocksResetStageRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
board: Literal["inventory", "artefacts"]
|
||||
stage: str = Field(min_length=1, max_length=40) # kanban column to reset back to
|
||||
|
||||
|
||||
class BlocksStep(BaseModel):
|
||||
label: str
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BlocksFineStep(BaseModel):
|
||||
label: str
|
||||
phase: str = ""
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BlocksStatusResponse(BaseModel):
|
||||
@@ -98,8 +72,6 @@ class BlocksStatusResponse(BaseModel):
|
||||
progress: str | None = None
|
||||
error: str | None = None
|
||||
partial: bool = False
|
||||
steps: list[BlocksStep] = []
|
||||
feine_steps: list[BlocksFineStep] = []
|
||||
|
||||
|
||||
class FolderResponse(BaseModel):
|
||||
@@ -110,7 +82,7 @@ class FolderResponse(BaseModel):
|
||||
class BlocksSourceUpdate(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
type: SourceType = "thema"
|
||||
location: str = Field(default="", max_length=2000)
|
||||
location: str = Field(default="", max_length=20000) # link mode: one URL per line
|
||||
spec: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
|
||||
+39
-72
@@ -9,14 +9,11 @@ import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agents import run_agent, kill_process, cancel_scope, clear_scope
|
||||
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
|
||||
from database import update_guide
|
||||
from jsonio import read_json_file as _json_file
|
||||
from textkit import _STUFEN
|
||||
|
||||
log = logging.getLogger("creator.pipeline")
|
||||
|
||||
@@ -80,6 +77,15 @@ def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
|
||||
return f"{label} (exit {returncode}, no output)"
|
||||
|
||||
|
||||
_INFRA_MARKERS = ("HTTP 429", "HTTP 5", "rate_limit", "Timeout after",
|
||||
"ConnectError", "ConnectTimeout", "ReadError", "RemoteProtocolError")
|
||||
|
||||
|
||||
def _is_infra(err: str) -> bool:
|
||||
"""Transport-/Infra-Fehler (retry + pause) statt inhaltlichem Fehlschlag."""
|
||||
return any(m in (err or "") for m in _INFRA_MARKERS)
|
||||
|
||||
|
||||
def _gather_error(label: str, results: list) -> str:
|
||||
for r in results:
|
||||
if isinstance(r, BaseException):
|
||||
@@ -132,7 +138,6 @@ def _runde_schema(data, final: bool = False):
|
||||
return include, rest
|
||||
|
||||
|
||||
_RELEVANCE = ("relevant", "peripheral")
|
||||
_YESNO = ("ja", "nein")
|
||||
|
||||
|
||||
@@ -159,23 +164,14 @@ def _enum_map_schema(key: str, allowed):
|
||||
return parse
|
||||
|
||||
|
||||
_levels_schema = _enum_map_schema("levels", _STUFEN) # level ∈ beginner/advanced/expert
|
||||
_relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈ relevant/peripheral
|
||||
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
|
||||
|
||||
|
||||
from config import MAX_RESTARTS as _MAX_RESTARTS, 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()
|
||||
from config import (MAX_RESTARTS as _MAX_RESTARTS, HEDGE_NACH_S as _HEDGE_NACH_S, # noqa: E402 — zentral tunebar
|
||||
INFRA_MAX_RETRIES as _INFRA_MAX_RETRIES, INFRA_BACKOFF_BASE as _INFRA_BACKOFF)
|
||||
|
||||
|
||||
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,18 +184,10 @@ 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))}
|
||||
infra_attempts = {i: 0 for i in range(len(slots))}
|
||||
infra_erschoepft: set[int] = set()
|
||||
tasks: dict[asyncio.Task, int] = {}
|
||||
keys: dict[asyncio.Task, str] = {}
|
||||
born: dict[asyncio.Task, float] = {}
|
||||
@@ -209,9 +197,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 +212,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 +220,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 +233,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,26 +275,36 @@ 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
|
||||
|
||||
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {err}")
|
||||
infra = _is_infra(err)
|
||||
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}{' infra' if infra else ''}): {err}")
|
||||
attempts[i] += 1
|
||||
# If the minimum already stands, restarts are pointless — the restart
|
||||
# would be killed at the grace end anyway. A still-running twin IS the retry.
|
||||
enough = grace is not None and len(results) >= quorum
|
||||
zwilling = any(i2 == i for i2 in tasks.values())
|
||||
if attempts[i] <= _MAX_RESTARTS and not enough and not zwilling and not (cancelled and cancelled()):
|
||||
if enough or zwilling or (cancelled and cancelled()):
|
||||
continue
|
||||
if infra:
|
||||
# 429/Timeout/Netz: eigener Zähler + wachsende Pause, dann Eskalation
|
||||
infra_attempts[i] += 1
|
||||
if infra_attempts[i] > _INFRA_MAX_RETRIES:
|
||||
infra_erschoepft.add(i) # kein Respawn → raise am Quorum-Miss
|
||||
continue
|
||||
await asyncio.sleep(_INFRA_BACKOFF * 2 ** (infra_attempts[i] - 1))
|
||||
spawn(i)
|
||||
elif attempts[i] <= _MAX_RESTARTS:
|
||||
spawn(i)
|
||||
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
|
||||
_detach_rest()
|
||||
return results
|
||||
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
|
||||
if infra_erschoepft:
|
||||
raise AgentInfraError(
|
||||
f"{label}: {len(infra_erschoepft)} Slot(s) nach {_INFRA_MAX_RETRIES} "
|
||||
"Infra-Retries erschöpft (429/Timeout)")
|
||||
return None
|
||||
finally:
|
||||
for task, i in tasks.items():
|
||||
@@ -365,6 +327,11 @@ class GenContext:
|
||||
OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
|
||||
|
||||
|
||||
class AgentInfraError(Exception):
|
||||
"""Slot nach Infra-Retries erschöpft (429/Timeout). KEIN inhaltliches Urteil —
|
||||
der Aufrufer pausiert den Lauf, nie fail-open."""
|
||||
|
||||
|
||||
async def run_single_slot(
|
||||
ctx: GenContext, label: str, *,
|
||||
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, on_line=None,
|
||||
|
||||
+188
-43
@@ -11,7 +11,8 @@ previous report of the same topic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
@@ -19,23 +20,28 @@ from pathlib import Path
|
||||
|
||||
import database as db
|
||||
import embedding
|
||||
from config import STORAGE_DIR, SUB_DUP_KANDIDAT_COS
|
||||
from config import JUDGE_CHUNK, STORAGE_DIR, SUB_DUP_KANDIDAT_COS
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from paths import arbeit_dir
|
||||
from textkit import _norm_title
|
||||
|
||||
log = logging.getLogger("creator.qa")
|
||||
|
||||
QA_DIR = STORAGE_DIR / "qa"
|
||||
JACCARD_FLOOR = 0.5 # title token overlap that makes a pair suspicious
|
||||
EMB_FLOOR = 0.82 # casefolded title cosine (own threshold, NOT the pipeline's 0.65)
|
||||
SECTION_CHARS = 4000 # own paragraph splitter — independent of _text_sections
|
||||
COVER_MIN_TOKENS = 2 # distinctive block tokens a section must share to count as covered
|
||||
FREMD_MIN_TOKENS = 1 # distinctive title tokens that must appear in the corpus
|
||||
CONCEPT_COVER_RATIO = 0.6 # fraction of a named result's stems a block must share to count as covered
|
||||
LLM_SAMPLE = 12 # pairs/sections per judge call with --llm
|
||||
# Note 0-10, deterministisch aus den Quoten (transparent, diffbar — keine LLM-"Gefühlsnote").
|
||||
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff); Dubletten-VERDACHT enthält
|
||||
# bewusst Rauschen und wiegt daher wenig.
|
||||
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "hygiene": 0.5}
|
||||
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff). konzept_luecken (benannte Kernresultate
|
||||
# ohne Baustein) ist heuristisch → mittleres Gewicht. Dubletten-VERDACHT enthält bewusst Rauschen,
|
||||
# bildet aber das Nutzerproblem (Dopplungen) ab → leichtestes Gewicht, aber nicht null.
|
||||
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "konzept_luecken": 1.5,
|
||||
"dubletten_verdacht": 1.0, "hygiene": 0.5}
|
||||
# subs/artefacts only exist after board 2 — at gate time these quotas would always be 0
|
||||
# and water down the inventory score, hence a separate score.
|
||||
# sub_dubletten counts only with --llm (confirmed pairs); the bare candidate list is
|
||||
@@ -92,9 +98,15 @@ def _corpus_texts(topic: str) -> dict[str, str]:
|
||||
out = {}
|
||||
for f in sorted(folder.glob("*.txt")):
|
||||
try:
|
||||
out[f.name] = f.read_text(encoding="utf-8")
|
||||
text = f.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
# QUELLE:-Header (Metadata, kein Lerninhalt) strippen — sonst bildet er bei großen
|
||||
# Seiten einen isolierten, block-losen Abschnitt = False-Positive-Lücke.
|
||||
lines = text.splitlines()
|
||||
if lines and lines[0].startswith("QUELLE:"):
|
||||
text = "\n".join(lines[1:]).lstrip("\n")
|
||||
out[f.name] = text
|
||||
return out
|
||||
|
||||
|
||||
@@ -179,6 +191,55 @@ def luecken(blocks: list[dict], subs_by_norm: dict[str, list[str]], corpus: dict
|
||||
return out
|
||||
|
||||
|
||||
# A NAMED result carries a concept name, not just a number: "Satz 7.13 (Christofides)",
|
||||
# "Satz 6.24: Cook-Levin". These are the core statements the token-based `luecken` misses — 108
|
||||
# over-granular blocks cover every SECTION, yet the named core result may have no block at all.
|
||||
# Generic + domain-safe: the NUMBER is mandatory (a numbered labeled unit is a formal statement in
|
||||
# a structured document, any field), so prose like "ein Satz von Goethe" (no number) never matches.
|
||||
_NAMED_RESULT_RE = re.compile(
|
||||
r'\b(?:Satz|Lemma|Korollar|Theorem|Proposition|Folgerung|Definition|Algorithmus)\s+\d+(?:\.\d+)*\s*'
|
||||
r'(?:\(\s*([^()\n]{3,60}?)\s*\)|:\s*([^\n.;·]{3,60}?)\s*(?:[.\n;·]|$))', re.M)
|
||||
|
||||
|
||||
def _stem(t: str) -> str:
|
||||
"""Declension-tolerant token stem: drop trailing digits, keep the 5-char prefix
|
||||
('Eulerschen'/'Eulerscher' → 'euler', 'Kreise'/'Kreis' → 'kreis')."""
|
||||
return t.rstrip("0123456789")[:5]
|
||||
|
||||
|
||||
def _named_results(corpus: dict[str, str]) -> dict[str, set[str]]:
|
||||
"""Named/attributed corpus results → {concept name: distinctive stems}. A bare 'Satz 7.18'
|
||||
(number, no name) yields nothing to match. Same catalogue vocabulary as the title strip.
|
||||
Umbruch-Schutz (generisch): endet ein Doppelpunkt-Fang am Zeilenumbruch und läuft die
|
||||
nächste Zeile klein weiter, ist es ein UMBROCHENER SATZ, kein Name — solche Fragmente
|
||||
(„Satz 6.16: Wenn ein … in P⏎liegt …") kann kein Block je ankern (Phantom-Lücke)."""
|
||||
out: dict[str, set[str]] = {}
|
||||
for text in corpus.values():
|
||||
for m in _NAMED_RESULT_RE.finditer(text):
|
||||
name = (m.group(1) or m.group(2) or "").strip()
|
||||
if m.group(2) and m.end() > 0 and text[m.end() - 1] == "\n":
|
||||
rest = text[m.end():].lstrip(" \t")
|
||||
if rest[:1].islower():
|
||||
continue # Satzfragment: Fortsetzung klein auf der nächsten Zeile
|
||||
toks = _distinctive(name)
|
||||
if len(name) >= 3 and toks:
|
||||
out.setdefault(name, set()).update(_stem(t) for t in toks)
|
||||
return out
|
||||
|
||||
|
||||
def konzept_luecken(blocks: list[dict], named: dict[str, set[str]]) -> list[str]:
|
||||
"""Named corpus results that NO block covers — concept gaps the token-based `luecken` cannot see.
|
||||
Covered = a block whose title+description share ≥ CONCEPT_COVER_RATIO of the result's distinctive
|
||||
stems (declension-tolerant). Errs toward 'covered' so the heuristic never invents a false gap."""
|
||||
anchors = [{_stem(t) for t in _distinctive(b["title"]) | _distinctive(b.get("description") or "")}
|
||||
for b in blocks]
|
||||
out = []
|
||||
for name, stems in named.items():
|
||||
if not any(len(stems & a) >= CONCEPT_COVER_RATIO * len(stems) for a in anchors):
|
||||
out.append(name)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def fremd(blocks: list[dict], corpus: dict[str, str]) -> list[str]:
|
||||
"""Blocks whose distinctive title tokens never appear in the corpus (scope creep).
|
||||
Token/stem match, NOT raw substring — 'bergang' ⊂ 'Übergang' had whitewashed the
|
||||
@@ -224,8 +285,10 @@ def hygiene(blocks: list[dict]) -> list[dict]:
|
||||
|
||||
def _zaehlbare_luecken(lk: list[dict], llm: bool) -> list[dict]:
|
||||
"""With --llm only non-refuted gaps count ('?' = unjudged stays, conservative) — refuted
|
||||
ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0)."""
|
||||
return [x for x in lk if x.get("llm") != "nein"] if llm else lk
|
||||
ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0).
|
||||
Persistierte Freisprüche (2:1 im Repair) zählen nie."""
|
||||
offen = [x for x in lk if not x.get("freispruch")]
|
||||
return [x for x in offen if x.get("llm") != "nein"] if llm else offen
|
||||
|
||||
|
||||
def note(quoten: dict, gewichte: dict = NOTE_GEWICHTE) -> float:
|
||||
@@ -281,19 +344,72 @@ def _qa_prompt(name: str, **kwargs) -> str:
|
||||
return (TEMPLATES_DIR / "QA" / f"{name}.md").read_text(encoding="utf-8").format(**kwargs)
|
||||
|
||||
|
||||
async def _llm_verdicts(template: str, topic: str, key: str, items: list[str]) -> dict[int, str]:
|
||||
async def judge_wave(template: str, topic: str, key: str, slot: str, items: list[str],
|
||||
*, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA",
|
||||
extra: str = "") -> dict[int, str]:
|
||||
"""Gechunkte Ja/Nein-Judge-Welle über ALLE Items, Chunks parallel (die Semaphoren in
|
||||
agents.py begrenzen); Ergebnis mit globalen 1-basierten Indizes. Fail-open pro Chunk
|
||||
(Items bleiben ohne Urteil), aber nie stumm. Ersetzt die drei strukturgleichen
|
||||
Handkopien in repair/qa/guide_qa. `extra` füllt den {extra}-Slot des Templates
|
||||
(z. B. Inventar-Kontext für das Lücken-Urteil im Repair)."""
|
||||
from agents import run_agent
|
||||
from pipeline import _yesno_schema
|
||||
from pipeline import _timeout, _yesno_schema
|
||||
from jsonio import parse_json_text
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(items, 1))
|
||||
slot = {"Dubletten": "pairs", "Luecken": "sections", "Bausteine": "blocks", "Sub": "pairs"}[template.split("-")[1]]
|
||||
rc, out, _err = await run_agent(f"qa-{topic}-{key}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}),
|
||||
600, role="judge", capabilities="none", scope=topic, label=f"QA {key}")
|
||||
return (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {}
|
||||
|
||||
async def _chunk(lo: int) -> dict[int, str]:
|
||||
teil = items[lo:lo + chunk]
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1))
|
||||
try:
|
||||
rc, out, _err = await run_agent(
|
||||
f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra=extra, **{slot: listing}),
|
||||
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}")
|
||||
except Exception:
|
||||
log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo)
|
||||
return {}
|
||||
if rc != 0:
|
||||
log.warning("[%s] %s-Judge %s+%d fehlgeschlagen (rc=%s) — %d Items ohne Urteil",
|
||||
topic, label, key, lo, rc, len(teil))
|
||||
return {}
|
||||
return _yesno_schema(parse_json_text(out)) or {}
|
||||
|
||||
offsets = range(0, len(items), chunk)
|
||||
results = await asyncio.gather(*[_chunk(lo) for lo in offsets])
|
||||
return {lo + k: urteil for lo, v in zip(offsets, results) for k, urteil in v.items()}
|
||||
|
||||
|
||||
# ── Report ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def report_paths(topic: str, guide: bool = False) -> list[Path]:
|
||||
"""QA-Reports eines Topics, mtime-aufsteigend (Run-ID- und Timestamp-Namen sortieren
|
||||
lexikographisch nicht). guide=True → die separate guide-*-Serie (guide_qa.py).
|
||||
freispruch.json teilt den Ordner, ist aber kein Report — immer außen vor."""
|
||||
tdir = QA_DIR / topic
|
||||
if not tdir.is_dir():
|
||||
return []
|
||||
return sorted((p for p in tdir.glob("*.json")
|
||||
if p.name.startswith("guide-") == guide and p.name != "freispruch.json"),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
_latest_cache: dict[tuple[str, bool], tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def latest_report(topic: str, guide: bool = False) -> dict | None:
|
||||
"""Jüngster Report als geparstes dict, mtime-gecacht — die Board-Snapshots lesen das
|
||||
im 1,2-s-Frontend-Takt, ein JSON-Read je Poll war unnötiges Datei-I/O. glob+stat
|
||||
bleiben (billig), der Read passiert nur bei geänderter mtime."""
|
||||
reports = report_paths(topic, guide)
|
||||
if not reports:
|
||||
return None
|
||||
p = reports[-1]
|
||||
mtime = p.stat().st_mtime
|
||||
key = (topic, guide)
|
||||
cached = _latest_cache.get(key)
|
||||
if cached is None or cached[0] != mtime:
|
||||
_latest_cache[key] = (mtime, _json_file(p) or {})
|
||||
return _latest_cache[key][1]
|
||||
|
||||
|
||||
def freispruch_pfad(topic: str) -> Path:
|
||||
return QA_DIR / topic / "freispruch.json"
|
||||
|
||||
@@ -302,6 +418,22 @@ def _paar_key(a: str, b: str) -> str:
|
||||
return "||".join(sorted((_norm_title(a), _norm_title(b))))
|
||||
|
||||
|
||||
def luecken_key(item: dict) -> str:
|
||||
"""Stabiler Freispruch-Schlüssel einer Lücken-Fundstelle: Datei + Vorschau-Hash.
|
||||
Abschnittsnummern verschieben sich bei Korpusänderung — dann veralten die Keys
|
||||
und werden inert (gewollt: neue Fundstellen brauchen ein neues Urteil)."""
|
||||
v = " ".join(str(item.get("vorschau", "")).casefold().split())
|
||||
return f"{item.get('datei', '')}||{hashlib.sha1(v.encode()).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def offene_luecken(report: dict) -> tuple[list[dict], list[str]]:
|
||||
"""Zählbare offene Lücken eines Reports: (luecken-Items ohne Freispruch/LLM-nein,
|
||||
konzept_luecken-Namen). Grundlage für Lücken-Research und Lücken-Urteil im Repair."""
|
||||
lk = [x for x in report.get("luecken", [])
|
||||
if not x.get("freispruch") and x.get("llm") != "nein"]
|
||||
return lk, list(report.get("konzept_luecken", []))
|
||||
|
||||
|
||||
def lade_freispruch(topic: str) -> dict[str, list[str]]:
|
||||
"""Persistierte 2:1-Freisprüche des Repair-Stichentscheids (repair._mit_stichentscheid):
|
||||
mehrheitlich als „behalten" geurteilte Befunde zählen nicht mehr in die Note — sonst
|
||||
@@ -331,6 +463,8 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
sd = sub_dubletten(sub_rows)
|
||||
lk = luecken(blocks, subs_by_norm, corpus) if corpus else []
|
||||
fr = fremd(blocks, corpus) if corpus else []
|
||||
named = _named_results(corpus) if corpus else {}
|
||||
kl = konzept_luecken(blocks, named) if corpus else []
|
||||
bl = beleg(blocks, sub_rows)
|
||||
hy = hygiene(blocks)
|
||||
n_sections = sum(len(_sections(t)) for t in corpus.values()) or 1
|
||||
@@ -339,43 +473,54 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
frei_fremd = set(frei.get("fremd") or [])
|
||||
fremd_frei = [t for t in fr if _norm_title(t) in frei_fremd]
|
||||
fr = [t for t in fr if _norm_title(t) not in frei_fremd]
|
||||
# Persistierte 2:1-Freisprüche: markiert bleibt sichtbar (diffbar), zählt aber nicht mehr
|
||||
# in Quote und Judge-Welle — sonst pendelte der Verdachts-Floor ewig unter 10.
|
||||
frei_dub = set(frei.get("dubletten") or [])
|
||||
for p in d:
|
||||
if _paar_key(p["a"], p["b"]) in frei_dub:
|
||||
p["freispruch"] = True
|
||||
d_offen = [p for p in d if not p.get("freispruch")]
|
||||
frei_lk = set(frei.get("luecken") or [])
|
||||
for x in lk:
|
||||
if luecken_key(x) in frei_lk:
|
||||
x["freispruch"] = True
|
||||
lk_offen = [x for x in lk if not x.get("freispruch")]
|
||||
frei_kl = set(frei.get("konzept_luecken") or [])
|
||||
kl_frei = [nm for nm in kl if _norm_title(nm) in frei_kl]
|
||||
kl = [nm for nm in kl if _norm_title(nm) not in frei_kl]
|
||||
|
||||
if llm and d:
|
||||
v = await _llm_verdicts("QA-Dubletten", topic, "dubletten",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]])
|
||||
for k, p in enumerate(d[:LLM_SAMPLE], 1):
|
||||
if llm and d_offen:
|
||||
v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in d_offen[:LLM_SAMPLE]])
|
||||
for k, p in enumerate(d_offen[:LLM_SAMPLE], 1):
|
||||
p["llm"] = v.get(k, "?")
|
||||
if llm and lk:
|
||||
v = await _llm_verdicts("QA-Luecken", topic, "luecken",
|
||||
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]])
|
||||
for k, x in enumerate(lk[:LLM_SAMPLE], 1):
|
||||
if llm and lk_offen:
|
||||
v = await judge_wave("QA-Luecken", topic, "luecken", "sections",
|
||||
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk_offen[:LLM_SAMPLE]])
|
||||
for k, x in enumerate(lk_offen[:LLM_SAMPLE], 1):
|
||||
x["llm"] = v.get(k, "?")
|
||||
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note
|
||||
for lo in range(0, len(sd), 40):
|
||||
chunk = sd[lo:lo + 40]
|
||||
v = await _llm_verdicts("QA-Sub-Dubletten", topic, f"sub-dubletten-{lo}",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in chunk])
|
||||
for k, p in enumerate(chunk, 1):
|
||||
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]
|
||||
@@ -400,16 +545,19 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
|
||||
"topic": topic, "erstellt": datetime.now(timezone.utc).isoformat(),
|
||||
"run_id": summary.get("run_id", ""), "bloecke": len(blocks),
|
||||
"quoten": {
|
||||
"dubletten_verdacht": round(len(d) / max(len(blocks), 1), 3),
|
||||
"dubletten_verdacht": round(len(d_offen) / max(len(blocks), 1), 3),
|
||||
"luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3),
|
||||
"fremd": round(len(fr) / max(len(blocks), 1), 3),
|
||||
**({"konzept_luecken": round(len(kl) / max(len(named), 1), 3)} if corpus else {}),
|
||||
"hygiene": round(len(hy) / max(len(blocks), 1), 3),
|
||||
**({"unechte_bloecke": round(len(unecht) / max(len(blocks), 1), 3)} if unecht is not None else {}),
|
||||
},
|
||||
"quoten_artefakte": quoten_art,
|
||||
**({"unecht": unecht} if unecht is not None else {}),
|
||||
**({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}),
|
||||
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "fremd": fr, "beleg": bl, "hygiene": hy,
|
||||
**({"konzept_luecken_freigesprochen": kl_frei} if kl_frei else {}),
|
||||
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "konzept_luecken": kl,
|
||||
"fremd": fr, "beleg": bl, "hygiene": hy,
|
||||
"artefakte": art,
|
||||
"lauf": summary,
|
||||
}
|
||||
@@ -432,10 +580,7 @@ def _diff(prev: dict | None, cur: dict) -> dict:
|
||||
def _write_report(report: dict) -> Path:
|
||||
tdir = QA_DIR / report["topic"]
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
# by mtime: run-id names (…-1311-5e5c) and timestamp names don't sort lexicographically.
|
||||
# guide-* reports share the directory but are a SEPARATE series (guide_qa.py).
|
||||
older = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
older = report_paths(report["topic"])
|
||||
prev = _json_file(older[-1]) if older else None
|
||||
report["diff_zum_vorlauf"] = _diff(prev, report)
|
||||
name = report["run_id"] or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
+263
-74
@@ -2,72 +2,73 @@
|
||||
|
||||
Blindes Re-Filtern reproduziert die blinden Flecken der Pipeline (sie hat die Befunde ja
|
||||
durchgelassen). Hier fließen die QA-BEFUNDE als Input in gezielte Aktionen: Hygiene
|
||||
deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach
|
||||
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
|
||||
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
|
||||
deterministisch (+ Beschreibung/Suffix per Sanierung/Rename), bestätigte Dubletten mergen
|
||||
(Zweitmeinung), Fremd/Unecht nur nach Gegen-Judge entfernen, Beleg-Nachfass je Sub,
|
||||
Lücken per Stichentscheid klären (Freispruch oder bestätigt = braucht Recherche).
|
||||
Fail-open überall: Zweifel/Judge-Fehler → behalten, nichts persistieren."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import database as db
|
||||
import qa
|
||||
from agents import run_agent
|
||||
from blocks import _blocks_files, _evidence_pack, source_folder
|
||||
from blocks import _blocks_files, _evidence_pack, material_folder, source_folder
|
||||
from config import EVIDENCE_PER_BLOCK
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import parse_json_text, read_json_file as _json_file
|
||||
from pipeline import _yesno_schema
|
||||
from textkit import _norm_title, _title, clean_title
|
||||
from jsonio import read_json_file as _json_file
|
||||
from textkit import _norm_title, _title, clean_title, parse_facts
|
||||
|
||||
log = logging.getLogger("creator.repair")
|
||||
|
||||
JUDGE_TIMEOUT = 600
|
||||
from config import EVIDENCE_PER_BLOCK, JUDGE_CHUNK # noqa: E402 — zentral tunebar
|
||||
|
||||
|
||||
async def repair_befunde(topic: str) -> dict:
|
||||
tdir = qa.QA_DIR / topic
|
||||
reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
|
||||
key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
|
||||
async def repair_befunde(topic: str, ebene: str | None = None, luecken_urteil: bool = True) -> dict:
|
||||
"""ebene=None → alle Fixes; „inventory" → nur Inventar; „artefacts" → nur Artefakte.
|
||||
luecken_urteil: offene Lücken/Konzept-Lücken dem Stichentscheid vorlegen (Freispruch
|
||||
oder bestätigt) — der QA-Gate-Watch schaltet es aus, solange seine Lücken-Recherche
|
||||
noch Runden hat. Misst am Ende IMMER beide Noten neu. `aktionen` zählt alles, was die
|
||||
Runde getan hat (Fixes+Merges+Freisprüche) — der Auto-Loop stoppt erst bei 0."""
|
||||
reports = qa.report_paths(topic)
|
||||
report = _json_file(reports[-1]) if reports else None
|
||||
if not report:
|
||||
return {"fehler": "kein QA-Report — erst QA laufen lassen"}
|
||||
files = _blocks_files(topic)
|
||||
cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
|
||||
by_norm = {_norm_title(c["payload"].get("title", "")): c for c in cards}
|
||||
inv = ebene in (None, "inventory")
|
||||
art = ebene in (None, "artefacts")
|
||||
|
||||
hygiene = await _fix_hygiene(topic, report, by_norm, files)
|
||||
merges = await _merge_dubletten(topic, report, by_norm, files)
|
||||
sub_merges, frei_subs = await _merge_sub_dubletten(topic, report, files)
|
||||
entfernt, frei_bloecke = await _entferne_fremd_unecht(topic, report, by_norm, files)
|
||||
aufgeraeumt = await _raeume_waisen(topic)
|
||||
hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else []
|
||||
hygiene += (await _fix_beschreibungen(topic, report, by_norm)) if inv else []
|
||||
hygiene += (await _fix_suffixe(topic, report, by_norm, files)) if inv else []
|
||||
merges, frei_paare = (await _merge_dubletten(topic, report, by_norm, files)) if inv else ([], [])
|
||||
sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], [])
|
||||
entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], [])
|
||||
frei_luecken = (await _pruefe_luecken(topic, report, by_norm)) if inv and luecken_urteil else []
|
||||
beleg_fix = (await _belege_nachfassen(topic, report, files)) if art else []
|
||||
aufgeraeumt = (await _raeume_waisen(topic)) if art else 0
|
||||
|
||||
# llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report
|
||||
# blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
|
||||
neu = await qa.qa_report(topic, llm=True)
|
||||
if neu:
|
||||
await qa.write_report(neu)
|
||||
na = neu.get("note_artefakte") if neu else None
|
||||
freigesprochen = frei_paare + frei_subs + frei_bloecke + frei_luecken
|
||||
lk_rest, kl_rest = qa.offene_luecken(neu or {})
|
||||
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
|
||||
"aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke,
|
||||
"braucht_research": len(report.get("luecken", []))}
|
||||
"beleg_fix": beleg_fix, "aufgeraeumt": aufgeraeumt, "freigesprochen": freigesprochen,
|
||||
"aktionen": (len(hygiene) + len(merges) + len(sub_merges) + len(entfernt)
|
||||
+ len(beleg_fix) + aufgeraeumt + len(freigesprochen)),
|
||||
"braucht_research": len(lk_rest) + len(kl_rest), # offene Lücken NACH dieser Runde
|
||||
"note": float(neu["note"]) if neu else 10.0, # neu gemessene Inventar-Note
|
||||
"note_artefakte": float(na) if na is not None else None} # None solange Board 2 leer
|
||||
|
||||
|
||||
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
|
||||
"""No-Tool-Judge-Wellen über alle Items (fail-open: Fehler → leeres Verdikt = behalten)."""
|
||||
verdicts: dict[int, str] = {}
|
||||
for lo in range(0, len(items), JUDGE_CHUNK):
|
||||
chunk = items[lo:lo + JUDGE_CHUNK]
|
||||
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(chunk, 1))
|
||||
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
|
||||
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str],
|
||||
extra: str = "") -> dict[int, str]:
|
||||
"""No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten)."""
|
||||
return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair",
|
||||
extra=extra)
|
||||
|
||||
|
||||
def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
|
||||
@@ -80,7 +81,7 @@ def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
|
||||
|
||||
async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
|
||||
lines: list[str], befund: str, kategorie: str = "",
|
||||
ids: list[str] | None = None) -> tuple[dict[int, str], list[str]]:
|
||||
ids: list[str] | None = None, extra: str = "") -> tuple[dict[int, str], list[str]]:
|
||||
"""Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei
|
||||
Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über
|
||||
die strittigen Items, Mehrheit 2/3 (Muster Crossblock-Tiebreaker). Ohne ihn pendelte
|
||||
@@ -89,11 +90,11 @@ async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
|
||||
Explizites 2:1-„behalten" wird als FREISPRUCH persistiert (kategorie+ids) — die QA
|
||||
zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht
|
||||
(fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen)."""
|
||||
v = await _judge(template, topic, key, slot, lines)
|
||||
v = await _judge(template, topic, key, slot, lines, extra=extra)
|
||||
strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund]
|
||||
frei: list[str] = []
|
||||
if strittig:
|
||||
v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig])
|
||||
v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig], extra=extra)
|
||||
gegen = "nein" if befund == "ja" else "ja"
|
||||
frei_keys: list[str] = []
|
||||
for pos, i in enumerate(strittig, 1):
|
||||
@@ -108,9 +109,165 @@ async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
|
||||
return v, frei
|
||||
|
||||
|
||||
async def _fix_beschreibungen(topic: str, report: dict, by_norm: dict) -> list[str]:
|
||||
"""Hygiene-Resttyp „leere-beschreibung": EINE Beschreibung aus Material-Auszügen
|
||||
generieren (Blocks-Sanierung-Template; der Titel bleibt — Repair ist flow-los und
|
||||
hat keinen Korpus-Anker-Kontext für Renames). Ohne Material/Auszüge bleibt der Befund."""
|
||||
from agents import run_agent
|
||||
from pipeline import _prompt, _timeout
|
||||
from jsonio import parse_json_text
|
||||
from board_inventory import _sanierung_schema
|
||||
items = [h for h in report.get("hygiene", []) if "leere-beschreibung" in (h.get("probleme") or [])]
|
||||
folder = source_folder(topic) or material_folder(topic)
|
||||
if not items or not folder:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for h in items:
|
||||
norm = _norm_title(h.get("titel", ""))
|
||||
card = by_norm.get(norm)
|
||||
if not card or (card["payload"].get("description") or "").strip():
|
||||
continue
|
||||
titel = card["payload"].get("title", "")
|
||||
ev = _evidence_pack(folder, card["payload"].get("sources") or None, [titel], budget=4000)
|
||||
if not ev:
|
||||
continue
|
||||
try:
|
||||
rc, txt, _err = await run_agent(
|
||||
f"repair-{topic}-sanierung-{norm[:24]}",
|
||||
_prompt("Blocks-Sanierung", topic=topic, title=titel, description="(leer)", excerpts=ev),
|
||||
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic,
|
||||
label="Repair Beschreibung")
|
||||
except Exception:
|
||||
log.exception("[%s] Beschreibungs-Sanierung fehlgeschlagen — Befund bleibt", topic)
|
||||
continue
|
||||
verdict = _sanierung_schema(parse_json_text(txt)) if rc == 0 else None
|
||||
if not verdict or not verdict[1].strip():
|
||||
continue
|
||||
p = dict(card["payload"])
|
||||
p["description"] = verdict[1].strip()
|
||||
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
|
||||
await db.set_block_status(topic, norm, "consensus", description=p["description"])
|
||||
card["payload"] = p
|
||||
out.append(f"beschrieben: {titel}")
|
||||
return out
|
||||
|
||||
|
||||
_SUFFIX_RE = re.compile(r"\s*\(\d+\)\s*$")
|
||||
|
||||
|
||||
async def _fix_suffixe(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
|
||||
"""Hygiene-Resttyp „kollisions-suffix" („Titel (2)", entstanden beim Spiegeln):
|
||||
Basis-Norm frei (Partner inzwischen gemerged/verworfen) → Rename auf den Basistitel
|
||||
inkl. Re-Key aller angehängten Tabellen (db.rename_block_norm) + Board-2-Karte +
|
||||
Sidecars. Basis besetzt → Judge entscheidet Dublette (Merge); sonst bleibt der
|
||||
Befund sichtbar (zwei echte gleichnamige Konzepte sind ein Naming-Problem)."""
|
||||
items = [h for h in report.get("hygiene", []) if "kollisions-suffix" in (h.get("probleme") or [])]
|
||||
out: list[str] = []
|
||||
for h in items:
|
||||
alt = h.get("titel", "")
|
||||
basis = _SUFFIX_RE.sub("", alt).strip()
|
||||
alt_norm, basis_norm = _norm_title(alt), _norm_title(basis)
|
||||
card = by_norm.get(alt_norm)
|
||||
if not card or not basis or basis_norm == alt_norm:
|
||||
continue
|
||||
if basis_norm in by_norm: # Basis lebt noch → Dubletten-Frage statt Rename
|
||||
merged, _frei = await _merge_dubletten(
|
||||
topic, {"dubletten": [{"a": basis, "b": alt, "llm": "?"}]}, by_norm, files)
|
||||
out += [f"suffix-merge: {z}" for z in merged]
|
||||
continue
|
||||
await db.rename_block_norm(topic, alt_norm, basis_norm, basis)
|
||||
b2 = await db.kanban_get_card(topic, "artefacts", alt_norm)
|
||||
if b2:
|
||||
p2 = dict(b2["payload"])
|
||||
p2["title"] = basis
|
||||
await db.kanban_upsert_card(topic, "artefacts", basis_norm, b2["kind"], b2["stage"], p2)
|
||||
await db.kanban_delete_card(topic, "artefacts", alt_norm)
|
||||
p = dict(card["payload"])
|
||||
p["title"] = basis
|
||||
if p.get("mirrored_norm"):
|
||||
p["mirrored_norm"] = basis_norm
|
||||
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
|
||||
card["payload"] = p
|
||||
_rename_in_files(files, alt_norm, basis)
|
||||
by_norm.pop(alt_norm, None)
|
||||
by_norm[basis_norm] = card
|
||||
out.append(f"suffix: {alt} → {basis}")
|
||||
return out
|
||||
|
||||
|
||||
async def _pruefe_luecken(topic: str, report: dict, by_norm: dict) -> list[str]:
|
||||
"""Lücken-Urteil (2:1): offene luecken-Fundstellen und konzept_luecken-Namen dem
|
||||
Stichentscheid vorlegen — 2:1-„keine echte Lücke" wird als Freispruch persistiert,
|
||||
bestätigte bleiben als braucht_research sichtbar (Gate-Watch recherchiert, der Nutzer
|
||||
klickt „+ Recherche"). Das Inventar geht als Kontext in den {extra}-Slot, damit die
|
||||
Judges Abdeckung gegen den Bestand prüfen können."""
|
||||
lk, kl = qa.offene_luecken(report)
|
||||
if not lk and not kl:
|
||||
return []
|
||||
extra = ("\n\nEXISTING INVENTORY BLOCKS (judge coverage against these):\n"
|
||||
+ "\n".join(f"- {c['payload'].get('title', '')}" for c in by_norm.values()))
|
||||
frei: list[str] = []
|
||||
if lk:
|
||||
_v, f = await _mit_stichentscheid(
|
||||
"QA-Luecken", topic, "luecken", "sections",
|
||||
[f"[{x.get('datei')} #{x.get('abschnitt')}] {x.get('vorschau', '')}" for x in lk],
|
||||
"ja", kategorie="luecken", ids=[qa.luecken_key(x) for x in lk], extra=extra)
|
||||
frei += f
|
||||
if kl:
|
||||
_v, f = await _mit_stichentscheid(
|
||||
"QA-Konzept-Luecken", topic, "konzept-luecken", "results", list(kl),
|
||||
"ja", kategorie="konzept_luecken", ids=[_norm_title(n) for n in kl], extra=extra)
|
||||
frei += f
|
||||
return frei
|
||||
|
||||
|
||||
async def _belege_nachfassen(topic: str, report: dict, files: dict) -> list[str]:
|
||||
"""subs_ohne_beleg (mentions=0): Beleg-Nachfass je Sub mit Material-Auszügen.
|
||||
Judge+QA einig „nein" (kein Beleg) → Sub verwerfen (Artefakte/Fragen räumt das
|
||||
nachlaufende _raeume_waisen); 2:1-„ja" → mentions=1 nachtragen — Datenfix am
|
||||
gemessenen Datum selbst, kein Freispruch nötig, Detektor unverändert. Produktiv ist
|
||||
der Detektor derzeit inaktiv (alle Schreiber setzen mentions=1) — Pfad ist Robustheit.
|
||||
Ohne Quellordner kein Urteil möglich → Befund bleibt."""
|
||||
eintraege = (report.get("beleg") or {}).get("subs_ohne_beleg") or []
|
||||
folder = source_folder(topic) or material_folder(topic)
|
||||
if not eintraege or not folder:
|
||||
return []
|
||||
rows = {f"{r['block']} · {r['sub_title']}": r for r in await db.list_subblocks(topic)
|
||||
if r["status"] != "variant"}
|
||||
items = [(e, rows[e]) for e in eintraege if e in rows and not rows[e]["mentions"]]
|
||||
if not items:
|
||||
return []
|
||||
lines = []
|
||||
for e, r in items:
|
||||
ev = _evidence_pack(folder, None, [r["block"], r["sub_title"]], budget=EVIDENCE_PER_BLOCK)
|
||||
lines.append(f"{e}\n{ev or '(keine Treffer im Material)'}")
|
||||
v1 = await _judge("QA-Repair-Beleg", topic, "beleg", "blocks", lines)
|
||||
strittig = [i for i in range(1, len(items) + 1) if v1.get(i) == "ja"]
|
||||
v3 = (await _judge("QA-Repair-Beleg", topic, "beleg-st", "blocks",
|
||||
[lines[i - 1] for i in strittig])) if strittig else {}
|
||||
out: list[str] = []
|
||||
|
||||
async def _verwerfen(e: str, r: dict):
|
||||
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], status="discarded")
|
||||
_entferne_sub_in_files(files, r["block_norm"], r["sub_norm"])
|
||||
out.append(f"entfernt: {e[:60]}")
|
||||
|
||||
for i, (e, r) in enumerate(items, 1):
|
||||
if v1.get(i) == "nein": # QA (mentions=0) + Judge einig → weg
|
||||
await _verwerfen(e, r)
|
||||
elif v1.get(i) == "ja": # Dissens → Stichentscheid
|
||||
pos = strittig.index(i) + 1
|
||||
if v3.get(pos) == "ja":
|
||||
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], mentions=1)
|
||||
out.append(f"belegt: {e[:60]}")
|
||||
elif v3.get(pos) == "nein":
|
||||
await _verwerfen(e, r)
|
||||
return out
|
||||
|
||||
|
||||
async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
|
||||
"""Nur der norm-invariante Teil (`**`/Backticks); `(n)`-Suffix und leere Beschreibung
|
||||
ändern die Norm bzw. brauchen Inhalt — bleiben Befund."""
|
||||
ändern die Norm bzw. brauchen Inhalt — eigene Handler (_fix_suffixe/_fix_beschreibungen)."""
|
||||
fixed = []
|
||||
for h in report.get("hygiene", []):
|
||||
alt = h.get("titel", "")
|
||||
@@ -130,33 +287,68 @@ async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) ->
|
||||
return fixed
|
||||
|
||||
|
||||
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
|
||||
"""Nur QA-bestätigte Paare (llm=ja); eine Zweitmeinung, Merge nur bei erneut ja.
|
||||
Merge spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped."""
|
||||
paare = [p for p in report.get("dubletten", []) if p.get("llm") == "ja"
|
||||
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
|
||||
if not paare:
|
||||
return []
|
||||
v, _frei = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in paare], "ja")
|
||||
merged = []
|
||||
for i, p in enumerate(paare, 1):
|
||||
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
|
||||
if v.get(i) != "ja" or not a or not b or a["card_id"] == b["card_id"]:
|
||||
continue
|
||||
win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
|
||||
len(c["payload"].get("title") or "")), reverse=True)
|
||||
wp, lp = dict(win["payload"]), dict(lose["payload"])
|
||||
wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or []))
|
||||
wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or []))
|
||||
lp.update(reason="merged", merged_into=wp.get("title", ""))
|
||||
await db.kanban_set_payload(topic, "inventory", win["card_id"], wp)
|
||||
await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp)
|
||||
await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
|
||||
await _purge_block(topic, lp.get("title", ""), files)
|
||||
by_norm.pop(_norm_title(lp.get("title", "")), None)
|
||||
merged.append(f"{lp.get('title')} → {wp.get('title')}")
|
||||
return merged
|
||||
async def _merge_paar(topic: str, p: dict, by_norm: dict, files: dict) -> str | None:
|
||||
"""Ein bestätigtes Dubletten-Paar mergen (spiegelt die dedup-Stage: Union ins
|
||||
Gewinner-Payload, Verlierer → grouped). → Journalzeile oder None."""
|
||||
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
|
||||
if not a or not b or a["card_id"] == b["card_id"]:
|
||||
return None
|
||||
win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
|
||||
len(c["payload"].get("title") or "")), reverse=True)
|
||||
wp, lp = dict(win["payload"]), dict(lose["payload"])
|
||||
wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or []))
|
||||
wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or []))
|
||||
lp.update(reason="merged", merged_into=wp.get("title", ""))
|
||||
await db.kanban_set_payload(topic, "inventory", win["card_id"], wp)
|
||||
await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp)
|
||||
await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
|
||||
await _purge_block(topic, lp.get("title", ""), files)
|
||||
by_norm.pop(_norm_title(lp.get("title", "")), None)
|
||||
return f"{lp.get('title')} → {wp.get('title')}"
|
||||
|
||||
|
||||
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
|
||||
"""QA-bestätigte Paare (llm=ja): Zweitmeinung, Merge nur bei erneut ja; 2:1-„behalten"
|
||||
persistiert jetzt als Freispruch (fehlte — die Paare pendelten ewig im Verdacht).
|
||||
Widerlegte/unbeurteilte Paare (llm=nein/?) bekommen den Klärungskanal: Judge-„nein" →
|
||||
Freispruch (mit QA-nein 2:0; beim bloßen Verdacht reicht das klare Gegen-Urteil),
|
||||
Judge-„ja" → Stichentscheid (ja → Merge 2:1, nein → Freispruch 2:1, Ausfall → nichts)."""
|
||||
alle = [p for p in report.get("dubletten", []) if not p.get("freispruch")
|
||||
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
|
||||
ja = [p for p in alle if p.get("llm") == "ja"]
|
||||
offen = [p for p in alle if p.get("llm") != "ja"]
|
||||
merged: list[str] = []
|
||||
frei: list[str] = []
|
||||
if ja:
|
||||
v, f = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in ja], "ja",
|
||||
kategorie="dubletten",
|
||||
ids=[qa._paar_key(p["a"], p["b"]) for p in ja])
|
||||
frei += f
|
||||
for i, p in enumerate(ja, 1):
|
||||
if v.get(i) == "ja" and (zeile := await _merge_paar(topic, p, by_norm, files)):
|
||||
merged.append(zeile)
|
||||
if offen:
|
||||
ids = [qa._paar_key(p["a"], p["b"]) for p in offen]
|
||||
v1 = await _judge("QA-Dubletten", topic, "dubletten-v", "pairs",
|
||||
[f"A: {p['a']}\nB: {p['b']}" for p in offen])
|
||||
frei_keys = [ids[i - 1] for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
|
||||
frei += [f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80]
|
||||
for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
|
||||
strittig = [i for i in range(1, len(offen) + 1) if v1.get(i) == "ja"]
|
||||
if strittig:
|
||||
v3 = await _judge("QA-Dubletten", topic, "dubletten-v-st", "pairs",
|
||||
[f"A: {offen[i - 1]['a']}\nB: {offen[i - 1]['b']}" for i in strittig])
|
||||
for pos, i in enumerate(strittig, 1):
|
||||
if v3.get(pos) == "ja" and (zeile := await _merge_paar(topic, offen[i - 1], by_norm, files)):
|
||||
merged.append(zeile)
|
||||
elif v3.get(pos) == "nein":
|
||||
frei_keys.append(ids[i - 1])
|
||||
frei.append(f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80])
|
||||
if frei_keys:
|
||||
_speichere_freispruch(topic, "dubletten", frei_keys)
|
||||
log.info("[%s] Repair dubletten: %d Verdacht(e) freigesprochen", topic, len(frei_keys))
|
||||
return merged, frei
|
||||
|
||||
|
||||
_SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S)
|
||||
@@ -165,10 +357,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)
|
||||
|
||||
|
||||
+99
-79
@@ -3,12 +3,13 @@ import json
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from agents import active_agents, provider_available
|
||||
from agents import active_agents, agent_ebene, provider_available
|
||||
from config import DEFAULT_PROVIDER, PROJECTS_DIR, UNI_DIR, PROVIDERS
|
||||
from database import (
|
||||
create_guide, delete_guide, get_guide, list_guides, update_guide,
|
||||
@@ -19,10 +20,11 @@ from database import (
|
||||
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
|
||||
get_sub_artefakte, kanban_reset, delete_guide_board,
|
||||
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
|
||||
list_runs, latest_board_runs, get_db,
|
||||
)
|
||||
from textkit import _norm_title
|
||||
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
|
||||
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
|
||||
from board_inventory import add_research_agent, board_snapshot, requeue_dead
|
||||
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup, leitner_step, PRACTICE_NEW_PER_SESSION
|
||||
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
|
||||
from pipeline import cancel_guide
|
||||
@@ -30,9 +32,9 @@ from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learns
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
|
||||
GuideCardResetRequest, GuideFormatRequest,
|
||||
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
|
||||
BlocksCreateRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
|
||||
GuideFormatRequest,
|
||||
GuideChatRequest, GuideChatResponse,
|
||||
ProviderInfo,
|
||||
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
||||
BlockChatRequest, BlockChatResponse,
|
||||
@@ -72,6 +74,20 @@ async def get_stats():
|
||||
return {"topics": len(topics), "formats": formats_stats(guides, levels)}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
await (await get_db()).execute("SELECT 1")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def get_runs(topic: str, limit: int = 10):
|
||||
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
|
||||
`latest` = jüngster Lauf MIT Daten je Ebene (inventory/artefacts/guide) — die
|
||||
Board-Kopfzeilen zeigen so auch nach „Nur Artefakte"-/Guide-Läufen ihre Zahlen."""
|
||||
return {"runs": await list_runs(topic, limit), "latest": await latest_board_runs(topic)}
|
||||
|
||||
|
||||
@router.get("/topics/progress")
|
||||
async def topic_progress(topic: str):
|
||||
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
|
||||
@@ -89,14 +105,28 @@ async def add_topic(req: TopicCreateRequest):
|
||||
|
||||
@router.delete("/topics")
|
||||
async def remove_topic(topic: str):
|
||||
guides = [g for g in await list_guides() if g["topic"] == topic]
|
||||
status = await blocks_status(topic)
|
||||
if status["generating"] or any(g["status"] == "generating" for g in guides):
|
||||
raise HTTPException(409, "Generierung läuft — erst abbrechen")
|
||||
await delete_topic(topic)
|
||||
await delete_block_data(topic)
|
||||
await delete_topic_pipeline(topic)
|
||||
await delete_source(topic) # topic config (DB) — removed together with the topic
|
||||
await delete_guide_content(topic)
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
# guides/Board/Kanban mitlöschen — GET /topics leitet Topics aus guides ab,
|
||||
# sonst taucht das gelöschte Topic sofort wieder auf
|
||||
for g in guides:
|
||||
await delete_guide(g["id"])
|
||||
await delete_guide_board(topic)
|
||||
await kanban_reset(topic)
|
||||
import qa
|
||||
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}
|
||||
|
||||
|
||||
@@ -134,17 +164,13 @@ async def create_blocks(req: BlocksCreateRequest):
|
||||
# Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
|
||||
if not qp.exists():
|
||||
type, location = req.source_type, req.source_location.strip()
|
||||
if type in ("projekt", "uni"):
|
||||
folder = safe_folder(location)
|
||||
if folder is None or not folder.is_dir():
|
||||
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
|
||||
elif type == "link":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
_validate_source(type, location)
|
||||
qp.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
|
||||
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider,
|
||||
research=req.research, qa_force=req.qa_force))
|
||||
research=req.research, qa_force=req.qa_force,
|
||||
auto_inventory=req.auto_inventory, auto_artefacts=req.auto_artefacts,
|
||||
auto_guide=req.auto_guide, only_artefacts=req.only_artefacts))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -156,7 +182,8 @@ async def get_blocks_board(topic: str):
|
||||
snap["generating"] = status["generating"]
|
||||
snap["progress"] = status["progress"]
|
||||
snap["error"] = status["error"]
|
||||
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
||||
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"),
|
||||
"runtime": a["runtime"], "ebene": agent_ebene(a["key"])}
|
||||
for a in active_agents(f"blocks-{topic}-")]
|
||||
return snap
|
||||
|
||||
@@ -177,11 +204,13 @@ async def run_qa_route(req: QaRunRequest):
|
||||
raise HTTPException(status_code=404, detail="keine fertigen Bausteine")
|
||||
await qa.write_report(report)
|
||||
note_guide = None
|
||||
# ebene-scoped: ein Inventar-/Artefakt-QA-Klick misst NICHT den (teuren) Guide mit.
|
||||
guide_scope = req.ebene in (None, "guide")
|
||||
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
|
||||
import guide_qa
|
||||
from database import list_guide_cards
|
||||
if any(c["stage"] == "done" and (c.get("md") or "").strip()
|
||||
for c in await list_guide_cards(req.topic)):
|
||||
if guide_scope and any(c["stage"] == "done" and (c.get("md") or "").strip()
|
||||
for c in await list_guide_cards(req.topic)):
|
||||
grep = await guide_qa.guide_qa_report(req.topic, llm=req.llm)
|
||||
if grep:
|
||||
await asyncio.to_thread(guide_qa._write_report, grep)
|
||||
@@ -208,7 +237,7 @@ async def run_repair_route(req: RepairRequest):
|
||||
_repair_laeuft.add(topic)
|
||||
try:
|
||||
import repair
|
||||
res = await repair.repair_befunde(topic)
|
||||
res = await repair.repair_befunde(topic, req.ebene)
|
||||
if "fehler" in res:
|
||||
raise HTTPException(status_code=404, detail=res["fehler"])
|
||||
return res
|
||||
@@ -227,43 +256,11 @@ async def add_blocks_research(topic: str, provider: str = DEFAULT_PROVIDER):
|
||||
return {"ok": True, "attached": False}
|
||||
|
||||
|
||||
@router.post("/blocks/reset-stage")
|
||||
async def reset_blocks_stage(req: BlocksResetStageRequest):
|
||||
"""Reset cards from a column onward back to that column (no regeneration)."""
|
||||
topic = req.topic.strip()
|
||||
if (await blocks_status(topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"} # don't interfere with a running generation
|
||||
moved = await reset_board_from_stage(topic, req.board, req.stage, _blocks_files(topic))
|
||||
return {"ok": True, "moved": moved}
|
||||
|
||||
|
||||
@router.post("/blocks/requeue-dead")
|
||||
async def requeue_blocks_dead(topic: str):
|
||||
return {"ok": True, "requeued": await requeue_dead(topic)}
|
||||
|
||||
|
||||
@router.post("/blocks/card-restart")
|
||||
async def blocks_card_restart(req: BlocksCardRestartRequest):
|
||||
if (await blocks_status(req.topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"}
|
||||
if not await restart_artefact_card(req.topic, req.card_id):
|
||||
raise HTTPException(404, "Card not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/guides/board/card-reset")
|
||||
async def guide_card_reset(req: GuideCardResetRequest):
|
||||
running = [g for g in await list_guides()
|
||||
if g["topic"] == req.topic and g["format"] == req.format
|
||||
and g["status"] in ("queued", "generating")]
|
||||
if running:
|
||||
return {"ok": True, "status": "generating"}
|
||||
from guide_board import reset_card
|
||||
if not await reset_card(req.topic, req.format, req.block_norm, req.ab_stage):
|
||||
raise HTTPException(404, "Card not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/blocks/cancel")
|
||||
async def cancel_blocks_route(topic: str):
|
||||
if not cancel_blocks(topic):
|
||||
@@ -279,6 +276,18 @@ async def remove_blocks(topic: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/blocks/artefacts")
|
||||
async def remove_artefacts(topic: str):
|
||||
"""Artefakte-Ebene leeren: Board-2-Daten (Subs/Facts/Flashcards) weg, Inventar bleibt.
|
||||
Karten gehen zurück auf `generate` — via „Artefakte generieren" (Fortsetzen) neu baubar."""
|
||||
topic = topic.strip()
|
||||
if (await blocks_status(topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"}
|
||||
from board_inventory import reset_board_from_stage
|
||||
moved = await reset_board_from_stage(topic, "artefacts", "generate", _blocks_files(topic))
|
||||
return {"ok": True, "moved": moved}
|
||||
|
||||
|
||||
@router.delete("/blocks/progress")
|
||||
async def reset_block_progress(topic: str, block: str):
|
||||
"""Reset learning progress of ONE block to zero (score/streak/flags/open question)."""
|
||||
@@ -287,14 +296,17 @@ async def reset_block_progress(topic: str, block: str):
|
||||
|
||||
|
||||
def _validate_source(type: str, location: str) -> None:
|
||||
"""Check source input (same rules as on creation)."""
|
||||
"""Check source input (shared by create + edit)."""
|
||||
if type in ("projekt", "uni"):
|
||||
folder = safe_folder(location)
|
||||
if folder is None or not folder.is_dir():
|
||||
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
|
||||
elif type == "link":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
urls = [ln.strip() for ln in location.splitlines() if ln.strip()]
|
||||
if not urls:
|
||||
raise HTTPException(400, "Enter at least one link.")
|
||||
if not all(u.lower().startswith(("http://", "https://")) for u in urls):
|
||||
raise HTTPException(400, "Each link must start with http:// or https:// (one per line).")
|
||||
|
||||
|
||||
@router.get("/blocks/source", response_model=BlocksSourceResponse)
|
||||
@@ -325,9 +337,10 @@ async def blocks_completeness(topic: str):
|
||||
counts = await kanban_stage_counts(topic)
|
||||
inv = counts.get("inventory", {})
|
||||
blocks = await list_blocks(topic, status="consensus")
|
||||
subs = 0
|
||||
for b in blocks:
|
||||
subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus")
|
||||
# ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen
|
||||
consensus_blocks = {b["title_norm"] for b in blocks}
|
||||
subs = sum(1 for s in await list_subblocks(topic)
|
||||
if s["status"] == "consensus" and s["block_norm"] in consensus_blocks)
|
||||
ziele = await list_lernziele(topic)
|
||||
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
|
||||
degradiert = ueberstimmt = 0
|
||||
@@ -466,15 +479,26 @@ async def block_chat_route(req: BlockChatRequest):
|
||||
|
||||
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
|
||||
# overwrite the absolute score with a stale base (race).
|
||||
_check_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {}
|
||||
|
||||
|
||||
def _check_lock(topic: str, block: str) -> asyncio.Lock:
|
||||
@asynccontextmanager
|
||||
async def _check_lock(topic: str, block: str):
|
||||
"""Per-(topic,block)-Lock mit Refcount, das den Eintrag nach dem letzten Nutzer
|
||||
entfernt — die Map wuchs sonst unbegrenzt (ein Lock pro je geprüftem Block)."""
|
||||
key = (topic, block)
|
||||
lock = _check_locks.get(key)
|
||||
if lock is None:
|
||||
lock = _check_locks[key] = asyncio.Lock()
|
||||
return lock
|
||||
entry = _check_locks.get(key)
|
||||
if entry is None:
|
||||
entry = _check_locks[key] = (asyncio.Lock(), [0])
|
||||
lock, ref = entry
|
||||
ref[0] += 1
|
||||
try:
|
||||
async with lock:
|
||||
yield
|
||||
finally:
|
||||
ref[0] -= 1
|
||||
if ref[0] == 0 and _check_locks.get(key) is entry:
|
||||
del _check_locks[key]
|
||||
|
||||
|
||||
def _basis(state: dict, question: str) -> tuple[int, bool]:
|
||||
@@ -639,7 +663,13 @@ async def block_exam_route(req: BlockExamRequest):
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
guides, levels = await load_learnstate()
|
||||
reason = guide_lock(req.topic.strip(), req.format, guides, levels)
|
||||
from database import list_blocks
|
||||
from paths import blocks_path
|
||||
topic = req.topic.strip()
|
||||
# DB is authoritative (generate_guide builds from it); blocks.md is only a legacy fallback
|
||||
# and is absent when the inventory paused at the QA gate — don't lock the guide on the file.
|
||||
has_blocks = bool(await list_blocks(topic, status="consensus")) or blocks_path(topic).exists()
|
||||
reason = guide_lock(topic, req.format, guides, levels, has_blocks)
|
||||
if reason:
|
||||
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
|
||||
await create_topic(req.topic.strip())
|
||||
@@ -655,7 +685,7 @@ async def create(req: GuideCreateRequest):
|
||||
"updated_at": now,
|
||||
}
|
||||
await create_guide(guide)
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider, ab_step=req.ab_step))
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider))
|
||||
return guide
|
||||
|
||||
|
||||
@@ -682,7 +712,7 @@ async def get_guide_board(topic: str, format: str = "Guide"):
|
||||
|
||||
|
||||
@router.post("/guides/board/repair")
|
||||
async def repair_guide_board(req: GuideBoardResetRequest):
|
||||
async def repair_guide_board(req: GuideFormatRequest):
|
||||
"""Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`,
|
||||
dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
|
||||
import guide_board
|
||||
@@ -702,19 +732,6 @@ async def repair_guide_board(req: GuideBoardResetRequest):
|
||||
return {"ok": True, "betroffen": betroffen}
|
||||
|
||||
|
||||
@router.post("/guides/board/reset")
|
||||
async def reset_guide_board(req: GuideBoardResetRequest):
|
||||
"""Reset cards from a stage onward — without generation (pendant to blocks reset-stage)."""
|
||||
import guide_board
|
||||
topic = req.topic.strip()
|
||||
guide = next((g for g in await list_guides()
|
||||
if g["topic"] == topic and g["format"] == req.format), None)
|
||||
if guide and guide["status"] in ("queued", "generating"):
|
||||
return {"ok": True, "status": "generating"}
|
||||
moved = await guide_board.reset_from_stage(topic, req.format, req.ab_stage)
|
||||
return {"ok": True, "moved": moved}
|
||||
|
||||
|
||||
@router.get("/guides/{guide_id}/content")
|
||||
async def guide_content(guide_id: str, level: int = 4):
|
||||
"""Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this
|
||||
@@ -799,6 +816,9 @@ async def remove_guide_format(req: GuideFormatRequest):
|
||||
for p in guide_slot_files(content):
|
||||
p.unlink(missing_ok=True)
|
||||
content.unlink(missing_ok=True)
|
||||
import qa # Guide-QA-Report mitlöschen — sonst zeigt board_snapshot die alte Note weiter
|
||||
for p in qa.report_paths(req.topic, guide=True):
|
||||
p.unlink(missing_ok=True)
|
||||
return {"ok": True, "removed": len(doomed)}
|
||||
|
||||
|
||||
|
||||
+22
-7
@@ -14,7 +14,7 @@ import json
|
||||
from database import list_block_scores_all, subs_per_level_all, list_guides
|
||||
from guide import guide_slot_files
|
||||
from learning import cap_final, LEVELS, _threshold
|
||||
from paths import blocks_path, guide_content_path
|
||||
from paths import guide_content_path
|
||||
from textkit import _norm_title
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
@@ -50,14 +50,26 @@ async def load_learnstate() -> tuple[list[dict], dict[str, dict[str, set[str]]]]
|
||||
return await list_guides(), levels
|
||||
|
||||
|
||||
_content_cache: dict[str, tuple[float, dict | None]] = {}
|
||||
|
||||
|
||||
def _content_json(topic: str, fmt: str) -> dict | None:
|
||||
"""Guide-Content-JSON (kann MB groß sein), mtime-gecacht — /stats und /topics/progress
|
||||
lasen die Datei bei JEDEM Frontend-Poll neu und synchron im Event-Loop."""
|
||||
path = guide_content_path(topic, fmt)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
_content_cache.pop(str(path), None)
|
||||
return None
|
||||
cached = _content_cache.get(str(path))
|
||||
if cached is None or cached[0] != mtime:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
data = None
|
||||
_content_cache[str(path)] = (mtime, data)
|
||||
return _content_cache[str(path)][1]
|
||||
|
||||
|
||||
|
||||
@@ -116,13 +128,16 @@ def formats_stats(guides: list[dict], levels: dict[str, dict[str, set[str]]]) ->
|
||||
return formats
|
||||
|
||||
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
||||
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]],
|
||||
has_blocks: bool) -> str | None:
|
||||
"""Reason why a fresh start for topic+format is locked — None = allowed.
|
||||
|
||||
Exactly the rules from POST /guides: blocks required, no duplicate start,
|
||||
learning debt only for genuine new creations (resume/regenerate are free).
|
||||
`has_blocks` = DB consensus blocks OR the legacy blocks.md exist — checked by the
|
||||
caller, because generate_guide builds from the DB (a paused inventory writes no file).
|
||||
"""
|
||||
if not blocks_path(topic).exists():
|
||||
if not has_blocks:
|
||||
return "Create blocks first"
|
||||
for g in guides:
|
||||
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
|
||||
|
||||
@@ -196,56 +196,73 @@ async def test_api_truncation_flagged(monkeypatch):
|
||||
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", [])
|
||||
monkeypatch.setattr(agents, "_cli_recent_starts", [])
|
||||
return monkeypatch
|
||||
|
||||
|
||||
_EST = agents._RAM_PER_PROC_KB # default batch-agent estimate
|
||||
|
||||
|
||||
async def test_ram_gate_admits_with_free_ram(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (4_000_000, 8_000_000)) # 50 % frei
|
||||
assert await agents._ram_gate("k") is True
|
||||
assert len(agents._opencode_recent_starts) == 1 # Commit registriert
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
assert len(agents._cli_recent_starts) == 1 # Commit registriert
|
||||
|
||||
|
||||
async def test_ram_gate_waits_when_low(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000)) # 10 % frei
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("k"), 0.1)
|
||||
await asyncio.wait_for(agents._ram_gate("k", _EST), 0.1)
|
||||
# RAM wird frei → Gate lässt nach ≥1 Poll durch
|
||||
vals = iter([(800_000, 8_000_000)])
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: next(vals, (4_000_000, 8_000_000)))
|
||||
assert await agents._ram_gate("k") is True
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
|
||||
|
||||
async def test_ram_gate_floor_and_fail_open(ram_gate):
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (100_000, 8_000_000)) # fast nichts frei
|
||||
ram_gate.setattr(agents, "_opencode_running", 1) # unter Floor
|
||||
assert await agents._ram_gate("k") is True
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_cli_running", 1) # unter Floor
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: None) # kein /proc/meminfo
|
||||
assert await agents._ram_gate("k") is True
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
ram_gate.setattr(agents, "RAM_MIN_FREE_PCT", 0) # Gate aus
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: pytest.fail("Gate aus liest kein meminfo"))
|
||||
assert await agents._ram_gate("k") is True
|
||||
assert await agents._ram_gate("k", _EST) is True
|
||||
|
||||
|
||||
async def test_ram_gate_commit_accounting(ram_gate):
|
||||
"""Knapp über der Schwelle, aber 2 frische Zulassungen → deren geschätzter RSS zählt."""
|
||||
"""Knapp über der Schwelle, aber 2 frische Zulassungen → deren geschätzter RSS zählt.
|
||||
Ein `full`-Spawn wiegt mehr (MCP-Server) und drückt die verfügbare RAM stärker."""
|
||||
import time as _time
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_cli_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()])
|
||||
agents._cli_recent_starts.extend([(_time.monotonic(), _EST), (_time.monotonic(), _EST)])
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("k"), 0.1)
|
||||
await asyncio.wait_for(agents._ram_gate("k", _EST), 0.1)
|
||||
|
||||
|
||||
async def test_ram_gate_full_weighs_more(ram_gate):
|
||||
"""Ein frischer Vorgänger belegt Commit für das Fenster. Ein leichter (350 MB) lässt den
|
||||
nächsten noch durch; ein `full` (1250 MB, MCP-Server) reißt die Schwelle → nächster wartet."""
|
||||
import time as _time
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (2_000_000, 8_000_000)) # 25 % frei
|
||||
agents._cli_recent_starts[:] = [(_time.monotonic(), _EST)] # leichter Vorgänger
|
||||
assert await agents._ram_gate("nach-leicht", _EST) is True
|
||||
agents._cli_recent_starts[:] = [(_time.monotonic(), agents._RAM_PER_PROC_FULL_KB)] # full-Vorgänger
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(agents._ram_gate("nach-full", _EST), 0.1)
|
||||
|
||||
|
||||
async def test_ram_gate_cancelled_scope(ram_gate):
|
||||
ram_gate.setattr(agents, "_opencode_running", 5)
|
||||
ram_gate.setattr(agents, "_cli_running", 5)
|
||||
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000))
|
||||
agents.cancel_scope("blocks-cxl-")
|
||||
try:
|
||||
assert await agents._ram_gate("blocks-cxl-x") is False
|
||||
assert await agents._ram_gate("blocks-cxl-x", _EST) is False
|
||||
finally:
|
||||
agents.clear_scope("blocks-cxl-")
|
||||
|
||||
@@ -253,3 +270,15 @@ async def test_ram_gate_cancelled_scope(ram_gate):
|
||||
def test_meminfo_reads_proc():
|
||||
mem = agents._meminfo()
|
||||
assert mem is not None and 0 < mem[0] <= mem[1] # Linux-Testumgebung
|
||||
|
||||
|
||||
def test_agent_ebene_klassifiziert_board():
|
||||
"""Board-2-Marker (art-/sb-/outline) → artefacts; alles andere → inventory."""
|
||||
T = "shopware"
|
||||
for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y",
|
||||
f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"):
|
||||
assert agents.agent_ebene(key) == "inventory", key
|
||||
for key in (f"blocks-{T}-alpha-sb-enrich-h", f"blocks-{T}-alpha-sb-verify-h-j1",
|
||||
f"blocks-{T}-alpha-art-gen-h-t1", f"blocks-{T}-alpha-art-check-h",
|
||||
f"blocks-{T}-outline-judge"):
|
||||
assert agents.agent_ebene(key) == "artefacts", key
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand (unbewegt), Limit."""
|
||||
import auto_loop
|
||||
|
||||
|
||||
async def test_fertig_ohne_repair():
|
||||
async def rep():
|
||||
raise AssertionError("darf bei 100 % nie reparieren")
|
||||
|
||||
r = await auto_loop.auto_repair_loop("x", 10.0, rep)
|
||||
assert r == {"ebene": "x", "note": 10.0, "runden": 0, "grund": "fertig"}
|
||||
|
||||
|
||||
async def test_verbessert_bis_voll():
|
||||
noten = iter([9.0, 10.0])
|
||||
|
||||
async def rep():
|
||||
return next(noten), True
|
||||
|
||||
r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0
|
||||
assert r["grund"] == "fertig" and r["runden"] == 2
|
||||
|
||||
|
||||
async def test_stillstand():
|
||||
async def rep():
|
||||
return 8.0, False # nichts getan, Note steht
|
||||
|
||||
r = await auto_loop.auto_repair_loop("art", 8.0, rep)
|
||||
assert r["grund"] == "stillstand" and r["runden"] == 1
|
||||
assert r["note"] == 8.0
|
||||
|
||||
|
||||
async def test_bewegt_ohne_notengewinn_laeuft_weiter():
|
||||
"""Eine bewegte Runde (z. B. Lücken-Research senkt die Note transient) ist KEIN
|
||||
Stillstand — erst unbewegt + keine Verbesserung stoppt; max_iter begrenzt Livelocks."""
|
||||
laeufe = iter([(7.5, True), (8.0, True), (8.0, False)])
|
||||
|
||||
async def rep():
|
||||
return next(laeufe)
|
||||
|
||||
r = await auto_loop.auto_repair_loop("inv", 8.0, rep)
|
||||
assert r["grund"] == "stillstand" and r["runden"] == 3
|
||||
|
||||
|
||||
async def test_limit():
|
||||
stand = {"n": 1.0}
|
||||
|
||||
async def rep():
|
||||
stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0
|
||||
return stand["n"], True
|
||||
|
||||
r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10)
|
||||
assert r["grund"] == "limit" and r["runden"] == 10
|
||||
@@ -123,86 +123,74 @@ def env(testdb, tmp_path, monkeypatch):
|
||||
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."""
|
||||
def _mk_enrich_slot(output):
|
||||
"""run_single_slot-Fake für die Anreicherung: schreibt die gescriptete Antwort via
|
||||
payload (wie der Engine-Sink); output=None → Ausfall."""
|
||||
calls = []
|
||||
|
||||
async def fake_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
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
if output is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(output), ""))
|
||||
|
||||
fake_race.calls = calls
|
||||
return fake_race
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def test_generate_schnittmenge_wird_consensus(env, monkeypatch):
|
||||
"""Von beiden Generatoren genannt → consensus (Facts-Union); Einzelnennungen
|
||||
werden unsicher und gehen zum Prüfer."""
|
||||
async def test_enrich_reichert_feste_subs_an(env, monkeypatch):
|
||||
"""Die Subs stehen fest (Board 1). EIN Call füllt Facts + Level + Relevanz — er
|
||||
erfindet und entfernt nichts; die Board-1-Subs bleiben in Reihenfolge."""
|
||||
db, ctx, files = env
|
||||
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")]},
|
||||
}))
|
||||
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
|
||||
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [
|
||||
_sub("Sub A", kp=["k1"]),
|
||||
_sub("Sub B", level="expert", relevance="peripheral", kp=["k2"])]}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
|
||||
assert gen["raw"] == {"Alpha": ["Sub A"]}
|
||||
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"]
|
||||
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
|
||||
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1"]
|
||||
assert gen["unsicher"] == []
|
||||
assert gen["votes"]["sub b"] == {"level": ["expert"], "relevance": ["peripheral"]}
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows["sub a"] == "consensus"
|
||||
assert rows["sub b"] == rows["sub c"] == "candidate"
|
||||
assert rows["sub a"] == rows["sub b"] == "consensus"
|
||||
|
||||
|
||||
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."""
|
||||
async def test_enrich_ohne_subs_kein_call(env, monkeypatch):
|
||||
"""Kein Board-1-Sub → nichts anzureichern, kein Modell-Call."""
|
||||
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"))
|
||||
called = []
|
||||
|
||||
async def spy(*a, **k):
|
||||
called.append(1)
|
||||
return OK, None
|
||||
monkeypatch.setattr(bc, "run_single_slot", spy)
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
assert gen == {"raw": {"Alpha": []}, "facts": {"Alpha": {}}, "unsicher": [], "votes": {}}
|
||||
assert called == []
|
||||
|
||||
|
||||
async def test_generate_beide_ausgefallen_ist_none(env, monkeypatch):
|
||||
async def test_enrich_ausgelassener_sub_bleibt_leer(env, monkeypatch):
|
||||
"""Lässt der Call einen Sub aus, bleibt er erhalten (Vollständigkeit) — mit leeren
|
||||
Facts, die der Prüfer/QA als Lücke sieht."""
|
||||
db, ctx, files = env
|
||||
monkeypatch.setattr(bc, "_race", _mk_gen_race({}))
|
||||
assert await bc._generate_block(ctx, files, "Alpha", "d") is None
|
||||
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
|
||||
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]}))
|
||||
gen = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
|
||||
assert gen["facts"]["Alpha"]["sub b"]["key_points"] == []
|
||||
assert gen["facts"]["Alpha"]["sub b"]["cited_facts"] == []
|
||||
|
||||
|
||||
async def test_generate_seed_garantie(env, monkeypatch):
|
||||
"""Ungedeckte Seeds gehen als unsicher zum Prüfer (Beleg-Gate liegt dort);
|
||||
lexikalisch gedeckte Seeds erzeugen keine Dublette."""
|
||||
async def test_enrich_resume_ohne_neue_calls(env, monkeypatch):
|
||||
"""Vorhandene enrich-Datei → kein neuer Call, Ergebnis wird übernommen."""
|
||||
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)
|
||||
await _seed_rows(db, "alpha", ["Sub A"])
|
||||
fake = _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]})
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
n = len(fake.calls)
|
||||
gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
|
||||
assert len(fake.calls) == n # alles resumed
|
||||
assert len(fake.calls) == n # aus Datei resumed
|
||||
assert gen2["raw"] == gen1["raw"]
|
||||
|
||||
|
||||
@@ -275,24 +263,31 @@ async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
|
||||
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."""
|
||||
async def test_verify_keep_all_behaelt_alle_atome(env, monkeypatch):
|
||||
"""Bottom-up (keep_all=True): kein Board-1-Atom wird entfernt oder gefaltet, selbst
|
||||
wenn beide Prüfer fremd/gruppen/discard melden. Nur Facts-Korrektur (Hinweis) und
|
||||
Level/Relevanz greifen weiter."""
|
||||
db, ctx, files = env
|
||||
subs = ["Sub A"]
|
||||
unsicher = [_sub("Unsicher B", kp=["kp b"]), _sub("Unsicher C")]
|
||||
subs = ["Sub A", "Sub B", "Sub 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"}}})
|
||||
# Fix-Call liefert die Korrektur für Sub A UND einen belegten NEUEN Fund — der darf
|
||||
# bei keep_all NICHT als Sub eingeschleust werden (Sub-Zahl bleibt bei den Board-1-Atomen).
|
||||
fix = {"subs": [_sub("Sub A", kp=["korrigiert"]),
|
||||
_sub("Extra Fund", kp=["belegt"], cf=[{"text": "x", "source": "s"}])]}
|
||||
verdikt = {"fremd": [3], "gruppen": [{"haupt": 1, "weitere": [2]}],
|
||||
"facts_probleme": [{"nr": 1, "discard": True, "hinweis": "Zahl falsch"},
|
||||
{"nr": 2, "discard": True}],
|
||||
"levels": {"2": "expert"}}
|
||||
fake = _judge_slot({"1": verdikt, "2": verdikt}, fix=fix)
|
||||
monkeypatch.setattr(bc, "run_single_slot", fake)
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
|
||||
assert res["raw"] == {"Alpha": ["Sub A", "Unsicher B"]}
|
||||
assert res["facts"]["Alpha"]["unsicher b"]["key_points"] == ["kp b"]
|
||||
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {}, keep_all=True)
|
||||
assert res["raw"] == {"Alpha": subs} # exakt die drei Atome — nichts entfernt, nichts neu
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert 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"]
|
||||
assert all(rows[_norm_title(s)] == "consensus" for s in subs)
|
||||
assert "extra fund" not in rows # der Fix-Neuzugang wurde unterdrückt
|
||||
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
|
||||
assert side["Sub A"]["facts"]["key_points"] == ["korrigiert"] # Hinweis trotz discard=True
|
||||
assert side["Sub B"]["level"] == "expert" # Level-Korrektur greift
|
||||
|
||||
|
||||
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):
|
||||
|
||||
@@ -78,7 +78,8 @@ async def board_env(testdb, tmp_path, monkeypatch):
|
||||
for s in subs}},
|
||||
"unsicher": [], "votes": {}}
|
||||
|
||||
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None, melde=None):
|
||||
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
|
||||
melde=None, keep_all=False):
|
||||
subs = gen["raw"].get(title) or []
|
||||
bfacts = gen["facts"].get(title) or {}
|
||||
sidecar = [{"title": s, "level": "beginner",
|
||||
@@ -102,12 +103,6 @@ async def board_env(testdb, tmp_path, monkeypatch):
|
||||
("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
|
||||
monkeypatch.setattr(ba, name, fn)
|
||||
|
||||
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
|
||||
@staticmethod
|
||||
def available():
|
||||
return False
|
||||
monkeypatch.setattr(ba, "embedding", _EmbOff)
|
||||
|
||||
work = tmp_path / "arbeit"
|
||||
work.mkdir()
|
||||
files = {"arbeit": work, "final": tmp_path / "blocks.md",
|
||||
@@ -324,8 +319,9 @@ async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
|
||||
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "dedup"
|
||||
|
||||
|
||||
async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
|
||||
"""Nur 1 von 3 Panel-Stimmen bestätigt den Judge-Demote → Karte überlebt (Journal)."""
|
||||
async def test_single_recheck_vote_confirms_proposal(board_env, tmp_path, monkeypatch):
|
||||
"""M3.2: ein pass-1-Vorschlag wird schon von EINER Recheck-Stimme bestätigt → rejected.
|
||||
(15/17 mehrheitlich geretteten Karten waren später QA-Dubletten — Overturn nur einstimmig.)"""
|
||||
db, ctx, files = board_env
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", _confirm_votes({"1"}, {"fragments": {"1": 2}, "drop": []})),
|
||||
@@ -335,6 +331,23 @@ async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
|
||||
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
|
||||
])
|
||||
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
|
||||
assert c1["stage"] == "rejected"
|
||||
assert c1["payload"]["reason"] == "fragment"
|
||||
assert c1["payload"]["parent_norm"] == "codeblock"
|
||||
|
||||
|
||||
async def test_unanimous_recheck_keep_overrules_proposal(board_env, tmp_path, monkeypatch):
|
||||
"""M3.2: nur ein EINSTIMMIGES Recheck-Panel (0 Demote-Stimmen) hebt den pass-1-Vorschlag auf."""
|
||||
db, ctx, files = board_env
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", _confirm_votes(set(), {"fragments": {"1": 2}, "drop": []})),
|
||||
("-filter-", {"fragments": {"1": 2}, "drop": []}),
|
||||
]))
|
||||
await _run_filter(db, ctx, tmp_path, [
|
||||
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
|
||||
])
|
||||
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup"
|
||||
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
|
||||
assert journal["ueberstimmt"] == ["Blockzitat"]
|
||||
@@ -587,6 +600,49 @@ def test_canonical_key_camel_and_catalogue():
|
||||
assert k("Section 2.1 Matching") == k("Matching")
|
||||
|
||||
|
||||
def test_canonical_key_glued_problem_suffix():
|
||||
"""Verklebtes Kompositum-Suffix „…problem" wird abgetrennt, damit „Cliquenproblem" und
|
||||
„Clique" denselben Blocking-Key teilen (aak: sonst nie Dedup-Kandidat). Fugen-n/-s + Plural
|
||||
inklusive; die generische Suffix-Regel darf keine echten Varianten über-mergen."""
|
||||
from blocks import _canonical_key as k
|
||||
assert k("Clique") == k("Cliquenproblem") == k("Cliquenprobleme") != ""
|
||||
assert k("Set Cover") == k("SetCover-Problem") # bestehender Hyphen-Pfad bleibt
|
||||
assert k("SAT") != k("3-SAT") # Varianten-Ziffer bleibt Signal
|
||||
assert k("Problem") != k("Clique") # bloßes „Problem" wird nicht zum Stamm
|
||||
|
||||
|
||||
def test_reference_strip_and_is_reference():
|
||||
"""Katalog-Nummern werden gestrippt, der Konzeptname bleibt; reine Nummern → leer + Referenz.
|
||||
Reale aak-Schadensfälle (Satz-/Bemerkungs-Titel liefen wörtlich bis done)."""
|
||||
from blocks import _reference_strip as strip, _is_reference as isref
|
||||
assert strip("Satz 7.13 (Christofides)") == "Christofides"
|
||||
assert strip("Satz 7.6: Kriterium für Eulerschen Kreis") == "Kriterium für Eulerschen Kreis"
|
||||
assert strip("N P via nicht-deterministische Turingmaschine (Definition 6.19)") \
|
||||
== "N P via nicht-deterministische Turingmaschine"
|
||||
assert strip("Bemerkung 7.22") == ""
|
||||
assert strip("Vertex Cover") == "Vertex Cover" # kein Katalog-Gerüst → unverändert
|
||||
# Zuschreibungs-Klammer vor ':' gehört zum Katalog-Gerüst (aak: "(Lawler 1976): …" überlebte)
|
||||
assert strip("Satz 7.12 (Lawler 1976): Minimum-Weight Perfect Matching") \
|
||||
== "Minimum-Weight Perfect Matching"
|
||||
assert strip("(a1=1)-SubsetSum Problem") == "(a1=1)-SubsetSum Problem" # Mathe-Klammer bleibt
|
||||
assert isref("Bemerkung 7.22") and isref("Satz 7.18") and isref("Korollar 6.18")
|
||||
assert isref("Bedingung (**)")
|
||||
assert not isref("Satz 7.13 (Christofides)") # hat Konzept → keine Referenz
|
||||
assert not isref("Satz 7.6: Kriterium für Eulerschen Kreis")
|
||||
assert not isref("P⊆NP") and not isref("Σ*") # kurze Symbole bleiben echt
|
||||
|
||||
|
||||
def test_is_named_statement_construction_suffix():
|
||||
"""Reduktion mit Konstruktions-Suffix (: / = nach dem Ziel) ist ein Fragment, kein Statement —
|
||||
darf nicht mehr vor Demotion geschützt sein (aak: „3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")."""
|
||||
from blocks import _is_named_statement as named
|
||||
assert not named("3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")
|
||||
assert named("3-SAT ≤ Clique") # saubere Reduktion bleibt geschützt
|
||||
assert named("Clique → Vertex Cover")
|
||||
assert not named("X ist NP-vollständig") # unäre Aussage bleibt demotable
|
||||
assert named("Satz von Cook/Levin: SAT ist NP-vollständig ⇔ …") # benanntes Ergebnis bleibt
|
||||
|
||||
|
||||
def test_relation_guard_ignores_trailing_scaffolding():
|
||||
"""Trailing „Reduktion/Transformation" ist kein Operand — sonst blockt der Guard
|
||||
den korrekten Merge; Richtungs-Konflikte bleiben erkannt."""
|
||||
@@ -719,9 +775,11 @@ async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
||||
base_verify = ba._verify_block
|
||||
snapshot = {}
|
||||
|
||||
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None, melde=None):
|
||||
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
|
||||
melde=None, keep_all=False):
|
||||
await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires
|
||||
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl, sources=sources)
|
||||
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl,
|
||||
sources=sources, keep_all=keep_all)
|
||||
|
||||
base_outline = ba._outline_block
|
||||
|
||||
@@ -903,15 +961,22 @@ async def _run_flow(ctx, files, timeout=30, **kw):
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
||||
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate."""
|
||||
async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
|
||||
"""Auto an, aber Repair schließt die Befunde nicht → Stillstand → Flow pausiert,
|
||||
Board-2-Karten warten in generate."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def bad_qa(topic, llm=False):
|
||||
return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
|
||||
return {"note": 5.0, "aktionen": 0} # bewegt nichts
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
@@ -921,6 +986,90 @@ async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
|
||||
|
||||
|
||||
async def test_qa_gate_luecken_research_runden(board_env, monkeypatch):
|
||||
"""Offene Lücken im Auto-Loop: erst LUECKEN_RESEARCH_MAX Recherche-Runden
|
||||
(Lücken-Urteil ausgesetzt), dann das Urteil — erst danach Stillstand/Pause."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
gap_report = {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {},
|
||||
"luecken": [{"datei": "f.txt", "abschnitt": 1, "vorschau": "x"}],
|
||||
"konzept_luecken": []}
|
||||
|
||||
async def bad_qa(topic, llm=False):
|
||||
return dict(gap_report)
|
||||
|
||||
research_calls = []
|
||||
|
||||
async def fake_producer(ctx2, flow, rep):
|
||||
research_calls.append(1)
|
||||
return 1 # Recherche hat Karten erzeugt → Runde gilt als bewegt
|
||||
|
||||
urteile = []
|
||||
|
||||
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
|
||||
urteile.append(luecken_urteil)
|
||||
return {"note": 8.0, "aktionen": 0}
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "latest_report", lambda t, guide=False: dict(gap_report))
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(bi, "_luecken_producer", fake_producer)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
assert len(research_calls) == 2 # LUECKEN_RESEARCH_MAX
|
||||
assert urteile[:2] == [False, False] and urteile[2] is True
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 # pausiert
|
||||
|
||||
|
||||
async def test_qa_gate_auto_reaches_100(board_env, monkeypatch):
|
||||
"""Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def bad_qa(topic, llm=False):
|
||||
return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def good_repair(topic, ebene=None, luecken_urteil=True):
|
||||
return {"note": 10.0, "aktionen": 1}
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", good_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5
|
||||
|
||||
|
||||
async def test_qa_gate_auto_off_pauses(board_env, monkeypatch):
|
||||
"""Auto aus → nach der QA anhalten (Kontrollpunkt), egal welche Note; kein Repair."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def good_qa(topic, llm=False):
|
||||
return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def no_repair(topic, ebene=None, luecken_urteil=True):
|
||||
raise AssertionError("Auto aus darf nicht reparieren")
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", good_qa)
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", no_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files, auto_inventory=False)
|
||||
assert ok
|
||||
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
|
||||
assert len(warten) == 4 # Board 2 wartet trotz Note 10.0 (Auto aus = Kontrollpunkt)
|
||||
|
||||
|
||||
async def test_qa_gate_force_overrides(board_env, monkeypatch):
|
||||
"""qa_force=True („Trotzdem fortsetzen") übersteuert die schlechte Note."""
|
||||
import qa as qa_mod
|
||||
@@ -937,7 +1086,7 @@ async def test_qa_gate_force_overrides(board_env, monkeypatch):
|
||||
ok = await _run_flow(ctx, files, qa_force=True)
|
||||
assert ok
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5
|
||||
assert called["n"] <= 1 # Gate-Lauf übersprungen; höchstens Abschluss-QA
|
||||
assert called["n"] <= 2 # Gate-QA übersprungen (force); nur Artefakt-Loop + Abschluss-QA
|
||||
|
||||
|
||||
async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch):
|
||||
@@ -954,7 +1103,7 @@ async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch):
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
assert called["n"] == 1 # kein Gate-Lauf; nur die Abschluss-QA der Lauf-Summary
|
||||
assert called["n"] == 2 # kein Gate-Lauf; Artefakt-Loop-QA + Abschluss-QA der Lauf-Summary
|
||||
|
||||
|
||||
async def test_qa_gate_fail_open(board_env, monkeypatch):
|
||||
@@ -982,6 +1131,11 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
|
||||
counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
|
||||
v = bi._qa_view(TOPIC, counts, None)
|
||||
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
|
||||
# 9.6 lag über QA_GATE_NOTE, pausiert den Auto-Loop aber trotzdem (Gate öffnet nur bei VOLL)
|
||||
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
|
||||
{"note": 9.6, "quoten": {}, "fremd": [], "unecht": []}), encoding="utf-8")
|
||||
qa_mod._latest_cache.clear()
|
||||
assert bi._qa_view(TOPIC, counts, None)["pausiert"] is True
|
||||
from types import SimpleNamespace
|
||||
laufend = SimpleNamespace(state={})
|
||||
assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False # Flow läuft noch
|
||||
@@ -1113,24 +1267,27 @@ def test_hat_anker_ziffern_suffix():
|
||||
assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker
|
||||
|
||||
|
||||
async def test_reset_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."""
|
||||
async def test_reset_generate_behaelt_subs_loescht_artefakte(testdb, tmp_path):
|
||||
"""Reset auf Spalte generate (Artefakte entfernen): die Board-1-Subs BLEIBEN (Board 2
|
||||
reichert sie neu an), aber die abgeleiteten Fragen/Artefakte + globalen Sidecar-Dateien
|
||||
+ ab-*-Resume-Slots gehen weg — sonst mergen Reste in den frischen Lauf zurück."""
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"})
|
||||
await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus")
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "s1", "Alpha", "S1", "Frage?")
|
||||
arbeit = tmp_path / "arbeit"
|
||||
(arbeit / "ab-alpha").mkdir(parents=True)
|
||||
(arbeit / "ab-alpha" / "facts.json").write_text("{}", encoding="utf-8")
|
||||
files = {"arbeit": arbeit}
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
|
||||
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"):
|
||||
files[k] = tmp_path / f"{k}.json"
|
||||
files[k].write_text("{}", encoding="utf-8")
|
||||
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
|
||||
assert moved == 1
|
||||
assert not await db.list_subblocks(TOPIC)
|
||||
assert len(await db.list_subblocks(TOPIC)) == 1 # Sub bleibt (Board-1-Eigentum)
|
||||
assert not await db.list_question_pattern(TOPIC, "alpha") # abgeleitete Frage weg
|
||||
assert not (arbeit / "ab-alpha").exists()
|
||||
assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"))
|
||||
assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"))
|
||||
|
||||
|
||||
# ── Naming-Abstraktion: freier Name nur mit Anker ───────────────────────────────────
|
||||
@@ -1213,6 +1370,49 @@ async def test_namecheck_ok_behaelt_titel(testdb, tmp_path, monkeypatch):
|
||||
assert block["payload"]["description"] == "Eigene Beschreibung"
|
||||
|
||||
|
||||
async def test_namecheck_strips_source_numbering(testdb, tmp_path, monkeypatch):
|
||||
"""M1.2: wählt der Check-Judge einen numerierten Titel, wird die Katalog-Nummer deterministisch
|
||||
zum Konzept gestrippt ('Satz 7.13 (Christofides)' → 'Christofides')."""
|
||||
db = testdb
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"norm": "satz 7.13 (christofides)", "title": "Satz 7.13 (Christofides)",
|
||||
"description": "3/2-Approximation für metrisches TSP", "readers": ["r1"], "sources": []},
|
||||
{"norm": "b", "title": "B", "description": "db", "readers": ["r2"], "sources": []}]
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
return "ok", payload((0, '{"best": 1}', "")) # Judge wählt den numerierten Member-Titel
|
||||
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
|
||||
payload = {"title": "Satz 7.13 (Christofides)", "description": "3/2-Approximation",
|
||||
"main_norm": "satz 7.13 (christofides)"}
|
||||
await db.kanban_upsert_card(TOPIC, B, "c7", "cluster", "naming_check", payload)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
await bi._namecheck_one(ctx, _mk_flow(tmp_path), {"card_id": "c7", "payload": payload})
|
||||
block = await db.kanban_get_card(TOPIC, B, "b-c7")
|
||||
assert block["payload"]["title"] == "Christofides"
|
||||
|
||||
|
||||
async def test_consensus_gate_single_reader_majority_quorum(testdb, tmp_path, monkeypatch):
|
||||
"""M5.1: Einzel-Reader-Fund → clarify mit Mehrheits-Quorum statt Einstimmigkeit."""
|
||||
db = testdb
|
||||
monkeypatch.setattr(bi, "source_folder", lambda t: None) # thema: kein Korpus
|
||||
|
||||
async def fake_members(topic, cid):
|
||||
return [{"title": "Seltenes Konzept", "description": "einmal erwähnt",
|
||||
"readers": ["r1"], "supplement": False}]
|
||||
|
||||
monkeypatch.setattr(bi, "_member_rows", fake_members)
|
||||
monkeypatch.setattr(bi, "_rep", lambda rows: rows[0])
|
||||
await db.kanban_upsert_card(TOPIC, B, "cs", "cluster", "consensus_gate", {})
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), [{"card_id": "cs", "payload": {}}])
|
||||
card = await db.kanban_get_card(TOPIC, B, "cs")
|
||||
assert card["stage"] == "clarify"
|
||||
assert card["payload"]["quorum"] == "majority"
|
||||
|
||||
|
||||
# ── Sanierung: Titel auf Korpus-Form, Beschreibungspflicht (QA: fremd/hygiene) ──────
|
||||
|
||||
def test_sanierung_schema_varianten():
|
||||
@@ -1229,6 +1429,11 @@ def test_sanierung_noetig():
|
||||
assert bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, ctoks) # kein Korpus-Anker
|
||||
assert not bi._sanierung_noetig({"title": "k-Color", "description": "d"}, ctoks)
|
||||
assert not bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, None) # thema: kein Korpus
|
||||
# M1.3: Referenz-Titel / unbalancierte Klammer / >80 Zeichen triggern auch ohne Korpus (Singletons)
|
||||
assert bi._sanierung_noetig({"title": "Bemerkung 7.22", "description": "d"}, None)
|
||||
assert bi._sanierung_noetig({"title": "N P (Definition 6.19", "description": "d"}, None)
|
||||
assert bi._sanierung_noetig({"title": "A" * 81, "description": "d"}, None)
|
||||
assert not bi._sanierung_noetig({"title": "Clique", "description": "d"}, None)
|
||||
|
||||
|
||||
def _sanierung_env(tmp_path, monkeypatch, antwort):
|
||||
@@ -1352,3 +1557,86 @@ async def test_sanierung_anker_und_beschreibung_ok_kein_judge(testdb, tmp_path,
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c4", "payload": payload})
|
||||
assert (await db.kanban_get_card(TOPIC, B, "c4"))["stage"] == "naming_check"
|
||||
|
||||
|
||||
def test_themen_zielband_skaliert_mit_atomzahl():
|
||||
"""√n-Band: kleine Themen deutlich unter 15, große über dem alten 18er-Cluster-Cap."""
|
||||
lo30, hi30 = bi._themen_zielband(30)
|
||||
assert lo30 >= 2 and hi30 < 15
|
||||
lo285, hi285 = bi._themen_zielband(285)
|
||||
assert hi285 > 18
|
||||
lo4, hi4 = bi._themen_zielband(4)
|
||||
assert lo4 >= 2 and hi4 > lo4
|
||||
# Monotonie: mehr Items → nie kleineres Band
|
||||
prev = (0, 0)
|
||||
for n in (4, 30, 100, 285, 1000):
|
||||
band = bi._themen_zielband(n)
|
||||
assert band >= prev
|
||||
prev = band
|
||||
|
||||
|
||||
def test_gruppierung_prompt_rendert_zielband(tmp_path):
|
||||
"""Template↔Call-Site-Vertrag: alle Platzhalter versorgt, Band-String im Prompt."""
|
||||
from pipeline import _prompt
|
||||
p = _prompt("Blocks-Gruppierung", topic="t", candidates="x", list="1. a",
|
||||
out_path=tmp_path / "g.json", theme_lo=4, theme_hi=8, n_items=9)
|
||||
assert "~4–8 themes" in p and "(9 items)" in p
|
||||
|
||||
|
||||
async def test_attach_nachzuegler_wird_sub_statt_zwergblock(testdb, tmp_path, monkeypatch):
|
||||
"""Nachzügler-Atom (Supplement-/Lücken-Welle) mit passendem fertigen Block →
|
||||
wird dessen Sub (Bottom-up), Karte → grouped/attach, Board-2-Karte → generate.
|
||||
Ohne Zuordnung bleibt das Atom standalone (Erstwelle: keine done_blocks → no-op)."""
|
||||
db = testdb
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
flow = _mk_flow(tmp_path)
|
||||
# fertiger Block mit Board-2-Karte
|
||||
await db.kanban_upsert_card(TOPIC, B, "b1", "block", "done_block",
|
||||
{"title": "Turingmaschinen", "description": "Modelle und Akzeptanz",
|
||||
"mirrored_norm": "turingmaschinen", "children": ["Palindrom-TM"]})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "turingmaschinen", "ablock", "done_artefact",
|
||||
{"title": "Turingmaschinen"})
|
||||
rows = [{"card_id": "n1", "payload": {"title": "Halteproblem", "description": "hält die TM?"},
|
||||
"title": "Halteproblem", "description": "hält die TM?", "title_norm": "halteproblem"}]
|
||||
await db.kanban_upsert_card(TOPIC, B, "n1", "block", "grouping", rows[0]["payload"])
|
||||
|
||||
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
assert "-gruppierung-attach-" in key and "Turingmaschinen" in prompt
|
||||
m = re.search(r"(/\S+\.json)", prompt) # files-Agent: out_path aus dem Prompt schreiben
|
||||
from pathlib import Path
|
||||
Path(m.group(1)).write_text('{"additions": [{"umbrella": 0, "members": [1]}]}', encoding="utf-8")
|
||||
return bi.OK, payload((0, "", ""))
|
||||
|
||||
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
|
||||
attached = await bi._attach_nachzuegler(ctx, flow, rows)
|
||||
assert attached == {"n1"}
|
||||
subs = {r["sub_norm"] for r in await db.list_subblocks(TOPIC, "turingmaschinen")}
|
||||
assert "halteproblem" in subs
|
||||
card = await db.kanban_get_card(TOPIC, B, "n1")
|
||||
assert card["stage"] == "grouped" and card["payload"]["reason"] == "attach"
|
||||
assert card["payload"]["merged_into"] == "Turingmaschinen"
|
||||
b2 = await db.kanban_get_card(TOPIC, "artefacts", "turingmaschinen")
|
||||
assert b2["stage"] == "generate" # Re-Anreicherung des neuen Subs
|
||||
block = await db.kanban_get_card(TOPIC, B, "b1")
|
||||
assert "Halteproblem" in block["payload"]["children"]
|
||||
|
||||
|
||||
async def test_attach_nachzuegler_ohne_zuordnung_bleibt_standalone(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
flow = _mk_flow(tmp_path)
|
||||
await db.kanban_upsert_card(TOPIC, B, "b1", "block", "done_block",
|
||||
{"title": "Turingmaschinen", "mirrored_norm": "turingmaschinen"})
|
||||
rows = [{"card_id": "n2", "payload": {"title": "Ganz neues Konzept"},
|
||||
"title": "Ganz neues Konzept", "description": "", "title_norm": "ganz neues konzept"}]
|
||||
await db.kanban_upsert_card(TOPIC, B, "n2", "block", "grouping", rows[0]["payload"])
|
||||
|
||||
async def fake_slot(ctx2, label, **kw):
|
||||
m = re.search(r"(/\S+\.json)", kw["prompt"])
|
||||
from pathlib import Path
|
||||
Path(m.group(1)).write_text('{"additions": []}', encoding="utf-8")
|
||||
return bi.OK, kw["payload"]((0, "", ""))
|
||||
|
||||
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
|
||||
assert await bi._attach_nachzuegler(ctx, flow, rows) == set()
|
||||
assert (await db.kanban_get_card(TOPIC, B, "n2"))["stage"] == "grouping"
|
||||
@@ -1,7 +1,7 @@
|
||||
"""E2E über die ECHTE Engine mit Fake-Agenten: kompletter Generierungspfad in Sekunden.
|
||||
|
||||
Anders als test_board_inventory (dort sind die Block-Funktionen gefakt) läuft hier alles
|
||||
bis run_agent echt — _race, Quorum, Panels, Konsolidierung, Cross-Block, QA-Gate.
|
||||
bis run_agent echt — _race, Quorum, Panels, In-Block-Konsolidierung, QA-Gate.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
|
||||
import board_inventory as bi
|
||||
from pipeline import GenContext
|
||||
from tests.invarianten import pruefe_invarianten, pruefe_guide_invarianten
|
||||
from invarianten import pruefe_invarianten, pruefe_guide_invarianten
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
@@ -42,11 +42,16 @@ async def test_e2e_thema_vollpfad(fake_welt, testdb, tmp_path):
|
||||
db = testdb
|
||||
done = [c for c in await db.kanban_cards(TOPIC, board="inventory", stage="done_block")]
|
||||
titel = {c["payload"]["title"] for c in done}
|
||||
# Bottom-up: 7 Atome zu 3 Themen-Clustern gruppiert
|
||||
assert titel == {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"}
|
||||
# Cross-Block-Dublette: „Gemeinsamer Grundbegriff" überlebt in genau EINEM Block
|
||||
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
|
||||
# jedes Atom liegt in genau EINEM Cluster — keine Cross-Block-Dublette mehr
|
||||
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
|
||||
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "variant"]
|
||||
assert [r["status"] for r in gemeinsam] == ["consensus"]
|
||||
# Vollständigkeit: alle 7 Atome sind als consensus-Sub erhalten
|
||||
consensus = {r["sub_norm"] for r in subs if r["status"] == "consensus"}
|
||||
assert consensus == {"definition alpha", "alpha eigenschaften", "gemeinsamer grundbegriff",
|
||||
"beta ablauf", "beta grenzen", "gamma praxisfall", "gamma werkzeuge"}
|
||||
fehler = await pruefe_invarianten(TOPIC, files)
|
||||
assert fehler == []
|
||||
|
||||
@@ -67,6 +72,27 @@ async def test_e2e_guide(fake_welt, testdb, tmp_path):
|
||||
assert await pruefe_guide_invarianten(TOPIC) == []
|
||||
|
||||
|
||||
async def test_e2e_only_artefacts(fake_welt, testdb, tmp_path):
|
||||
"""Artefakt-only (inventory=False): nach removeArtefacts läuft NUR Board 2 auf den
|
||||
fertigen Blocks — kein Board 1, keine Research-Agenten. Karten aus done_block geseedet."""
|
||||
ok, files = await _lauf(tmp_path)
|
||||
assert ok
|
||||
db = testdb
|
||||
subs_vorher = {r["sub_norm"] for r in await db.list_subblocks(TOPIC)}
|
||||
assert subs_vorher
|
||||
# Artefakte entfernen (Karten zurück auf generate, Board-1-Subs bleiben)
|
||||
await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
|
||||
fake_welt.calls.clear()
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
ok2 = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
|
||||
research=False, inventory=False), timeout=60)
|
||||
assert ok2
|
||||
assert not any("-research-" in k for k in fake_welt.calls) # Board 1 lief NICHT
|
||||
assert any("-sb-enrich-" in k or "-sb-verify-" in k for k in fake_welt.calls) # Board 2 lief
|
||||
assert {r["sub_norm"] for r in await db.list_subblocks(TOPIC)} == subs_vorher # vollständig
|
||||
|
||||
|
||||
async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
|
||||
"""Zweiter Lauf (Continue, research=False) hinterlässt keine Waisen/Reste."""
|
||||
ok, files = await _lauf(tmp_path)
|
||||
@@ -83,9 +109,7 @@ 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"-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
|
||||
])
|
||||
@@ -97,35 +121,6 @@ async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
|
||||
assert await pruefe_invarianten(TOPIC, files) == []
|
||||
|
||||
|
||||
async def test_e2e_crossblock_dissent_failopen(fake_welt, testdb, tmp_path):
|
||||
"""j1 sagt a, j2 sagt b, j3 fällt aus → Paar bleibt (fail-open), Rest konsistent."""
|
||||
fake_welt.stoerungen += [
|
||||
{"muster": r"-sub-crossblock-.*-j2$", "modus": "antwort",
|
||||
"antwort": '{"pairs": {"1": "b"}}', "mal": 1, "rest": 1},
|
||||
{"muster": r"-sub-crossblock-.*-j3$", "modus": "fehler", "mal": 3, "rest": 3},
|
||||
]
|
||||
ok, files = await _lauf(tmp_path)
|
||||
assert ok
|
||||
db = testdb
|
||||
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
|
||||
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
|
||||
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "consensus"] # kein Fold
|
||||
assert await pruefe_invarianten(TOPIC, files) == []
|
||||
|
||||
|
||||
async def test_e2e_inblock_gruppe_faltet(fake_welt, testdb, tmp_path):
|
||||
"""Welt-Regel: „Alpha Eigenschaften" faltet unter „Definition Alpha" — beide Judges
|
||||
liefern die Gruppe, der Verlierer wird variant, seine facts wandern zum Gewinner."""
|
||||
fake_welt.gruppen.append(("Definition Alpha", ["Alpha Eigenschaften"]))
|
||||
ok, files = await _lauf(tmp_path)
|
||||
assert ok
|
||||
db = testdb
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha-konzept")}
|
||||
assert rows.get("alpha eigenschaften") == "variant"
|
||||
assert rows.get("definition alpha") == "consensus"
|
||||
assert await pruefe_invarianten(TOPIC, files) == []
|
||||
|
||||
|
||||
async def test_e2e_gate_vollinventur_ohne_fix(fake_welt, testdb, tmp_path):
|
||||
"""Gate-Judge liefert eine Voll-Inventur (belegte Claims mit „Belegt…"-Grund) —
|
||||
der Schema-Filter wirft sie raus, es läuft KEIN Fakten-Fix."""
|
||||
@@ -189,10 +184,11 @@ async def test_e2e_uni_anker_gate(fake_welt, testdb, tmp_path, monkeypatch):
|
||||
assert ok
|
||||
db = testdb
|
||||
alle = [dict(c) for c in await db.kanban_cards(TOPIC, board="inventory")]
|
||||
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Kanon-Klassiker"
|
||||
# das unbelegte Atom „Klassiker Detail" (einziges Atom von Kanon-Klassiker) wird rejected
|
||||
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Klassiker Detail"
|
||||
for c in alle)
|
||||
assert not any(c["kind"] == "block" and c["payload"].get("title") == "Kanon-Klassiker"
|
||||
for c in alle) # nie zum Block geworden
|
||||
assert not any(c["payload"].get("title") in ("Klassiker Detail", "Kanon-Klassiker")
|
||||
and c["stage"] == "done_block" for c in alle) # nie zum Block geworden
|
||||
done = {c["payload"].get("title") for c in alle
|
||||
if c["kind"] == "block" and c["stage"] == "done_block"}
|
||||
assert {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"} <= done
|
||||
+116
-38
@@ -131,8 +131,8 @@ async def test_guide_error_event(testdb):
|
||||
|
||||
def test_timeout_calibration_smoke():
|
||||
from pipeline import _timeout
|
||||
assert _timeout("subblock", 10) == 400 + 150
|
||||
assert _timeout("content", 10) == 450 + 300
|
||||
assert _timeout("subblock_check", 10) == 150 + 100
|
||||
assert _timeout("writer", 10) == 450 + 600
|
||||
|
||||
|
||||
def test_env_file_wins(tmp_path, monkeypatch):
|
||||
@@ -146,41 +146,6 @@ def test_env_file_wins(tmp_path, monkeypatch):
|
||||
assert os.environ["X_CREATOR_TESTKEY"] == "neu"
|
||||
|
||||
|
||||
async def test_restart_artefact_card_wipes_only_that_block(testdb):
|
||||
import board_inventory as bi
|
||||
db = testdb
|
||||
for norm in ("alpha", "beta"):
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact",
|
||||
{"title": norm.title(), "raw": {norm: ["S"]}, "facts": {}})
|
||||
await db.upsert_subblock(TOPIC, norm, "s1", norm.title(), "Sub Eins")
|
||||
await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?")
|
||||
await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}")
|
||||
assert await bi.restart_artefact_card(TOPIC, "alpha") is True
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "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
|
||||
|
||||
|
||||
async def test_guide_reset_card_single(testdb):
|
||||
import guide_board as gb
|
||||
db = testdb
|
||||
for n in ("alpha", "beta"):
|
||||
await db.upsert_guide_card(TOPIC, "Guide", n, n.title())
|
||||
await db.set_guide_card(TOPIC, "Guide", n, stage="done", status="ok",
|
||||
writer_rounds=2, md="# SECTION Text", gate_info="x")
|
||||
await db.put_lernziel(TOPIC, n, "z1", "Ziel eins")
|
||||
assert await gb.reset_card(TOPIC, "Guide", "alpha", 0) is True
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||
assert cards["alpha"]["stage"] == "lernziele" and cards["alpha"]["md"] == "" and cards["alpha"]["writer_rounds"] == 0
|
||||
assert cards["beta"]["stage"] == "done" and cards["beta"]["md"] # untouched
|
||||
assert await db.list_lernziele(TOPIC) and all(z["block_norm"] != "alpha" for z in await db.list_lernziele(TOPIC))
|
||||
# ab_stage 3 (pruefer) behält md
|
||||
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||
assert cards["beta"]["stage"] == "pruefer" and cards["beta"]["md"]
|
||||
|
||||
|
||||
async def test_completeness_route(testdb, tmp_path, monkeypatch):
|
||||
import routes, paths
|
||||
db = testdb
|
||||
@@ -253,7 +218,7 @@ async def test_opencode_cmd_sets_title(monkeypatch):
|
||||
"""Der Agent-Key wird Session-Titel — der Join-Schlüssel fürs Token-Logging."""
|
||||
seen = {}
|
||||
|
||||
async def fake_comm(agent_key, cmd, stdin, timeout, stagger=False, on_line=None, label="", env=None):
|
||||
async def fake_comm(agent_key, cmd, stdin, timeout, stagger=False, on_line=None, label="", env=None, est_kb=None):
|
||||
seen["cmd"] = cmd
|
||||
return 0, "", ""
|
||||
|
||||
@@ -317,3 +282,116 @@ async def test_events_run_summary_aggregates(testdb):
|
||||
assert s["agents"]["gesamt"] == 2 and s["agents"]["ok"] == 1 and s["agents"]["timeout"] == 1
|
||||
assert s["agents"]["verlorene_min"] == 2
|
||||
assert s["tokens"] == {"input": 15, "output": 2, "cache_read": 80, "cache_write": 1}
|
||||
|
||||
|
||||
async def test_events_run_summary_per_board(testdb):
|
||||
"""meta.board scoped die Aggregation: board="artefacts" summiert nur Artefakt-Agenten."""
|
||||
db = testdb
|
||||
db.set_current_run(TOPIC, "r2")
|
||||
await db.add_event(TOPIC, "agent", key="inv", status="ok", dur_ms=1000,
|
||||
meta={"board": "inventory", "tokens": {"input": 10, "output": 2}})
|
||||
await db.add_event(TOPIC, "agent", key="art", status="ok", dur_ms=2000,
|
||||
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
|
||||
db.set_current_run(TOPIC, None)
|
||||
art = await db.events_run_summary(TOPIC, "r2", board="artefacts")
|
||||
assert art["agents"]["gesamt"] == 1
|
||||
assert art["tokens"] == {"input": 5, "output": 7, "cache_read": 0, "cache_write": 0}
|
||||
inv = await db.events_run_summary(TOPIC, "r2", board="inventory")
|
||||
assert inv["agents"]["gesamt"] == 1 and inv["tokens"]["input"] == 10
|
||||
assert (await db.events_run_summary(TOPIC, "r2"))["agents"]["gesamt"] == 2 # ohne board = alle
|
||||
|
||||
|
||||
async def test_latest_board_runs_je_ebene(testdb):
|
||||
"""Je Ebene der jüngste Lauf MIT Daten: ein „Nur Artefakte"-Lauf (r2) lässt die
|
||||
Inventar-Anzeige auf r1 stehen; Guide-Läufe (Suffix -g+hex4) haben eine eigene Zeile."""
|
||||
db = testdb
|
||||
assert await db.latest_board_runs(TOPIC) == {"inventory": None, "artefacts": None, "guide": None}
|
||||
db.set_current_run(TOPIC, "20260708-1000-aaaa") # Voll-Lauf: beide Ebenen
|
||||
await db.add_event(TOPIC, "agent", key="i1", status="ok",
|
||||
meta={"board": "inventory", "tokens": {"input": 10, "output": 1}})
|
||||
await db.add_event(TOPIC, "agent", key="a1", status="ok", meta={"board": "artefacts"})
|
||||
db.set_current_run(TOPIC, "20260708-1100-bbbb") # „Nur Artefakte"
|
||||
await db.add_event(TOPIC, "agent", key="a2", status="ok",
|
||||
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
|
||||
db.set_current_run(TOPIC, "20260708-1200-gabc1") # Guide (Events untagged)
|
||||
await db.add_event(TOPIC, "agent", key="g1", status="ok",
|
||||
meta={"tokens": {"input": 3, "output": 4}})
|
||||
latest = await db.latest_board_runs(TOPIC)
|
||||
assert latest["inventory"]["run_id"] == "20260708-1000-aaaa"
|
||||
assert latest["inventory"]["tokens"]["input"] == 10
|
||||
assert latest["artefacts"]["run_id"] == "20260708-1100-bbbb"
|
||||
assert latest["artefacts"]["tokens"]["output"] == 7
|
||||
assert latest["guide"]["run_id"] == "20260708-1200-gabc1"
|
||||
assert latest["guide"]["tokens"]["input"] == 3
|
||||
# aktiv folgt der Registry: nur der Guide-Lauf ist noch der aktuelle
|
||||
assert latest["guide"]["aktiv"] and not latest["inventory"]["aktiv"]
|
||||
db.set_current_run(TOPIC, None)
|
||||
|
||||
|
||||
async def test_topic_delete_entfernt_guides_und_kanban(testdb, tmp_path, monkeypatch):
|
||||
"""DELETE /topics: guides/guide_cards/kanban_cards mitlöschen — GET /topics leitet
|
||||
Topics aus guides ab, sonst taucht das gelöschte Topic sofort wieder auf."""
|
||||
import routes, qa
|
||||
db = testdb
|
||||
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
|
||||
monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa")
|
||||
await db.create_topic(TOPIC)
|
||||
await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "",
|
||||
"status": "done", "progress": None,
|
||||
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
|
||||
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "b-1", "block", "done_block", {"title": "Alpha"})
|
||||
res = await routes.remove_topic(TOPIC)
|
||||
assert res["ok"]
|
||||
assert all(g["topic"] != TOPIC for g in await db.list_guides())
|
||||
assert await db.list_guide_cards(TOPIC, "Guide") == []
|
||||
assert await db.kanban_cards(TOPIC, "inventory") == []
|
||||
assert TOPIC not in await routes.get_topics()
|
||||
|
||||
|
||||
async def test_topic_delete_409_bei_laufendem_guide(testdb, tmp_path, monkeypatch):
|
||||
"""Läuft eine Generierung, wird nicht gelöscht (409) — ein laufender Flow schrieb
|
||||
sonst nach dem Löschen munter neue Rows/Dateien."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
import routes
|
||||
db = testdb
|
||||
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
|
||||
await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "",
|
||||
"status": "generating", "progress": None,
|
||||
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await routes.remove_topic(TOPIC)
|
||||
assert e.value.status_code == 409
|
||||
assert any(g["topic"] == TOPIC for g in await db.list_guides())
|
||||
|
||||
|
||||
async def test_runs_endpoint_liefert_bilanz(testdb):
|
||||
"""GET /api/runs: pro run_id Zeitspanne + Agent-/Token-Bilanz + Fails, jüngster zuerst,
|
||||
aktiv-Flag aus dem Run-Registry."""
|
||||
import routes
|
||||
db = testdb
|
||||
db.set_current_run(TOPIC, "r1")
|
||||
await db.add_event(TOPIC, "agent", key="a", status="ok", dur_ms=1000,
|
||||
meta={"tokens": {"input": 10, "output": 20, "cache_read": 0, "cache_write": 0}})
|
||||
await db.add_event(TOPIC, "agent", key="b", status="timeout", dur_ms=120000)
|
||||
await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"})
|
||||
res = await routes.get_runs(TOPIC)
|
||||
runs = res["runs"]
|
||||
assert set(res["latest"]) == {"inventory", "artefacts", "guide"} # je Ebene letzter Lauf mit Daten
|
||||
assert len(runs) == 1 and runs[0]["run_id"] == "r1" and runs[0]["aktiv"] is True
|
||||
assert runs[0]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1
|
||||
assert runs[0]["tokens"]["output"] == 20
|
||||
assert runs[0]["fails"][0]["error"] == "kaputt" and runs[0]["fails"][0]["status"] == "dead"
|
||||
assert runs[0]["start"] <= runs[0]["ende"]
|
||||
db.set_current_run(TOPIC, "r2")
|
||||
await db.add_event(TOPIC, "agent", key="c", status="ok")
|
||||
db.set_current_run(TOPIC, None)
|
||||
runs = (await routes.get_runs(TOPIC))["runs"]
|
||||
assert [r["run_id"] for r in runs] == ["r2", "r1"]
|
||||
assert runs[0]["aktiv"] is False # Registry geräumt → Lauf beendet
|
||||
|
||||
|
||||
async def test_health(testdb):
|
||||
import routes
|
||||
assert (await routes.health())["ok"] is True
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Finalize (board_artefacts._proc_finalize) und blocks-Helfer — Judges gefaked, gegen
|
||||
Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-Umbau in
|
||||
block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
from kanban import Flow
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "finalize"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
|
||||
|
||||
|
||||
def test_neg_set_lemmatisiert():
|
||||
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
|
||||
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
|
||||
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
|
||||
assert a == b == frozenset({"kein"})
|
||||
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
|
||||
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
|
||||
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_finalize(_ctx(), flow, files, [card])
|
||||
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
|
||||
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
|
||||
|
||||
|
||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||||
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
|
||||
"sidecar": {}, "pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
assert row["level"] == "advanced" and row["relevance"] == "relevant"
|
||||
|
||||
|
||||
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
|
||||
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
|
||||
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
|
||||
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows == {"neu": "consensus", "alte-variante": "variant"}
|
||||
@@ -58,25 +58,6 @@ def test_auftraege_schwelle_und_kritisch():
|
||||
assert len(z) == 2 and k is True
|
||||
|
||||
|
||||
async def test_reset_from_stage(testdb):
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "a", "A")
|
||||
await db.upsert_guide_card(TOPIC, FMT, "b", "B")
|
||||
await db.set_guide_card(TOPIC, FMT, "a", stage="done", md="text", writer_rounds=2)
|
||||
await db.set_guide_card(TOPIC, FMT, "b", stage="pruefer", md="text")
|
||||
await db.put_lernziel(TOPIC, "a", "z1", "Ziel")
|
||||
# reset ab writer (idx 2): beide Karten zurück, md geleert, Ziele bleiben
|
||||
moved = await gb.reset_from_stage(TOPIC, FMT, 2)
|
||||
assert moved == 2
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
|
||||
assert cards["a"]["stage"] == "writer" and cards["a"]["md"] == "" and cards["a"]["writer_rounds"] == 0
|
||||
assert cards["b"]["stage"] == "writer"
|
||||
assert await db.list_lernziele(TOPIC, "a")
|
||||
# reset ab lernziele (idx 0): Ziele weg
|
||||
await gb.reset_from_stage(TOPIC, FMT, 0)
|
||||
assert not await db.list_lernziele(TOPIC, "a")
|
||||
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "lernziele"
|
||||
|
||||
|
||||
async def test_done_step(testdb):
|
||||
db = testdb
|
||||
@@ -444,3 +425,108 @@ 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_repair_karten_stil_befunde_direkt_auf_fix(testdb, tmp_path, monkeypatch):
|
||||
"""Nur-Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit
|
||||
Auftragszeilen im gate_info (KEIN KRITISCH-Präfix → ein Rewrite ohne Re-Prüfer);
|
||||
die Redundanz-Zeile trägt den Cross-Karten-Kontext, den der Prüfer nie sieht."""
|
||||
import json as _json
|
||||
import qa as qa_mod
|
||||
db = testdb
|
||||
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
|
||||
for n in ("alpha", "beta", "gamma"):
|
||||
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
|
||||
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
|
||||
tdir = tmp_path / "qa" / TOPIC
|
||||
tdir.mkdir(parents=True)
|
||||
(tdir / "guide-20260705-000001.json").write_text(_json.dumps({
|
||||
"marker_fehlend": [], "fachlich_falsch": ["Gamma"], "ziel_ohne_anker": [],
|
||||
"redundanz": [{"a": "Alpha: gemeinsamer Absatztext", "b": "Beta: gemeinsamer Absatztext"}],
|
||||
"laengen_ausreisser": [{"block": "Beta", "zeichen": 9000, "budget": 3000}],
|
||||
"lesbarkeit": ["Alpha: Sätze kürzen"],
|
||||
}), encoding="utf-8")
|
||||
betroffen = await gb.repair_karten(TOPIC, FMT)
|
||||
assert sorted(betroffen) == ["Alpha", "Beta", "Gamma"]
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
|
||||
assert cards["gamma"]["stage"] == "pruefer" # kritisch → Prüfer
|
||||
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "fix"
|
||||
assert not cards["beta"]["gate_info"].startswith("KRITISCH")
|
||||
assert "BALLAST" in cards["beta"]["gate_info"] and "Alpha" in cards["beta"]["gate_info"]
|
||||
assert "LÄNGE" in cards["beta"]["gate_info"] and "3000" in cards["beta"]["gate_info"]
|
||||
assert "LESBARKEIT: Sätze kürzen" in cards["alpha"]["gate_info"]
|
||||
|
||||
|
||||
async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch):
|
||||
"""Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie
|
||||
bleiben im gate_info sichtbar (vorher wurde gate_info geleert)."""
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
env = gb._Env(None, "g-ff", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
|
||||
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\nText."
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md,
|
||||
"stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
assert "-gfix-" in key
|
||||
return gb.FAILED, None
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
assert await gb._stage_fix(env, card)
|
||||
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
|
||||
assert karte["stage"] == "done"
|
||||
assert "offene Befunde" in karte["gate_info"] and "Claim c1" in karte["gate_info"]
|
||||
|
||||
|
||||
async def test_repruefer_ausfall_wird_vermerkt(testdb, tmp_path, monkeypatch):
|
||||
"""Fällt der Re-Prüfer aus (kein Cancel), wird das im gate_info vermerkt statt die
|
||||
Karte stumm als geprüft durchzuwinken."""
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
env = gb._Env(None, "g-ra", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
|
||||
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md,
|
||||
"stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
if "-gfix-" in key:
|
||||
import re as _re
|
||||
m = _re.search(r"(/\S+\.md)", prompt)
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(md.replace("Text im Rahmen.", "Korrigiert."))
|
||||
return gb.OK, payload(None)
|
||||
return gb.FAILED, None # Re-Prüfer fällt aus
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
assert await gb._stage_fix(env, card)
|
||||
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
|
||||
assert karte["stage"] == "done" and "Korrigiert." in karte["md"]
|
||||
assert "Re-Prüfer ohne Ergebnis" in karte["gate_info"]
|
||||
|
||||
|
||||
async def test_progress_reporter_ueberlebt_db_fehler(testdb, monkeypatch):
|
||||
"""Ein DB-Fehler beendet den Reporter nicht (der Fortschritt fror sonst still ein);
|
||||
unveränderter Stand wird nicht erneut geschrieben."""
|
||||
import asyncio
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
|
||||
calls = {"n": 0}
|
||||
writes = []
|
||||
orig = db.guide_stage_counts
|
||||
|
||||
async def flaky(topic, fmt):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise RuntimeError("kaputt")
|
||||
return await orig(topic, fmt)
|
||||
|
||||
async def fake_update(guide_id, **kw):
|
||||
writes.append(kw["progress"])
|
||||
|
||||
monkeypatch.setattr(gb.db, "guide_stage_counts", flaky)
|
||||
monkeypatch.setattr(gb.db, "update_guide", fake_update)
|
||||
task = asyncio.create_task(gb._progress_reporter("g-pr", TOPIC, FMT, takt=0.01))
|
||||
await asyncio.sleep(0.08)
|
||||
task.cancel()
|
||||
assert calls["n"] >= 3 # lief nach dem Fehler weiter
|
||||
assert writes.count(writes[0]) == 1 # gleicher Stand nur einmal geschrieben
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Guide-Ebene der Auto-Sequenz: generieren → QA → Repair-Loop bis 100 %/Stillstand."""
|
||||
import blocks
|
||||
import guide
|
||||
import guide_qa
|
||||
import guide_board
|
||||
|
||||
|
||||
def _patch(monkeypatch, noten, calls):
|
||||
async def fake_gen(*a, **k):
|
||||
calls["gen"] += 1
|
||||
|
||||
async def fake_rep(topic, fmt):
|
||||
calls["rep"] += 1
|
||||
return []
|
||||
|
||||
async def fake_qa(topic, llm=False):
|
||||
return {"note_guide": noten.pop(0) if len(noten) > 1 else noten[0]}
|
||||
|
||||
monkeypatch.setattr(guide, "generate_guide", fake_gen)
|
||||
monkeypatch.setattr(guide_board, "repair_karten", fake_rep)
|
||||
monkeypatch.setattr(guide_qa, "guide_qa_report", fake_qa)
|
||||
|
||||
|
||||
async def test_guide_fertig_ohne_repair(testdb, monkeypatch):
|
||||
blocks._blocks_errors.pop("G1", None)
|
||||
calls = {"gen": 0, "rep": 0}
|
||||
_patch(monkeypatch, [10.0], calls)
|
||||
await blocks._guide_ebene("G1", "", "claude", lambda: False, lambda *a, **k: None)
|
||||
assert calls["gen"] == 1 and calls["rep"] == 0 # nur initial gebaut
|
||||
assert "G1" not in blocks._blocks_errors
|
||||
|
||||
|
||||
async def test_guide_repariert_bis_100(testdb, monkeypatch):
|
||||
blocks._blocks_errors.pop("G2", None)
|
||||
calls = {"gen": 0, "rep": 0}
|
||||
_patch(monkeypatch, [7.0, 10.0], calls) # erst 7.0, nach einem Repair 10.0
|
||||
await blocks._guide_ebene("G2", "", "claude", lambda: False, lambda *a, **k: None)
|
||||
assert calls["rep"] == 1
|
||||
assert "G2" not in blocks._blocks_errors
|
||||
|
||||
|
||||
async def test_guide_stillstand_meldet_systemfehler(testdb, monkeypatch):
|
||||
blocks._blocks_errors.pop("G3", None)
|
||||
calls = {"gen": 0, "rep": 0}
|
||||
_patch(monkeypatch, [6.0], calls) # Repair bewegt die Note nie
|
||||
await blocks._guide_ebene("G3", "", "claude", lambda: False, lambda *a, **k: None)
|
||||
assert "G3" in blocks._blocks_errors
|
||||
assert "Guide" in blocks._blocks_errors["G3"]
|
||||
@@ -1,265 +0,0 @@
|
||||
"""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 blocks
|
||||
import board_artefacts as ba
|
||||
from kanban import Flow
|
||||
from pipeline import FAILED, OK, GenContext
|
||||
|
||||
TOPIC = "konsolidierung"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def _fake_slot(antworten):
|
||||
"""run_single_slot-Fake: pro Judge-Key eine Antwort; schreibt via payload (wie der Engine-Sink)."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
j = key.rsplit("-", 1)[-1] # "j1"/"j2"
|
||||
antwort = antworten.get(j)
|
||||
if antwort is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(antwort), ""))
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def _seed_block(db, bnorm, subs):
|
||||
for s in subs:
|
||||
await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus")
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
|
||||
|
||||
|
||||
def test_neg_set_lemmatisiert():
|
||||
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
|
||||
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
|
||||
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
|
||||
assert a == b == frozenset({"kein"})
|
||||
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
|
||||
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
|
||||
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_finalize(_ctx(), flow, files, [card])
|
||||
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
|
||||
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
|
||||
|
||||
|
||||
# ── Cross-Block ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeEmb:
|
||||
"""Gleicher Text → gleicher Einheitsvektor, sonst orthogonal (cos 1.0 / 0.0)."""
|
||||
|
||||
@staticmethod
|
||||
def available():
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def embed_sims(texts):
|
||||
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
|
||||
arr = np.zeros((len(texts), max(len(uniq), 1)))
|
||||
for r, t in enumerate(texts):
|
||||
arr[r, uniq[t]] = 1.0
|
||||
return arr @ arr.T
|
||||
|
||||
|
||||
async def _cross_env(db, tmp_path, finalisiert=True):
|
||||
"""Zwei finalisierte Karten in der End-Barriere; die Sub-Rows liegen in der DB
|
||||
(post-finalize ist die DB die Wahrheit, nicht mehr das Karten-Payload)."""
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
cards = []
|
||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
|
||||
("beta", ["Gleiche Aussage", "Nur in Beta"])):
|
||||
payload = {"title": bnorm.title()}
|
||||
if finalisiert:
|
||||
payload.update(pattern={}, artefacts={})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, bnorm, subs)
|
||||
cards.append({"card_id": bnorm, "payload": payload})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
return flow, cards, files
|
||||
|
||||
|
||||
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmig „a" → Betas geteilte Aussage wird variant, ihre Frage wandert zum
|
||||
Gewinner (falte_sub), Karten gehen auf DONE."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
sn = blocks._norm_title("Gleiche Aussage")
|
||||
await db.upsert_question_pattern(TOPIC, "beta", sn, "Beta", "Gleiche Aussage", "F?")
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert "Gleiche Aussage" in fake.calls[0]["prompt"]
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||
beta_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert beta_rows[sn] == "variant"
|
||||
alpha_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert alpha_rows[sn] == "consensus"
|
||||
fragen = await db.list_question_pattern(TOPIC)
|
||||
assert {(r["block_norm"], r["sub_norm"]) for r in fragen} == {("alpha", sn)} # umgehängt
|
||||
|
||||
|
||||
async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
|
||||
"""j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
|
||||
"j3": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert len(fake.calls) == 3
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
|
||||
|
||||
|
||||
async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
|
||||
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}}) # j3 fehlt → FAILED
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == ba.DONE
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||
|
||||
|
||||
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
|
||||
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}}) # j2 → FAILED
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
|
||||
|
||||
|
||||
async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
|
||||
class _Aus:
|
||||
@staticmethod
|
||||
def available():
|
||||
return False
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("ohne Embedding kein Judge")
|
||||
|
||||
monkeypatch.setattr(ba, "embedding", _Aus)
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||
|
||||
|
||||
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")
|
||||
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "artefakte"
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||
|
||||
|
||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||||
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
|
||||
"sidecar": {}, "pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
assert row["level"] == "advanced" and row["relevance"] == "relevant"
|
||||
|
||||
|
||||
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
|
||||
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
|
||||
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
|
||||
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows == {"neu": "consensus", "alte-variante": "variant"}
|
||||
|
||||
|
||||
async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
|
||||
"""Paare werden gechunkt beurteilt (ein Hänger blockiert nur noch seinen Chunk);
|
||||
die Verdicts falten global über alle Chunks."""
|
||||
db = testdb
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
cards = []
|
||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
|
||||
("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
|
||||
payload = {"title": bnorm.title(), "pattern": {}, "artefacts": {}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, bnorm, subs)
|
||||
cards.append({"card_id": bnorm, "payload": payload})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1) # 2 Paare → 2 Chunks
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
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
|
||||
@@ -26,6 +26,7 @@ def test_convert_writes_markdown_txt(tmp_path):
|
||||
|
||||
def test_cache_skips_fresh_txt(tmp_path):
|
||||
_mini_pdf(tmp_path / "a.pdf")
|
||||
(tmp_path / ".pdf-txt-norm").write_text(str(blx._PDF_NORM_VERSION), encoding="utf-8")
|
||||
marker = tmp_path / "a.txt"
|
||||
marker.write_text("MARKER", encoding="utf-8")
|
||||
now = time.time() + 60
|
||||
@@ -34,6 +35,39 @@ def test_cache_skips_fresh_txt(tmp_path):
|
||||
assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert
|
||||
|
||||
|
||||
def test_norm_version_erzwingt_rekonvertierung(tmp_path, monkeypatch):
|
||||
"""Fehlender/alter .pdf-txt-norm-Marker ignoriert den mtime-Cache einmalig."""
|
||||
_mini_pdf(tmp_path / "a.pdf")
|
||||
stale = tmp_path / "a.txt"
|
||||
stale.write_text("ALT", encoding="utf-8")
|
||||
now = time.time() + 60
|
||||
os.utime(stale, (now, now))
|
||||
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
|
||||
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "neu konvertiert")
|
||||
blx._convert_pdfs(tmp_path)
|
||||
assert stale.read_text(encoding="utf-8") == "neu konvertiert"
|
||||
assert (tmp_path / ".pdf-txt-norm").read_text(encoding="utf-8") == str(blx._PDF_NORM_VERSION)
|
||||
stale.write_text("BLEIBT", encoding="utf-8")
|
||||
os.utime(stale, (now, now))
|
||||
blx._convert_pdfs(tmp_path) # Marker aktuell → Cache greift wieder
|
||||
assert stale.read_text(encoding="utf-8") == "BLEIBT"
|
||||
|
||||
|
||||
def test_entzerre_pdf_woerter():
|
||||
"""Small-Caps-Splits mergen; Variablen-Schutzfall und unbelegte Paare bleiben."""
|
||||
# Regel A inkl. Kette
|
||||
assert blx._entzerre_pdf_woerter("Das H ITTING S ET Problem") == "Das HITTING SET Problem"
|
||||
assert blx._entzerre_pdf_woerter("V ERTEX C OVER ist schwer") == "VERTEX COVER ist schwer"
|
||||
# Schutzfall: Großlauf klein fortgesetzt → L ist Variable
|
||||
assert blx._entzerre_pdf_woerter("die Sprache L NP-vollständig ist") == "die Sprache L NP-vollständig ist"
|
||||
# Regel B: Paar nur mit Frequenz-Beleg (≥3× ungespalten im Dokument)
|
||||
belegt = "N P ist zentral. " + "NP NP NP."
|
||||
assert blx._entzerre_pdf_woerter(belegt).startswith("NP ist zentral.")
|
||||
assert blx._entzerre_pdf_woerter("C Y bleibt getrennt") == "C Y bleibt getrennt"
|
||||
# kein Match mitten im Wort
|
||||
assert blx._entzerre_pdf_woerter("HALTTM IST hier") == "HALTTM IST hier"
|
||||
|
||||
|
||||
def test_fallback_to_pdftotext(tmp_path, monkeypatch):
|
||||
_mini_pdf(tmp_path / "b.pdf")
|
||||
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
|
||||
@@ -69,3 +103,13 @@ def test_fidelity_guard_prefers_faithful_plaintext():
|
||||
assert blx._pick_conversion(None, plain)[1] == "pdftotext"
|
||||
assert blx._pick_conversion(md_ok, None)[1] == "pymupdf4llm"
|
||||
assert blx._pick_conversion(None, None) is None
|
||||
|
||||
|
||||
def test_entzerre_hyphen_space():
|
||||
"""Regel C: GROSSLAUF + Leerzeichen vor Bindestrich-Kompositum („NP -vollständig",
|
||||
Makro-Spacing) wird gemerged; echter Gedankenstrich („X - y") bleibt."""
|
||||
assert blx._entzerre_pdf_woerter("die NP -vollständigkeit gilt") == "die NP-vollständigkeit gilt"
|
||||
assert blx._entzerre_pdf_woerter("Begriffe der NP -schwere und NP -vollständigkeit") == \
|
||||
"Begriffe der NP-schwere und NP-vollständigkeit"
|
||||
assert blx._entzerre_pdf_woerter("Term X - y bleibt") == "Term X - y bleibt"
|
||||
assert blx._entzerre_pdf_woerter("Liste:\n-punkt eins") == "Liste:\n-punkt eins"
|
||||
@@ -93,9 +93,53 @@ def test_note_kalibrierung():
|
||||
assert qa.note({"fremd": 1.0}) == 0.0 # komplett fremdes Inventar = 0, nicht 7.7
|
||||
|
||||
|
||||
def test_note_verdacht_zaehlt_nicht():
|
||||
"""dubletten_verdacht ist Verdachtsliste, kein Urteil — beeinflusst die Note nicht."""
|
||||
assert qa.note({"dubletten_verdacht": 1.0}) == 10.0
|
||||
def test_note_dubletten_verdacht_zaehlt():
|
||||
"""dubletten_verdacht bildet das Dopplungs-Nutzerproblem ab → zählt jetzt (Gewicht 1.0),
|
||||
bleibt aber das leichteste Gewicht. 36 % Verdacht → −3.6 → 6.4; die Liste kann >1 sein → geklemmt."""
|
||||
assert qa.note({"dubletten_verdacht": 0.0}) == 10.0
|
||||
assert qa.note({"dubletten_verdacht": 0.36}) == 6.4
|
||||
assert qa.note({"dubletten_verdacht": 2.0}) == 0.0 # >1 wird auf 1.0 geklemmt
|
||||
|
||||
# sub_dubletten_verdacht bleibt gewichtslos (Artefakt-Verdacht, kein Urteil)
|
||||
assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0
|
||||
|
||||
|
||||
def test_note_konzept_luecken_zaehlt():
|
||||
"""Fehlende benannte Kernresultate drücken die Note (Gewicht 1.5): 20 % → −3.0 → 7.0."""
|
||||
assert qa.note({"konzept_luecken": 0.0}) == 10.0
|
||||
assert qa.note({"konzept_luecken": 0.2}) == 7.0
|
||||
|
||||
|
||||
def test_named_results_only_numbered(monkeypatch):
|
||||
"""Benannte Ergebnisse: nur NUMMERIERTE Katalog-Referenzen mit Namen zählen — Prosa ohne
|
||||
Nummer ('Satz von Goethe') und nackte Nummern ('Satz 7.18') liefern nichts (generisch)."""
|
||||
corpus = {"skript.txt":
|
||||
"Satz 7.13 (Christofides) liefert eine 3/2-Approximation.\n"
|
||||
"Satz 6.24: Satz von Cook und Levin.\n"
|
||||
"Nach Satz 7.18 folgt daraus die Schranke.\n"
|
||||
"Ein Satz von Goethe steht hier."}
|
||||
named = qa._named_results(corpus)
|
||||
assert "Christofides" in named
|
||||
assert any("Cook" in n and "Levin" in n for n in named)
|
||||
assert not any("Goethe" in n for n in named) # keine Nummer → kein False Positive
|
||||
assert all(n.strip() for n in named)
|
||||
|
||||
|
||||
def test_konzept_luecken_flags_missing():
|
||||
"""Benanntes Kernresultat ohne Baustein = Lücke; ein gedecktes Resultat nicht."""
|
||||
corpus = {"s.txt": "Satz 7.13 (Christofides). Satz 6.16 (Kriterium für P=NP)."}
|
||||
named = qa._named_results(corpus)
|
||||
blocks = [{"title": "Christofides-Algorithmus", "description": "3/2-Approximation für TSP"}]
|
||||
gaps = qa.konzept_luecken(blocks, named)
|
||||
assert "Christofides" not in gaps
|
||||
assert any("P=NP" in g or "Kriterium" in g for g in gaps)
|
||||
|
||||
|
||||
def test_konzept_luecken_declension_tolerant():
|
||||
"""Andere Flexion im Baustein deckt das Resultat trotzdem — kein falscher Lücken-Alarm."""
|
||||
named = qa._named_results({"s.txt": "Satz 7.6: Kriterium für Eulerschen Kreis."})
|
||||
blocks = [{"title": "Kriterium für Eulerscher Kreis", "description": "Grad aller Knoten gerade"}]
|
||||
assert qa.konzept_luecken(blocks, named) == []
|
||||
|
||||
|
||||
def test_note_artefakte_getrennt():
|
||||
@@ -227,7 +271,7 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
|
||||
{"title": titel, "description": "d"})
|
||||
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
|
||||
|
||||
async def fake_verdicts(template, topic, key, items):
|
||||
async def fake_wave(template, topic, key, slot, items, **kw):
|
||||
if template != "QA-Bausteine":
|
||||
return {}
|
||||
if key.startswith("bausteine-b2"): # Bestätiger sieht nur die Geflaggten
|
||||
@@ -235,7 +279,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"]
|
||||
|
||||
@@ -269,3 +313,35 @@ async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
|
||||
(qdir / "alt.json").write_text("{}", encoding="utf-8")
|
||||
await routes.remove_topic("t")
|
||||
assert not qdir.exists()
|
||||
|
||||
|
||||
def test_luecken_key_stabil_gegen_whitespace():
|
||||
"""Freispruch-Schlüssel: Datei + normalisierter Vorschau-Hash — Whitespace-Varianten
|
||||
derselben Fundstelle mappen auf denselben Key, andere Vorschau nicht."""
|
||||
a = {"datei": "f.txt", "abschnitt": 3, "vorschau": "Der Satz\nvon Foo"}
|
||||
b = {"datei": "f.txt", "abschnitt": 7, "vorschau": "der satz von foo"}
|
||||
c = {"datei": "f.txt", "abschnitt": 3, "vorschau": "ganz anderer Text"}
|
||||
assert qa.luecken_key(a) == qa.luecken_key(b)
|
||||
assert qa.luecken_key(a) != qa.luecken_key(c)
|
||||
assert qa.luecken_key(a).startswith("f.txt||")
|
||||
|
||||
|
||||
def test_offene_luecken_filtert_freispruch_und_nein():
|
||||
rep = {"luecken": [{"datei": "f", "vorschau": "a", "llm": "ja"},
|
||||
{"datei": "f", "vorschau": "b", "llm": "nein"},
|
||||
{"datei": "f", "vorschau": "c", "freispruch": True}],
|
||||
"konzept_luecken": ["Satz von Foo"]}
|
||||
lk, kl = qa.offene_luecken(rep)
|
||||
assert [x["vorschau"] for x in lk] == ["a"] and kl == ["Satz von Foo"]
|
||||
|
||||
|
||||
def test_named_results_ueberspringt_umbrochene_saetze():
|
||||
"""Doppelpunkt-Fang, der am Zeilenumbruch endet und klein weiterläuft, ist ein
|
||||
umbrochener SATZ, kein Konzeptname — solche Phantom-Lücken kann kein Block ankern.
|
||||
Echte Namen (auch \\n-terminiert mit großer Folgezeile) bleiben."""
|
||||
text = ("Konsequenz von Satz 6.16: Wenn ein NP-vollständiges Entscheidungsproblem in P\n"
|
||||
"liegt, dann sind alle NP Entscheidungsprobleme in P.\n\n"
|
||||
"Satz 7.13 (Christofides). Beweis folgt.\n"
|
||||
"Satz 6.24: Cook-Levin.\n"
|
||||
"Satz 9.1: Vier-Farben-Satz\nDer Beweis nutzt Computer.\n")
|
||||
assert sorted(qa._named_results({"f": text})) == ["Christofides", "Cook-Levin", "Vier-Farben-Satz"]
|
||||
@@ -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."""
|
||||
|
||||
+142
-10
@@ -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,9 +292,141 @@ async def test_waisen_cleanup(env, monkeypatch):
|
||||
async def no_agent(*a, **kw):
|
||||
raise AssertionError("Aufräumen braucht keinen Agenten")
|
||||
|
||||
monkeypatch.setattr(repair, "run_agent", no_agent)
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["aufgeraeumt"] == 3
|
||||
rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
|
||||
assert rest == {("sub0", "flashcard"), ("doppel", "example")}
|
||||
assert not [r for r in await db.list_question_pattern(TOPIC)]
|
||||
|
||||
|
||||
async def test_dubletten_verdacht_freispruch(env, monkeypatch):
|
||||
"""Widerlegtes/unbeurteiltes Verdachtspaar bekommt den Klärungskanal: Judge sagt
|
||||
behalten → Freispruch persistiert; der nächste qa_report zählt das Paar nicht mehr
|
||||
(vorher zählte widerlegter Verdacht ewig in die Quote — Mess-Rauschen ohne Ausweg)."""
|
||||
import qa as qa_mod
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
await seed("Alpha Kreis", "beschr")
|
||||
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "nein"}}', "" # Judge: keine Dublette
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["merges"] == [] and len(res["freigesprochen"]) == 1 and res["aktionen"] == 1
|
||||
frei = qa_mod.lade_freispruch(TOPIC)
|
||||
assert qa_mod._paar_key("Alpha", "Alpha Kreis") in set(frei.get("dubletten") or [])
|
||||
|
||||
|
||||
async def test_dubletten_verdacht_stichentscheid_merged(env, monkeypatch):
|
||||
"""Verdachtspaar (llm=nein), aber Judge+Stichentscheid sagen beide Dublette →
|
||||
Merge mit 2:1 gegen das QA-Urteil."""
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "kurz")
|
||||
await seed("Alpha Kreis", "deutlich längere Beschreibung — Gewinner")
|
||||
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "ja"}}', ""
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC)
|
||||
assert res["merges"] == ["Alpha → Alpha Kreis"] and res["freigesprochen"] == []
|
||||
|
||||
|
||||
async def test_beleg_nachfass_discard_und_datenfix(env, monkeypatch):
|
||||
"""mentions=0-Subs: Judge bestätigt „kein Beleg" → Sub verworfen; Dissens mit
|
||||
Stichentscheid „belegt" → mentions=1 nachgetragen (Datenfix, kein Freispruch)."""
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
norm = repair._norm_title("Alpha")
|
||||
await db.put_subblock(TOPIC, norm, "ohne beleg", "Alpha", "Ohne Beleg", status="consensus")
|
||||
await db.put_subblock(TOPIC, norm, "doch belegt", "Alpha", "Doch Belegt", status="consensus")
|
||||
await db.set_subblock_fields(TOPIC, norm, "ohne beleg", mentions=0)
|
||||
await db.set_subblock_fields(TOPIC, norm, "doch belegt", mentions=0)
|
||||
write_report(_report(beleg={"subs_ohne_beleg": ["Alpha · Ohne Beleg", "Alpha · Doch Belegt"]}))
|
||||
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
|
||||
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
if "-beleg-st-" in key:
|
||||
return 0, '{"relevant": {"1": "ja"}}', "" # Stichentscheid: belegt
|
||||
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC, "artefacts")
|
||||
assert sorted(res["beleg_fix"]) == ["belegt: Alpha · Doch Belegt", "entfernt: Alpha · Ohne Beleg"]
|
||||
rows = {r["sub_norm"]: r for r in await db.list_subblocks(TOPIC, norm)}
|
||||
assert rows["ohne beleg"]["status"] == "discarded"
|
||||
assert rows["doch belegt"]["status"] == "consensus" and rows["doch belegt"]["mentions"] == 1
|
||||
|
||||
|
||||
async def test_fix_beschreibung_fuellt_leere(env, monkeypatch):
|
||||
"""Hygiene „leere-beschreibung" → Sanierung schreibt EINE Beschreibung aus den
|
||||
Auszügen; Titel bleibt unverändert."""
|
||||
db, seed, files, write_report = env
|
||||
cid = await seed("Leerling", "")
|
||||
write_report(_report(hygiene=[{"titel": "Leerling", "probleme": ["leere-beschreibung"]}]))
|
||||
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
|
||||
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
assert "-sanierung-" in key
|
||||
return 0, '{"title": "Leerling", "description": "Aus dem Material."}', ""
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC, "inventory")
|
||||
assert "beschrieben: Leerling" in res["hygiene"]
|
||||
card = await db.kanban_get_card(TOPIC, "inventory", cid)
|
||||
assert card["payload"]["description"] == "Aus dem Material."
|
||||
|
||||
|
||||
async def test_fix_suffix_rename_rekeyt_alles(env, monkeypatch):
|
||||
"""Kollisions-Suffix, Basis-Norm frei → Rename inkl. Re-Key von Subblocks/Fragen/
|
||||
Artefakten + Board-2-Karte; danach keine Invarianten-Waisen."""
|
||||
from invarianten import pruefe_invarianten
|
||||
db, seed, files, write_report = env
|
||||
cid = await seed("Gamma (2)", "beschr")
|
||||
norm_alt = repair._norm_title("Gamma (2)")
|
||||
await db.upsert_question_pattern(TOPIC, norm_alt, "sub0", "Gamma (2)", "Sub 0", "F?")
|
||||
write_report(_report(hygiene=[{"titel": "Gamma (2)", "probleme": ["kollisions-suffix"]}]))
|
||||
|
||||
async def no_agent(*a, **kw):
|
||||
raise AssertionError("Rename bei freier Basis braucht keinen Agenten")
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
|
||||
res = await repair.repair_befunde(TOPIC, "inventory")
|
||||
assert "suffix: Gamma (2) → Gamma" in res["hygiene"]
|
||||
card = await db.kanban_get_card(TOPIC, "inventory", cid)
|
||||
assert card["payload"]["title"] == "Gamma" and card["payload"]["mirrored_norm"] == "gamma"
|
||||
assert await db.kanban_get_card(TOPIC, "artefacts", norm_alt) is None
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "gamma"))["payload"]["title"] == "Gamma"
|
||||
assert {r["block_norm"] for r in await db.list_subblocks(TOPIC, "gamma")} == {"gamma"}
|
||||
assert not await db.list_subblocks(TOPIC, norm_alt)
|
||||
assert {r["block_norm"] for r in await db.list_question_pattern(TOPIC)} == {"gamma"}
|
||||
befunde = await pruefe_invarianten(TOPIC) # DB-Sicht: keine Waisen nach dem Re-Key
|
||||
assert not [b for b in befunde if "Waise" in b]
|
||||
|
||||
|
||||
async def test_pruefe_luecken_freispruch(env, monkeypatch):
|
||||
"""Offene Lücken + Konzept-Lücken: 2:1 „keine echte Lücke" → Freispruch persistiert;
|
||||
_zaehlbare_luecken zählt freigesprochene Fundstellen nicht mehr."""
|
||||
import qa as qa_mod
|
||||
db, seed, files, write_report = env
|
||||
await seed("Alpha", "beschr")
|
||||
lk = [{"datei": "f.txt", "abschnitt": 1, "vorschau": "verwaister Abschnitt", "llm": "ja"}]
|
||||
write_report(_report(luecken=lk, konzept_luecken=["Satz von Foo"]))
|
||||
|
||||
async def fake_agent(key, prompt, timeout, **kw):
|
||||
return 0, '{"relevant": {"1": "nein"}}', "" # beide Judges: keine echte Lücke
|
||||
|
||||
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
|
||||
res = await repair.repair_befunde(TOPIC, "inventory")
|
||||
assert len(res["freigesprochen"]) == 2
|
||||
frei = qa_mod.lade_freispruch(TOPIC)
|
||||
assert qa_mod.luecken_key(lk[0]) in set(frei.get("luecken") or [])
|
||||
assert "satz von foo" in {s.casefold() for s in frei.get("konzept_luecken") or []}
|
||||
lk[0]["freispruch"] = True
|
||||
assert qa_mod._zaehlbare_luecken(lk, llm=True) == []
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Link source = curated URL list (one page per line, no crawl following)."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import blocks
|
||||
from pipeline import GenContext
|
||||
from routes import _validate_source
|
||||
|
||||
|
||||
# --- _validate_source: per-line validation ---------------------------------
|
||||
|
||||
def test_validate_source_link_multiline_ok():
|
||||
_validate_source("link", "https://a.com/x\nhttp://b.com/y") # no raise
|
||||
|
||||
|
||||
def test_validate_source_link_rejects_bad_line():
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_source("link", "https://a.com/x\nftp://bad")
|
||||
|
||||
|
||||
def test_validate_source_link_rejects_empty():
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_source("link", " \n ")
|
||||
|
||||
|
||||
# --- _prepare_source: load every line, all pages as content, no triage -----
|
||||
|
||||
async def test_prepare_source_link_loads_all_urls_as_content(testdb, tmp_path, monkeypatch):
|
||||
topic = "T"
|
||||
folder = tmp_path / "source"
|
||||
folder.mkdir()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_load(urls, target, *, cancelled=None):
|
||||
captured["urls"] = list(urls)
|
||||
for i, u in enumerate(urls):
|
||||
(Path(target) / f"p{i}.txt").write_text(f"QUELLE: {u}\n\nInhalt {i}", encoding="utf-8")
|
||||
return len(urls)
|
||||
|
||||
monkeypatch.setattr(blocks, "load_pages", fake_load)
|
||||
monkeypatch.setattr(blocks, "_convert_pdfs", lambda f: None)
|
||||
monkeypatch.setattr(blocks, "_crawl_done", lambda t: False)
|
||||
monkeypatch.setattr(blocks, "_step_idx", lambda t, n: 0) # step list needs source.json — irrelevant here
|
||||
|
||||
ctx = GenContext(topic=topic, provider="claude", is_cancelled=lambda: False)
|
||||
q = {"type": "link", "location": " https://a.com/x \n\nhttps://b.com/y\n", "spec": ""}
|
||||
ok = await blocks._prepare_source(ctx, lambda *a, **k: None, {"arbeit": tmp_path}, q, folder, "")
|
||||
|
||||
assert ok is True
|
||||
assert captured["urls"] == ["https://a.com/x", "https://b.com/y"] # split + strip + blanks dropped
|
||||
content = await testdb.list_content(topic)
|
||||
assert len(content) == 2 # every page kept, no relevance triage
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Titel-Hygiene: klammer-bewusster Split + Dash-Normalisierung (M1.1).
|
||||
|
||||
Reale aak-Schadensfälle: ein en-Dash mit einseitigem Space INNERHALB eines Titels
|
||||
wurde zum Separator → Split mitten im Titel („Aε-Algorithmus (Güte 1+ε, Laufzeit O(n3").
|
||||
"""
|
||||
|
||||
from textkit import _norm_dash, _title, _split_top, _unclosed, _parse_selection
|
||||
|
||||
|
||||
def test_norm_dash_top_level_only():
|
||||
# einseitiger Space am Dash → Separator (Modelle liefern das so)
|
||||
assert _norm_dash("Titel –Beschreibung") == "Titel — Beschreibung"
|
||||
assert _norm_dash("Titel— Beschreibung") == "Titel — Beschreibung"
|
||||
# Dash INNERHALB offener Klammer bleibt unangetastet (kein Separator)
|
||||
assert _norm_dash("f (a – b) — Rest") == "f (a – b) — Rest"
|
||||
# ASCII-Hyphen und geklebte/rangebasierte Dashes sind nie Separatoren
|
||||
assert _norm_dash("3-SAT") == "3-SAT"
|
||||
assert _norm_dash("12–15") == "12–15"
|
||||
|
||||
|
||||
def test_split_top_bracket_aware():
|
||||
assert _split_top("a (b — c) — d", " — ") == "a (b — c)"
|
||||
assert _split_top("a — b", " — ") == "a"
|
||||
assert _split_top("kein Separator hier", " — ") == "kein Separator hier"
|
||||
|
||||
|
||||
def test_unclosed_bracket_detection():
|
||||
assert _unclosed("a (b") is True
|
||||
assert _unclosed("a [b {c") is True
|
||||
assert _unclosed("a (b) c") is False
|
||||
assert _unclosed("a) b") is False # überzähliger Schließer ist kein Abschnitt
|
||||
|
||||
|
||||
def test_title_keeps_bracketed_dash():
|
||||
"""Der Separator innerhalb der Klammer zerschneidet den Titel nicht mehr."""
|
||||
t = _title("Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³)) — der Approx-Algorithmus")
|
||||
assert t == "Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³))"
|
||||
|
||||
|
||||
def test_title_plain_split_unchanged():
|
||||
assert _title("Vertex Cover — minimale Knotenüberdeckung") == "Vertex Cover"
|
||||
assert _title("Ohne Separator bleibt ganz") == "Ohne Separator bleibt ganz"
|
||||
|
||||
|
||||
def test_title_unclosed_bracket_keeps_entry():
|
||||
"""Malformed: offene Klammer nie geschlossen → keinen abgeschnittenen Titel ausgeben,
|
||||
stattdessen den ganzen Eintrag behalten (Repair = Split rückgängig)."""
|
||||
e = "N P via Turingmaschine (Definition 6.19 — Rest"
|
||||
assert _title(e) == e
|
||||
|
||||
|
||||
def test_parse_selection_then_title_regression():
|
||||
"""End-to-end der Parse-Kette (wie in der Pipeline): _norm_dash + _title lassen den
|
||||
Klammer-Inhalt intakt statt mitten im Titel zu splitten (der reale aak-Bug)."""
|
||||
line = "1. Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³)) — der Approximations-Algorithmus"
|
||||
entries = _parse_selection(line)
|
||||
assert _title(entries[1]) == "Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³))"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+58
-3
@@ -3,12 +3,22 @@
|
||||
No state, no IO — safe to import anywhere.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now
|
||||
|
||||
|
||||
def parse_facts(raw) -> dict:
|
||||
"""subblocks.facts ist ein JSON-Blob aus LLM-Hand — leer/kaputt/kein dict → {}."""
|
||||
try:
|
||||
d = json.loads(raw) if raw else {}
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return d if isinstance(d, dict) else {}
|
||||
|
||||
|
||||
def _norm_title(s: str) -> str:
|
||||
"""Normalize a title for key comparison.
|
||||
|
||||
@@ -22,8 +32,46 @@ def _norm_title(s: str) -> str:
|
||||
return s.casefold()
|
||||
|
||||
|
||||
# Bracket-aware separator handling: a ' — ' (or a dash that _norm_dash would normalize) INSIDE
|
||||
# ()[]{} is part of the title, not a title/description separator. Without this a spaced dash within
|
||||
# a math title truncates it: 'Aε-Algorithmus (Güte 1+ε, Laufzeit O(n³)) — …' → 'Aε-Algorithmus (Güte…'.
|
||||
_BRACKETS = {"(": ")", "[": "]", "{": "}"}
|
||||
_CLOSERS = {v: k for k, v in _BRACKETS.items()}
|
||||
|
||||
|
||||
def _unclosed(s: str) -> bool:
|
||||
"""True if s has a dangling opening bracket ()[]{} — the symptom of a mid-bracket cut. Extra
|
||||
closers (more ')' than '(') do NOT count; only an unclosed opener signals a truncated title."""
|
||||
depth = 0
|
||||
for c in s:
|
||||
if c in _BRACKETS:
|
||||
depth += 1
|
||||
elif c in _CLOSERS and depth > 0:
|
||||
depth -= 1
|
||||
return depth > 0
|
||||
|
||||
|
||||
def _split_top(s: str, sep: str) -> str:
|
||||
"""First bracket-depth-0 segment of s split on sep; whole s if sep never occurs at depth 0."""
|
||||
depth = 0
|
||||
for i, c in enumerate(s):
|
||||
if c in _BRACKETS:
|
||||
depth += 1
|
||||
elif c in _CLOSERS and depth > 0:
|
||||
depth -= 1
|
||||
elif depth == 0 and s.startswith(sep, i):
|
||||
return s[:i]
|
||||
return s
|
||||
|
||||
|
||||
def _title(entry: str) -> str:
|
||||
return entry.split(" — ")[0].strip() or entry
|
||||
"""Title = text before the first TOP-LEVEL ' — '. A separator inside ()[]{} does not split
|
||||
(keeps math titles intact); if the extracted title has an unclosed bracket (malformed source),
|
||||
keep the whole entry rather than emit a truncated title."""
|
||||
title = _split_top(entry, " — ")
|
||||
if _unclosed(title):
|
||||
return entry.strip() or entry
|
||||
return title.strip() or entry
|
||||
|
||||
|
||||
def clean_title(s: str) -> str:
|
||||
@@ -64,6 +112,9 @@ def _resolve_title(idx: dict[str, int], t: str) -> int | None:
|
||||
return idx.get(_norm_title(t)) or idx.get(_norm_title(_title(t)))
|
||||
|
||||
|
||||
_DASH_SEP_RE = re.compile(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*")
|
||||
|
||||
|
||||
def _norm_dash(s: str) -> str:
|
||||
"""Dash variants (en/em/figure/bar) with whitespace on AT LEAST ONE side → uniform separator ' — '.
|
||||
Some models (especially non-western ones) use an en-dash "–" instead of the em-dash; without
|
||||
@@ -71,8 +122,12 @@ def _norm_dash(s: str) -> str:
|
||||
space ("Titel —Beschreibung" / "Titel— Beschreibung") also breaks the split and leaks the source
|
||||
filename into the description — so a dash with a space on either side is repaired too. The ASCII
|
||||
hyphen "-" is deliberately NOT in the class (would split "n - 1"/"3-SAT"); requiring ≥1 surrounding
|
||||
space keeps glued compounds like "Backtracking—Verfahren" and number ranges like "12–15" untouched."""
|
||||
return re.sub(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*", " — ", s)
|
||||
space keeps glued compounds like "Backtracking—Verfahren" and number ranges like "12–15" untouched.
|
||||
A qualifying dash INSIDE an open bracket ()[]{} is left untouched — it is part of the title, not a
|
||||
separator (else 'Algorithmus (Güte 1+ε – O(n³)) — …' would split mid-parenthetical)."""
|
||||
def _repl(m):
|
||||
return m.group(0) if _unclosed(s[:m.start()]) else " — "
|
||||
return _DASH_SEP_RE.sub(_repl, s)
|
||||
|
||||
|
||||
def _parse_selection(text: str) -> dict[int, str]:
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
@@ -27,7 +27,7 @@ PARAMS: dict[str, dict] = {
|
||||
"SEED_COVER_COS": {"default": 0.80, "min": 0.7, "max": 0.9, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
|
||||
"SUB_DUP_KANDIDAT_COS": {"default": 0.75, "min": 0.65, "max": 0.85, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
|
||||
"EMBEDDING_BLOCK_FLOOR": {"default": 0.5, "min": 0.35, "max": 0.65, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"},
|
||||
"CROSS_CHUNK_PAARE": {"default": 40, "min": 15, "max": 80, "step": 10, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"GROUP_THEMES_PER_SQRT": {"default": 1.0, "min": 0.6, "max": 1.6, "step": 0.2, "kategorie": "auswahl", "fidelity": "voll"},
|
||||
# Guide
|
||||
"GATE_FIX_MIN": {"default": 3, "min": 1, "max": 6, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
"WRITER_SPLIT_SUBS": {"default": 30, "min": 15, "max": 45, "step": 5, "kategorie": "qualitaet", "fidelity": "board2"},
|
||||
@@ -36,7 +36,6 @@ PARAMS: dict[str, dict] = {
|
||||
"MAX_RESTARTS": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"HEDGE_NACH_S": {"default": 90, "min": 30, "max": 240, "step": 30, "kategorie": "laufzeit", "fidelity": "board2"},
|
||||
"EVIDENCE_BUDGET_CHARS": {"default": 48000, "min": 16000, "max": 64000, "step": 8000, "kategorie": "tokens", "fidelity": "board2"},
|
||||
"QUELLE_RELEVANZ_CHUNK": {"default": 12, "min": 6, "max": 24, "step": 3, "kategorie": "laufzeit", "fidelity": "voll"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-4
@@ -4,10 +4,14 @@ services:
|
||||
context: .
|
||||
container_name: creator
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
||||
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
|
||||
- DEFAULT_PROVIDER=${DEFAULT_PROVIDER:-}
|
||||
# komplette .env durchreichen — 3 Einzel-Vars ließen ROLE_*/MAX_CONCURRENT_* etc.
|
||||
# still weg (Dev sourct die ganze .env, Prod bekam nur einen Teil)
|
||||
env_file: .env
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- web
|
||||
volumes:
|
||||
|
||||
+49
-67
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
||||
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
||||
import { usePolling } from './composables/usePolling.js'
|
||||
import TopicSidebar from './components/TopicSidebar.vue'
|
||||
import TopicDetail from './components/TopicDetail.vue'
|
||||
@@ -21,7 +21,7 @@ const darkMode = ref(
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: localStorage.getItem('darkMode') === 'true',
|
||||
)
|
||||
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
|
||||
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false }
|
||||
const blocks = ref({ ...EMPTY_BLOCKS })
|
||||
const activeBlocks = ref([])
|
||||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||
@@ -41,6 +41,13 @@ async function guard(label, fn) {
|
||||
try { return await fn() } catch (e) { console.error(label, e) }
|
||||
}
|
||||
|
||||
// Aktion ausführen und einen Backend-Fehler (409/400/500) in der Sidebar-Fehlerzeile
|
||||
// zeigen statt als unhandled rejection zu verschlucken. → true bei Erfolg.
|
||||
async function withUiError(fn) {
|
||||
uiError.value = null
|
||||
try { await fn(); return true } catch (e) { uiError.value = e.message; return false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
|
||||
}
|
||||
@@ -175,12 +182,31 @@ async function loadBlocks() {
|
||||
}
|
||||
}
|
||||
|
||||
// „Auto"-Ebenen-Flags pro Topic (localStorage): nach der QA „Befunde beheben" bis 100 %.
|
||||
const autoInventory = ref(true)
|
||||
const autoArtefacts = ref(true)
|
||||
const autoGuide = ref(true)
|
||||
function ladeAuto(topic) {
|
||||
const g = (e) => { const v = localStorage.getItem(`auto_${topic}_${e}`); return v === null ? true : v === 'true' }
|
||||
autoInventory.value = g('inventory')
|
||||
autoArtefacts.value = g('artefacts')
|
||||
autoGuide.value = g('guide')
|
||||
}
|
||||
function handleSetAuto({ ebene, value }) {
|
||||
const r = { inventory: autoInventory, artefacts: autoArtefacts, guide: autoGuide }[ebene]
|
||||
if (!r) return
|
||||
r.value = value
|
||||
if (selectedTopic.value) localStorage.setItem(`auto_${selectedTopic.value}_${ebene}`, value ? 'true' : 'false')
|
||||
}
|
||||
const autoObj = () => ({ inventory: autoInventory.value, artefacts: autoArtefacts.value, guide: autoGuide.value })
|
||||
|
||||
function selectTopic(topic) {
|
||||
selectedTopic.value = topic
|
||||
previewGuide.value = null
|
||||
sidebarSticky.value = false
|
||||
mainView.value = 'generation' // topic click → generation board (guide only on pill click)
|
||||
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
|
||||
ladeAuto(topic)
|
||||
localStorage.setItem('lastTopic', topic)
|
||||
loadBlocks()
|
||||
}
|
||||
@@ -192,28 +218,12 @@ watch(previewGuide, (g) => {
|
||||
|
||||
async function handleCancelBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiCancelBausteine(selectedTopic.value)
|
||||
await loadBlocks()
|
||||
if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiDeleteBausteine(selectedTopic.value)
|
||||
await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetStage({ board, stage, restart = false }) {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiResetBlocksStage(selectedTopic.value, board, stage)
|
||||
if (restart) await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) // Continue: Queue abarbeiten
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadBlocks()
|
||||
if (restart) startPolling()
|
||||
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleAddResearch() {
|
||||
@@ -231,18 +241,22 @@ async function handleAddResearch() {
|
||||
|
||||
async function handleRequeueDead() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiRequeueDead(selectedTopic.value)
|
||||
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
|
||||
const ok = await withUiError(async () => {
|
||||
await apiRequeueDead(selectedTopic.value)
|
||||
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false, false, autoObj())
|
||||
})
|
||||
if (!ok) return
|
||||
await loadBlocks()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleBlocksClick({ instructions = '', research = true, qaForce = false }) {
|
||||
async function handleBlocksClick({ instructions = '', research = true, qaForce = false, onlyArtefacts = false }) {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce)
|
||||
// onlyArtefacts=true = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate).
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj(), onlyArtefacts)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
@@ -292,7 +306,7 @@ function handleOpenBlocksView() {
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
async function handleFormatClick({ format, instructions = '', abStep = null }) {
|
||||
async function handleFormatClick({ format, instructions = '' }) {
|
||||
if (!selectedTopic.value) return
|
||||
// No duplicate start: if a generation is already running for topic+format, ignore
|
||||
const running = guides.value.some(
|
||||
@@ -302,7 +316,7 @@ async function handleFormatClick({ format, instructions = '', abStep = null }) {
|
||||
if (running) { handleOpenGuideBoard(format); return }
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep)
|
||||
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
@@ -336,34 +350,6 @@ async function handleRemoveGuideFormat(format) {
|
||||
await loadGuides()
|
||||
}
|
||||
|
||||
async function handleRestartCard(cardId) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiRestartBlocksCard(selectedTopic.value, cardId)
|
||||
await handleBlocksClick({ research: false }) // Continue: der Flow zieht die Karte
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetGuideCard({ format, blockNorm, abStage }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiResetGuideCard(selectedTopic.value, format, blockNorm, abStage)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGuideBoardReset({ format, abStage }) {
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiResetGuideBoard(selectedTopic.value, format, abStage)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function handleGuideBoardPreview() {
|
||||
const g = doneByFormat.value[guideBoardFormat.value]
|
||||
if (g) handlePreview(g)
|
||||
@@ -407,17 +393,12 @@ const polling = usePolling(
|
||||
const startPolling = polling.start
|
||||
|
||||
async function handleCancel(guideId) {
|
||||
await apiCancel(guideId)
|
||||
await loadGuides()
|
||||
if (await withUiError(() => apiCancel(guideId))) await loadGuides()
|
||||
}
|
||||
|
||||
async function handleDeleteTopic(topic) {
|
||||
const topicGuides = guides.value.filter((g) => g.topic === topic)
|
||||
for (const g of topicGuides) {
|
||||
await deleteGuide(g.id)
|
||||
}
|
||||
await apiDeleteBausteine(topic)
|
||||
await apiDeleteTopic(topic)
|
||||
// Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409).
|
||||
if (!await withUiError(() => apiDeleteTopic(topic))) return
|
||||
await loadTopics()
|
||||
if (selectedTopic.value === topic) {
|
||||
selectedTopic.value = null
|
||||
@@ -497,22 +478,23 @@ onMounted(async () => {
|
||||
:progress="blocks.progress"
|
||||
:ready="blocks.ready"
|
||||
:partial="blocks.partial"
|
||||
:error="blocks.error"
|
||||
:guideFormat="guideBoardFormat"
|
||||
:autoInventory="autoInventory"
|
||||
:autoArtefacts="autoArtefacts"
|
||||
:autoGuide="autoGuide"
|
||||
@close="mainView = 'blocks'"
|
||||
@resetStage="handleResetStage"
|
||||
@restartAll="() => handleBlocksClick({ research: true })"
|
||||
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })"
|
||||
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce), onlyArtefacts: !!(opts && opts.onlyArtefacts) })"
|
||||
@addResearch="handleAddResearch"
|
||||
@requeueDead="handleRequeueDead"
|
||||
@removeAll="handleResetBlocks"
|
||||
@cancel="handleCancelBlocks"
|
||||
@cancelGuide="handleCancel"
|
||||
@startGuide="handleFormatClick"
|
||||
@resetGuideStage="handleGuideBoardReset"
|
||||
@preview="handleGuideBoardPreview"
|
||||
@removeFormat="handleRemoveGuideFormat"
|
||||
@restartCard="handleRestartCard"
|
||||
@resetGuideCard="handleResetGuideCard"
|
||||
@setAuto="handleSetAuto"
|
||||
/>
|
||||
<GeneralExamPanel
|
||||
v-else-if="selectedTopic && mainView === 'general'"
|
||||
|
||||
+93
-249
@@ -1,315 +1,159 @@
|
||||
const BASE = '/api'
|
||||
|
||||
// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken
|
||||
async function jsonOrThrow(res) {
|
||||
function qs(query) {
|
||||
if (!query) return ''
|
||||
const p = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null) p.set(k, v)
|
||||
}
|
||||
const s = p.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
// Ein Request-Weg für ALLE Aufrufe: Backend-Fehler (400/409/500) werfen statt sie zu
|
||||
// verschlucken, mit err.status für Aufrufer, die 404 ("noch nichts da") gesondert behandeln.
|
||||
async function req(path, { method = 'GET', body, query } = {}) {
|
||||
const opts = { method }
|
||||
if (body !== undefined) {
|
||||
opts.headers = { 'Content-Type': 'application/json' }
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}${qs(query)}`, opts)
|
||||
if (!res.ok) {
|
||||
let detail = `Fehler (HTTP ${res.status})`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
|
||||
} catch { /* kein JSON-Body */ }
|
||||
throw new Error(detail)
|
||||
const err = new Error(detail)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
const text = await res.text() // DELETE/manche POSTs liefern keinen Body
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
|
||||
export async function fetchGuides() {
|
||||
const res = await fetch(`${BASE}/guides`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchGuides = () => req('/guides')
|
||||
|
||||
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
|
||||
const res = await fetch(`${BASE}/guides`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const createGuide = (topic, format, instructions = '', provider = 'claude') =>
|
||||
req('/guides', { method: 'POST', body: { topic, format, instructions, provider } })
|
||||
|
||||
export async function fetchActiveBlocks() {
|
||||
const res = await fetch(`${BASE}/blocks/active`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchActiveBlocks = () => req('/blocks/active')
|
||||
|
||||
export async function fetchBlocksStatus(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
|
||||
|
||||
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) {
|
||||
const res = await fetch(`${BASE}/blocks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false, auto = {}, onlyArtefacts = false) =>
|
||||
req('/blocks', { method: 'POST', body: {
|
||||
topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce,
|
||||
auto_inventory: auto.inventory ?? true, auto_artefacts: auto.artefacts ?? true, auto_guide: auto.guide ?? true,
|
||||
only_artefacts: onlyArtefacts,
|
||||
} })
|
||||
|
||||
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
|
||||
export async function fetchBlocksBoard(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/board?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
|
||||
|
||||
// Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report.
|
||||
export async function runQa(topic, llm = true) {
|
||||
const res = await fetch(`${BASE}/blocks/qa`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, llm }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const runQa = (topic, llm = true, ebene = null) => req('/blocks/qa', { method: 'POST', body: { topic, llm, ebene } })
|
||||
|
||||
// QA-Befunde gezielt beheben (Hygiene, bestätigte Dubletten, Fremd/Unecht nach Gegen-Judge).
|
||||
export async function runRepair(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/repair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
// QA-Befunde gezielt beheben; ebene = 'inventory' | 'artefacts' | undefined (alle).
|
||||
export const runRepair = (topic, ebene = null) => req('/blocks/repair', { method: 'POST', body: { topic, ebene } })
|
||||
|
||||
// Karten ab Spalte zurücksetzen (keine Generierung).
|
||||
export async function resetBlocksStage(topic, board, stage) {
|
||||
const res = await fetch(`${BASE}/blocks/reset-stage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, board, stage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
// Artefakte-Ebene leeren (Board-2-Daten weg, Inventar bleibt).
|
||||
export const removeArtefacts = (topic) => req('/blocks/artefacts', { method: 'DELETE', query: { topic } })
|
||||
|
||||
// Einen weiteren Research-Agenten anhängen (Attach-or-Start).
|
||||
export async function addBlocksResearch(topic, provider = 'claude') {
|
||||
const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}&provider=${encodeURIComponent(provider)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const addBlocksResearch = (topic, provider = 'claude') =>
|
||||
req('/blocks/research', { method: 'POST', query: { topic, provider } })
|
||||
|
||||
export async function restartBlocksCard(topic, cardId) {
|
||||
const res = await fetch(`${BASE}/blocks/card-restart`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, card_id: cardId }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const removeGuideFormat = (topic, format) =>
|
||||
req('/guides/board/remove', { method: 'POST', body: { topic, format } })
|
||||
|
||||
export async function removeGuideFormat(topic, format) {
|
||||
const res = await fetch(`${BASE}/guides/board/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resetGuideCard(topic, format, blockNorm, abStage) {
|
||||
const res = await fetch(`${BASE}/guides/board/card-reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, block_norm: blockNorm, ab_stage: abStage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function requeueBlocksDead(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const requeueBlocksDead = (topic) =>
|
||||
req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
|
||||
|
||||
// Live-Board der Guide-Erzeugung.
|
||||
export async function fetchGuideBoard(topic, format = 'Guide') {
|
||||
const res = await fetch(`${BASE}/guides/board?topic=${encodeURIComponent(topic)}&format=${encodeURIComponent(format)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resetGuideBoard(topic, format, abStage) {
|
||||
const res = await fetch(`${BASE}/guides/board/reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, ab_stage: abStage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchGuideBoard = (topic, format = 'Guide') =>
|
||||
req('/guides/board', { query: { topic, format } })
|
||||
|
||||
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
|
||||
export async function repairGuideBoard(topic, format) {
|
||||
const res = await fetch(`${BASE}/guides/board/repair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, ab_stage: 0 }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const repairGuideBoard = (topic, format) =>
|
||||
req('/guides/board/repair', { method: 'POST', body: { topic, format } })
|
||||
|
||||
export async function cancelBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })
|
||||
|
||||
export async function deleteBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||
}
|
||||
export const deleteBlocks = (topic) => req('/blocks', { method: 'DELETE', query: { topic } })
|
||||
|
||||
// Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
|
||||
export const fetchRuns = (topic, limit = 10) => req('/runs', { query: { topic, limit } })
|
||||
|
||||
// --- Block-Learning: Chat, Exam ---
|
||||
|
||||
export async function fetchBlockLearnState(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlockLearnState = (topic) => req('/blocks/learnstate', { query: { topic } })
|
||||
|
||||
export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) {
|
||||
const res = await fetch(`${BASE}/blocks/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const chatBlock = ({ topic, block, section, section_compact = '', messages, provider }) =>
|
||||
req('/blocks/chat', { method: 'POST', body: { topic, block, section, section_compact, messages, provider } })
|
||||
|
||||
export async function examBlock({
|
||||
export const examBlock = ({
|
||||
topic, block, section, section_compact = '', provider,
|
||||
action = 'question', question = '', last_rating = '', avoid = [],
|
||||
asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
|
||||
selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/blocks/exam`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
}) => req('/blocks/exam', {
|
||||
method: 'POST',
|
||||
body: { topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer },
|
||||
})
|
||||
|
||||
export async function fetchQuestionPattern(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchQuestionPattern = (topic, block) =>
|
||||
req('/blocks/question-pattern', { query: { topic, block } })
|
||||
|
||||
export async function fetchTopicProgress(topic) {
|
||||
const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchTopicProgress = (topic) => req('/topics/progress', { query: { topic } })
|
||||
|
||||
export async function fetchStats() {
|
||||
const res = await fetch(`${BASE}/stats`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchStats = () => req('/stats')
|
||||
|
||||
export async function fetchProviders() {
|
||||
const res = await fetch(`${BASE}/providers`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchProviders = () => req('/providers')
|
||||
|
||||
export async function fetchFolders(kind) {
|
||||
const res = await fetch(`${BASE}/folders?kind=${encodeURIComponent(kind)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchFolders = (kind) => req('/folders', { query: { kind } })
|
||||
|
||||
export async function fetchSource(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchSource = (topic) => req('/blocks/source', { query: { topic } })
|
||||
|
||||
export async function updateSource(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/blocks/source`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, type, location: ort, spec }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const updateSource = (topic, { type, ort = '', spec = '' }) =>
|
||||
req('/blocks/source', { method: 'PUT', body: { topic, type, location: ort, spec } })
|
||||
|
||||
export async function fetchBlocksCompleteness(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksCompleteness = (topic) => req('/blocks/completeness', { query: { topic } })
|
||||
|
||||
export async function fetchBlocksOverview(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksOverview = (topic) => req('/blocks/overview', { query: { topic } })
|
||||
|
||||
export async function cancelGuide(id) {
|
||||
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
|
||||
}
|
||||
export const cancelGuide = (id) => req(`/guides/${id}/cancel`, { method: 'POST' })
|
||||
|
||||
export async function deleteGuide(id, slots = false) {
|
||||
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
|
||||
}
|
||||
export const deleteGuide = (id, slots = false) =>
|
||||
req(`/guides/${id}`, { method: 'DELETE', query: slots ? { slots: 1 } : undefined })
|
||||
|
||||
export async function fetchGuideContent(id, level = 4) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
|
||||
if (!res.ok) throw new Error(`Content not available (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchGuideContent = (id, level = 4) => req(`/guides/${id}/content`, { query: { level } })
|
||||
|
||||
// Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
|
||||
export async function fetchPracticeDeck(topic) {
|
||||
const res = await fetch(`${BASE}/practice/deck?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchPracticeDeck = (topic) => req('/practice/deck', { query: { topic } })
|
||||
|
||||
// Leitner-Schritt buchen (correct = „Gewusst").
|
||||
export async function answerPracticeCard({ topic, block_norm, sub_norm, correct }) {
|
||||
const res = await fetch(`${BASE}/practice/answer`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block_norm, sub_norm, correct }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const answerPracticeCard = ({ topic, block_norm, sub_norm, correct }) =>
|
||||
req('/practice/answer', { method: 'POST', body: { topic, block_norm, sub_norm, correct } })
|
||||
|
||||
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
|
||||
export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, snippet, hint, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const pruefeBlock = (id, { block, spot, snippet, hint = '', provider }) =>
|
||||
req(`/guides/${id}/block/pruefen`, { method: 'POST', body: { block, spot, snippet, hint, provider } })
|
||||
|
||||
// Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
|
||||
export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, alt, revised, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const uebernehmeBlock = (id, { block, spot, alt, revised, provider }) =>
|
||||
req(`/guides/${id}/block/uebernehmen`, { method: 'POST', body: { block, spot, alt, revised, provider } })
|
||||
|
||||
// Reset a block's learning progress to zero (score/streak/flags/open question).
|
||||
export async function resetBlockProgress(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const resetBlockProgress = (topic, block) =>
|
||||
req('/blocks/progress', { method: 'DELETE', query: { topic, block } })
|
||||
|
||||
export async function fetchTopics() {
|
||||
const res = await fetch(`${BASE}/topics`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchTopics = () => req('/topics')
|
||||
|
||||
export async function createTopic(name) {
|
||||
await fetch(`${BASE}/topics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
}
|
||||
export const createTopic = (name) => req('/topics', { method: 'POST', body: { name } })
|
||||
|
||||
export async function deleteTopic(name) {
|
||||
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ section, outline, messages, provider }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
export const deleteTopic = (name) => req('/topics', { method: 'DELETE', query: { topic: name } })
|
||||
|
||||
export const chatGuide = (id, { section, outline, messages, provider = 'claude' }) =>
|
||||
req(`/guides/${id}/chat`, { method: 'POST', body: { section, outline, messages, provider } })
|
||||
@@ -95,7 +95,8 @@
|
||||
.sub-stufe {
|
||||
position: relative;
|
||||
border-left: 3px solid color-mix(in srgb, var(--stufe-farbe) 55%, transparent);
|
||||
padding-left: 0.75rem;
|
||||
background: color-mix(in srgb, var(--stufe-farbe) 8%, transparent);
|
||||
padding: 0.15rem 0.5rem 0.15rem 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -116,7 +117,8 @@
|
||||
.sub-neu {
|
||||
position: relative;
|
||||
border-left: 3px solid var(--neu-farbe);
|
||||
padding-left: 0.75rem;
|
||||
background: color-mix(in srgb, var(--neu-farbe) 12%, transparent);
|
||||
padding: 0.15rem 0.5rem 0.15rem 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Geteilte, komponentenübergreifende Stile (global, NICHT scoped). */
|
||||
|
||||
/* Live-Puls für „läuft"-Indikatoren — vorher als bk-/gb-/gen-/gen-side-pulse 4× kopiert. */
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const props = defineProps({
|
||||
hasPrev: { type: Boolean, default: false },
|
||||
hasNext: { type: Boolean, default: false },
|
||||
fortschritt: { type: Object, default: () => ({ total: 0, beginner: 0, advanced: 0, expert: 0, master: 0 }) },
|
||||
stufe: { type: Number, default: 4 }, // höchste sichtbare Stufe (1=A … 4=V) — wie die Standardansicht
|
||||
})
|
||||
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
@@ -111,8 +112,10 @@ const segments = computed(() => {
|
||||
const anchor = compact ? (props.block.anker_compact || '') : (props.block.anchor || '')
|
||||
if (anchor.trim()) out.push(seg('', anchor))
|
||||
for (const sub of subs) {
|
||||
const rank = SUB_RANK[sub.level] || 1
|
||||
if (rank > props.stufe) continue // Stufen-Filter (wie die Standardansicht)
|
||||
const body = compact ? (sub.compact || sub.md) : (sub.md || sub.compact)
|
||||
if (body && body.trim()) out.push(seg(sub.level, body))
|
||||
if (body && body.trim()) out.push(seg(sub.level, body)) // jede Stufe A/F/E/V markiert (Filter bleibt)
|
||||
}
|
||||
return out
|
||||
})
|
||||
@@ -197,8 +200,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
<template v-for="(seg, si) in segments" :key="si">
|
||||
<div :class="{ 'sub-stufe': !!seg.level }"
|
||||
:style="seg.level ? { '--stufe-farbe': VIEW_FARBE[SUB_RANK[seg.level] || 1] } : null">
|
||||
<span v-if="seg.level" class="sub-stufe-badge"
|
||||
:title="`Stufe ${VIEW_KURZ[SUB_RANK[seg.level] || 1]}`">{{ VIEW_KURZ[SUB_RANK[seg.level] || 1] }}</span>
|
||||
<template v-for="b in seg.blocks" :key="b.i">
|
||||
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(b.i, $event)"></div>
|
||||
<div v-if="suggestions[b.i]" class="block-vorschlag">
|
||||
@@ -207,12 +208,12 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
<p v-if="suggestions[b.i].error" class="bv-fehler">{{ suggestions[b.i].error }}</p>
|
||||
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[b.i].revised)"></div>
|
||||
<div class="bv-aktionen">
|
||||
<button class="bv-btn ja" title="Apply" @click="applyBlock(b.i)">✓</button>
|
||||
<button class="bv-btn" title="Discard" @click="discardBlock(b.i)">✗</button>
|
||||
<button class="bv-btn ja" title="Übernehmen" @click="applyBlock(b.i)">✓</button>
|
||||
<button class="bv-btn" title="Verwerfen" @click="discardBlock(b.i)">✗</button>
|
||||
<button class="bv-btn" :class="{ aktiv: suggestions[b.i].editOpen }" title="Add hint" @click="editBlock(b.i)">✏️</button>
|
||||
</div>
|
||||
<div v-if="suggestions[b.i].editOpen" class="bv-edit">
|
||||
<input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(b.i)" />
|
||||
<input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Zusatzinfo → erneut prüfen" @keyup.enter="sendBlockEdit(b.i)" />
|
||||
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(b.i)">↻</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -282,10 +283,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
border-radius: 999px; border: 1px solid; white-space: nowrap;
|
||||
}
|
||||
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
|
||||
/* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */
|
||||
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
|
||||
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */
|
||||
@@ -302,9 +299,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
);
|
||||
}
|
||||
.xp-seg { height: 100%; transition: width 0.3s ease; }
|
||||
.xp-seg.gold { background: #d4af37; }
|
||||
.xp-seg.lila { background: #8b5cf6; }
|
||||
.xp-seg.gruen { background: var(--success-border); }
|
||||
.fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
|
||||
.fokus-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
|
||||
@@ -5,12 +5,13 @@ import { usePruefSlot } from '../pruefungCache.js'
|
||||
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
|
||||
import { stufeFuer, malusRegel } from '../levels.js'
|
||||
import { useChat, istUnten } from '../composables/useChat.js'
|
||||
import ChatTranscript from './ChatTranscript.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
block: { type: String, required: true },
|
||||
section: { type: String, default: '' }, // detailed version
|
||||
sectionKompakt: { type: String, default: '' }, // compact version (key points) — exam/chat context
|
||||
sectionCompact: { type: String, default: '' }, // compact version (key points) — exam/chat context
|
||||
provider: { type: String, default: 'claude' },
|
||||
status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered}
|
||||
cap: { type: Number, default: 6 }, // score cap = max of the highest format (6/12/18/30)
|
||||
@@ -70,7 +71,7 @@ function tabClick(tab) {
|
||||
|
||||
// --- Block chat (ephemeral) ---
|
||||
const chat = useChat((msgs) => chatBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
messages: msgs, provider: props.provider,
|
||||
}))
|
||||
|
||||
@@ -130,7 +131,7 @@ async function examSend(payload, onOk) {
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), ...payload,
|
||||
})
|
||||
if (run !== examRun) return
|
||||
@@ -243,7 +244,7 @@ function buildSingleQuestion(mode = nextMode()) {
|
||||
const pattern = takePattern()
|
||||
const base = {
|
||||
topic: props.topic, block: props.block, section: props.section,
|
||||
section_compact: props.sectionKompakt, provider: props.provider,
|
||||
section_compact: props.sectionCompact, provider: props.provider,
|
||||
}
|
||||
if (form === 'quiz' && pattern) {
|
||||
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
|
||||
@@ -381,7 +382,7 @@ async function quizAnswer() {
|
||||
try {
|
||||
const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0)
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap,
|
||||
selection: q.gewaehlt, correct, schwer: q.schwer,
|
||||
})
|
||||
@@ -412,7 +413,7 @@ async function clozeAnswer() {
|
||||
? { schwer: true, solution: l.solution, alternatives: l.alternatives, input: l.input }
|
||||
: { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) }
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, action: 'gap_answer', question: l.sentence, cap: props.cap, ...specific,
|
||||
})
|
||||
l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback
|
||||
@@ -451,7 +452,7 @@ async function quickEvaluate() {
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(),
|
||||
})
|
||||
if (mine !== evalRun) return
|
||||
@@ -477,7 +478,7 @@ async function preciseEvaluate(thorough = false, reason = '') {
|
||||
if (thorough) examLoading.value = true
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason,
|
||||
})
|
||||
applyExam(res)
|
||||
@@ -629,26 +630,8 @@ function onExamKey(e) {
|
||||
<div v-if="mode === 'full' && activeTab" class="bp-panel">
|
||||
<!-- Block chat -->
|
||||
<div v-if="activeTab === 'chat'">
|
||||
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
|
||||
<p v-if="!chat.messages.value.length" class="bp-hint">Ask something about this block. The history is not saved.</p>
|
||||
<template v-for="(m, i) in chat.messages.value" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="bp-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Thinking…</div>
|
||||
</div>
|
||||
<div class="bp-input">
|
||||
<textarea
|
||||
:ref="chat.inputEl"
|
||||
v-model="chat.input.value"
|
||||
rows="2"
|
||||
placeholder="Question about the block…"
|
||||
@keydown.enter.exact.prevent="chat.send"
|
||||
></textarea>
|
||||
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
|
||||
{{ chat.loading.value ? '✕' : '➤' }}
|
||||
</button>
|
||||
</div>
|
||||
<ChatTranscript :chat="chat" hint="Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert."
|
||||
placeholder="Frage zum Baustein…" />
|
||||
</div>
|
||||
|
||||
<!-- Exam: guided dialog -->
|
||||
@@ -663,7 +646,7 @@ function onExamKey(e) {
|
||||
<!-- Quiz: question + multiple choice (widget stays even at the cap — practice without points) -->
|
||||
<template v-if="shownForm === 'quiz'">
|
||||
<div v-if="!quizCurrent" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
<div v-else class="bp-quiz">
|
||||
@@ -696,7 +679,7 @@ function onExamKey(e) {
|
||||
<!-- Cloze: sentence with gap + input -->
|
||||
<template v-else-if="shownForm === 'gaptext'">
|
||||
<div v-if="!clozeCurrent" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
<div v-else class="bp-gap">
|
||||
@@ -707,7 +690,7 @@ function onExamKey(e) {
|
||||
id="bp-gap-input"
|
||||
v-model="clozeCurrent.input"
|
||||
:disabled="clozeCurrent.done"
|
||||
placeholder="Term for the gap…"
|
||||
placeholder="Begriff für die Lücke…"
|
||||
@keyup.enter="clozeAnswer"
|
||||
/>
|
||||
<p v-if="clozeCurrent.schwer && clozeCurrent.done && !clozeCurrent.feedback.startsWith('Correct')" class="bp-gap-solution">Solution: <span class="markdown" v-html="renderMarkdownInline(clozeCurrent.solution)"></span></p>
|
||||
@@ -744,7 +727,7 @@ function onExamKey(e) {
|
||||
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.rating" title="Click: check thoroughly" @click="openThorough(m)">
|
||||
<span v-if="m.points != null" class="bp-tier">{{ pointsLabel(m.points) }}</span>{{ m.content }}<span v-if="!m.checked" class="bp-pruefend"> · being checked…</span>
|
||||
<div v-if="thoroughMsg === m" class="bp-thorough" @click.stop>
|
||||
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Why unsatisfied? (optional)" @keyup.enter="submitThorough" />
|
||||
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="submitThorough" />
|
||||
<button class="bp-action primary" @click="submitThorough">Check thoroughly</button>
|
||||
<button class="bp-action" @click="thoroughMsg = null">×</button>
|
||||
</div>
|
||||
@@ -757,7 +740,7 @@ function onExamKey(e) {
|
||||
</div>
|
||||
|
||||
<div v-if="examPhase === 'idle'" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
|
||||
@@ -767,11 +750,11 @@ function onExamKey(e) {
|
||||
ref="examInputEl"
|
||||
v-model="examInput"
|
||||
rows="2"
|
||||
placeholder="Answer — or ask if unclear…"
|
||||
placeholder="Antwort — oder nachfragen…"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
|
||||
<template v-else-if="examPhase === 'question_offen'">
|
||||
<button class="bp-action" title="Alt+1" :disabled="!examInput.trim()" @click="askFollowUp"><span class="bp-kbd">1</span>Ask</button>
|
||||
<button class="bp-action primary" title="Alt+2" :disabled="!examInput.trim()" @click="submitAnswer"><span class="bp-kbd">2</span>Submit answer</button>
|
||||
@@ -831,8 +814,8 @@ function onExamKey(e) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
.bp-chip.lila { background: color-mix(in srgb, var(--level-expert) 16%, var(--panel)); border-color: var(--level-expert); color: #6d28d9; }
|
||||
.bp-chip.gold { background: color-mix(in srgb, var(--level-master) 20%, var(--panel)); border-color: var(--level-master); color: #8a6d12; }
|
||||
|
||||
.bp-panel {
|
||||
margin-top: 0.6rem;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
@@ -26,13 +27,10 @@ async function loadCompleteness() {
|
||||
watch(() => [props.topic, props.ready, props.generating], loadCompleteness, { immediate: true })
|
||||
|
||||
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
|
||||
// das Kanban-Board selbst lebt in der Generierungs-View).
|
||||
let timer = null
|
||||
function startPoll() { stopPoll(); timer = setInterval(load, 5000) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
// das Kanban-Board selbst lebt in der Generierungs-View). Visibility-Pause via usePolling.
|
||||
const { start: startPoll } = usePolling(load, () => props.generating, 5000)
|
||||
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
|
||||
watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true })
|
||||
onUnmounted(stopPoll)
|
||||
watch(() => props.generating, (g) => { if (g) startPoll(); else load() }, { immediate: true })
|
||||
|
||||
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
|
||||
const LEVELS = [
|
||||
@@ -73,10 +71,10 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
<div class="bk-view">
|
||||
<header class="bk-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="bk-sub">Blocks overview</span>
|
||||
<span class="bk-sub">Bausteine-Übersicht</span>
|
||||
<span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
|
||||
<span class="bk-spacer"></span>
|
||||
<button class="bk-close" title="Close" @click="emit('close')">✕</button>
|
||||
<button class="bk-close" title="Schließen" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<button v-if="generating" class="bk-banner" @click="emit('openGeneration')">
|
||||
@@ -107,9 +105,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="bk-empty-state">Loading…</div>
|
||||
<div v-if="loading" class="bk-empty-state">Lädt…</div>
|
||||
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
|
||||
<div v-else-if="!items.length" class="bk-empty-state">Noch keine Bausteine.</div>
|
||||
|
||||
<div v-else class="bk-grid">
|
||||
<article
|
||||
@@ -126,12 +124,12 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
<span class="bk-level-label">{{ g.label }}</span>
|
||||
<ul>
|
||||
<li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
|
||||
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Peripheral topic — comes later in the 'Rest'">Edge</span>
|
||||
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Randthema — kommt später im „Rest">Rand</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="bk-no-subs">No subblocks.</p>
|
||||
<p v-else class="bk-no-subs">Keine Subbausteine.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,9 +178,8 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: bk-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
// Präsentationaler Chat (Nachrichtenliste + Typing + Eingabe). Die Mechanik (send/cancel/
|
||||
// scroll/focus) lebt in useChat; hier wird nur das `chat`-Objekt gerendert. Ersetzt die
|
||||
// zeichengleichen Chat-Blöcke in BlockPanel und TopicDetail.
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true }, // Rückgabe von useChat()
|
||||
hint: { type: String, default: '' }, // Platzhaltertext bei leerer Historie
|
||||
placeholder: { type: String, default: '' },
|
||||
rows: { type: Number, default: 2 },
|
||||
autoGrow: { type: Boolean, default: false },
|
||||
maxHeight: { type: String, default: '320px' },
|
||||
})
|
||||
|
||||
function onInput() {
|
||||
if (props.autoGrow) props.chat.autoGrow()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :ref="chat.messagesEl" class="ct-messages" :style="{ maxHeight }" @scroll="chat.onScroll">
|
||||
<p v-if="!chat.messages.value.length" class="ct-hint">{{ hint }}</p>
|
||||
<template v-for="(m, i) in chat.messages.value" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="ct-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="ct-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="chat.loading.value" class="ct-msg assistant ct-typing">Denkt nach…</div>
|
||||
</div>
|
||||
<div class="ct-input">
|
||||
<textarea
|
||||
:ref="chat.inputEl"
|
||||
v-model="chat.input.value"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
@keydown.enter.exact.prevent="chat.send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!chat.input.value.trim() && !chat.loading.value"
|
||||
:class="{ cancel: chat.loading.value }"
|
||||
:title="chat.loading.value ? 'Abbrechen' : 'Senden'"
|
||||
@click="chat.send"
|
||||
>{{ chat.loading.value ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* flex:1 füllt ein Panel mit fester Höhe (TopicDetail); maxHeight begrenzt sonst (BlockPanel-Tab). */
|
||||
.ct-messages { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.4rem; overflow-y: auto; padding: 0.2rem; }
|
||||
.ct-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
|
||||
.ct-msg {
|
||||
max-width: 85%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ct-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
|
||||
.ct-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
|
||||
.ct-typing { color: var(--text-faint); font-style: italic; }
|
||||
.ct-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
|
||||
.ct-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--input-bg, var(--panel));
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ct-input textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.ct-input button {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.ct-input button:disabled { opacity: 0.5; cursor: default; }
|
||||
.ct-input button.cancel { background: var(--danger); }
|
||||
</style>
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchBlocksBoard, runQa, runRepair } from '../api.js'
|
||||
import { fetchBlocksBoard, fetchRuns, runQa, runRepair, removeArtefacts } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
import { fmtRuntime, fmtTokens } from '../format.js'
|
||||
import KanbanBoard from './KanbanBoard.vue'
|
||||
import GuideBoardSection from './GuideBoardSection.vue'
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
@@ -10,112 +14,141 @@ const props = defineProps({
|
||||
progress: { type: String, default: null },
|
||||
ready: { type: Boolean, default: false },
|
||||
partial: { type: Boolean, default: false },
|
||||
error: { type: String, default: null },
|
||||
guideFormat: { type: String, default: 'Guide' },
|
||||
autoInventory: { type: Boolean, default: true },
|
||||
autoArtefacts: { type: Boolean, default: true },
|
||||
autoGuide: { type: Boolean, default: true },
|
||||
})
|
||||
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
|
||||
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
|
||||
const emit = defineEmits(['close', 'restartAll', 'continueAll', 'addResearch', 'requeueDead',
|
||||
'removeAll', 'removeArtefacts', 'cancel', 'cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
|
||||
|
||||
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
|
||||
// ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
|
||||
const board = ref(null)
|
||||
let timer = null
|
||||
const pollError = ref(null)
|
||||
|
||||
async function pollBoard() {
|
||||
try {
|
||||
board.value = await fetchBlocksBoard(props.topic)
|
||||
} catch { /* Board noch leer */ }
|
||||
pollError.value = null
|
||||
} catch (e) {
|
||||
if (e.status === 404) board.value = null
|
||||
else pollError.value = e.message
|
||||
}
|
||||
}
|
||||
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
const { start: startPoll } = usePolling(pollBoard, () => props.generating, 1200)
|
||||
|
||||
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
|
||||
watch(() => props.generating, (g) => {
|
||||
if (g) startPoll()
|
||||
else { stopPoll(); pollBoard() } // Endstand nachladen
|
||||
else pollBoard() // Endstand nachladen
|
||||
}, { immediate: true })
|
||||
onUnmounted(stopPoll)
|
||||
|
||||
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
|
||||
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
|
||||
const dead = computed(() => board.value?.dead || [])
|
||||
const qa = computed(() => board.value?.qa || null)
|
||||
|
||||
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel).
|
||||
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming',
|
||||
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
|
||||
'subblocks', 'facts', 'konsolidierung', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
|
||||
const sel = ref(null) // gewählte Spalte {board, key, label}
|
||||
const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts)
|
||||
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
|
||||
// Ehrlicher Fortschritt aus den Spaltenzahlen: gewichteter Spaltenindex (Terminal = 1,0).
|
||||
const TERMINAL = new Set(['done_block', 'done_artefact', 'rejected', 'grouped'])
|
||||
function boardProgress(cols) {
|
||||
const total = cols.reduce((n, c) => n + c.total, 0)
|
||||
if (!total) return { value: 0, done: 0, total: 0 }
|
||||
const last = Math.max(1, cols.length - 1)
|
||||
let acc = 0
|
||||
cols.forEach((c, i) => { acc += (TERMINAL.has(c.key) ? 1 : i / last) * c.total })
|
||||
const done = cols.filter((c) => TERMINAL.has(c.key)).reduce((n, c) => n + c.total, 0)
|
||||
return { value: acc / total, done, total }
|
||||
}
|
||||
const inventoryProgress = computed(() => boardProgress(inventoryCols.value))
|
||||
const artefactProgress = computed(() => boardProgress(artefactCols.value))
|
||||
const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0)
|
||||
|
||||
function stageClick(c) {
|
||||
if (props.generating || !RESETTABLE.has(c.key)) return
|
||||
confirm.value = null
|
||||
selCard.value = null
|
||||
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
|
||||
}
|
||||
// Score 0–10 → „100 %"
|
||||
const pct = (note) => Math.round(note * 10) + ' %'
|
||||
|
||||
function cardClick(k) {
|
||||
if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten
|
||||
confirm.value = null
|
||||
sel.value = null
|
||||
selCard.value = selCard.value?.card_id === k.card_id ? null : k
|
||||
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
|
||||
const run = ref(null)
|
||||
const latest = ref(null) // jüngster Lauf MIT Daten je Ebene — überlebt „Nur Artefakte"-Läufe
|
||||
const now = ref(Date.now())
|
||||
let clock = null
|
||||
async function loadRun() {
|
||||
try {
|
||||
const res = await fetchRuns(props.topic, 1)
|
||||
run.value = res.runs[0] || null
|
||||
latest.value = res.latest || null
|
||||
} catch { run.value = null; latest.value = null }
|
||||
}
|
||||
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
|
||||
// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte. Live tickt nur die Ebene,
|
||||
// deren Lauf der AKTUELLE ist (b.aktiv) — sonst zählt die alte Inventar-Zeile mit hoch.
|
||||
function boardZeit(b) {
|
||||
if (!b?.start) return null
|
||||
const start = Date.parse(b.start)
|
||||
const ende = b.aktiv ? now.value : Date.parse(b.ende || b.start)
|
||||
return fmtRuntime((ende - start) / 1000)
|
||||
}
|
||||
function boardTokens(b) {
|
||||
const t = b?.tokens
|
||||
return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null
|
||||
}
|
||||
const invStat = computed(() => latest.value?.inventory)
|
||||
const artStat = computed(() => latest.value?.artefacts)
|
||||
watch(() => props.generating, (g) => {
|
||||
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
|
||||
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
|
||||
}, { immediate: true })
|
||||
watch(() => props.topic, () => { run.value = null; latest.value = null; loadRun() })
|
||||
onUnmounted(() => { if (clock) clearInterval(clock) })
|
||||
|
||||
function restartCard() {
|
||||
const k = selCard.value
|
||||
selCard.value = null
|
||||
confirm.value = null
|
||||
later(() => emit('restartCard', k.card_id))
|
||||
}
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
|
||||
fn()
|
||||
setTimeout(pollBoard, 600)
|
||||
}
|
||||
|
||||
function resetHere(restart) {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
confirm.value = null
|
||||
later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
|
||||
}
|
||||
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung
|
||||
function later(fn) { fn(); setTimeout(pollBoard, 600) }
|
||||
|
||||
// ── QA + Befunde beheben (je Ebene) ──────────────────────────────────────────────
|
||||
const qaBusy = ref(false)
|
||||
const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden
|
||||
async function runQaClick() {
|
||||
async function runQaClick(ebene = null) {
|
||||
if (qaBusy.value) return
|
||||
qaBusy.value = true
|
||||
qaBusy.value = ebene || true // markiert die geklickte Ebene (Button-Label)
|
||||
try { await runQa(props.topic, true, ebene) }
|
||||
finally { qaBusy.value = false; pollBoard(); guideRefresh.value++ }
|
||||
}
|
||||
|
||||
const repairBusy = ref('') // '' | 'inventory' | 'artefacts'
|
||||
const repairInfo = ref({}) // { inventory, artefacts }
|
||||
function repairText(r) {
|
||||
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
|
||||
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
|
||||
return n === 0 ? 'keine behebbaren Befunde'
|
||||
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt`
|
||||
}
|
||||
// Auto an → „Befunde beheben"-Loop bis 100 % / Stillstand / 10×; sonst genau ein Durchlauf.
|
||||
async function repairClick(ebene) {
|
||||
if (repairBusy.value) return
|
||||
const auto = ebene === 'inventory' ? props.autoInventory : props.autoArtefacts
|
||||
const max = auto ? 10 : 1
|
||||
repairBusy.value = ebene
|
||||
repairInfo.value = { ...repairInfo.value, [ebene]: '' }
|
||||
let prev = null
|
||||
try {
|
||||
await runQa(props.topic)
|
||||
for (let i = 0; i < max; i++) {
|
||||
const r = await runRepair(props.topic, ebene)
|
||||
const note = ebene === 'artefacts' ? r.note_artefakte : r.note
|
||||
const suffix = max > 1 ? ` — Runde ${i + 1}${note != null ? ` (${Math.round(note * 10)} %)` : ''}` : ''
|
||||
repairInfo.value = { ...repairInfo.value, [ebene]: repairText(r) + suffix }
|
||||
if (note == null || note >= 10) break // 100 % erreicht
|
||||
if (r.aktionen === 0 && prev != null && note <= prev) break // Stillstand: nichts getan, Note steht
|
||||
prev = note
|
||||
}
|
||||
} catch (e) {
|
||||
repairInfo.value = { ...repairInfo.value, [ebene]: String(e.message || e) }
|
||||
} finally {
|
||||
qaBusy.value = false
|
||||
pollBoard()
|
||||
guideRefresh.value++
|
||||
repairBusy.value = ''; pollBoard()
|
||||
}
|
||||
}
|
||||
|
||||
const repairBusy = ref(false)
|
||||
const repairInfo = ref('')
|
||||
async function repairClick() {
|
||||
if (repairBusy.value) return
|
||||
repairBusy.value = true
|
||||
repairInfo.value = ''
|
||||
try {
|
||||
const r = await runRepair(props.topic)
|
||||
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
|
||||
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
|
||||
repairInfo.value = n === 0
|
||||
? 'keine behebbaren Befunde'
|
||||
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt · ${(r.freigesprochen || []).length} freigesprochen`
|
||||
} catch (e) {
|
||||
repairInfo.value = String(e.message || e)
|
||||
} finally {
|
||||
repairBusy.value = false
|
||||
pollBoard()
|
||||
}
|
||||
function removeArtefactsClick() {
|
||||
later(() => removeArtefacts(props.topic).then(pollBoard))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -125,86 +158,74 @@ async function repairClick() {
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="gen-sub">Generierung</span>
|
||||
<span class="gen-spacer"></span>
|
||||
<button class="gen-close" title="Close" @click="emit('close')">✕</button>
|
||||
<button class="gen-close" title="Schließen" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<div v-if="qa && qa.pausiert" class="qa-pause">
|
||||
<strong>QA-Gate: Note {{ qa.note.toFixed(1) }} unter Schwelle {{ qa.schwelle }} — pausiert.</strong>
|
||||
<span v-if="qa.befunde.length"> Befunde: {{ qa.befunde.join(' · ') }}</span>
|
||||
<button class="gen-act" @click="emit('continueAll', { qaForce: true })">Trotzdem fortsetzen</button>
|
||||
</div>
|
||||
<div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</div>
|
||||
|
||||
<section class="gen-section">
|
||||
<div class="gen-steps-top">
|
||||
<span class="gen-title">Bausteine</span>
|
||||
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
|
||||
<div v-if="!generating" class="gen-actions">
|
||||
<button class="gen-act" :disabled="qaBusy" title="QA-Lauf wie am Gate (inkl. LLM-Stichprobe)"
|
||||
@click="runQaClick">{{ qaBusy ? 'QA läuft…' : 'QA' }}</button>
|
||||
<button v-if="qa" class="gen-act" :disabled="repairBusy"
|
||||
title="QA-Befunde gezielt beheben: Hygiene, bestätigte Dubletten mergen, Fremd/Unecht nach Gegen-Judge entfernen"
|
||||
@click="repairClick">{{ repairBusy ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
|
||||
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
|
||||
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Continue</button>
|
||||
<button
|
||||
v-if="ready || partial"
|
||||
class="gen-act danger"
|
||||
:class="{ armed: confirm === 'remove' }"
|
||||
@click="arm('remove', () => later(() => emit('removeAll')))"
|
||||
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
|
||||
</div>
|
||||
<div v-else class="gen-actions">
|
||||
<button class="gen-act" @click="emit('addResearch')">+ Research</button>
|
||||
<button class="gen-act danger" @click="emit('cancel')">Cancel</button>
|
||||
<span v-if="run?.fails?.length" class="gen-run-fail" :title="run.fails.map((f) => f.key + ': ' + f.error).join('\n')">⚠ {{ run.fails.length }} Fehler</span>
|
||||
<div v-if="generating" class="gen-actions">
|
||||
<button class="gen-act" @click="emit('addResearch')">+ Recherche</button>
|
||||
<button class="gen-act danger" @click="emit('cancel')">Abbrechen</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="dead.length"
|
||||
v-if="dead.length && generating"
|
||||
class="gen-act"
|
||||
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
|
||||
@click="later(() => emit('requeueDead'))"
|
||||
>⟳ {{ dead.length }} dead</button>
|
||||
</div>
|
||||
|
||||
<!-- Ebene Inventar -->
|
||||
<div class="gen-board-label">
|
||||
Inventar
|
||||
<span v-if="qa" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'"
|
||||
:title="'QA-Schwelle ' + qa.schwelle">QA {{ qa.note.toFixed(1) }}/10</span>
|
||||
<span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note) }}</span>
|
||||
<span v-if="boardZeit(invStat)" class="gen-run">⏱ {{ boardZeit(invStat) }}<template v-if="boardTokens(invStat)"> · {{ boardTokens(invStat) }} Tokens</template></span>
|
||||
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
|
||||
<input type="checkbox" :checked="autoInventory" @change="emit('setAuto', { ebene: 'inventory', value: $event.target.checked })"> Auto
|
||||
</label>
|
||||
</div>
|
||||
<KanbanBoard
|
||||
:columns="inventoryCols"
|
||||
:agents="board?.agents || []"
|
||||
:generating="generating"
|
||||
:selectable="!generating"
|
||||
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
|
||||
@stageClick="stageClick"
|
||||
/>
|
||||
<div v-if="!generating" class="gen-bar">
|
||||
<button class="gen-act" :disabled="!!qaBusy" @click="runQaClick('inventory')">{{ qaBusy === 'inventory' ? 'QA läuft…' : 'QA' }}</button>
|
||||
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('inventory')">{{ repairBusy === 'inventory' ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo.inventory" class="repair-info">{{ repairInfo.inventory }}</span>
|
||||
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Recherche' : 'Generieren' }}</button>
|
||||
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
|
||||
<button v-if="ready || partial || error" class="gen-act danger" :class="{ armed: isArmed('remove') }"
|
||||
title="Löscht das gesamte Topic-Inventar (Artefakte + Guide hängen daran)"
|
||||
@click="armOrRun('remove', () => later(() => emit('removeAll')))">{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
</div>
|
||||
<ProgressBar v-if="inventoryProgress.total" :value="inventoryProgress.value"
|
||||
:label="`${inventoryProgress.done}/${inventoryProgress.total} Karten fertig · ${Math.round(inventoryProgress.value * 100)} %`"
|
||||
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
|
||||
<KanbanBoard :columns="inventoryCols" :agents="(board?.agents || []).filter(a => a.ebene !== 'artefacts')" :generating="generating" />
|
||||
|
||||
<!-- Ebene Artefakte -->
|
||||
<div class="gen-board-label">
|
||||
Artefakte
|
||||
<span v-if="qa && qa.note_artefakte != null" class="qa-note"
|
||||
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'"
|
||||
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span>
|
||||
<span v-if="qa && qa.note_artefakte != null" class="qa-note" :class="qa.note_artefakte >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note_artefakte) }}</span>
|
||||
<span v-if="boardZeit(artStat)" class="gen-run">⏱ {{ boardZeit(artStat) }}<template v-if="boardTokens(artStat)"> · {{ boardTokens(artStat) }} Tokens</template></span>
|
||||
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
|
||||
<input type="checkbox" :checked="autoArtefacts" @change="emit('setAuto', { ebene: 'artefacts', value: $event.target.checked })"> Auto
|
||||
</label>
|
||||
</div>
|
||||
<KanbanBoard
|
||||
:columns="artefactCols"
|
||||
:generating="generating"
|
||||
:selectable="!generating"
|
||||
:cardSelectable="!generating"
|
||||
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
|
||||
@stageClick="stageClick"
|
||||
@cardClick="cardClick"
|
||||
/>
|
||||
|
||||
<div v-if="selCard && !generating" class="gen-step-actions">
|
||||
<span class="gen-step-actions-label">Karte «{{ selCard.title }}»:</span>
|
||||
<button class="gen-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? restartCard() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : '↻ Karte neu generieren' }}</button>
|
||||
<button class="gen-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
<div v-if="sel && !generating" class="gen-step-actions">
|
||||
<span class="gen-step-actions-label">Ab «{{ sel.label }}»:</span>
|
||||
<button class="gen-act play" @click="resetHere(true)">↻ neu generieren</button>
|
||||
<button class="gen-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : '✕ nur zurücksetzen' }}</button>
|
||||
<button class="gen-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
|
||||
<div v-if="!generating" class="gen-bar">
|
||||
<button class="gen-act" :disabled="!!qaBusy" @click="runQaClick('artefacts')">{{ qaBusy === 'artefacts' ? 'QA läuft…' : 'QA' }}</button>
|
||||
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('artefacts')">{{ repairBusy === 'artefacts' ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo.artefacts" class="repair-info">{{ repairInfo.artefacts }}</span>
|
||||
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen — nur Artefakte, ohne Board 1" @click="emit('continueAll', { qaForce: true, onlyArtefacts: true })">Generieren</button>
|
||||
<button v-if="ready || partial" class="gen-act danger" :class="{ armed: isArmed('remove-art') }"
|
||||
title="Nur die Artefakte leeren — Inventar bleibt"
|
||||
@click="armOrRun('remove-art', removeArtefactsClick)">{{ isArmed('remove-art') ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
</div>
|
||||
<ProgressBar v-if="artefactProgress.total" :value="artefactProgress.value"
|
||||
:label="`${artefactProgress.done}/${artefactProgress.total} Karten fertig · ${Math.round(artefactProgress.value * 100)} %`"
|
||||
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
|
||||
<KanbanBoard :columns="artefactCols" :agents="(board?.agents || []).filter(a => a.ebene === 'artefacts')" :generating="generating" />
|
||||
</section>
|
||||
|
||||
<section class="gen-section">
|
||||
@@ -212,12 +233,12 @@ async function repairClick() {
|
||||
:topic="topic"
|
||||
:format="guideFormat"
|
||||
:refresh="guideRefresh"
|
||||
:auto="autoGuide"
|
||||
@cancelGuide="(id) => emit('cancelGuide', id)"
|
||||
@startGuide="(p) => emit('startGuide', p)"
|
||||
@resetStage="(p) => emit('resetGuideStage', p)"
|
||||
@preview="emit('preview')"
|
||||
@removeFormat="(f) => emit('removeFormat', f)"
|
||||
@resetCard="(p) => emit('resetGuideCard', p)"
|
||||
@setAuto="(v) => emit('setAuto', { ebene: 'guide', value: v })"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@@ -266,12 +287,15 @@ async function repairClick() {
|
||||
}
|
||||
.gen-title { font-size: 0.9rem; font-weight: 700; }
|
||||
.gen-board-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
margin: 0.7rem 0 0.3rem;
|
||||
}
|
||||
.gen-progress {
|
||||
display: flex;
|
||||
@@ -286,22 +310,25 @@ async function repairClick() {
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: gen-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
|
||||
.gen-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
|
||||
|
||||
.gen-step-actions {
|
||||
display: flex;
|
||||
.auto-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
gap: 0.25rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
.auto-box input { cursor: pointer; margin: 0; }
|
||||
|
||||
.gen-act {
|
||||
border: 1px solid var(--border-strong);
|
||||
@@ -314,30 +341,23 @@ async function repairClick() {
|
||||
font-weight: 600;
|
||||
}
|
||||
.gen-act:hover { border-color: var(--accent); }
|
||||
.gen-act:disabled { opacity: 0.55; cursor: default; }
|
||||
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.gen-act.play:hover { background: var(--accent-hover); }
|
||||
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.gen-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
.gen-act.ghost { color: var(--text-muted); }
|
||||
.qa-note {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
|
||||
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
|
||||
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
|
||||
.qa-pause {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0.8rem;
|
||||
margin-bottom: 0.8rem;
|
||||
border: 1px solid color-mix(in srgb, #ef4444 40%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, #ef4444 8%, transparent);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.gen-poll-error { color: var(--danger); font-size: 0.82rem; padding: 0.3rem 0; }
|
||||
.gen-run { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.gen-run-fail { font-size: 0.78rem; color: var(--danger); white-space: nowrap; }
|
||||
</style>
|
||||
@@ -1,145 +1,168 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchGuideBoard, repairGuideBoard } from '../api.js'
|
||||
import { fetchGuideBoard, fetchRuns, repairGuideBoard, runQa } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
import { fmtRuntime, fmtTokens } from '../format.js'
|
||||
import KanbanBoard from './KanbanBoard.vue'
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
format: { type: String, default: 'Guide' },
|
||||
refresh: { type: Number, default: 0 }, // Eltern-Signal: QA/Repair schrieben einen Guide-Report
|
||||
auto: { type: Boolean, default: true },
|
||||
})
|
||||
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard'])
|
||||
const emit = defineEmits(['cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
|
||||
|
||||
const board = ref(null)
|
||||
let timer = null
|
||||
const pollError = ref(null)
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
board.value = await fetchGuideBoard(props.topic, props.format)
|
||||
} catch { /* Board noch leer */ }
|
||||
pollError.value = null
|
||||
} catch (e) {
|
||||
if (e.status === 404) board.value = null
|
||||
else pollError.value = e.message
|
||||
}
|
||||
}
|
||||
function startPoll() { stopPoll(); timer = setInterval(poll, 1200) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
|
||||
|
||||
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
|
||||
watch(() => props.refresh, () => poll())
|
||||
watch(() => board.value?.generating, (g) => { if (g) startPoll(); else stopPoll() })
|
||||
onUnmounted(stopPoll)
|
||||
watch(() => board.value?.generating, (g) => { if (g) startPoll() })
|
||||
|
||||
const generating = computed(() => !!board.value?.generating)
|
||||
const columns = computed(() => board.value?.columns || [])
|
||||
const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
|
||||
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
|
||||
const progressValue = computed(() => (total.value ? done.value / total.value : 0))
|
||||
// QA-Badge nur wenn es ein Board gibt — sonst zeigt ein verwaister Report eine alte Note.
|
||||
const qaNote = computed(() => (total.value ? board.value?.qa_guide : null))
|
||||
|
||||
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
|
||||
const STAGES = ['lernziele', 'zuweisung', 'writer', 'pruefer', 'fix']
|
||||
const sel = ref(null)
|
||||
const selCard = ref(null)
|
||||
const confirm = ref(null)
|
||||
// Laufzeit + Tokens aus /api/runs — `latest.guide` ist der jüngste GUIDE-Lauf
|
||||
// (der jüngste Lauf insgesamt kann ein Blocks-Lauf sein und zeigte hier dessen Zahlen).
|
||||
const run = ref(null)
|
||||
const now = ref(Date.now())
|
||||
let clock = null
|
||||
async function loadRun() {
|
||||
try {
|
||||
run.value = (await fetchRuns(props.topic, 1)).latest?.guide || null
|
||||
} catch { run.value = null }
|
||||
}
|
||||
const { start: startRunPoll } = usePolling(loadRun, () => generating.value, 5000)
|
||||
const runLaufzeit = computed(() => {
|
||||
if (!run.value?.start) return null
|
||||
const start = Date.parse(run.value.start)
|
||||
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
|
||||
return fmtRuntime((ende - start) / 1000)
|
||||
})
|
||||
const runTokens = computed(() => {
|
||||
const t = run.value?.tokens
|
||||
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
|
||||
})
|
||||
watch(() => generating.value, (g) => {
|
||||
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
|
||||
else { loadRun(); if (clock) { clearInterval(clock); clock = null } }
|
||||
}, { immediate: true })
|
||||
watch(() => props.topic, () => { run.value = null; loadRun() })
|
||||
onUnmounted(() => { if (clock) clearInterval(clock) })
|
||||
|
||||
function stageClick(c) {
|
||||
if (generating.value || !STAGES.includes(c.key)) return
|
||||
confirm.value = null
|
||||
selCard.value = null
|
||||
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
|
||||
const { isArmed, armOrRun } = useConfirm() // 2-Klick-Bestätigung
|
||||
|
||||
const qaBusy = ref(false)
|
||||
async function runQaClick() {
|
||||
if (qaBusy.value) return
|
||||
qaBusy.value = true
|
||||
try { await runQa(props.topic) }
|
||||
finally { qaBusy.value = false; poll() }
|
||||
}
|
||||
|
||||
function cardClick(k) {
|
||||
if (generating.value || !k.card_id) return
|
||||
confirm.value = null
|
||||
sel.value = null
|
||||
const idx = Math.max(0, STAGES.indexOf(k.column))
|
||||
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
|
||||
function removeClick() {
|
||||
emit('removeFormat', props.format)
|
||||
board.value = null // optimistisch: Karten + QA-Badge sofort weg, poll bestätigt
|
||||
setTimeout(poll, 800)
|
||||
}
|
||||
|
||||
function resetCardHere() {
|
||||
const k = selCard.value
|
||||
selCard.value = null
|
||||
confirm.value = null
|
||||
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
|
||||
setTimeout(poll, 400)
|
||||
}
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
const repairBusy = ref(false)
|
||||
const repairInfo = ref('')
|
||||
// Wartet, bis die Guide-Regenerierung der offenen Karten fertig ist (für den Auto-Loop).
|
||||
function warteBisFertig(timeoutMs = 300000) {
|
||||
return new Promise((resolve) => {
|
||||
const t0 = Date.now()
|
||||
const iv = setInterval(async () => {
|
||||
await poll()
|
||||
if (!board.value?.generating || Date.now() - t0 > timeoutMs) { clearInterval(iv); resolve() }
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
// Auto an → Loop: Befunde beheben → regenerieren → QA, bis 100 % / Stillstand / 10×.
|
||||
// Auto aus → genau ein Durchlauf (nur anstoßen).
|
||||
async function repairClick() {
|
||||
if (repairBusy.value) return
|
||||
const max = props.auto ? 10 : 1
|
||||
repairBusy.value = true
|
||||
repairInfo.value = ''
|
||||
let prev = null
|
||||
try {
|
||||
const r = await repairGuideBoard(props.topic, props.format)
|
||||
repairInfo.value = (r.betroffen || []).length
|
||||
? `${r.betroffen.length} Abschnitt(e) → Prüfen`
|
||||
: 'keine Befunde im letzten QA-Report'
|
||||
if ((r.betroffen || []).length) startPoll()
|
||||
for (let i = 0; i < max; i++) {
|
||||
const r = await repairGuideBoard(props.topic, props.format)
|
||||
if (!(r.betroffen || []).length) {
|
||||
repairInfo.value = i === 0 ? 'keine Befunde im letzten QA-Report' : `fertig nach ${i} Runde(n)`
|
||||
break
|
||||
}
|
||||
repairInfo.value = `Runde ${i + 1}: ${r.betroffen.length} Abschnitt(e) → Prüfen…`
|
||||
startPoll()
|
||||
if (max === 1) break // ohne Auto: nur anstoßen, nicht auf Fertigstellung warten
|
||||
await warteBisFertig() // Regenerierung abwarten
|
||||
await runQa(props.topic) // neue Guide-QA messen
|
||||
await poll()
|
||||
const note = board.value?.qa_guide
|
||||
if (note == null || note >= 10) { repairInfo.value = `100 % nach ${i + 1} Runde(n)`; break }
|
||||
if (prev != null && note <= prev) { repairInfo.value = `Stillstand bei ${Math.round(note * 10)} %`; break }
|
||||
prev = note
|
||||
}
|
||||
} catch (e) {
|
||||
repairInfo.value = String(e.message || e)
|
||||
} finally {
|
||||
repairBusy.value = false
|
||||
poll()
|
||||
}
|
||||
}
|
||||
function restartHere() {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
emit('startGuide', { format: props.format, abStep: s.idx })
|
||||
startPoll()
|
||||
}
|
||||
function resetHere() {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
emit('resetStage', { format: props.format, abStage: s.idx })
|
||||
setTimeout(poll, 400)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="gb-board">
|
||||
<div class="gb-top">
|
||||
<span class="gb-title">Guide · {{ format }}</span>
|
||||
<span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 9 ? 'ok' : 'bad'"
|
||||
title="Guide-QA (make qa-guide)">QA {{ board.qa_guide.toFixed(1) }}/10</span>
|
||||
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
|
||||
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
|
||||
<div class="gen-board-label">
|
||||
Guide · {{ format }}
|
||||
<span v-if="qaNote != null" class="qa-note" :class="qaNote >= 10 ? 'ok' : 'bad'">QA {{ Math.round(qaNote * 10) }} %</span>
|
||||
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
|
||||
<input type="checkbox" :checked="auto" @change="emit('setAuto', $event.target.checked)"> Auto
|
||||
</label>
|
||||
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten</span>
|
||||
<span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
|
||||
<div v-if="board?.progress && generating" class="gen-progress"><span class="gen-progress-dot"></span>{{ board.progress }}</div>
|
||||
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
|
||||
<div class="gb-actions">
|
||||
<template v-if="generating">
|
||||
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
|
||||
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
|
||||
<button v-if="total && board?.qa_guide != null && board.qa_guide < 10" class="gb-act"
|
||||
:disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span>
|
||||
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="pollError" class="gb-error">Board nicht erreichbar: {{ pollError }}</div>
|
||||
</div>
|
||||
|
||||
<KanbanBoard
|
||||
:columns="columns"
|
||||
:agents="board?.agents || []"
|
||||
:generating="generating"
|
||||
:selectable="!generating"
|
||||
:cardSelectable="!generating"
|
||||
:selectedKey="sel?.key || null"
|
||||
@stageClick="stageClick"
|
||||
@cardClick="cardClick"
|
||||
/>
|
||||
<div v-if="generating" class="gen-bar">
|
||||
<button class="gen-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
|
||||
</div>
|
||||
<div v-else class="gen-bar">
|
||||
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft…' : 'QA' }}</button>
|
||||
<button v-if="total && qaNote != null && qaNote < 10" class="gen-act" :disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert…' : 'Befunde beheben' }}</button>
|
||||
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
|
||||
<button class="gen-act play" @click="emit('startGuide', { format }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
|
||||
<button v-if="done === total && total" class="gen-act" @click="emit('preview')">Guide öffnen</button>
|
||||
<button v-if="total" class="gen-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', removeClick)">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selCard && !generating" class="gb-stage-actions">
|
||||
<span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
|
||||
<button class="gb-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? resetCardHere() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : '↻ Karte neu (ab Lernziele)' }}</button>
|
||||
<button class="gb-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
<div v-if="sel && !generating" class="gb-stage-actions">
|
||||
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
|
||||
<button class="gb-act play" @click="restartHere">↻ neu generieren</button>
|
||||
<button class="gb-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : '✕ nur zurücksetzen' }}</button>
|
||||
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
<ProgressBar v-if="total" :value="progressValue"
|
||||
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
|
||||
|
||||
<KanbanBoard :columns="columns" :agents="board?.agents || []" :generating="generating" />
|
||||
|
||||
<div v-if="!total && !generating" class="gb-empty">Noch kein Board — «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
|
||||
</section>
|
||||
@@ -147,48 +170,63 @@ function resetHere() {
|
||||
|
||||
<style scoped>
|
||||
.gb-board { padding: 0.85rem 0 0; }
|
||||
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; }
|
||||
.gb-title { font-size: 0.9rem; font-weight: 700; }
|
||||
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
|
||||
.gb-qa {
|
||||
.gen-board-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
margin: 0.2rem 0 0.3rem;
|
||||
}
|
||||
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
|
||||
.gb-count { color: var(--text-muted); font-size: 0.82rem; text-transform: none; letter-spacing: 0; font-weight: 400; }
|
||||
.qa-note {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.gb-qa.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
|
||||
.gb-qa.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
|
||||
.gb-progress {
|
||||
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
|
||||
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
|
||||
.auto-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auto-box input { cursor: pointer; margin: 0; }
|
||||
.gen-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.gb-progress-dot {
|
||||
.gen-progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: gb-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
.gb-error { color: var(--danger); font-size: 0.82rem; }
|
||||
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
|
||||
.gb-stage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
.gb-stage-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
.gb-error { color: var(--danger); font-size: 0.82rem; text-transform: none; letter-spacing: 0; font-weight: 400; }
|
||||
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
|
||||
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
|
||||
|
||||
.gb-act {
|
||||
.gen-act {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
@@ -198,10 +236,10 @@ function resetHere() {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gb-act:hover { border-color: var(--accent); }
|
||||
.gb-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.gb-act.play:hover { background: var(--accent-hover); }
|
||||
.gb-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.gb-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
.gb-act.ghost { color: var(--text-muted); }
|
||||
.gen-act:hover { border-color: var(--accent); }
|
||||
.gen-act:disabled { opacity: 0.55; cursor: default; }
|
||||
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.gen-act.play:hover { background: var(--accent-hover); }
|
||||
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.gen-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
</style>
|
||||
@@ -1,24 +1,13 @@
|
||||
<script setup>
|
||||
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
|
||||
// Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen.
|
||||
// Karten-Titeln. Reines Display — Steuerung läuft über die Ebenen-Buttons.
|
||||
import { fmtRuntime } from '../format.js'
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}]
|
||||
agents: { type: Array, default: () => [] }, // [{label, runtime}]
|
||||
generating: { type: Boolean, default: false },
|
||||
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
|
||||
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
|
||||
selectedKey: { type: String, default: null },
|
||||
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
|
||||
})
|
||||
const emit = defineEmits(['stageClick', 'cardClick'])
|
||||
|
||||
function visible(c) {
|
||||
return !props.hideEmpty || c.total > 0
|
||||
}
|
||||
|
||||
function fmtRuntime(s) {
|
||||
return s >= 60 ? `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s` : `${Math.round(s)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,26 +18,20 @@ function fmtRuntime(s) {
|
||||
</div>
|
||||
<div class="kb-cols">
|
||||
<div
|
||||
v-for="c in columns.filter(visible)"
|
||||
v-for="c in columns"
|
||||
:key="(c.board || '') + c.key"
|
||||
class="kb-col"
|
||||
:class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }"
|
||||
:class="{ active: c.total > 0, collapsed: !c.total }"
|
||||
>
|
||||
<button
|
||||
class="kb-col-head"
|
||||
:disabled="!selectable"
|
||||
:title="selectable ? `Aktionen ab «${c.label}»` : c.label"
|
||||
@click="selectable && emit('stageClick', c)"
|
||||
>
|
||||
<div class="kb-col-head" :title="c.label">
|
||||
<span class="kb-col-label">{{ c.label }}</span>
|
||||
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="c.cards && c.cards.length" class="kb-cards">
|
||||
<li
|
||||
v-for="(k, i) in c.cards" :key="i" class="kb-card"
|
||||
:class="[k.status, { klickbar: cardSelectable && k.card_id }]"
|
||||
:title="cardSelectable && k.card_id ? `${k.title} — Klick: Karte neu generieren` : (k.info || k.title)"
|
||||
@click="cardSelectable && k.card_id && emit('cardClick', { ...k, column: c.key, colBoard: c.board })"
|
||||
:class="k.status"
|
||||
:title="k.info || k.title"
|
||||
>
|
||||
<div class="kb-card-row">
|
||||
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
// Schmaler Fortschrittsbalken (0–1) mit Beschriftung und optionalem Hinweis.
|
||||
defineProps({
|
||||
value: { type: Number, default: 0 }, // 0..1
|
||||
label: { type: String, default: '' },
|
||||
hint: { type: String, default: '' }, // z. B. „Umfang wächst noch"
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb">
|
||||
<div class="pb-track">
|
||||
<div class="pb-fill" :style="{ width: Math.round(Math.min(1, Math.max(0, value)) * 100) + '%' }"></div>
|
||||
</div>
|
||||
<span v-if="label" class="pb-label">{{ label }}</span>
|
||||
<span v-if="hint" class="pb-hint">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pb { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.pb-track { flex: 1; height: 6px; border-radius: 999px; background: var(--panel-soft); overflow: hidden; }
|
||||
.pb-fill { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.4s ease; }
|
||||
.pb-label { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; }
|
||||
.pb-hint { font-size: 0.74rem; color: var(--text-faint); white-space: nowrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
// Quellenauswahl (Typ-Buttons + Link-Feld/Ordnerwahl), geteilt zwischen dem Anlegen-Panel
|
||||
// und dem Bearbeiten-Panel der Sidebar. v-model trägt { type, ort }.
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true }, // { type, ort }
|
||||
folders: { type: Object, default: () => ({}) }, // { projekt: [...], uni: [...] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'submit'])
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'thema', label: 'Thema' },
|
||||
{ key: 'link', label: 'Link' },
|
||||
{ key: 'projekt', label: 'Projekt' },
|
||||
{ key: 'uni', label: 'Uni' },
|
||||
]
|
||||
|
||||
function setType(key) {
|
||||
emit('update:modelValue', { type: key, ort: '' }) // Typwechsel verwirft den alten Ort
|
||||
}
|
||||
function setOrt(ort) {
|
||||
emit('update:modelValue', { ...props.modelValue, ort })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dlg-sources">
|
||||
<button v-for="t in TYPES" :key="t.key" :class="{ active: modelValue.type === t.key }"
|
||||
@click="setType(t.key)">{{ t.label }}</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-if="modelValue.type === 'link'"
|
||||
class="dlg-input dlg-textarea" :value="modelValue.ort"
|
||||
rows="4"
|
||||
placeholder="https://… — ein Link pro Zeile"
|
||||
@input="setOrt($event.target.value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="modelValue.type === 'projekt' || modelValue.type === 'uni'"
|
||||
class="dlg-input" :value="modelValue.ort"
|
||||
@change="setOrt($event.target.value)"
|
||||
>
|
||||
<option value="" disabled>Ordner wählen…</option>
|
||||
<option v-for="fo in (folders[modelValue.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg-sources { display: flex; gap: 0.3rem; }
|
||||
.dlg-sources button {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.dlg-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.dlg-input:focus { outline: none; border-color: var(--accent); }
|
||||
select.dlg-input { cursor: pointer; }
|
||||
.dlg-textarea { resize: vertical; min-height: 5rem; }
|
||||
</style>
|
||||
@@ -2,10 +2,11 @@
|
||||
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchBlockLearnState } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, viewLevelFuer } from '../levels.js'
|
||||
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, BADGE_VIEW_RANK } from '../levels.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import BlockFocus from './BlockFocus.vue'
|
||||
import ChatTranscript from './ChatTranscript.vue'
|
||||
|
||||
const props = defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
@@ -35,8 +36,12 @@ let mdObserver = null
|
||||
// Auto: Ansichtsstufe des Blocks folgt dem Prüfungs-Score (Erreicht + 1); Override = global fest.
|
||||
function viewLevelFor(title) {
|
||||
if (props.stufeAnsicht !== 'auto') return Number(props.stufeAnsicht)
|
||||
const l = learnstate.value[title]
|
||||
return viewLevelFuer(l?.good_answers || 0, l?.cap || 10)
|
||||
// Auto = an die erreichte Badge-Stufe gekoppelt (gleiche Schwelle wie das Level-Abzeichen):
|
||||
// Advanced-Badge → advanced-Subs sichtbar. Vorher hing es an `freie_level` (Sub-Zahl × 25),
|
||||
// das bei vielen beginner-Subs SPÄTER umschaltet als der Badge (0.4 × cap) — dann zeigte der
|
||||
// Badge „Advanced", die advanced-Inhalte blieben aber verborgen.
|
||||
const st = levelOf(title)
|
||||
return st ? (BADGE_VIEW_RANK[st.key] || 1) : 1
|
||||
}
|
||||
|
||||
function htmlFor(s) {
|
||||
@@ -54,13 +59,12 @@ function htmlFor(s) {
|
||||
const rank = SUB_RANK[sub.level] || 1
|
||||
if (rank > lvl) continue
|
||||
const body = renderMarkdown(compact ? (sub.compact || sub.md) : (sub.md || sub.compact))
|
||||
if (props.stufeAnsicht === 'auto' && lvl > 1 && rank === lvl) {
|
||||
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}"><span class="sub-neu-badge" title="Neu ab Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
|
||||
} else if (Number(props.stufeAnsicht) === 4) {
|
||||
// Vollansicht: jede Stufe dezent kennzeichnen (Rand + Badge in Stufen-Farbe)
|
||||
parts.push(`<div class="sub-stufe" style="--stufe-farbe:${VIEW_FARBE[rank]}"><span class="sub-stufe-badge" title="Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
|
||||
if (props.stufeAnsicht === 'auto' && rank > 1 && rank === lvl) {
|
||||
// gerade neu freigeschaltete Stufe: kräftigere Färbung (Rand), ohne Badge
|
||||
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}">${body}</div>`)
|
||||
} else {
|
||||
parts.push(body)
|
||||
// jede Stufe A/F/E/V leicht in ihrer Farbe kennzeichnen (Rand + Färbung), ohne Badge
|
||||
parts.push(`<div class="sub-stufe" style="--stufe-farbe:${VIEW_FARBE[rank]}">${body}</div>`)
|
||||
}
|
||||
}
|
||||
h = parts.join('')
|
||||
@@ -199,8 +203,7 @@ const chat = useChat((msgs) => {
|
||||
section, outline, messages: msgs, provider: props.provider,
|
||||
})
|
||||
})
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll, send } = chat
|
||||
const autoGrow = () => chat.autoGrow()
|
||||
const { inputEl } = chat // fürs Fokussieren beim Öffnen; Rendering übernimmt ChatTranscript
|
||||
const chatOpen = ref(false)
|
||||
const panelEl = ref(null)
|
||||
|
||||
@@ -335,6 +338,7 @@ function extractContext() {
|
||||
:fortschritt="progress"
|
||||
:status="learnstate[focusBlock.title]"
|
||||
:cap="capOf(focusBlock.title)"
|
||||
:stufe="viewLevelFor(focusBlock.title)"
|
||||
:tab="focusTab"
|
||||
:ansicht="ansichtModus"
|
||||
:has-prev="focusIndex > 0"
|
||||
@@ -355,30 +359,8 @@ function extractContext() {
|
||||
<span>Questions about the guide</span>
|
||||
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
|
||||
</header>
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Ask a question about the current section.</p>
|
||||
<template v-for="(m, i) in messages" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="chat-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="loading" class="chat-msg assistant chat-typing">Thinking…</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
rows="3"
|
||||
placeholder="Ask a question…"
|
||||
@input="autoGrow"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Cancel' : 'Send'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
<ChatTranscript :chat="chat" hint="Stelle eine Frage zum aktuellen Abschnitt."
|
||||
placeholder="Frage stellen…" :rows="3" auto-grow max-height="none" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -466,8 +448,8 @@ function extractContext() {
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border: 1px solid #d4af37;
|
||||
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
|
||||
border: 1px solid var(--level-master);
|
||||
color: #8a6d12;
|
||||
}
|
||||
|
||||
@@ -530,26 +512,26 @@ function extractContext() {
|
||||
|
||||
/* Understood blocks (10/10): purple */
|
||||
.block-done.understood {
|
||||
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
|
||||
border-color: #8b5cf6;
|
||||
background: color-mix(in srgb, var(--level-expert) 16%, var(--panel));
|
||||
border-color: var(--level-expert);
|
||||
color: #6d28d9;
|
||||
}
|
||||
.guide-content .section-card.understood {
|
||||
border-color: #8b5cf6;
|
||||
border-top: 3px solid #8b5cf6;
|
||||
background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
|
||||
border-color: var(--level-expert);
|
||||
border-top: 3px solid var(--level-expert);
|
||||
background: color-mix(in srgb, var(--level-expert) 7%, var(--panel));
|
||||
}
|
||||
|
||||
/* Mastered blocks (master path 25/25): gold */
|
||||
.block-done.mastered {
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border-color: #d4af37;
|
||||
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
|
||||
border-color: var(--level-master);
|
||||
color: #8a6d12;
|
||||
}
|
||||
.guide-content .section-card.mastered {
|
||||
border-color: #d4af37;
|
||||
border-top: 3px solid #d4af37;
|
||||
background: color-mix(in srgb, #d4af37 8%, var(--panel));
|
||||
border-color: var(--level-master);
|
||||
border-top: 3px solid var(--level-master);
|
||||
background: color-mix(in srgb, var(--level-master) 8%, var(--panel));
|
||||
}
|
||||
|
||||
/* Guides: cards carry the chapter accent color */
|
||||
@@ -672,101 +654,7 @@ function extractContext() {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-hint {
|
||||
color: var(--text-faint);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 85%;
|
||||
padding: 7px 11px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-msg.markdown {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-typing {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 0.6rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 72px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
width: 38px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-input button.cancel {
|
||||
background: var(--danger);
|
||||
}
|
||||
/* Chat-Transkript + Eingabe leben jetzt in ChatTranscript.vue (geteilt mit BlockPanel). */
|
||||
|
||||
/* .sub-neu/.sub-stufe: global in assets/markdown.css — scoped greift nicht auf v-html-Inhalt. */
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
import { fetchSource } from '../api.js'
|
||||
import SourceForm from './SourceForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topics: { type: Array, required: true },
|
||||
@@ -179,10 +180,15 @@ async function toggleTopicPanel(t) {
|
||||
}
|
||||
}
|
||||
|
||||
function setEditType(t) {
|
||||
editForm.value.type = t
|
||||
editForm.value.ort = ''
|
||||
}
|
||||
// v-model-Brücken für SourceForm ({type, ort}) auf die beiden State-Objekte
|
||||
const createSource = computed({
|
||||
get: () => ({ type: form.value.sourceType, ort: form.value.sourceOrt }),
|
||||
set: (v) => { form.value.sourceType = v.type; form.value.sourceOrt = v.ort },
|
||||
})
|
||||
const editSource = computed({
|
||||
get: () => ({ type: editForm.value.type, ort: editForm.value.ort }),
|
||||
set: (v) => { editForm.value.type = v.type; editForm.value.ort = v.ort },
|
||||
})
|
||||
|
||||
function saveSource() {
|
||||
if (!canSave.value || !editTopic.value) return
|
||||
@@ -241,29 +247,12 @@ function saveSource() {
|
||||
|
||||
<!-- Create: inline expandable (no modal) -->
|
||||
<div v-if="dlg" class="thema-panel">
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Topic name…" @keyup.enter="createTopic" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Topic</button>
|
||||
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
|
||||
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Project</button>
|
||||
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="form.sourceType === 'link'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
placeholder="https://…" @keyup.enter="createTopic"
|
||||
/>
|
||||
<select
|
||||
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Themenname…" @keyup.enter="createTopic" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
|
||||
<SourceForm v-model="createSource" :folders="folders" @submit="createTopic" />
|
||||
<div class="dlg-actions">
|
||||
<button class="dlg-cancel" @click="dlg = false">Cancel</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button>
|
||||
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-toggle" v-if="providers.length">
|
||||
@@ -279,7 +268,7 @@ function saveSource() {
|
||||
<div class="format-section" v-if="selectedTopic">
|
||||
<div class="format-error ui-error" v-if="uiError">
|
||||
<span class="format-error-text">{{ uiError }}</span>
|
||||
<button class="format-error-x" title="Hide" @click="emit('dismissUiError')">×</button>
|
||||
<button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button>
|
||||
</div>
|
||||
<div class="progress-info" v-if="activeGenerations.length">
|
||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||
@@ -329,7 +318,7 @@ function saveSource() {
|
||||
>{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
|
||||
<div v-if="errorMsg(f.key)" class="format-error">
|
||||
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
|
||||
<button class="format-error-x" title="Hide" @click="dismissError(f.key)">×</button>
|
||||
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="format-row ord-exam">
|
||||
@@ -352,37 +341,20 @@ function saveSource() {
|
||||
>
|
||||
<div class="topic-row">
|
||||
<span class="topic-name" @click="emit('select', t)">{{ t }}</span>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Options" @click.stop="toggleTopicPanel(t)">▾</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Optionen" @click.stop="toggleTopicPanel(t)">▾</button>
|
||||
</div>
|
||||
<div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop>
|
||||
<p v-if="editLoading" class="dlg-hint">Loading…</p>
|
||||
<p v-if="editLoading" class="dlg-hint">Lädt…</p>
|
||||
<template v-else>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Topic</button>
|
||||
<button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</button>
|
||||
<button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Project</button>
|
||||
<button :class="{ active: editForm.type === 'uni' }" @click="setEditType('uni')">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="editForm.type === 'link'"
|
||||
class="dlg-input" v-model="editForm.ort"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
<select
|
||||
v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'"
|
||||
class="dlg-input" v-model="editForm.ort"
|
||||
>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
|
||||
<SourceForm v-model="editSource" :folders="folders" @submit="saveSource" />
|
||||
<div class="dlg-actions">
|
||||
<button
|
||||
class="dlg-delete"
|
||||
:class="{ armed: pendingConfirm === 'topic-' + t }"
|
||||
@click="confirmDeleteTopic(t)"
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sure?' : 'Delete' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button>
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Aktualisieren</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -626,21 +598,6 @@ function saveSource() {
|
||||
}
|
||||
|
||||
|
||||
/* Coarse phases as numbered pills (1–5) — display + clickable for re-run from here. */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.format-section {
|
||||
flex-shrink: 0;
|
||||
@@ -658,9 +615,8 @@ function saveSource() {
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: gen-side-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gen-side-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.ord-blocks {
|
||||
order: 2;
|
||||
@@ -771,28 +727,6 @@ function saveSource() {
|
||||
.panel-btn.danger:hover { border-color: var(--danger); }
|
||||
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
|
||||
.format-x {
|
||||
display: none;
|
||||
color: var(--danger);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.format-name:hover .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Running/paused: always show × — there is no hover on touch */
|
||||
.fmt-generating .format-x,
|
||||
.fmt-queued .format-x,
|
||||
.fmt-paused .format-x,
|
||||
.blocks-row.is-active .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.format-x.armed,
|
||||
.format-error-x.armed,
|
||||
.delete-topic.armed {
|
||||
display: inline-block;
|
||||
@@ -855,45 +789,6 @@ function saveSource() {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.format-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn.play {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.action-btn.play:hover {
|
||||
background: var(--success-soft);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn:disabled:hover {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
@@ -938,18 +833,6 @@ function saveSource() {
|
||||
}
|
||||
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.dlg-textarea { resize: vertical; min-height: 2rem; }
|
||||
.dlg-sources { display: flex; gap: 0.3rem; }
|
||||
.dlg-sources button {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); }
|
||||
.dlg-actions { display: flex; justify-content: flex-end; gap: 0.4rem; margin-top: 0.1rem; }
|
||||
.dlg-actions button {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Anzeige-Formatierer, geteilt über Board-Ansichten.
|
||||
|
||||
// Sekunden → „m:ss" / „h:mm:ss" (Laufzeit eines Laufs oder Agenten).
|
||||
export function fmtRuntime(s) {
|
||||
s = Math.max(0, Math.round(s || 0))
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
if (h) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Token-Zahl kompakt (48.234 → „48,2k", 1.2M → „1,2M").
|
||||
export function fmtTokens(n) {
|
||||
n = n || 0
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace('.', ',')}M`
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace('.', ',')}k`
|
||||
return String(n)
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export function malusRegel(score, cap) {
|
||||
return '−20'
|
||||
}
|
||||
|
||||
// Reached badge level (LEVELS key) → view level 1..4, so unlocked SUBS match the badge:
|
||||
// Advanced-Badge → advanced subs visible. Same threshold as stufeFuer, no drift.
|
||||
export const BADGE_VIEW_RANK = { beginner: 1, advanced: 2, expert: 3, master: 4 }
|
||||
|
||||
// Sub-level tag (from the guide markers) → view level 1..4 (A/F/E/V).
|
||||
export const SUB_RANK = { beginner: 1, advanced: 2, expert: 3, peripheral: 4, einfach: 1, mittel: 2, schwer: 3 }
|
||||
export const VIEW_KURZ = { 1: 'A', 2: 'F', 3: 'E', 4: 'V' }
|
||||
@@ -35,11 +39,3 @@ export const VIEW_FARBE = {
|
||||
1: 'var(--level-beginner)', 2: 'var(--level-advanced)',
|
||||
3: 'var(--level-expert)', 4: 'var(--level-master)',
|
||||
}
|
||||
|
||||
// Auto view level per block: reached learning level + 1 (nothing reached → A).
|
||||
// beginner → F unlocked, advanced → E, expert/master → V.
|
||||
export function viewLevelFuer(score, cap) {
|
||||
const s = stufeFuer(score, cap)
|
||||
if (!s) return 1
|
||||
return Math.min(4, LEVELS.findIndex((l) => l.key === s.key) + 2)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/markdown.css'
|
||||
import './assets/shared.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -27,5 +27,8 @@ HARD RULES:
|
||||
- Address the learner directly, in clear German, no fluff.
|
||||
- `$…$` ONLY for real mathematics. Code, paths, namespaces, file names, JSON and identifiers ALWAYS in backticks (`` `…` ``) — NEVER as bare text, NEVER in `$…$` (not `$Acme\Example$`, but `` `Acme\Example` ``).
|
||||
|
||||
CHECKER'S NOTES ON THE LAST VERSION:
|
||||
{kritik_block}
|
||||
|
||||
Output ONLY this JSON (no other text):
|
||||
{{"question": "exactly one short question"}}
|
||||
@@ -1,5 +1,7 @@
|
||||
The FINAL block inventory for the topic "{topic}" was assembled from several sources. Despite earlier filtering it can still carry duplicates: the same concept listed under two names. For EACH pair below, decide: do A and B denote the SAME block → **ja**, or TWO DIFFERENT blocks → **nein**?
|
||||
|
||||
Decide on the underlying CONCEPT — from title and description — not on wording alone. Merge only when A and B are EQUIVALENT (the same statement, mutually interchangeable); a special case, a restriction, or one direction of a relation is NOT equivalent → **nein**.
|
||||
|
||||
THE MOST COMMON ERROR is merging a named VARIANT with its base entity. A variant is NEVER its base: "3-X" ≠ "X", "Max-X" ≠ "Max-3-X", "Modified X" ≠ "X", "k-X" ≠ "X". A digit or qualifier prefix that restricts the entity makes it a DIFFERENT entity → **nein**.
|
||||
|
||||
PAIRS:
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
Topic "{topic}". Umbrella blocks were formed, each bundling the constituent parts of ONE model/definition. Some parts were missed and are still listed as standalone blocks. Your job: for each umbrella, find which of the remaining standalone blocks are ALSO constituent parts of that same parent, so the model is complete.
|
||||
Topic "{topic}". Theme blocks were formed by card-sorting a fine-grained item list. Some items were left unassigned. Your job: attach EACH remaining item to the theme it belongs to, so every item lands in a theme. This is organisation, not judgement — items are never discarded.
|
||||
|
||||
UMBRELLAS (parent — already-collected parts):
|
||||
THEMES (heading — already-assigned members):
|
||||
{umbrellas}
|
||||
|
||||
REMAINING STANDALONE BLOCKS (numbered):
|
||||
REMAINING ITEMS (numbered):
|
||||
{rest}
|
||||
|
||||
## Rule — attach a block to an umbrella only if BOTH hold
|
||||
1. **Presupposition:** the block's definition **requires the umbrella's parent to already exist** — it makes no sense as a topic on its own without that model (e.g. „Alphabet Σ", „Übergangsfunktion δ", „Konfiguration", „Akzeptierende Berechnung" all presuppose the Turing-machine; „Literale", „Klausel", „Belegung" presuppose the KNF/logic definition). The parent must NOT presuppose the block (directional).
|
||||
2. **Not standalone:** the block is a *definitional component / notation*, NOT itself a named **algorithm, problem, theorem, reduction, or complexity class** (those stay their own block — a downstream guard will reject them anyway).
|
||||
|
||||
Do NOT attach a block merely because it shares a topic. When unsure → leave it standalone. Most remaining blocks will NOT be attached; a few genuine missed parts will.
|
||||
## Rule
|
||||
- Attach each remaining item to the ONE theme whose subject it shares — the theme a learner would meet it under (a variable/flag/field/instance/facet of that theme's model, system, setup, tool family, or category).
|
||||
- Assign as many as you reasonably can. Only leave an item unassigned if it fits NO theme at all — it then becomes its own single-item block (a genuinely standalone concept).
|
||||
- Each item goes into at most ONE theme.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (`additions` may be empty; `umbrella` = the UMBRELLA index, `members` = standalone block numbers to attach):
|
||||
Format (`additions` may be empty; `umbrella` = the THEME index, `members` = item numbers to attach):
|
||||
{{"additions": [{{"umbrella": 0, "members": [8, 12, 34]}}]}}
|
||||
@@ -1,34 +1,25 @@
|
||||
Topic "{topic}". A previous step produced a flat list of learning blocks that is TOO FINE-GRAINED — several blocks are **constituent sub-definitions / notation of ONE larger definition or model** and should become a single umbrella block. Find these groups. A good run finds several genuine umbrellas AND leaves most blocks standalone; judge each candidate on its merits.
|
||||
Topic "{topic}". A previous step produced a flat list of learning items that is TOO FINE-GRAINED for a table of contents — single environment variables, single CLI flags, single config fields, near-synonyms. Your job is **card sorting**: group the items into a small set of **theme blocks**, so that EVERY item lands in exactly one theme. Nothing is discarded — grouping never loses an item; it only organises them.
|
||||
|
||||
**Propose generously.** A deterministic guard downstream rejects any umbrella that swallows a named algorithm/problem/theorem, so a wrong-but-plausible merge is cheap — a missed umbrella is not. Do NOT withhold a merge merely because the members are lexically dissimilar (facets of one model routinely are) or because you are unsure of the parent's exact name.
|
||||
Think of the result as chapters: ~{theme_lo}–{theme_hi} themes for a whole topic of this size ({n_items} items), each a coherent learning block that a learner meets as one unit. A theme with only one fitting item is fine (a genuinely standalone concept), but prefer pulling related items together over leaving many singletons.
|
||||
|
||||
CANDIDATES (your starting point):
|
||||
CANDIDATES you may group (your starting point — a pre-clustered neighbourhood):
|
||||
{candidates}
|
||||
|
||||
FULL BLOCK LIST (you may pull in ANY numbers below that are constituents of the same definition):
|
||||
FULL ITEM LIST (you may pull in ANY numbers below that share the theme):
|
||||
{list}
|
||||
|
||||
## Merge test — propose an umbrella when ALL THREE hold
|
||||
1. **One parent.** The members are constituent parts/facets of ONE named parent model or definition — each member PRESUPPOSES that parent (you cannot introduce the member without first invoking the parent). TM-model parts (Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung) presuppose „Turingmaschine"; KNF parts (Literale, Klauseln, Boolesche Variablen) presuppose „Konjunktive Normalform".
|
||||
2. **Studied together.** A learner meets them together as one unit.
|
||||
3. **No standalone unit among them.** No member is itself a named **algorithm, problem, theorem, reduction/transformation, or complexity class** („List Scheduling", „3-SAT", „Cook/Levin", „3-SAT ≤ Clique", „NP", „NP-Vollständigkeit", „Polynomielle Transformation"). Each of those is its own concept, so a group containing one is a set of SIBLINGS, not the decomposition of one model — keep them separate.
|
||||
## How to form a theme
|
||||
- **One coherent subject.** The members belong together because a learner would study them as one block — the parts/facets/instances of one model, system, setup, tool family, or category. Examples of the *kind* of grouping (not domain rules): many individual settings/variables/flags of one configuration → one „…-Konfiguration" theme; the parts of one setup → one „…-Setup" theme; a base concept plus its variants/instances → one theme.
|
||||
- **Most specific home wins.** An item tied to ONE concrete object (an algorithm/theorem/counterexample FOR object X, a setting OF component X) belongs in X's theme — not in a technique/property family that also fits. A learner meets „the algorithm for X" while studying X, not in a generic „algorithms" chapter.
|
||||
- **The title must be true of EVERY member.** Never assert a property or class in the theme title (a complexity claim, „deprecated …", „…-vollständige …") unless every single member has that property — otherwise pick a neutral title for the shared subject.
|
||||
- **Lexical dissimilarity is NORMAL.** Facets of one theme often read very differently — that is not a reason to split them.
|
||||
- **Title:** name the theme after the shared subject. A real self-contained heading; must NOT contain „ — " (reserved separator) — use „(…)" or „:".
|
||||
- **Description:** name EVERY member explicitly (the next step recovers them as sub-points). E.g. „Environment-Konfiguration: APP_ENV, APP_SECRET, DATABASE_URL, MAILER_DSN, …".
|
||||
- **Members:** the item NUMBERS from the full list. Each number goes into exactly ONE theme; do not repeat a number across themes.
|
||||
|
||||
*Note on test 1: „can this be defined at all?" is the WRONG question — Alphabet Σ and DTM CAN be stated in isolation, yet in THIS topic they are parts of the Turing-machine model and belong together. The question is whether the member PRESUPPOSES the shared parent, not whether a standalone sentence exists.*
|
||||
|
||||
## Examples
|
||||
DO NOT MERGE — distinct named units that merely share a topic:
|
||||
- „Greedy-Algorithmus GA" + „ModifiedGreedy" + „Multiple-Choice-Knapsack" → two algorithms + a problem, each standalone (test 3 fails). Keep separate.
|
||||
|
||||
MERGE — one definition decomposed (the canonical cases — end here so this is your default lens):
|
||||
- „Alphabet Σ" + „NDTM" + „DTM" + „Akzeptierende Berechnung" + „Folgekonfiguration" → ONE umbrella **„Turingmaschine (Modell)"**. (The members are lexically very different from each other — that is EXPECTED for facets of one model and is NOT a reason to keep them apart.)
|
||||
- „Klausel" + „Boolesche Variable" + „Erfüllende Belegung" + „KNF" → ONE umbrella **„Aussagenlogik & KNF"**.
|
||||
|
||||
## Synthesize each umbrella
|
||||
- `title`: the parent concept's name (e.g. „Turingmaschine (Modell)"). A real self-contained definition; must NOT contain „ — " (a reserved separator) — use „(…)" or „:".
|
||||
- `description`: **name EVERY merged child explicitly** — the next step recovers the children as sub-points from the source. E.g. „Formales TM-Modell: Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung, Folgekonfiguration."
|
||||
- `members`: the block NUMBERS (from the full list) folded in. At least 2 per umbrella; each number appears in at most one umbrella.
|
||||
Assign as many items as you confidently can. Items you are unsure about you may leave out — a later completion step and a singleton fallback catch them; never force a bad fit.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (`umbrellas` may be empty):
|
||||
{{"umbrellas": [{{"title": "Turingmaschine (Modell)", "description": "Formales TM-Modell: Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung, Folgekonfiguration.", "members": [1, 2, 5, 12, 34]}}]}}
|
||||
Format (`umbrellas` = the themes; may be empty if nothing fits together):
|
||||
{{"umbrellas": [{{"title": "Environment-Konfiguration", "description": "Konfiguration über Umgebungsvariablen: APP_ENV, APP_SECRET, DATABASE_URL, MAILER_DSN, .env-Dateien.", "members": [1, 2, 5, 12, 34]}}]}}
|
||||
@@ -14,7 +14,7 @@ What is NOT a block (→ discard, EVEN if a description is attached):
|
||||
- Pure references WITHOUT substantive description: "Satz 7.18" (nothing meaningful after the —).
|
||||
- Too vague or evidently fabricated.
|
||||
|
||||
**Reference title WITH real content → include AND rename:** If an entry has an uninformative title ("Satz 7.18", "Korollar 6.18", "Lemma 6.2") but the description describes a real concept → include it and give a speaking name (in GERMAN) drawn from the content in the `rename` field. In the `keep` entry the **original title** stays (for matching); the new name goes ONLY in `rename`.
|
||||
**Reference title WITH real content → include AND rename:** If an entry has an uninformative title ("Satz 7.18", "Korollar 6.18", "Lemma 6.2") but the description describes a real concept → include it and give a speaking name (in GERMAN) drawn from the content in the `rename` field. In the `keep` entry the **original title** stays (for matching); the new name goes ONLY in `rename`. A source numbering is never a canonical title — even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium …"), rename to the concept/result ("Satz von Christofides", "Kriterium für Eulerschen Kreis"); the numbering may remain in the description. A named theorem that states its own result stays a block — named after the RESULT, not the number.
|
||||
|
||||
Examples:
|
||||
- "Clique-Member — k-Clique, die einen festen Knoten v enthält" → **keep** (standalone problem).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
Topic "{topic}". A quality audit found GAPS: source-material passages and named results that NO existing inventory block covers. Your job: propose new learning blocks ONLY for these gaps — nothing else. An empty list is a valid answer (when the gaps are filler or already covered under another name).
|
||||
|
||||
EXISTING BLOCKS (do NOT re-propose these or near-synonyms of them):
|
||||
{blocks}
|
||||
|
||||
UNCOVERED MATERIAL (gap passages and named results, with source excerpts):
|
||||
{gaps}
|
||||
|
||||
Rules:
|
||||
- One block per genuinely missing, teachable concept — self-contained, exam-relevant.
|
||||
- Ground every proposal in the material above; never invent canon knowledge the material does not treat.
|
||||
- Title: concrete concept name (max 8 words, no catalog references like "(Satz 6.33)").
|
||||
- Description: ONE sentence naming what the block teaches, grounded in the material.
|
||||
- Skip organizational text, prefaces, exercise scaffolding, duplicates of existing blocks.
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (`blocks` may be empty):
|
||||
{{"blocks": [{{"title": "…", "description": "…"}}]}}
|
||||
{extra}
|
||||
@@ -10,6 +10,7 @@ Check whether the current title is the best canonical name for this block.
|
||||
Rules:
|
||||
- Current title good (concrete, precise, self-explanatory, no reference/placeholder like "Satz 7.18") → confirm it.
|
||||
- Otherwise pick the best member ("best" REQUIRED) and optionally propose a SHORTER name: max 8 words, ONLY terms that appear in the members, exactly as concrete as the shared content — never a broader umbrella term, never a textbook concept the members don't mention. NO catalog/reference brackets ("(Satz 6.33)"); a known acronym in brackets ("(VC)") is fine.
|
||||
- A source numbering is never a canonical title, even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium …") → treat such a current title as NOT good and rename to the concept/result; the numbering belongs in the description.
|
||||
- When in doubt, confirm the current title.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text.
|
||||
|
||||
@@ -7,6 +7,7 @@ Rules:
|
||||
- "best" is REQUIRED: the member whose title fits the shared concept best (most concrete, precise, self-explanatory, established term; avoid reference/placeholder titles like "Satz 7.18", "Punkt 3", "(**)").
|
||||
- "name" is OPTIONAL — set it ONLY when no member title states the shared core well. It must be SHORT (max 8 words), use ONLY terms that appear in the members themselves, and stay exactly as concrete as the shared content — never a broader umbrella term, never a textbook concept the members don't mention.
|
||||
- In "name": NO catalog/reference brackets ("(Satz 6.33)", "(Kap. 4)"); a known acronym in brackets ("(VC)") is fine.
|
||||
- The title is the CONCEPT name — a source numbering is never canonical. Even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium für Eulerschen Kreis"), pick or propose the concept/result ("Satz von Christofides", "Kriterium für Eulerschen Kreis"); the numbering belongs in the description, never in the title.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ PAIRS:
|
||||
- a distinct **variant** is its own entity: „GA" ≠ „ModifiedGreedy (MGA)"; „SAT" ≠ „3-SAT"; „Knapsack" ≠ „Multiple-Choice-Knapsack";
|
||||
- a **reduction between two problems** is its own entity: „Clique" ≠ „3-SAT ≤ Clique";
|
||||
- two reductions/relations that share ONE side but differ on the OTHER (or run in the opposite direction) are DIFFERENT results → **nein**: „SAT ≤ Clique" ≠ „SAT ≤ 3-Dim-Matching", „VertexCover ≤ FVS" ≠ „VertexCover ≤ Δ-Cover". A restriction/special case („3-SAT ≤ X") is NARROWER than the general („SAT ≤ X"), never the same.
|
||||
- a **statement that RELATES several standalone objects** (an implication, a shared bound, a joint consequence — „Hypothese H ⇒ keine schnelle Lösung für X und Y") is its own entity, like a reduction. It is NEVER a duplicate of ONE of the objects it mentions — merging „X" into such a statement silently deletes X's own definition/properties from the inventory.
|
||||
- **SAME canonical entity → ja**, even when A and B emphasize DIFFERENT FACETS of it. Facets of one and the same object include: its **formal definition**, a **mechanism/step** (how it works), a **property** (approximation ratio, a bound, complexity, ∈ NP), a **characterization**, a **naming variant**. Two entries describing different facets of the SAME entity are duplicates.
|
||||
|
||||
**STEP 3 — „When in doubt → nein" applies ONLY when STEP 1 is ambiguous** (you genuinely cannot tell whether the two names denote the same object). It does NOT fire merely because the two descriptions differ — differing descriptions of the SAME entity are **ja**.
|
||||
@@ -27,6 +28,7 @@ NOT A DUPLICATE (nein) — different entity:
|
||||
- A: „Greedy-Algorithmus GA" B: „ModifiedGreedy (MGA)" → two different algorithms → **nein**
|
||||
- A: „Lower Bound Clique bzgl. Knoten" B: „Lower Bound Clique bzgl. Kanten" → different parameter → **nein**
|
||||
- A: „Clique" B: „3-SAT ≤ Clique" → a problem vs. a reduction (its own block) → **nein**
|
||||
- A: „Partition" B: „Satz: ETH ⇒ kein 2^o(n) für Partition, SubSet Sum" → a problem vs. a joint bound over TWO problems → **nein**
|
||||
- A: „VertexCover ≤ FVS" B: „VertexCover ≤ Δ-Cover" → same source, different target → different reductions → **nein**
|
||||
- A: „Cliquenproblem" B: „Vertex-Cover-Problem" → different problems → **nein**
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ Rules:
|
||||
- Granularity: a block is a LEARNING UNIT, not a dictionary entry. Families learned together (e.g. the font-* properties, the modal verbs) are ONE block.
|
||||
- Abstraction level (important): a block is self-contained — explainable WITHOUT presupposing ANOTHER block as its subject. These are problems, methods/algorithms, definitions, named theorems with their own statement. A detail that presupposes another concept belongs IN that concept's block (as a part), not as its own entry:
|
||||
- A PROPERTY of a concept belongs to the concept: „Clique" is a block; „Clique ist NP-vollständig", „Clique ∈ NP", „Laufzeit von Clique" are parts of it — not their own entry.
|
||||
- A PROOF/reduction component belongs to the theorem/reduction: „A-Komponente", „Dummy-Item", „αEnde", „Hilfsvariable" — not its own entry.
|
||||
- A PROOF/reduction component or a constructed HELPER OBJECT belongs to the theorem/reduction it serves: „A-Komponente", „Dummy-Item", „αEnde", „Hilfsvariable", helper functions/weightings, helper schedules, constructed graphs/tours/gadgets — not its own entry.
|
||||
- A single COMPUTED instance or result value belongs to the argument that produced it: a specific number, graph, tour, or schedule built inside one example/proof („OPT(…) = n", a concrete constructed input) is not its own entry.
|
||||
- PARAMETER INSTANCES of the SAME argument are ONE block: the identical technique applied to different parameters or cases („… via k / via n / via |E|", „für n=1 / n=2") is a single block, not one entry per parameter.
|
||||
- Pure NOTATION/symbols belong to their definition: „|x|", „Σ∗" — not their own entry.
|
||||
- A standalone REDUCTION between two problems, however, is its own block („3-SAT ≤ Clique").
|
||||
- A standalone REDUCTION between two problems, however, is its own block („3-SAT ≤ Clique"); likewise a named theorem that states its OWN result stays a block — named after the result, not the source numbering.
|
||||
- NO categories, NO ranking, NO ordering by importance — only a flat, numbered list.
|
||||
- Aim for the natural number of genuine learning units for this material — there is no hard quota, but do NOT split hairs to inflate the count (prefer the "families learned together = ONE block" rule above). STOP when the only remaining candidates are exercises, meta-questions, administrative notes, or duplicates of blocks you already listed.
|
||||
- **EXCLUDE non-content — these are NEVER learning blocks:** exercise/task/assignment scaffolding ("Aufgabe 3", "Bonusaufgabe", "Übung", "Blatt 11", point values), meta/assessment items ("welche der folgenden…", true/false prompts, hand-in / exam-date notes), administrative & boilerplate (headings, page/room/exam numbers, names, copyright), and any **file names, paths, or URLs** (those are provenance, recorded separately — never a block or part of one).
|
||||
|
||||
@@ -8,7 +8,7 @@ MATERIAL EXCERPTS:
|
||||
{excerpts}
|
||||
|
||||
Tasks:
|
||||
- "title": If the title's terms do not literally appear in the excerpts, rewrite it using ONLY surface forms (exact spellings, even unusual ones) that appear in the excerpts — same meaning, same concreteness, max 8 words. If the title already matches the material wording, repeat it unchanged. Never broaden to a textbook term the excerpts don't use; NO catalog/reference brackets ("(Satz 6.33)", "(Kap. 4)").
|
||||
- "title": If the title's terms do not literally appear in the excerpts, rewrite it using ONLY surface forms (exact spellings, even unusual ones) that appear in the excerpts — same meaning, same concreteness, max 8 words. If the title already matches the material wording, repeat it unchanged. Never broaden to a textbook term the excerpts don't use; NO catalog/reference brackets ("(Satz 6.33)", "(Kap. 4)"). A source numbering ("Satz 7.13", "Definition 6.19") is NOT a valid title even when it appears literally in the excerpts — use the concept/result name from the excerpts; the numbering may go into the description.
|
||||
- "description": If the description is "(leer)", write ONE precise sentence grounded in the excerpts — no invented facts. Otherwise repeat it unchanged.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text.
|
||||
|
||||
@@ -24,7 +24,7 @@ HOW TO WRITE the detailed version — ONE coherent text for a junior who is lear
|
||||
- Resolve EVERY technical term at first mention in half a sentence. Use "Prerequisite" hints as anchors, "Hurdle" hints to clear misconceptions up front.
|
||||
- Order of the subblocks as in the assignment: first `[beginner]`, then `[advanced]`, then `[expert]`, last `[peripheral]`.
|
||||
- The NUMBER of subblocks is the depth signal: many subblocks → the block deserves length; few → keep it short. Never pad.
|
||||
- LENGTH TARGET for the detailed version: about {budget} characters total (guideline, not a hard limit — covering ALL learning objectives always beats brevity). Unguided sections came out 2–4× too long and had to be rewritten.
|
||||
- LENGTH CAP for the detailed version: {budget} characters total — a HARD ceiling, not a target. Cover EVERY learning objective, but write DENSELY: fewer words, not more; prefer cutting a sentence to adding one. If you approach the cap, tighten — do not spill over. Overshooting gets rewritten.
|
||||
- CONCISE: every sentence carries new information. No repetition, no filler, no preamble. When in doubt, leave it out.
|
||||
- Show "how" procedures step by step. An example ONLY where it genuinely carries the understanding.
|
||||
- If VERIFIED WORKED EXAMPLES are provided above, weave each into its subblock as instructed there; do NOT invent additional worked examples for subblocks that already have one.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
You are reviewing crawled pages for the learning topic "{topic}". Decide per page: **ja** (belongs to the topic) or **nein** (off-topic). The selection controls which pages get read at all — off-topic pages bloat the inventory with foreign material.
|
||||
|
||||
LEARNING GOAL / SPEC (this is the yardstick):
|
||||
{spec}
|
||||
|
||||
PAGES (number, then the URL, with a content excerpt below):
|
||||
{pages}
|
||||
|
||||
How you decide:
|
||||
- **ja**: The page covers exactly the subject area of the spec. Core, standard, or in-depth material on it. Even edge topics of the RIGHT field are `ja`.
|
||||
- **nein**: A different subject area than the spec. Example: the spec is backend development, the page covers frontend, storefront, theming, payment operation, or an entirely different role/product area. Pure marketing, community, event, or account pages without learning material are also `nein`.
|
||||
|
||||
Important:
|
||||
- Judge on the URL **and** content excerpt together. The URL slug is often the most telling.
|
||||
- **Ignore the navigation menu / boilerplate** in the excerpt (login, „Sign up", menu items, footer) — it appears on every page and says nothing about the topic.
|
||||
- **When in doubt, `ja`** — better to read one page too many than to exclude something important. Say `nein` only when the page clearly belongs to a different subject area.
|
||||
- Evaluate each page individually. Invent nothing.
|
||||
{extra}
|
||||
|
||||
Write ONLY the JSON file to: {out_path}
|
||||
|
||||
Format (exactly one value for EACH number; no other text):
|
||||
{{"relevant": {{"1": "ja", "2": "nein", "3": "ja"}}}}
|
||||
@@ -0,0 +1,32 @@
|
||||
The block below (topic "{topic}") is ALREADY decomposed into its subblocks — the list is FIXED. Your job is NOT to find subblocks, but to extract for EACH given subblock its 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}
|
||||
|
||||
FIXED SUBBLOCKS (numbered — return one entry per subblock, in this order):
|
||||
{subs}
|
||||
|
||||
{source}
|
||||
|
||||
HARD RULES:
|
||||
- Return EXACTLY these subblocks — do NOT add, remove, split, merge or rename any. Copy each `title` VERBATIM from the list above (code identifiers stay original).
|
||||
- One entry per given subblock, same order. If the material barely covers a subblock, still return it with whatever is backable (empty fields are fine) — never drop it.
|
||||
|
||||
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 a field empty than to claim falsely.
|
||||
|
||||
Reply with ONLY the JSON as your final message — no code fences, no other text. EXACTLY this format:
|
||||
{{"subs": [
|
||||
{{"title": "…", "level": "beginner|advanced|expert", "relevance": "relevant|peripheral",
|
||||
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
|
||||
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
|
||||
]}}
|
||||
{extra}
|
||||
@@ -1,13 +0,0 @@
|
||||
Das Lernbaustein-Inventar zum Thema "{topic}" ist in Blöcke mit je eigenen Subbausteinen zerlegt. Manche Aussage taucht in ZWEI Blöcken auf — im Lernguide steht sie dann doppelt. Für jedes Paar unten: Treffen A und B DIESELBE Aussage?
|
||||
|
||||
PAARE (jeweils mit Block-Zugehörigkeit und Kernpunkten):
|
||||
{pairs}
|
||||
|
||||
## Entscheidung pro Paar
|
||||
- **DIESELBE Aussage** (auch anders formuliert, oder eine ist Teilmenge der anderen): Welcher Block ist die natürliche Heimat der Aussage? → antworte **"a"** (A behält sie, B verliert sie) oder **"b"** (B behält sie, A verliert sie). Heimat ist der Block, in dessen Kernthema die Aussage gehört — nicht der, der sie nur am Rand streift.
|
||||
- **VERSCHIEDENE Aussagen** → antworte **"nein"**. Dazu zählt: gleiche Regel, aber auf VERSCHIEDENE Kontexte angewendet (die Anwendung im jeweiligen Block-Kontext ist eigener Lernstoff); Grundregel vs. Sonderfall; gegensätzliche Aussagen.
|
||||
- Im Zweifel: **"nein"**.
|
||||
|
||||
Antworte NUR mit JSON, ohne weiteren Text (jede Paar-Nummer mit "a", "b" oder "nein"):
|
||||
{{"pairs": {{"1": "a", "2": "nein"}}}}
|
||||
{extra}
|
||||
@@ -1,26 +1,18 @@
|
||||
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.
|
||||
You are auditing ONE block of the topic "{topic}". Its subblocks are FIXED (already decomposed and deduplicated upstream) — you do NOT add, remove, merge or split them. You only verify the captured FACTS against the source and correct the level/relevance classification.
|
||||
|
||||
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 from the excerpts only, using the numbers:
|
||||
1. **facts_probleme** — entries whose captured facts contain something wrong or unbackable: give a short `hinweis` what to correct (keep `discard: false` — the subblock itself always stays; only its facts get fixed).
|
||||
2. **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.
|
||||
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": "…"}}],
|
||||
{{"facts_probleme": [{{"nr": 3, "discard": false, "hinweis": "…"}}],
|
||||
"levels": {{"2": "expert"}}, "relevanz": {{"5": "peripheral"}}}}
|
||||
{extra}
|
||||
@@ -0,0 +1,15 @@
|
||||
You are an INDEPENDENT quality auditor for a learning-block inventory on the topic "{topic}". Below are NAMED results/concepts a scan found in the source material (named theorems, algorithms, definitions) that seem to map to NO inventory block. For each: is this a real, exam-relevant concept the inventory is missing → **ja**, or is it covered by an existing block under another name / a mangled-extraction artefact / mere organizational labeling → **nein**?
|
||||
|
||||
NAMED RESULTS WITHOUT A COVERING BLOCK:
|
||||
{results}
|
||||
|
||||
Rules:
|
||||
- Covered by an existing block under a synonym, symbol variant or broader umbrella → nein.
|
||||
- Garbled extraction artefacts (split or shredded words) and pure numbering references → nein.
|
||||
- A genuine named result the material teaches and no block captures → ja.
|
||||
- When genuinely unsure → ja (a missed gap is worse than one extra research round).
|
||||
|
||||
Reply with ONLY the JSON — no other text, no code fences.
|
||||
Format (one verdict per number):
|
||||
{{"relevant": {{"1": "ja", "2": "nein"}}}}
|
||||
{extra}
|
||||
@@ -0,0 +1 @@
|
||||
2
|
||||
+82
-82
@@ -6,9 +6,9 @@ man schnell den Mut verliert, eine exakte Lösung in Polynomialzeit zu berechnen
|
||||
Probleme scheinen schwerer zu sein als andere. Was ist die Schwere eines Problems? Die
|
||||
Komplexitätstheorie befasst sich noch weitergehend mit der Frage nach der Komplexität
|
||||
von Problemen, dh. man versucht zu klassifizieren wie schwer Probleme sind.
|
||||
In diesem Kapitel werden wir die Komplexitätsklassen P und N P einführen und wol-
|
||||
len dann die Begriffe der N P -schwere und N P -vollständigkeit verstehen. Im Anschluss
|
||||
werden wir einige Beispiele N P -vollständiger Probleme studieren.
|
||||
In diesem Kapitel werden wir die Komplexitätsklassen P und NP einführen und wol-
|
||||
len dann die Begriffe der NP-schwere und NP-vollständigkeit verstehen. Im Anschluss
|
||||
werden wir einige Beispiele NP-vollständiger Probleme studieren.
|
||||
|
||||
|
||||
6.1 Einleitung: Cliquenproblem
|
||||
@@ -97,14 +97,14 @@ über dem Alphabet Σ = {0, 1}.
|
||||
Bemerkung 6.6. u ist ein Wort der Länge O(|V |2 ), v ist ein Wort der Länge O(log |V |).
|
||||
|
||||
|
||||
6.2 Komplexitätsklassen P, N P
|
||||
6.2 Komplexitätsklassen P, NP
|
||||
Definition 6.7. Die Menge aller polynomiell entscheidbaren Sprachen bzw. Entschei-
|
||||
dungsproblemen wird mit P bezeichnet:
|
||||
|
||||
P = {L ⊆ Σ∗ | es existiert ein Algorithmus A, der L in Laufzeit
|
||||
TA (n) = O(nd ) mit d = O(1) entscheidet}.
|
||||
|
||||
Neben P gibt es unter anderem auch die Klasse N P . Diese wird häufig über nicht-
|
||||
Neben P gibt es unter anderem auch die Klasse NP . Diese wird häufig über nicht-
|
||||
deterministische Algorithmen eingeführt (bei denen es zu jeder Eingabe eine endliche
|
||||
Menge von Rechenwegen gibt).
|
||||
|
||||
@@ -118,9 +118,9 @@ Definition 6.8. Ein nicht-deterministischer Algorithmus entscheidet eine Sprache
|
||||
|
||||
Die Anzahl der Rechenwege kann dabei exponentiell von der Eingabe abhängen.
|
||||
|
||||
Die Klasse N P ist nun genau die Menge aller Sprachen, die durch einen nicht-deterministischen
|
||||
Die Klasse NP ist nun genau die Menge aller Sprachen, die durch einen nicht-deterministischen
|
||||
Algorithmus in Polynomialzeit entschieden werden können. Wir bedienen uns hier aller-
|
||||
dings einer alternativen Definition der Klasse N P über polynomielle Verifizier. Die so
|
||||
dings einer alternativen Definition der Klasse NP über polynomielle Verifizier. Die so
|
||||
definierte Klassen von Problemen sind aber gleich.
|
||||
|
||||
Definition 6.9. Es sei L ⊆ Σ∗ . Ein (deterministischer) Algorithmus, der auf Σ∗ × Σ∗
|
||||
@@ -135,27 +135,27 @@ Definition 6.10. Ein Algorithmus A ist ein polynomieller Verifizierer für eine
|
||||
L, wenn eine Konstante d existiert, so dass es für jedes x ∈ L ein Zertifikat c gibt mit
|
||||
TA (x, c) = O(|x|d ).
|
||||
|
||||
Definition 6.11. Die Klasse aller polynomiell verifizierbaren Sprachen wird mit N P
|
||||
Definition 6.11. Die Klasse aller polynomiell verifizierbaren Sprachen wird mit NP
|
||||
bezeichnet; d.h.
|
||||
|
||||
N P = {L ⊆ Σ∗ | es existiert ein polynomieller Verifizierer für L}.
|
||||
NP = {L ⊆ Σ∗ | es existiert ein polynomieller Verifizierer für L}.
|
||||
|
||||
|
||||
|
||||
202
|
||||
6.2 Komplexitätsklassen P, N P
|
||||
6.2 Komplexitätsklassen P, NP
|
||||
|
||||
|
||||
Satz 6.12. Es gilt P ⊆ N P .
|
||||
Satz 6.12. Es gilt P ⊆ NP .
|
||||
|
||||
Beweis. Zu einem Entscheidungsproblem L aus P gibt es einen Algorithmus A, der
|
||||
L entscheidet. Diesen Algorithmus kann man als Verifizierer auffassen, der die zweite
|
||||
Eingabe c einfach ignoriert. Damit ist L auch in N P .
|
||||
Eingabe c einfach ignoriert. Damit ist L auch in NP .
|
||||
|
||||
Nun können wir auf das Cliquenproblem zurückkommen und die folgende Aussage
|
||||
beweisen.
|
||||
|
||||
Lemma 6.13. Das Entscheidungsproblem k-Clique ist in N P .
|
||||
Lemma 6.13. Das Entscheidungsproblem k-Clique ist in NP .
|
||||
|
||||
Beweis. Durch Angabe eines polynomiellen Verifizieres für k-Clique. Der Algorithmus
|
||||
bekommt neben der Eingabe (einen Graph G = (V, E) und eine Zahl k) ein Zertifikat
|
||||
@@ -171,7 +171,7 @@ bekommt neben der Eingabe (einen Graph G = (V, E) und eine Zahl k) ein Zertifika
|
||||
O(|C|) = O(|V |) Zeit. Da die Eingabelänge Ω(|V | + |E|) ist, ist die Laufzeit des Algo-
|
||||
rithmus polynomiell.
|
||||
|
||||
Nachdem wir nun gesehen haben, wie die Klassen P und N P definiert haben, können
|
||||
Nachdem wir nun gesehen haben, wie die Klassen P und NP definiert haben, können
|
||||
wir den Begriff der Schwere genauer beleuchten. Dafür ist es notwendig zu verstehen,
|
||||
wann ein Problem auf ein anderes Problem reduziert werden kann. Wir benötigen die
|
||||
folgende Definition.
|
||||
@@ -190,11 +190,11 @@ Definition 6.14. Es seien L1 ⊆ Σ∗1 , L2 ⊆ Σ∗2 zwei Sprachen.
|
||||
|
||||
Dann heißt L1 polynomiell reduzierbar auf L2 . Als Notation verwenden wir L1 ≤ L2 .
|
||||
|
||||
Endlich können wir den Begriff der N P -Schwere bzw. N P -Vollständigkeit formal fassen:
|
||||
Endlich können wir den Begriff der NP -Schwere bzw. NP -Vollständigkeit formal fassen:
|
||||
|
||||
Definition 6.15. (i) Eine Sprache L0 heißt N P -schwer, g.d.w. ∀L ∈ N P : L ≤ L0 .
|
||||
Definition 6.15. (i) Eine Sprache L0 heißt NP-schwer, g.d.w. ∀L ∈ NP : L ≤ L0 .
|
||||
|
||||
(ii) Eine Sprache L0 heißt N P -vollständig, g.d.w. L0 ∈ N P und ∀L ∈ N P gilt: L ≤ L0 .
|
||||
(ii) Eine Sprache L0 heißt NP-vollständig, g.d.w. L0 ∈ NP und ∀L ∈ NP gilt: L ≤ L0 .
|
||||
|
||||
|
||||
|
||||
@@ -202,17 +202,17 @@ Definition 6.15. (i) Eine Sprache L0 heißt N P -schwer, g.d.w. ∀L ∈ N P
|
||||
6 Einführung in die Komplexitätstheorie
|
||||
|
||||
|
||||
Eine fundamentale Eigenschaft der N P -vollständigkeit ist die folgende:
|
||||
Eine fundamentale Eigenschaft der NP-vollständigkeit ist die folgende:
|
||||
|
||||
Satz 6.16. Es sei L0 N P -vollständig. Dann gilt P = N P , g.d.w. L0 ∈ P .
|
||||
Satz 6.16. Es sei L0 NP-vollständig. Dann gilt P = NP , g.d.w. L0 ∈ P .
|
||||
|
||||
Beweis. ⇒: Es sei P = N P . Zeige dann, dass L0 ∈ P .
|
||||
L0 ist NP-vollständig. Dann gilt insbesondere L0 ∈ N P . Wegen P = N P gilt dann
|
||||
Beweis. ⇒: Es sei P = NP . Zeige dann, dass L0 ∈ P .
|
||||
L0 ist NP-vollständig. Dann gilt insbesondere L0 ∈ NP . Wegen P = NP gilt dann
|
||||
L0 ∈ P .
|
||||
⇐: Es sei L0 ∈ P . Zeige hier, dass P = N P .
|
||||
⇐: Es sei L0 ∈ P . Zeige hier, dass P = NP .
|
||||
Da L0 ∈ P ist, gibt es einen Algorithmus A0 , der L0 in Polynomzeit T0 entscheidet.
|
||||
Es sei nun L ∈ N P beliebig gewählt. Zeige: L ∈ P .
|
||||
Da L0 N P -vollständig ist und L ∈ N P , gilt L ≤ L0 . Sei also Af ein Algorithmus,
|
||||
Es sei nun L ∈ NP beliebig gewählt. Zeige: L ∈ P .
|
||||
Da L0 NP-vollständig ist und L ∈ NP , gilt L ≤ L0 . Sei also Af ein Algorithmus,
|
||||
der die polynomielle Transformation f von L auf L0 in Polynomzeit Tf berechnet mit
|
||||
u ∈ L ⇔ f (u) ∈ L0 .
|
||||
Wir konstruieren A aus A0 und Af durch Hintereinanderschalten; siehe folgendes Bild:
|
||||
@@ -229,12 +229,12 @@ zu f (u) ∈ L0 bzw. u ∈ L. D.h. Algorithmus A entscheidet L mit Gesamtlaufzei
|
||||
Tf (|u|)+T0 (|f (u)|) ≤ T (|u|) für ein Polynom T . Hierbei verwende, dass |f (u)| ≤ poly(|u|)
|
||||
und dass die Komposition von Polynomen wieder ein Polynom ergibt.
|
||||
|
||||
Konsequenz von Satz 6.16: Wenn ein N P -vollständiges Entscheidungsproblem in P
|
||||
liegt, dann sind alle N P Entscheidungsprobleme in P .
|
||||
Konsequenz von Satz 6.16: Wenn ein NP-vollständiges Entscheidungsproblem in P
|
||||
liegt, dann sind alle NP Entscheidungsprobleme in P .
|
||||
|
||||
Die gleiche Aussage gilt auch für ein N P -schweres Problem.
|
||||
Die gleiche Aussage gilt auch für ein NP-schweres Problem.
|
||||
Offenes Problem der Komplexitätstheorie:
|
||||
Gilt P = N P oder P 6= N P ?
|
||||
Gilt P = NP oder P 6= NP ?
|
||||
|
||||
Dies ist eines der wichtigsten offenen Probleme der Informatik. Es wurde vom Clay
|
||||
Mathematics Institute in die Liste der Millennium-Probleme aufgenommen; mit einem
|
||||
@@ -244,29 +244,29 @@ Bemerkung 6.17. Wenn L1 ≤ L2 und L2 ≤ L3 , dann gilt auch L1 ≤ L3 .
|
||||
|
||||
Beweis. siehe Übung.
|
||||
|
||||
Korollar 6.18. Ist L0 N P -vollständig, L0 ≤ L1 und L1 ∈ N P , so ist auch L1 N P -
|
||||
Korollar 6.18. Ist L0 NP-vollständig, L0 ≤ L1 und L1 ∈ NP , so ist auch L1 NP -
|
||||
vollständig.
|
||||
|
||||
|
||||
|
||||
204
|
||||
6.3 Definition von P und N P per Turingmaschine
|
||||
6.3 Definition von P und NP per Turingmaschine
|
||||
|
||||
|
||||
Beweis. Zeige die folgende Behauptung: ∀L ∈ N P gilt L ≤ L1 .
|
||||
Sei dazu L ∈ N P beliebig gewählt. Zeige dann die Aussage L ≤ L1 . Wegen der N P -
|
||||
vollständigkeit von L0 und da L ∈ N P ist, gilt L ≤ L0 . Nach Voraussetzung gilt
|
||||
Beweis. Zeige die folgende Behauptung: ∀L ∈ NP gilt L ≤ L1 .
|
||||
Sei dazu L ∈ NP beliebig gewählt. Zeige dann die Aussage L ≤ L1 . Wegen der NP -
|
||||
vollständigkeit von L0 und da L ∈ NP ist, gilt L ≤ L0 . Nach Voraussetzung gilt
|
||||
zusätzlich L0 ≤ L1 . Da die Relation ≤ transitiv ist (siehe Bemerkung 6.17), gilt dann:
|
||||
L ≤ L1 .
|
||||
Da L1 ∈ N P wegen der Voraussetzung, ist daher L1 auch N P -vollständig.
|
||||
Da L1 ∈ NP wegen der Voraussetzung, ist daher L1 auch NP-vollständig.
|
||||
|
||||
Das obige Ergebnis in Korollar 6.18 ermöglicht uns, N P -vollständige Probleme aus
|
||||
bekannten N P -vollständigen Problemen zu konstruieren (bzw. deren N P -vollständigkeit
|
||||
Das obige Ergebnis in Korollar 6.18 ermöglicht uns, NP-vollständige Probleme aus
|
||||
bekannten NP-vollständigen Problemen zu konstruieren (bzw. deren NP-vollständigkeit
|
||||
zu beweisen). Für diese Entscheidungsprobleme gibt es keine polynomiellen Algorithmen,
|
||||
außer P = N P .
|
||||
außer P = NP .
|
||||
|
||||
|
||||
6.3 Definition von P und N P per Turingmaschine
|
||||
6.3 Definition von P und NP per Turingmaschine
|
||||
Eine nicht-deterministische Turingmaschine (NDTM) hat die Form:
|
||||
|
||||
M = (Q, Γ, q0 , δ, F ).
|
||||
@@ -344,11 +344,11 @@ w in L liegt.
|
||||
Definition 6.20. L ⊂ Σ∗ ∈ P ⇐⇒ ∃ eine DTM M über Alphabet Γ ⊃ Σ und ein Poly-
|
||||
nom T , so dass M die Sprache L in Zeit T entscheidet.
|
||||
|
||||
Nun bleibt zu zeigen, dass beide gezeigten Definitionen der Komplexitätsklasse N P
|
||||
Nun bleibt zu zeigen, dass beide gezeigten Definitionen der Komplexitätsklasse NP
|
||||
äquivalent sind, sie also die gleiche Klasse beschreiben.
|
||||
|
||||
Satz 6.21. Die Klassen der Sprachen bezüglich Verifizierer und NDTM sind äquivalent
|
||||
und beschreiben beide die gleiche Klasse N P .
|
||||
und beschreiben beide die gleiche Klasse NP .
|
||||
|
||||
Beweis. ” ⊃ ” : Sei N eine NDTM, die eine Sprache L in Zeit nc für eine Konstante
|
||||
c entscheidet. Für jedes x ∈ L gibt es eine Folge nicht-deterministischer Übergänge,
|
||||
@@ -377,8 +377,8 @@ entscheidet, da M diese Eingabe in Polynomzeit verifizieren kann.
|
||||
|
||||
|
||||
6.4 Satisfiability Problem (SAT)
|
||||
Der Kanadier Stephen A. Cook begründete 1971 die Klasse der N P -vollständigen Proble-
|
||||
me in N P , indem er zeigte, dass ein N P -vollständiges Problem existiert. Dieses Problem
|
||||
Der Kanadier Stephen A. Cook begründete 1971 die Klasse der NP-vollständigen Proble-
|
||||
me in NP , indem er zeigte, dass ein NP-vollständiges Problem existiert. Dieses Problem
|
||||
ist das sogenannte Satisfiability Problem (SAT). Leonid Levin hatte einen vergleichbaren
|
||||
Satz 1973 veröffentlicht.
|
||||
|
||||
@@ -417,7 +417,7 @@ Wahrheitstabelle:
|
||||
|
||||
Die Belegung ψ(x1) = ψ(x10) = true ist eine erfüllende Belegung.
|
||||
|
||||
Satz 6.24. (Cook, 1971/Levin, 1973): SAT ist N P -vollständig.
|
||||
Satz 6.24. (Cook, 1971/Levin, 1973): SAT ist NP-vollständig.
|
||||
|
||||
Beweis. SAT ∈ NP Wir skizzieren dies für eine entsprechende nichtdeterministische Tu-
|
||||
ringmaschine mit den Zwischenergebnissen auf dem Turingband in Abb. 6.1. Salopp
|
||||
@@ -576,8 +576,8 @@ Hierbei ist 0 ≤ t, i ≤ T (|u|), 0 ≤ k ≤ S, 1 ≤ l ≤ %, 0 ≤ j ≤ m.
|
||||
wahr geschieht der Übergang Ct → Ct+1 gemäß Zeile zl(t) . Insgesamt
|
||||
erhalten wir eine akzeptierende Berechnung von M auf u.
|
||||
|
||||
Im Folgenden werden wir nun einige N P -vollständige Probleme betrachten und ihre
|
||||
Schwere insbesondere beweisen. Es zeigt sich etwa, dass SAT sogar dann N P -vollständig
|
||||
Im Folgenden werden wir nun einige NP-vollständige Probleme betrachten und ihre
|
||||
Schwere insbesondere beweisen. Es zeigt sich etwa, dass SAT sogar dann NP-vollständig
|
||||
bleibt, wenn man pro Klausel nur maximal 3 Literale erlaubt. Dieses Problem nennen
|
||||
wir 3-SAT.
|
||||
|
||||
@@ -585,7 +585,7 @@ wir 3-SAT.
|
||||
6.5 SAT mit kleinen Klauseln (3-SAT)
|
||||
3-SAT = {α | α Boolescher Ausdruck in KNF mit ≤ 3 Literalen pro Klausel, α erfüllbar}.
|
||||
|
||||
Satz 6.25. 3-SAT ist N P -vollständig.
|
||||
Satz 6.25. 3-SAT ist NP-vollständig.
|
||||
|
||||
Beweis. Es reicht zu zeigen: SAT ≤ 3-SAT. Gesucht ist eine polynomzeit-berechenbare
|
||||
Transformation φ : α → ᾱ mit
|
||||
@@ -629,9 +629,9 @@ Gegeben: Ein ungerichteter Graph G = (V, E) und eine Zahl k ≥ 1.
|
||||
Entscheide: Hat der gegebene Graph G eine Clique C ⊆ V mit mindestens k Knoten
|
||||
(d.h. mit |C| ≥ k)?
|
||||
|
||||
Satz 6.26. Das Problem k-Clique ist N P -vollständig.
|
||||
Satz 6.26. Das Problem k-Clique ist NP-vollständig.
|
||||
|
||||
Beweis. (a) k-Clique ∈ N P : siehe Lemma 6.13 (b) Zeige: SAT ≤ k-Clique.
|
||||
Beweis. (a) k-Clique ∈ NP : siehe Lemma 6.13 (b) Zeige: SAT ≤ k-Clique.
|
||||
Es sei F ein Boolscher Ausdruck in KNF, wobei F = F1 ∧. . .∧Fm und Fi = (yi1 ∨. . .∨yi`i )
|
||||
eine Klausel mit `i Literalen ist. Konstruktion von G = (V, E) (dies geht in poly. Zeit):
|
||||
|
||||
@@ -691,10 +691,10 @@ Gegeben: Ein ungerichteter Graph G = (V, E) und eine Zahl k ≥ 1.
|
||||
Entscheide: Hat der gegebene Graph G eine k-Färbung?
|
||||
|
||||
|
||||
Satz 6.28. Das Färbungsproblem k-Color ist N P -vollständig.
|
||||
Satz 6.28. Das Färbungsproblem k-Color ist NP-vollständig.
|
||||
|
||||
|
||||
Beweis. (a) k-Color ∈ N P : Wähle als Zertifikat eine Abbildung f : V → {1, . . . , k} und
|
||||
Beweis. (a) k-Color ∈ NP : Wähle als Zertifikat eine Abbildung f : V → {1, . . . , k} und
|
||||
teste, ob f eine k-Färbung ist. Der Testschritt geht in Zeit O(|V | + |E|).
|
||||
(b) zeige: 3-SAT ≤ k-Color. Es sei F Boolescher Ausdruck in KNF mit F = F1 ∧. . .∧Fm
|
||||
und Fi Klausel der Länge ≤ 3 und ohne Paare xj ∨ ¬xj in einer Klausel (diese Klauseln
|
||||
@@ -786,8 +786,8 @@ Gegeben: Mengen U, V, W mit |U | = |V | = |W | und Teilmenge T ⊆ V × W × U .
|
||||
Entscheide: Gibt es eine Teilmenge M ⊆ T mit |M | = |U | mit der Eigenschaft: Für jede
|
||||
zwei unterschiedliche (v, w, u), (v 0 , w0 , u0 ) ∈ M gilt: u 6= u0 , v 6= v 0 und w 6= w0 ?
|
||||
|
||||
Satz 6.30. Das 3-dimensionale Matchingproblem (3-dim. Matching) ist N P -vollständig.
|
||||
Beweis. (a) 3-dim. Matching ist in N P . Wähle als Zertifikat eine entsprechende Teil-
|
||||
Satz 6.30. Das 3-dimensionale Matchingproblem (3-dim. Matching) ist NP-vollständig.
|
||||
Beweis. (a) 3-dim. Matching ist in NP . Wähle als Zertifikat eine entsprechende Teil-
|
||||
menge M ⊆ T und teste die obigen Bedingungen.
|
||||
(b) SAT ≤ 3-dim. Matching. Es sei F ein Boolescher Ausdruck mit Variablen x1 , . . . , xn
|
||||
und Klauseln C1 , . . . , Cm . Konstruiere Instanz (V, W, U, T ) mit:
|
||||
@@ -895,7 +895,7 @@ Entscheide:
|
||||
j=1,...,m Sij =
|
||||
S.
|
||||
|
||||
Satz 6.31. Das Problem 3-Exact Cover ist N P -vollständig.
|
||||
Satz 6.31. Das Problem 3-Exact Cover ist NP-vollständig.
|
||||
|
||||
Beweis. Dies folgt, da jede Instanz von 3-dim Matching als eine Instanz von 3-Exact
|
||||
Cover aufgefasst werden kann.
|
||||
@@ -909,9 +909,9 @@ Entscheide: Gibt es eine Teilmenge S ⊆ {1, . . . , n} mit
|
||||
j∈S cj = K?
|
||||
|
||||
|
||||
Satz 6.32. Das Problem SubSet Sum ist N P -vollständig.
|
||||
Satz 6.32. Das Problem SubSet Sum ist NP-vollständig.
|
||||
|
||||
Beweis. (a) SubSet Sum ∈ N P : Wähle als Zertifikat eine Teilmenge und teste ob Summe
|
||||
Beweis. (a) SubSet Sum ∈ NP : Wähle als Zertifikat eine Teilmenge und teste ob Summe
|
||||
gleich K ist.
|
||||
(b) 3-Exact Cover ≤ SubSet Sum. Gegeben sei eine Familie von n Mengen der Kardi-
|
||||
nalität 3 und Grundmenge U = {u1 , . . . , u3m }. Konstruiere Zahlen c1 , . . . , cn .
|
||||
@@ -955,8 +955,8 @@ Entscheide: Gibt es eine Teilmenge S ⊆ {1, . . . , n} mit
|
||||
P Pn
|
||||
j∈S cj = 1/2
|
||||
|
||||
Satz 6.33. Das Problem Partition ist N P -vollständig.
|
||||
Beweis. (a) Partition ∈ N P (klar).
|
||||
Satz 6.33. Das Problem Partition ist NP-vollständig.
|
||||
Beweis. (a) Partition ∈ NP (klar).
|
||||
(b) SubSet Sum ≤ Partition. Gegeben seien Zahlen c1 , . . . , cn , K. Setze N = nj=1 cj +
|
||||
P
|
||||
1 und konstruiere die folgende Menge von (n + 2) Gegenständen: {1, . . . , P n} ∪ {b, c}
|
||||
@@ -1000,7 +1000,7 @@ Entscheide: Gibt es eine Teilmenge S ⊆
|
||||
j∈S cj ≤ K und Gesamtprofit j∈S pj ≥ P ?
|
||||
P
|
||||
|
||||
Satz 6.34. Das Rucksackproblem ist N P -vollständig.
|
||||
Satz 6.34. Das Rucksackproblem ist NP-vollständig.
|
||||
Beweis. Spezialfall mit cj = pj für alle Gegenstände und P = K entspricht dem SubSet
|
||||
Sum Problem.
|
||||
|
||||
@@ -1327,7 +1327,7 @@ einen Hamiltonkreis besitzt.
|
||||
Übung 6.45. Die Eingabe des Problems SAT3 ist ein boolscher Ausdruck in KNF, für
|
||||
den entschieden werden soll, ob es mindestens 3 erfüllende Belegungen für diese Formel
|
||||
gibt.
|
||||
Zeigen Sie, dass SAT3 N P -vollständig ist.
|
||||
Zeigen Sie, dass SAT3 NP-vollständig ist.
|
||||
Übung 6.46. Eine NAE-k-SAT (Not-All-Equal) Formel hat folgende Form m i i
|
||||
V
|
||||
i=1 (z1 , . . . , zk ),
|
||||
@@ -1342,25 +1342,25 @@ Übung 6.47. Ein Independent Set in einem Graphen G = (V, E) ist eine Menge von
|
||||
Knoten I, so dass für je zwei Knoten i, j ∈ I gilt, dass {i, j} 6∈ E. Für das Problem
|
||||
Independent Set ist ein Graph G = (V, E) sowie eine Zahl k ∈ N gegeben und es soll
|
||||
entschieden werden, ob es in G ein Independent Set mit k Knoten gibt.
|
||||
Zeigen Sie, dass das Problem Independent Set N P -vollständig ist.
|
||||
Zeigen Sie, dass das Problem Independent Set NP-vollständig ist.
|
||||
Übung 6.48. Ein Vertex Cover in einem Graphen G = (V, E) ist eine Menge von
|
||||
Knoten C ⊆ V so dass für jede Kante {v, w} ∈ E gilt das v ∈ C oder w ∈ C. Für das
|
||||
Problem Vertex Cover ist ein Graph G = (V, E) sowie eine Zahl k ∈ N gegeben und
|
||||
es soll entschieden werden, ob es in G ein Vertex Cover mit k Knoten gibt.
|
||||
Zeigen Sie, dass das Problem Vertex Cover N P -vollständig ist.
|
||||
Zeigen Sie, dass das Problem Vertex Cover NP-vollständig ist.
|
||||
Übung 6.49. In dem Problem Clique-Member ist ein Graph G = (V, E), ein Knoten
|
||||
v ∈ V sowie eine Zahl k ∈ N gegeben und es soll entschieden werden, ob es eine Clique
|
||||
mit k Knoten gibt, die den Knoten v enthält.
|
||||
Zeigen Sie, dass Clique-Member N P -vollständig ist.
|
||||
Zeigen Sie, dass Clique-Member NP-vollständig ist.
|
||||
Übung 6.50. In dem Problem Clique-Nomember ist ein Graph G = (V, E), ein
|
||||
Knoten v ∈ V sowie eine Zahl k ∈ N gegeben und es soll entschieden werden, ob es eine
|
||||
Clique mit k Knoten gibt, die den Knoten v nicht enthält.
|
||||
Zeigen Sie, dass Clique-Nomember N P -vollständig ist, indem Sie eine Reduktion
|
||||
Zeigen Sie, dass Clique-Nomember NP-vollständig ist, indem Sie eine Reduktion
|
||||
von Clique auf Clique-Nomember angeben.
|
||||
Übung 6.51. In dem Problem Z-Clique ist ein zusammenhängender Graph G =
|
||||
(V, E) und eine Zahl k ∈ N gegeben und es soll entschieden werden, ob G eine Clique
|
||||
mit k Knoten enthält.
|
||||
Zeigen Sie, dass Z-Clique N P -vollständig ist.
|
||||
Zeigen Sie, dass Z-Clique NP-vollständig ist.
|
||||
Übung 6.52. Zeigen Sie, dass das Partition Problem ebenfalls NP-schwer ist, wenn
|
||||
eine Teilmenge A00 ⊆ A gesucht ist mit
|
||||
X X
|
||||
@@ -1378,7 +1378,7 @@ Übung 6.53. Für das Problem Subset Sum Cardinality seien n ganze Zahlen
|
||||
c1 , . . . , cn ∈ N>0 (wobei n gerade sei) sowie eine Zahl K ∈ N gegeben. Es
|
||||
P soll entschieden
|
||||
werden, ob es eine Teilmenge S ⊂ {1, . . . , n} gibt mit |S| = n/2 und i∈S ci = K.
|
||||
Zeigen Sie, dass das Problem Subset Sum Cardinality N P -vollständig ist.
|
||||
Zeigen Sie, dass das Problem Subset Sum Cardinality NP-vollständig ist.
|
||||
|
||||
Übung 6.54. Eine Eingabe des Problems Even-Knapsack ist eine Menge von Items
|
||||
I, sowie ein Rucksack mit gerader Größe B (d.h es gibt b ∈ N mit B = 2b), sowie
|
||||
@@ -1387,7 +1387,7 @@ Das Entscheidungsproblem ist eine Teilmenge I 0 ⊆ I zu finden mit
|
||||
i∈I 0 si ≤ B und
|
||||
i∈I 0 pi ≥ P .
|
||||
P
|
||||
Zeigen Sie, dass Even-Knapsack N P -vollständig ist.
|
||||
Zeigen Sie, dass Even-Knapsack NP-vollständig ist.
|
||||
|
||||
Übung 6.55. Die Probleme Multiple-Choice-Knapsack (kurz MC-Knapsack) ist wie folgt
|
||||
definiert: Gegeben sind endliche Mengen C1 , . . . Ck ⊆ N × N von Items, eine Kapazität
|
||||
@@ -1398,7 +1398,7 @@ gewählten Items gemeinsam
|
||||
P den Zielprofit erfüllen ohne die Kapazität zu überschreiten,
|
||||
d.h. i∈[k] pi ≥ P und i∈[k] wi ≤ B.
|
||||
P
|
||||
Das Problem ist in N P . Zeigen Sie: MC-Knapsack ist N P -vollständig
|
||||
Das Problem ist in NP . Zeigen Sie: MC-Knapsack ist NP-vollständig
|
||||
|
||||
|
||||
|
||||
@@ -1407,8 +1407,8 @@ d.h. i∈[k] pi ≥ P und i∈[k] wi ≤ B.
|
||||
7 Approximative Algorithmen
|
||||
Das letzte Kapitel hat uns Einblicke in die Schwierigkeit der polynomiellen Berechen-
|
||||
barkeit gegeben. Wir wissen nun insbesondere, dass es viele Probleme gibt, deren exakte
|
||||
Lösung eine große, nicht-polynomielle Laufzeit benötigt - es sei denn, es gilt P = N P .
|
||||
In Ermangelung einer Eingebung zu der Frage, ob P = N P oder P 6= N P gilt, hat sich
|
||||
Lösung eine große, nicht-polynomielle Laufzeit benötigt - es sei denn, es gilt P = NP .
|
||||
In Ermangelung einer Eingebung zu der Frage, ob P = NP oder P 6= NP gilt, hat sich
|
||||
die Disziplin der Approximativen Algorithmen entwickelt; anstatt eine exakte Lösung zu
|
||||
verlangen, lässt man die Berechnung geringfügig schlechterer Lösungen zu, welche sich
|
||||
aber deutlich schneller berechnen lassen. Die exakte Lösung wird in diesem Sinne nur
|
||||
@@ -1509,7 +1509,7 @@ Königsberger Brückenproblem Knoten mit ungeradem Grad besitzt und es daher k
|
||||
entsprechenden Rundweg gibt.
|
||||
Wir betrachten nun den folgenden Algorithmus:
|
||||
|
||||
Algorithmus ∆T SP1 (V = {1,...,n}, D = (d(i,j)) )
|
||||
Algorithmus ∆TSP1 (V = {1,...,n}, D = (d(i,j)) )
|
||||
1 Berechne einen minimalen spannenden Baum T des Graphen
|
||||
2 Kn mit Distanzen d(i, j);
|
||||
3 Konstruiere einen Multigraphen G aus T durch Verdoppeln
|
||||
@@ -1568,7 +1568,7 @@ G durch Verdopplung der Kanten mit w(G) = 12; siehe folgendes Bild:
|
||||
12; und durch die Abkürzungen (d.h. Überspringen der schon vorher besuchten Knoten)
|
||||
erhalten wir die Rundreise R = [C, A, E, B, D, C] mit Länge d(R) = 10.
|
||||
|
||||
Satz 7.8. Der Algorithmus ∆T SP1 hat eine multiplikative Güte 2; d.h. d(R) ≤ 2OP T (I),
|
||||
Satz 7.8. Der Algorithmus ∆TSP1 hat eine multiplikative Güte 2; d.h. d(R) ≤ 2OP T (I),
|
||||
wobei OP T (I) die Länge einer minimalen Rundreise zu der Eingabe I mit V = {1, . . . , n}
|
||||
und Distanzen D = (d(i, j)) ist.
|
||||
|
||||
@@ -1599,7 +1599,7 @@ Beweis. Die Behauptung folgt aus den folgenden Ungleichungen:
|
||||
Formal zeigt man die obige Ungleichung per Induktion nach t ≥ 1.
|
||||
|
||||
Bemerkung 7.9. Es gibt Beispiele für Eingaben I zu dem Traveling Salesman Problem
|
||||
mit OP T (I) = n, bei denen der Algorithmus ∆T SP1 eine Rundreise der Länge 2n − 2
|
||||
mit OP T (I) = n, bei denen der Algorithmus ∆TSP1 eine Rundreise der Länge 2n − 2
|
||||
berechnet.
|
||||
|
||||
Beweis. Zur Beweisidee geben wir hier ein Beispiel mit n = 8 Knoten. Betrachte ei-
|
||||
@@ -1663,7 +1663,7 @@ Grad optimaler durch Kanten augmentieren. Hier hilft uns ein Matching mit minima
|
||||
Gewicht. Man beachte, dass die Anzahl der Knoten mit ungeradem Grad gerade ist;
|
||||
siehe auch Lemma 5.4.
|
||||
|
||||
Algorithmus ∆T SP2 (V = {1,...,n}, D = (d(i,j)) )
|
||||
Algorithmus ∆TSP2 (V = {1,...,n}, D = (d(i,j)) )
|
||||
1 Berechne einen minimalen spannenden Baum T des
|
||||
2 Graphen Kn mit Gewichten d(i, j);
|
||||
3 Bestimme die Menge X der Knoten in T,
|
||||
@@ -1685,7 +1685,7 @@ Algorithmus ∆T SP2 (V = {1,...,n}, D = (d(i,j)) )
|
||||
12 Bestimme eine Rundreise R aus C durch Abkürzungen;
|
||||
13 return Rundreise R.
|
||||
|
||||
Satz 7.13. (Christofides): Der Algorithmus ∆T SP2 hat eine multiplikative Güte 1.5;
|
||||
Satz 7.13. (Christofides): Der Algorithmus ∆TSP2 hat eine multiplikative Güte 1.5;
|
||||
d.h. d(R) ≤ 1.5OP T (I).
|
||||
|
||||
Beweis. Es gilt d(C) = w(T ) + d(K) ≤ OP T (I) + d(K). Man zeige nun, dass d(K) ≤
|
||||
@@ -1787,13 +1787,13 @@ induzierten Graphen H rechts mit einem perfektem Matching K und Gewicht w(K) = 3
|
||||
Ein Eulerkreis in G ist C = [C, B, C, A, E, D, C] und nach den Abkürzungen erhalten
|
||||
wir R = [C, B, A, E, D, C] mit Länge d(R) = 9.
|
||||
Lemma 7.15. Es gibt Beispiele für Eingaben I zu dem Traveling Salesman Problem mit
|
||||
OP T (I) = n, bei denen der Algorithmus ∆T SP2 von Christofides eine Rundreise der
|
||||
OP T (I) = n, bei denen der Algorithmus ∆TSP2 von Christofides eine Rundreise der
|
||||
Länge (n − 1) + bn/2c berechnet.
|
||||
Beweis. zur Übung.
|
||||
|
||||
|
||||
7.2 Rucksackproblem
|
||||
Dass das Rucksackproblem N P -vollständig ist, haben wir schon am Ende des letzten Ka-
|
||||
Dass das Rucksackproblem NP-vollständig ist, haben wir schon am Ende des letzten Ka-
|
||||
pitels gesehen. Hier wollen wir nun einige einfache Approximationsalgorithmen studieren.
|
||||
|
||||
Gegeben:
|
||||
@@ -1866,11 +1866,11 @@ Algorithmus MGA(I = ((w0 , p0 ), . . . , (wn−1 , pn−1 ), B))
|
||||
7 Approximative Algorithmen
|
||||
|
||||
|
||||
Es sei M GA(I) der Gesamtgewinn der durch Algorithmus M GA berechneten Lösung
|
||||
Es sei MGA(I) der Gesamtgewinn der durch Algorithmus MGA berechneten Lösung
|
||||
S ⊆ {0, . . . , n − 1} und OP T (I) der Gesamtgewinn einer optimalen Lösung zur Eingabe
|
||||
I.
|
||||
|
||||
Satz 7.18. Die Worst-Case Güte von Algorithmus M GA ist 2 (d.h. OP T (I)/M GA(I) ≤
|
||||
Satz 7.18. Die Worst-Case Güte von Algorithmus MGA ist 2 (d.h. OP T (I)/MGA(I) ≤
|
||||
2 für alle Eingaben I).
|
||||
|
||||
Beweis. Betrachte relaxierte Version des Rucksackproblems, bei dem Gegenstände frak-
|
||||
@@ -1938,18 +1938,18 @@ ist, dass der Algorithmus GA nicht nimmt, gilt
|
||||
|
||||
OP Tf (I) ≤ GA(I) + pk+1 .
|
||||
|
||||
Der Algorithmus M GA berechnet Lösung mit Wert GA(I) oder pmax ≥ pk+1 (bzw. sogar
|
||||
Der Algorithmus MGA berechnet Lösung mit Wert GA(I) oder pmax ≥ pk+1 (bzw. sogar
|
||||
genauer mit Wert max{GA(I), pmax }). Daher gilt
|
||||
|
||||
OP T (I) ≤ OP Tf (I) ≤ GA(I) + pk+1
|
||||
≤ GA(I) + pmax ≤ 2 max{GA(I), pmax }
|
||||
= 2 · M GA(I)
|
||||
= 2 · MGA(I)
|
||||
|
||||
|
||||
|
||||
Bemerkung 7.19. (a) Der Algorithmus M GA läuft in Zeit O(n log n).
|
||||
Bemerkung 7.19. (a) Der Algorithmus MGA läuft in Zeit O(n log n).
|
||||
|
||||
(b) Durch eine Modifikation von M GA können wir auch einen approximativen Algo-
|
||||
(b) Durch eine Modifikation von MGA können wir auch einen approximativen Algo-
|
||||
rithmus angeben mit Worst Case Güte 2, der in O(n) Zeit läuft.
|
||||
|
||||
Beweis. Wir verwenden als Idee zur Laufzeitverbesserung (b) hierzu den Median-Algorithmus
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
Definition 1 (Vertex Cover (VC)). Für einen (ungerichteten) Graphen G = (V, E) ist ein Vertex
|
||||
Cover eine Menge von Knoten C ⊆ V , sodass u ∈ C oder v ∈ C für alle { u, v } ∈ E gilt, also ist
|
||||
für alle Kanten mindestens ein Endpunkt in C.
|
||||
Für das V ERTEX C OVER Problem ist neben einem Graphen G eine Zahl k ∈ N≥0 gegeben und
|
||||
Für das VERTEX COVER Problem ist neben einem Graphen G eine Zahl k ∈ N≥0 gegeben und
|
||||
es wird gefragt, ob ein Vertex Cover mit Größe (Anzahl der Knoten) höchstens k in G existiert.
|
||||
|
||||
Definition 2 (Feedback Vertex Set (FVS)). Für einen gerichteten Graphen G = (V, E) ist ein
|
||||
Feedback Vertex Set eine Menge von Knoten X ⊆ V , sodass G \ X kreisfrei ist.
|
||||
Für das F EEDBACK V ERTEX S ET Problem ist neben einem Graphen G eine Zahl k ∈ N≥0 gege-
|
||||
Für das FEEDBACK VERTEX SET Problem ist neben einem Graphen G eine Zahl k ∈ N≥0 gege-
|
||||
ben und es wird gefragt, ob ein Feedback Vertex Set mit Größe (Anzahl der Knoten) höchstens
|
||||
k in G existiert.
|
||||
|
||||
@@ -34,8 +34,8 @@ k in G existiert.
|
||||
Hausaufgabe 9.1 (Nichtdeterministischer Algorithmus für VC) (5 Punkte)
|
||||
1. Finden Sie ein minimales Vertex Cover in Abbildung 1. Für ein Vertex Cover ist die
|
||||
Orientierung der Kanten nicht relevant.
|
||||
2. Zeigen Sie V ERTEX C OVER ∈ NP, indem Sie einen nichtdeterministischen Algorithmus
|
||||
angeben, der V ERTEX C OVER in Polynomialzeit löst. Begründen Sie die Korrektheit und
|
||||
2. Zeigen Sie VERTEX COVER ∈ NP, indem Sie einen nichtdeterministischen Algorithmus
|
||||
angeben, der VERTEX COVER in Polynomialzeit löst. Begründen Sie die Korrektheit und
|
||||
die Laufzeit von Ihrem Algorithmus.
|
||||
|
||||
Lösung. 1. Ein minimales Vertex Cover ist zum Beispiel { 3, 5, 6, 7, 8 }.
|
||||
@@ -55,8 +55,8 @@ die Beschreibung und jeweils 1 Punkt für Korrektheit und Laufzeit.
|
||||
|
||||
Hausaufgabe 9.2 (Polynomieller Verifizierer für FVS) (5 Punkte)
|
||||
1. Finden Sie ein minimales Feedback Vertex Set in Abbildung 1.
|
||||
2. Zeigen Sie F EEDBACK V ERTEX S ET ∈ NP, indem Sie einen polynomiellen Verifizierer
|
||||
für F EEDBACK V ERTEX S ET angeben. Begründen Sie die Korrektheit und die Laufzeit
|
||||
2. Zeigen Sie FEEDBACK VERTEX SET ∈ NP, indem Sie einen polynomiellen Verifizierer
|
||||
für FEEDBACK VERTEX SET angeben. Begründen Sie die Korrektheit und die Laufzeit
|
||||
von Ihrem Verifizierer.
|
||||
|
||||
Lösung. 1. Ein minimales FVS ist zum Beispiel { 5, 6 }.
|
||||
@@ -110,7 +110,7 @@ der Algorithmus akzeptiert. Falls es kein Vertex Cover gibt, dann lehnt der Algo
|
||||
|
||||
Die Auswahl der Knoten und die Prüfung der Kanten läuft in polynomieller Zeit, also ist dies
|
||||
ein polynomieller nichtdeterministischer Algorithmus, der VERTEX COVER löst und damit ist
|
||||
V ERT EXCOV ER ∈ N P .
|
||||
VERT EXCOV ER ∈ NP .
|
||||
|
||||
|
||||
Hausaufgabe 9.2
|
||||
@@ -144,7 +144,7 @@ von dem Verifizierer akzeptiert und sonst abgelehnt.
|
||||
Die Prüfung der übergebenen Teilmenge und die Konstruktion des Graphen laufen in polyno-
|
||||
mieller Zeit. Ebenfalls die Tiefensuche läuft in polynomieller Zeit, somit ist auch der Verifizierer
|
||||
polynomiell und verifiziert die Lösung für das FEEDBACK VERTEX SET in polynomieller
|
||||
Zeit. Also gilt F EEDBACKV ERT EXSET ∈ N P .
|
||||
Zeit. Also gilt FEEDBACKV ERT EXSET ∈ NP .
|
||||
|
||||
|
||||
|
||||
@@ -163,27 +163,27 @@ Zeit. Also gilt F EEDBACKV ERT EXSET ∈ N P .
|
||||
|
||||
Definition 1 (Feedback Vertex Set (FVS)). Für einen gerichteten Graphen G = (V, E) ist ein
|
||||
Feedback Vertex Set eine Menge von Knoten X ⊆ V , sodass G \ X kreisfrei ist.
|
||||
Für das F EEDBACK V ERTEX S ET Problem ist neben einem Graphen G eine Zahl k ∈ N≥0 gege-
|
||||
Für das FEEDBACK VERTEX SET Problem ist neben einem Graphen G eine Zahl k ∈ N≥0 gege-
|
||||
ben und es wird gefragt, ob ein Feedback Vertex Set mit Größe (Anzahl der Knoten) höchstens
|
||||
k in G existiert.
|
||||
|
||||
Hausaufgabe 10.1 (F EEDBACK V ERTEX S ET ist NP-vollständig) (5 Punkte)
|
||||
Zeigen Sie, dass F EEDBACK V ERTEX S ET NP-vollständig ist.
|
||||
Hinweis: Reduzieren Sie V ERTEX C OVER auf F EEDBACK V ERTEX S ET. Wenn ein Vertex Cover
|
||||
Hausaufgabe 10.1 (FEEDBACK VERTEX SET ist NP-vollständig) (5 Punkte)
|
||||
Zeigen Sie, dass FEEDBACK VERTEX SET NP-vollständig ist.
|
||||
Hinweis: Reduzieren Sie VERTEX COVER auf FEEDBACK VERTEX SET. Wenn ein Vertex Cover
|
||||
aus einem Graphen entfernt wird, hat dieser keine Kanten mehr.
|
||||
|
||||
Lösung. F EEDBACK V ERTEX S ET ∈ NP ist bekannt aus der letzten Hausaufgabe. Nun zeigen
|
||||
wir eine Reduktion von V ERTEX C OVER auf F EEDBACK V ERTEX S ET. Betrachte die folgende
|
||||
Lösung. FEEDBACK VERTEX SET ∈ NP ist bekannt aus der letzten Hausaufgabe. Nun zeigen
|
||||
wir eine Reduktion von VERTEX COVER auf FEEDBACK VERTEX SET. Betrachte die folgende
|
||||
Reduktion:
|
||||
Für eine gegebene V ERTEX C OVER Instanz (G = (V, E), k) erzeuge den Graphen G′ = (V, E ′ )
|
||||
mit E ′ := { (u, v), (v, u) | {u, v} ∈ E }. Gebe (G′ , k) als F EEDBACK V ERTEX S ET Instanz aus.
|
||||
Für eine gegebene VERTEX COVER Instanz (G = (V, E), k) erzeuge den Graphen G′ = (V, E ′ )
|
||||
mit E ′ := { (u, v), (v, u) | {u, v} ∈ E }. Gebe (G′ , k) als FEEDBACK VERTEX SET Instanz aus.
|
||||
Korrektheit:
|
||||
⇒ (Falls (G, k) eine Ja-Instanz von V ERTEX C OVER ist, dann ist (G′ , k) eine Ja-Instanz von
|
||||
F EEDBACK V ERTEX S ET.):
|
||||
⇒ (Falls (G, k) eine Ja-Instanz von VERTEX COVER ist, dann ist (G′ , k) eine Ja-Instanz von
|
||||
FEEDBACK VERTEX SET.):
|
||||
Sei C ⊆ V ein Vertex Cover mit Größe kleiner oder gleich k. Dann enthält G \C keine Kanten.
|
||||
Damit hat G′ \C ebenfalls keine Kanten und somit keine Kreise.
|
||||
⇐ (Falls (G′ , k) eine Ja-Instanz von F EEDBACK V ERTEX S ET ist, dann ist (G, k) eine Ja-Instanz
|
||||
von V ERTEX C OVER.):
|
||||
⇐ (Falls (G′ , k) eine Ja-Instanz von FEEDBACK VERTEX SET ist, dann ist (G, k) eine Ja-Instanz
|
||||
von VERTEX COVER.):
|
||||
Sei X ⊆ V ′ ein Feedback Vertex Set mit Größe kleiner oder gleich k. Dann ist X ein Vertex
|
||||
Cover in G, da ansonsten eine Kante {u, v} ∈ E existiert, für die u ∈
|
||||
/ X ∧v ∈
|
||||
@@ -191,9 +191,9 @@ Cover in G, da ansonsten eine Kante {u, v} ∈ E existiert, für die u ∈
|
||||
aber (u, v, u) ein Kreis in G′ und X somit kein Feedback Vertex Set.
|
||||
Laufzeit: Das Erzeugen der Menge E ′ geht in Zeit O(|E|). Insgesamt ist die Reduktion dem-
|
||||
nach in Laufzeit O(|E|), also in polynomieller Laufzeit, durchführbar.
|
||||
Also existiert eine polynomielle Reduktion von V ERTEX C OVER auf F EEDBACK V ERTEX S ET.
|
||||
Da V ERTEX C OVER nach den aktuellen Präsenzaufgaben NP-vollständig ist und
|
||||
F EEDBACK V ERTEX S ET ∈ NP gilt, ist somit F EEDBACK V ERTEX S ET NP-vollständig.
|
||||
Also existiert eine polynomielle Reduktion von VERTEX COVER auf FEEDBACK VERTEX SET.
|
||||
Da VERTEX COVER nach den aktuellen Präsenzaufgaben NP-vollständig ist und
|
||||
FEEDBACK VERTEX SET ∈ NP gilt, ist somit FEEDBACK VERTEX SET NP-vollständig.
|
||||
Bewertung 2 Punkte für die Konstruktion, 1 Punkt für den 1. Fall, 1 Punkt für den 2. Fall,
|
||||
0.5 Punkte für die Laufzeit, 0.5 Punkte für die Beweisführung im Ganzen
|
||||
|
||||
@@ -256,11 +256,11 @@ Teil 1: FeedbackVertexSet ∈ NP
|
||||
• Akzeptieren, wenn die Tiefensuche nichts findet
|
||||
|
||||
Die Schritte 1-4 laufen in polynomieller Zeit, somit läuft der Verifizierer in polynomieller
|
||||
Zeit. Damit ist F eedbackV ertexSet ∈ N P .
|
||||
Zeit. Damit ist F eedbackV ertexSet ∈ NP .
|
||||
|
||||
Teil 2: FeedbackVertexSet ist NP-schwer
|
||||
Jetzt brauchen wir eine Reduktion von VertexCover auf FeedbackVertexSet, um zu zeigen,
|
||||
dass F eedbackV ertexSet N P − Schwer ist.
|
||||
dass F eedbackV ertexSet NP − Schwer ist.
|
||||
|
||||
Konstruktion: Sei dafür ein VertexCover gegeben mit einem Graphen G und einer Zahl k.
|
||||
Aus G wird ein G′ , wobei jede ungerichtete Kante {u, v} ∈ E durch zwei gerichteten Kanten
|
||||
@@ -297,7 +297,7 @@ Aus G wird ein G′ , wobei jede ungerichtete Kante {u, v} ∈ E durch zwei geri
|
||||
|
||||
• Das ist ein Widerspruch, somit ist G′ \ X kreisfrei und X ist ein VertexCover
|
||||
|
||||
Da F eedbackV ertexSet ∈ N P und N P − schwer ist, folgt dass F eedbackV ertexSet NP-
|
||||
Da F eedbackV ertexSet ∈ NP und NP − schwer ist, folgt dass F eedbackV ertexSet NP-
|
||||
Vollständig ist. □
|
||||
|
||||
|
||||
@@ -339,7 +339,7 @@ O(n) Schritte und das mit O(n) Durchläufen.
|
||||
Blatt 11
|
||||
|
||||
|
||||
Definition 1 (Dreiecksüberdeckung eines Graphen (∆ C OVER)). Gegeben ist ein endlicher un-
|
||||
Definition 1 (Dreiecksüberdeckung eines Graphen (∆ COVER)). Gegeben ist ein endlicher un-
|
||||
gerichteter Graph G = (V, E) und eine Zahl k ∈ N. Dieser Graph hat eine Dreiecksüberdeckung,
|
||||
wenn eine Menge von Knoten C∆ ⊆ V mit |C∆ | ≤ k existiert, so dass zu jedem Teilgraphen (Drei-
|
||||
eck) D = (VD , ED ) ⊆ (V, E) mit |VD | = 3 und ED = { {v1 , v2 } ∈ VD ×VD | v1 ̸= v2 } von G gilt,
|
||||
@@ -348,11 +348,11 @@ v ∈ C∆ für mindestens ein v ∈ VD .
|
||||
Hausaufgabe 11.1 (10 Punkte)
|
||||
|
||||
|
||||
(a) (1 Punkt) Markieren Sie im folgenden Graphen (Abbildung 1 (a)) ein V ERTEX C OVER
|
||||
(a) (1 Punkt) Markieren Sie im folgenden Graphen (Abbildung 1 (a)) ein VERTEX COVER
|
||||
der Größe 5.
|
||||
(b) (1 Punkt) Markieren Sie im folgenden Graphen (Abbildung 1 (b)) eine Dreiecksüberde-
|
||||
ckung der Größe 5.
|
||||
(c) (8 Punkte) Zeigen Sie, dass ∆ C OVER NP-vollständig ist.
|
||||
(c) (8 Punkte) Zeigen Sie, dass ∆ COVER NP-vollständig ist.
|
||||
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ Lösung.
|
||||
Abbildung 2: Beispielgraph Lösung
|
||||
|
||||
|
||||
(c) Wir zeigen zunächst, ∆ C OVER ∈ NP mittels eines polynomiellen Verifizierers.
|
||||
(c) Wir zeigen zunächst, ∆ COVER ∈ NP mittels eines polynomiellen Verifizierers.
|
||||
Der polynomielle Verifizierer erhält als Zertifikat eine Menge von Knoten C∆ ⊆ V . Zunächst
|
||||
überprüft er, ob |C∆ | ≤ k. Falls dies nicht der Fall ist, verwerfe die Instanz. Ansonsten überprüfe
|
||||
für alle Teilgraphen D = (VD , ED ) ⊆ (V, E) mit |VD | = 3 und ED = { {v1 , v2 } ∈ VD ×VD | v1 ̸= v2 }
|
||||
@@ -382,14 +382,14 @@ Lösung.
|
||||
daher überprüft werden müssen. Die Überprüfung geht in O(|V |) Zeit. Da alle anderen Schritte
|
||||
des Verifizierers in O(1) Zeit erledigt werden können, ergibt sich somit insgesamt eine Laufzeit
|
||||
von O(|V |4 ), was einer polynomiellen Laufzeit entspricht.
|
||||
Also gilt ∆ C OVER ∈ NP.
|
||||
Nun reduzieren wir V ERTEX C OVER auf ∆ C OVER. Betrachte folgende Reduktion:
|
||||
Sei I = (G = (V, E), k) eine Instanz von V ERTEX C OVER. Bilde den Graphen G′ = (V ′ , E ′ ) mit
|
||||
Also gilt ∆ COVER ∈ NP.
|
||||
Nun reduzieren wir VERTEX COVER auf ∆ COVER. Betrachte folgende Reduktion:
|
||||
Sei I = (G = (V, E), k) eine Instanz von VERTEX COVER. Bilde den Graphen G′ = (V ′ , E ′ ) mit
|
||||
V ′ = V ∪ { ve | e ∈ E } und E ′ = E ∪ { {ve , e1 }, {ve , e2 } | e = {e1 , e2 } ∈ E }. Gebe I ′ = (G′ , k)
|
||||
als ∆ C OVER Instanz aus.
|
||||
als ∆ COVER Instanz aus.
|
||||
Korrektheit:
|
||||
⇒ (Falls (G, k) eine Ja-Instanz von V ERTEX C OVER ist, dann ist (G′ , k) eine Ja-Instanz von ∆
|
||||
C OVER.):
|
||||
⇒ (Falls (G, k) eine Ja-Instanz von VERTEX COVER ist, dann ist (G′ , k) eine Ja-Instanz von ∆
|
||||
COVER.):
|
||||
Sei C ein Vertex Cover mit Größe kleiner oder gleich k von G. Dann ist C ebenfalls eine Drei-
|
||||
ecksüberdeckung mit Größe kleiner oder gleich k. Offensichtlich gilt |C| ≤ k. Da C ein Vertex
|
||||
Cover von G ist, gilt für jede Kante {u, v} ∈ E, dass u ∈ C oder v ∈ C. Für alle Teilgraphen
|
||||
@@ -399,8 +399,8 @@ Lösung.
|
||||
{ ve | e ∈ E } und die entsprechenden Kanten { e, {ve , e1 }, {ve , e2 } | e = {e1 , e2 } ∈ E } entste-
|
||||
hen. Für diese Dreiecke gilt aber auch, dass mindestens ein Knoten, nämlich e1 oder e2 , in C
|
||||
enthalten sein muss, da C ein Vertex Cover ist.
|
||||
⇐ (Falls (G′ , k) eine Ja-Instanz von ∆ C OVER ist, dann ist (G, k) eine Ja-Instanz von V ERTEX -
|
||||
C OVER.):
|
||||
⇐ (Falls (G′ , k) eine Ja-Instanz von ∆ COVER ist, dann ist (G, k) eine Ja-Instanz von VERTEX -
|
||||
COVER.):
|
||||
Sei C∆ eine Dreiecksüberdeckung von G′ der Größe |C∆ | ≤ k. Ohne Beschränkung der All-
|
||||
gemeinheit können wir davon ausgehen, dass C∆ ⊆ V gilt: Falls für einen Knoten ve ∈ C∆ gilt,
|
||||
können wir e1 zu C∆ hinzufügen und ve aus C∆ löschen. Dadurch bleibt die Überdeckungseigen-
|
||||
@@ -412,9 +412,9 @@ auch für die Dreiecke {ve , e1 , e2 }. Da außerdem C∆ ⊆ V gilt, gilt e1
|
||||
Kanten e ∈ E.
|
||||
Laufzeit: Das Hinzufügen von einem neuen Knoten und zwei neuen Kanten pro Kante geht in
|
||||
O(|E|). Damit geht die gesamte Reduktion in O(|E|), also in polynomieller Zeit.
|
||||
Also existiert eine polynomielle Reduktion von V ERTEX C OVER auf ∆ C OVER. Da V ERTEX -
|
||||
C OVER NP-vollständig aus den Präsenzaufgaben bekannt ist und ∆ C OVER ∈ NP gilt, ist somit
|
||||
∆ C OVER NP-vollständig.
|
||||
Also existiert eine polynomielle Reduktion von VERTEX COVER auf ∆ COVER. Da VERTEX -
|
||||
COVER NP-vollständig aus den Präsenzaufgaben bekannt ist und ∆ COVER ∈ NP gilt, ist somit
|
||||
∆ COVER NP-vollständig.
|
||||
|
||||
|
||||
Bewertung (a) und (b) jeweils 1 Punkt, (c) gibt 8 Punkte: 1 Punkt Angabe Verifizierer/NDTM,
|
||||
@@ -534,85 +534,85 @@ Kanten Dreiecke.
|
||||
Blatt 12
|
||||
|
||||
|
||||
Hausaufgabe 12.1 (Lower Bound: V ERTEX C OVER) (5 Punkte)
|
||||
Betrachten Sie die Reduktion aus der Musterlösung zu Präsenzserie 10 von C LIQUE auf V ER -
|
||||
TEX C OVER .
|
||||
Hausaufgabe 12.1 (Lower Bound: VERTEX COVER) (5 Punkte)
|
||||
Betrachten Sie die Reduktion aus der Musterlösung zu Präsenzserie 10 von CLIQUE auf VER -
|
||||
TEX COVER .
|
||||
|
||||
1. Geben Sie die Anzahl der Knoten, die Anzahl der Kanten und den Wert k für die re-
|
||||
sultierende V ERTEX C OVER Instanz in Abhängigkeit von der originalen C LIQUE Instanz
|
||||
sultierende VERTEX COVER Instanz in Abhängigkeit von der originalen CLIQUE Instanz
|
||||
an.
|
||||
2. Zeigen Sie Lower Bounds für V ERTEX C OVER bezüglich Anzahl der Knoten, Anzahl
|
||||
2. Zeigen Sie Lower Bounds für VERTEX COVER bezüglich Anzahl der Knoten, Anzahl
|
||||
der Kanten und k basierend auf der ETH und der Reduktion aus der Musterlösung zu
|
||||
Präsenzserie 10.
|
||||
|
||||
Lösung. 1. Sei I = (G = (V, E), k) eine Instanz von C LIQUE und I ′ = (G′ = (V ′ , E ′ ), k′ ) die
|
||||
aus der Reduktion resultierende Instanz von V ERTEX C OVER. Es gilt |V ′ | = |V |, |E ′ | =
|
||||
Lösung. 1. Sei I = (G = (V, E), k) eine Instanz von CLIQUE und I ′ = (G′ = (V ′ , E ′ ), k′ ) die
|
||||
aus der Reduktion resultierende Instanz von VERTEX COVER. Es gilt |V ′ | = |V |, |E ′ | =
|
||||
1 2 ′
|
||||
2 |V |(|V | − 1) − |E| ≤ |V | und k = |V | − k ≤ |V |.
|
||||
′
|
||||
2. • Angenommen es gäbe einen Algorithmus, der V ERTEX C OVER in 2o(|V |) · |I|O(1)
|
||||
löst. Durch die Kombination der Reduktion von C LIQUE auf V ERTEX C OVER und
|
||||
diesem Algorithmus, existiert dann auch ein 2o(|V |) ·|I|O(1) Algorithmus für C LIQUE,
|
||||
2. • Angenommen es gäbe einen Algorithmus, der VERTEX COVER in 2o(|V |) · |I|O(1)
|
||||
löst. Durch die Kombination der Reduktion von CLIQUE auf VERTEX COVER und
|
||||
diesem Algorithmus, existiert dann auch ein 2o(|V |) ·|I|O(1) Algorithmus für CLIQUE,
|
||||
da |V ′ | = |V | nach Aufgabenteil 1. Aus der Musterlösung der Präsenzserie 12 folgt
|
||||
somit, dass die ETH falsch ist.
|
||||
Damit kann es so einen Algorithmus für V ERTEX C OVER nur geben, wenn die ETH
|
||||
Damit kann es so einen Algorithmus für VERTEX COVER nur geben, wenn die ETH
|
||||
falsch ist.
|
||||
√ ′
|
||||
• Angenommen es gäbe einen Algorithmus, der V ERTEX C OVER in 2o( |E |) · |I|O(1)
|
||||
löst. Durch die Kombination der Reduktion von C LIQUE auf V ERTEX C OVER und
|
||||
diesem Algorithmus, existiert dann auch ein 2o(|V |) ·|I|O(1) Algorithmus für C LIQUE,
|
||||
• Angenommen es gäbe einen Algorithmus, der VERTEX COVER in 2o( |E |) · |I|O(1)
|
||||
löst. Durch die Kombination der Reduktion von CLIQUE auf VERTEX COVER und
|
||||
diesem Algorithmus, existiert dann auch ein 2o(|V |) ·|I|O(1) Algorithmus für CLIQUE,
|
||||
da |E ′ | ≤ |V |2 nach Aufgabenteil 1. Aus der Musterlösung der Präsenzserie 12 folgt
|
||||
somit, dass die ETH falsch ist.
|
||||
Damit kann es so einen Algorithmus für V ERTEX C OVER nur geben, wenn die ETH
|
||||
Damit kann es so einen Algorithmus für VERTEX COVER nur geben, wenn die ETH
|
||||
falsch ist.
|
||||
′
|
||||
• Angenommen es gäbe einen Algorithmus, der V ERTEX C OVER in 2o(k ) · |I|O(1) löst.
|
||||
Durch die Kombination der Reduktion von C LIQUE auf V ERTEX C OVER und die-
|
||||
sem Algorithmus, existiert dann auch ein 2o(|V |) · |I|O(1) Algorithmus für C LIQUE,
|
||||
• Angenommen es gäbe einen Algorithmus, der VERTEX COVER in 2o(k ) · |I|O(1) löst.
|
||||
Durch die Kombination der Reduktion von CLIQUE auf VERTEX COVER und die-
|
||||
sem Algorithmus, existiert dann auch ein 2o(|V |) · |I|O(1) Algorithmus für CLIQUE,
|
||||
da k′ ≤ |V | nach Aufgabenteil 1. Aus der Musterlösung der Präsenzserie 12 folgt
|
||||
somit, dass die ETH falsch ist.
|
||||
Damit kann es so einen Algorithmus für V ERTEX C OVER nur geben, wenn die ETH
|
||||
Damit kann es so einen Algorithmus für VERTEX COVER nur geben, wenn die ETH
|
||||
falsch ist.
|
||||
Bewertung 1 Punkt für den ersten Teil und 1.5 + 1.5 + 1 für den zweiten.
|
||||
|
||||
Definition 1 (H ITTING S ET). Bei diesem Problem ist ein Universum U, Teilmengen des Uni-
|
||||
Definition 1 (HITTING SET). Bei diesem Problem ist ein Universum U, Teilmengen des Uni-
|
||||
versums F1 , . . . , Fr ⊆ U und eine Zahl k ∈ N≥0 gegeben. Gibt es eine Menge S ⊆ U mit |U| ≤ k
|
||||
und S ∩ Fi ̸= 0/ für alle i ∈ [r]?
|
||||
Anschaulicher wird bei diesem Problem nach maximal k Elementen aus dem Universum ge-
|
||||
fragt, sodass diese Elemente alle gegebenen Mengen Fi „treffen“.
|
||||
|
||||
Hausaufgabe 12.2 (Lower Bound: H ITTING S ET) (5 Punkte)
|
||||
Betrachten Sie folgende Reduktion von 3-SAT auf H ITTING S ET.
|
||||
Hausaufgabe 12.2 (Lower Bound: HITTING SET) (5 Punkte)
|
||||
Betrachten Sie folgende Reduktion von 3-SAT auf HITTING SET.
|
||||
Für eine gegebene 3-SAT Instanz I mit n Variablen x1 , . . . , xn und m Klauseln C1 , . . . ,Cm : Als
|
||||
Universum wählen wir U = { x1 , x̄1 , . . . , xn , x̄n }. Für jede Variable xi fügen wir die Menge Fi :=
|
||||
{ xi , x̄i } hinzu. Für jede Klausel C j fügen wir die Menge Fn+ j := C j hinzu. Wir setzen k := n
|
||||
und erhalten damit eine H ITTING S ET Instanz I.
|
||||
und erhalten damit eine HITTING SET Instanz I.
|
||||
1. Geben Sie die Größe des Universums, die Anzahl der Mengen und die Zahl k aus der
|
||||
resultierenden H ITTING S ET Instanz in Abhängigkeit von der Anzahl der Variablen n und
|
||||
resultierenden HITTING SET Instanz in Abhängigkeit von der Anzahl der Variablen n und
|
||||
der Anzahl der Klauseln m aus der originalen 3-SAT Instanz an.
|
||||
2. Beweisen Sie Lower Bounds für H ITTING S ET bezüglich Universumsgröße, Anzahl der
|
||||
2. Beweisen Sie Lower Bounds für HITTING SET bezüglich Universumsgröße, Anzahl der
|
||||
Mengen und der Zahl k basierend auf der obigen Reduktion und der ETH.
|
||||
|
||||
Lösung. 1. Für die Universumsgröße gilt |U| = 2n, für die Anzahl der Mengen gilt r =
|
||||
n + m und es gilt k = n.
|
||||
2. • Angenommen es gäbe einen Algorithmus, der H ITTING S ET in 2o(|U|) · |I|O(1) löst.
|
||||
Durch die Kombination der Reduktion von 3-SAT auf H ITTING S ET und diesem
|
||||
2. • Angenommen es gäbe einen Algorithmus, der HITTING SET in 2o(|U|) · |I|O(1) löst.
|
||||
Durch die Kombination der Reduktion von 3-SAT auf HITTING SET und diesem
|
||||
Algorithmus, existiert dann auch ein 2o(n) · |I|O(1) Algorithmus für 3-SAT, da |U| =
|
||||
2n nach Aufgabenteil 1. Damit ist die ETH falsch.
|
||||
Damit kann es so einen Algorithmus für H ITTING S ET nur geben, wenn die ETH
|
||||
Damit kann es so einen Algorithmus für HITTING SET nur geben, wenn die ETH
|
||||
falsch ist.
|
||||
• Angenommen es gäbe einen Algorithmus, der H ITTING S ET in 2o(r) · |I|O(1) löst.
|
||||
Durch die Kombination der Reduktion von 3-SAT auf H ITTING S ET und diesem
|
||||
• Angenommen es gäbe einen Algorithmus, der HITTING SET in 2o(r) · |I|O(1) löst.
|
||||
Durch die Kombination der Reduktion von 3-SAT auf HITTING SET und diesem
|
||||
Algorithmus, existiert dann auch ein 2o(m) · |I|O(1) Algorithmus für 3-SAT, da r =
|
||||
n + m nach Aufgabenteil 1 und n ≤ 3m für 3-SAT gilt. Aus dem Sparsification
|
||||
Lemma folgt damit, dass die ETH falsch ist.
|
||||
Damit kann es so einen Algorithmus für H ITTING S ET nur geben, wenn die ETH
|
||||
Damit kann es so einen Algorithmus für HITTING SET nur geben, wenn die ETH
|
||||
falsch ist.
|
||||
• Angenommen es gäbe einen Algorithmus, der H ITTING S ET in 2o(k) · |I|O(1) löst.
|
||||
Durch die Kombination der Reduktion von 3-SAT auf H ITTING S ET und diesem
|
||||
• Angenommen es gäbe einen Algorithmus, der HITTING SET in 2o(k) · |I|O(1) löst.
|
||||
Durch die Kombination der Reduktion von 3-SAT auf HITTING SET und diesem
|
||||
Algorithmus, existiert dann auch ein 2o(n) · |I|O(1) Algorithmus für 3-SAT, da k = n
|
||||
nach Aufgabenteil 1. Damit ist die ETH falsch.
|
||||
Damit kann es so einen Algorithmus für H ITTING S ET nur geben, wenn die ETH
|
||||
Damit kann es so einen Algorithmus für HITTING SET nur geben, wenn die ETH
|
||||
falsch ist.
|
||||
|
||||
|
||||
@@ -695,11 +695,11 @@ blen steht. Somit kann HittingSet nicht schneller sein.
|
||||
Blatt 13
|
||||
|
||||
|
||||
Definition 1 (M AX -3-S AT). Bei dem Problem M AX -3-S AT ist eine Formel φ in konjunktiver
|
||||
Definition 1 (MAX -3-SAT). Bei dem Problem MAX -3-SAT ist eine Formel φ in konjunktiver
|
||||
Normalform gegeben, wobei jede Klausel 3 Literale enthält. Gesucht ist eine Belegung β der
|
||||
Variablen, die die Anzahl v(β ) der erfüllten Klauseln maximiert.
|
||||
|
||||
Hausaufgabe 13.1 (M AX -3-S AT) (10 Punkte)
|
||||
Hausaufgabe 13.1 (MAX -3-SAT) (10 Punkte)
|
||||
Betrachten Sie folgenden Algorithmus A:
|
||||
• Sei β0 die Belegung, die alle Variablen auf false setzt.
|
||||
• Sei β1 die Belegung, die alle Variablen auf true setzt.
|
||||
|
||||
+57
-57
@@ -25,7 +25,7 @@
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 1 von 12
|
||||
Aufgabe 1 KOMPLEXITÄT: A NWENDUNG LPT (4+2+2+2 Punkte)
|
||||
Aufgabe 1 KOMPLEXITÄT: ANWENDUNG LPT (4+2+2+2 Punkte)
|
||||
Gegeben ist folgende Scheduling Instanz:
|
||||
Jj J1 J2 J3 J4 J5 J6 J7
|
||||
m=3
|
||||
@@ -68,7 +68,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 3 von 12
|
||||
Aufgabe 2 KOMPLEXITÄT: B EWEIS : K -C LIQUE (10 Punkte)
|
||||
Aufgabe 2 KOMPLEXITÄT: BEWEIS : K -CLIQUE (10 Punkte)
|
||||
Beweisen Sie folgende Aussage aus der Vorlesung:
|
||||
SAT ≤ k-Clique.
|
||||
Hinweis: NICHT 3-SAT, sondern allgemeines SAT!
|
||||
@@ -103,9 +103,9 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 6 von 12
|
||||
Aufgabe 4 KOMPLEXITÄT: A PPROXIMATIVE A LGORITHMEN (3+7 Punkte)
|
||||
Aufgabe 4 KOMPLEXITÄT: APPROXIMATIVE ALGORITHMEN (3+7 Punkte)
|
||||
Wir betrachten folgendes Problem:
|
||||
Problem: A PPROXIMATE S UBSET S UM
|
||||
Problem: APPROXIMATE SUBSET SUM
|
||||
Eingabe: n ganze Zahlen a1 , . . . an und ein Zielwert T . Es gilt ai ≤ T ∀i ∈ [n] sowie ∑i∈[n] ai >
|
||||
T.
|
||||
Ziel: Wähle Zahlenmenge S ⊆ {1, . . . n} mit ∑ j∈S a j ≤ T und maximiere ∑ j∈S a j .
|
||||
@@ -214,7 +214,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 10 von 12
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 11 von 12
|
||||
Aufgabe 6 B ONUSAUFGABE (10 Punkte)
|
||||
Aufgabe 6 BONUSAUFGABE (10 Punkte)
|
||||
Beweisen oder widerlegen Sie folgende Aussage:
|
||||
Wenn die ETH fehlschlägt gilt P = NP.
|
||||
|
||||
@@ -251,7 +251,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 1 von 16
|
||||
Aufgabe 1 ∆T SP1 (6+2+2 Punkte)
|
||||
Aufgabe 1 ∆TSP1 (6+2+2 Punkte)
|
||||
Gegeben sei folgender Graph G = (V, E), bei dem die Kantengewichte jeweils an der nach außen
|
||||
gerichteten Seite der Kante stehen:
|
||||
|
||||
@@ -268,20 +268,20 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
d c
|
||||
2
|
||||
|
||||
a) Wenden Sie den Algorithmus ∆T SP1 auf den Graphen G beginnend bei a an. Geben Sie dabei
|
||||
a) Wenden Sie den Algorithmus ∆TSP1 auf den Graphen G beginnend bei a an. Geben Sie dabei
|
||||
die Ergebnisse jedes Zwischenschrittes des Algorithmus an.
|
||||
|
||||
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 2 von 16
|
||||
b) Beschreiben Sie, was der Algorithmus ∆T SP1 berechnet und geben Sie die Bedingungen an,
|
||||
b) Beschreiben Sie, was der Algorithmus ∆TSP1 berechnet und geben Sie die Bedingungen an,
|
||||
die an den Graphen gestellt sind, damit der Algorithmus eine korrekte Lösung berechnet.
|
||||
|
||||
|
||||
|
||||
|
||||
c) Begründen Sie, warum der Algorithmus ∆T SP1 eine approximative Güte von 2 besitzt.
|
||||
c) Begründen Sie, warum der Algorithmus ∆TSP1 eine approximative Güte von 2 besitzt.
|
||||
|
||||
|
||||
|
||||
@@ -326,11 +326,11 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 7 von 16
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 8 von 16
|
||||
Aufgabe 4 KOMPLEXITÄT: A PPROXIMATIVE A LGORITHMEN (10+(10) Punkte)
|
||||
Aufgabe 4 KOMPLEXITÄT: APPROXIMATIVE ALGORITHMEN (10+(10) Punkte)
|
||||
Wir betrachten das aus der Vorlesung bekannte Makespan Scheduling Problem P||Cmax . Dazu ist
|
||||
folgender Algorithmus gegeben:
|
||||
|
||||
Algorithmus ROUND ROBIN S CHEDULING(I=(J,m))
|
||||
Algorithmus ROUND ROBIN SCHEDULING(I=(J,m))
|
||||
|
||||
1 sortiere die Jobs in J so, dass p1 ≥ p2 ≥ · · · ≥ pn gilt;
|
||||
2 setze B1 = · · · = Bm = 0;
|
||||
@@ -509,27 +509,27 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 3 von 11
|
||||
Aufgabe 2 L ONGEST PATH (3+3+4 Punkte)
|
||||
Aufgabe 2 LONGEST PATH (3+3+4 Punkte)
|
||||
Betrachten Sie das folgende Problem:
|
||||
Problem: L ONGEST PATH
|
||||
Problem: LONGEST PATH
|
||||
Eingabe: Eine ungerichteter Graph G = (V, E) und eine Zahl k.
|
||||
Entscheide: Gibt es einen Pfad in G, der mindestens Länge k hat?
|
||||
|
||||
Zeigen Sie auf verschiedene Weisen, dass das Problem in NP liegt:
|
||||
|
||||
a) Geben Sie ein Zertifikat für L ONGEST PATH an.
|
||||
b) Beschreiben Sie einen Verifizierer für L ONGEST PATH und geben Sie dessen Laufzeit konkret
|
||||
a) Geben Sie ein Zertifikat für LONGEST PATH an.
|
||||
b) Beschreiben Sie einen Verifizierer für LONGEST PATH und geben Sie dessen Laufzeit konkret
|
||||
an.
|
||||
c) Beschreiben Sie eine nicht-deterministische Turing-Maschine (in Worten), die L ONGEST PATH
|
||||
c) Beschreiben Sie eine nicht-deterministische Turing-Maschine (in Worten), die LONGEST PATH
|
||||
löst. Geben Sie auch hier die Laufzeit konkret an.
|
||||
|
||||
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 4 von 11
|
||||
Aufgabe 3 A PPROXIMATIVE A LGORITHMEN (6+4 Punkte)
|
||||
Das Problem M AX -3-S AT sei folgendermaßen definiert:
|
||||
Problem: M AX -3-S AT
|
||||
Aufgabe 3 APPROXIMATIVE ALGORITHMEN (6+4 Punkte)
|
||||
Das Problem MAX -3-SAT sei folgendermaßen definiert:
|
||||
Problem: MAX -3-SAT
|
||||
Eingabe: Eine Formel φ in konjunktiver Normalform, wobei jede Klausel drei Literale enthält.
|
||||
Ausgabe: Eine Belegung β der Variablen, die die Anzahl v(β ) der erfüllten Klauseln maximiert.
|
||||
|
||||
@@ -550,16 +550,16 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 5 von 11
|
||||
Aufgabe 4 B EWEIS ZUR VORLESUNG (10 Punkte)
|
||||
Aufgabe 4 BEWEIS ZUR VORLESUNG (10 Punkte)
|
||||
Betrachten Sie folgendes Problem aus der Vorlesung:
|
||||
|
||||
Problem: k-C LIQUE
|
||||
Problem: k-CLIQUE
|
||||
Eingabe: Ein ungerichteter Graph G = (V, E) und eine Zahl k ≥ 1. Eine Clique ist eine Teil-
|
||||
menge C ⊆ V mit {u, v} ∈ E für alle u, v ∈ C mit u ̸= v.
|
||||
Entscheide: Hat der gegebene Graph G eine Clique C ⊆ V mit mindestens k Knoten (d.h. mit
|
||||
|C| ≥ k)?
|
||||
|
||||
Beweisen Sie: Das Problem k-C LIQUE ist NP-vollständig.
|
||||
Beweisen Sie: Das Problem k-CLIQUE ist NP-vollständig.
|
||||
|
||||
|
||||
|
||||
@@ -568,9 +568,9 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
Aufgabe 5 NP UND NP-VOLLSTÄNDIGKEIT (4+4+7 Punkte)
|
||||
|
||||
|
||||
a) Beweisen Sie die NP-Vollständigkeit von S UBSET S UM, bei der jede Itemgröße jeweils durch
|
||||
a) Beweisen Sie die NP-Vollständigkeit von SUBSET SUM, bei der jede Itemgröße jeweils durch
|
||||
3 oder durch 7 teilbar ist.
|
||||
b) Beweisen Sie die NP-Vollständigkeit von 3-C OLOR, wobei jeder Knoten im Graph mindes-
|
||||
b) Beweisen Sie die NP-Vollständigkeit von 3-COLOR, wobei jeder Knoten im Graph mindes-
|
||||
tens Grad 3 hat.
|
||||
c) Zeigen Sie: Für kein α > 1 gibt es einen approximativen Algorithmus mit Güte α (d.h. mit
|
||||
Zielfunktionswert ≤ α · OPT) für TSP, außer P = NP (Denken Sie an Wiliam Rowan Hamil-
|
||||
@@ -581,23 +581,23 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 7 von 11
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 8 von 11
|
||||
Aufgabe 6 U NTERE S CHRANKEN UND ETH (7+8 Punkte)
|
||||
Aufgabe 6 UNTERE SCHRANKEN UND ETH (7+8 Punkte)
|
||||
Betrachten Sie folgende Reduktionen:
|
||||
Problem: H ITTING S ET
|
||||
Problem: HITTING SET
|
||||
Eingabe: Eine Menge U sowie r Teilmengen F1 , . . . , Fr ⊆ U und eine Zahl k.
|
||||
Entscheide: Gibt es eine Menge S ⊆ U mit |S| ≤ k und S ∩ Fi ̸= 0/ für alle i ∈ [r]?
|
||||
|
||||
a) 3-SAT ⪯ H ITTING S ET: Seien v1 , . . . , vn die Variablen der Formel und C1 , . . . ,Cm die Klau-
|
||||
a) 3-SAT ⪯ HITTING SET: Seien v1 , . . . , vn die Variablen der Formel und C1 , . . . ,Cm die Klau-
|
||||
seln. Setze U = { v1 , v1 , . . . , vn , vn } und k = n. Für jede Variable vi definiere eine Menge
|
||||
Fi = {vi , vi } und für jede Klausel C j definiere eine Menge Fn+ j = C j
|
||||
Welche unteren Schranken ergeben sich durch obige Reduktion für die Laufzeit von Algorith-
|
||||
men für H ITTING S ET unter Annahme der ETH in Abhängigkeit von
|
||||
men für HITTING SET unter Annahme der ETH in Abhängigkeit von
|
||||
(i) r?
|
||||
(ii) |U|?
|
||||
Beweisen Sie die entsprechenden unteren Schranken!
|
||||
Hinweis: Sie dürfen – wie in den Hausaufgaben zur ETH – Faktoren, die polynomiell in der
|
||||
Eingabekodierung sind, vernachlässigen.
|
||||
b) 3-S AT ⪯ S UBSET S UM: Seien v1 , . . . , vn die Variablen der Formel und C1 , . . . ,Cm die Klauseln.
|
||||
b) 3-SAT ⪯ SUBSET SUM: Seien v1 , . . . , vn die Variablen der Formel und C1 , . . . ,Cm die Klauseln.
|
||||
Für jede Variable vi erzeuge zwei Items ai und bi , mit den Größen
|
||||
|
||||
s(ai ) = 10i−1 + ∑ 10n+ j−1 und s(bi ) = 10i−1 + ∑ 10n+ j−1 .
|
||||
@@ -614,7 +614,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
j∈[m] i∈[n]
|
||||
|
||||
Welche unteren Schranken ergeben sich durch obige Reduktion für die Laufzeit von Algorith-
|
||||
men für S UBSET S UM unter Annahme der ETH in Abhängigkeit von
|
||||
men für SUBSET SUM unter Annahme der ETH in Abhängigkeit von
|
||||
(i) der Anzahl der unterschiedlichen Itemgrößen d?
|
||||
(ii) der binären Kodierungslänge L der größten Itemgröße ∆ (d.h. L = log(∆))?
|
||||
Beweisen Sie die entsprechenden unteren Schranken!
|
||||
@@ -654,7 +654,7 @@ Hinweise:
|
||||
• Einsichtnahme: 2.11.23, 13:30-14:30 Uhr.
|
||||
• Die Klausur hat 6 Aufgaben auf 13 Seiten – überprüfen Sie bitte vor der Bearbeitung, dass Ihre Klausur
|
||||
vollständig ist.
|
||||
Aufgabe 1 C HRISTOFIDES ’ A LGORITHMUS (3+7 Punkte)
|
||||
Aufgabe 1 CHRISTOFIDES ’ ALGORITHMUS (3+7 Punkte)
|
||||
Gegeben sei folgender Graph G = (V, E) :
|
||||
|
||||
d
|
||||
@@ -688,29 +688,29 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 2 von 19
|
||||
Aufgabe 2 D OMINATING S ET (3+3+4 Punkte)
|
||||
Aufgabe 2 DOMINATING SET (3+3+4 Punkte)
|
||||
Betrachten Sie das folgende Problem:
|
||||
Problem: D OMINATING S ET
|
||||
Problem: DOMINATING SET
|
||||
Eingabe: Ein ungerichteter Graph G = (V, E) (wobei V = {1, . . . , n} für eine Zahl n ∈ N≥1 )
|
||||
und eine Zahl k ∈ N0 .
|
||||
Entscheide: Gibt es eine Teilmenge D ⊆ V der Knoten mit |D| ≤ k, sodass jeder Knoten v ∈ V
|
||||
in D enthalten ist oder benachbart zu mindestens einem der Knoten in D ist?
|
||||
|
||||
Zeigen Sie auf verschiedene Weisen, dass das Problem in NP liegt:
|
||||
a) Geben Sie ein Zertifikat für D OMINATING S ET an.
|
||||
b) Beschreiben Sie einen polynomiellen Verifizierer für D OMINATING S ET und schätzen Sie die
|
||||
a) Geben Sie ein Zertifikat für DOMINATING SET an.
|
||||
b) Beschreiben Sie einen polynomiellen Verifizierer für DOMINATING SET und schätzen Sie die
|
||||
Laufzeit in O-Notation konkret ab.
|
||||
c) Beschreiben Sie (in Worten) eine polynomielle, nicht-deterministische Turing-Maschine, die
|
||||
D OMINATING S ET löst. Schätzen Sie auch hier die Laufzeit in O-Notation konkret ab.
|
||||
DOMINATING SET löst. Schätzen Sie auch hier die Laufzeit in O-Notation konkret ab.
|
||||
|
||||
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 3 von 19
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 4 von 19
|
||||
Aufgabe 3 A PPROXIMATIVE A LGORITHMEN (6+4 Punkte)
|
||||
Das Problem M IN -E DGE -C OVER sei folgendermaßen definiert:
|
||||
Problem: M IN -E DGE -C OVER
|
||||
Aufgabe 3 APPROXIMATIVE ALGORITHMEN (6+4 Punkte)
|
||||
Das Problem MIN -EDGE -COVER sei folgendermaßen definiert:
|
||||
Problem: MIN -EDGE -COVER
|
||||
Eingabe: Ein zusammenhängender, ungerichteter Graph G = (V, E).
|
||||
Ausgabe: Eine Teilmenge der Kanten C ⊆ E mit minimaler Kardinalität, sodass jeder Knoten
|
||||
v ∈ V zu mindestens einer Kante aus C inzident ist.
|
||||
@@ -731,7 +731,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 5 von 19
|
||||
b) Sei n ∈ N≥4 gerade. Betrachten Sie den Graphen Gn , der genau n Knoten und n Kanten hat und
|
||||
einen Kreis beschreibt. D.h. Gn = ({1, . . . , n}, {{i, i+1} | i ∈ {1, . . . , n−1}}∪{{n, 1}}). Wie viele
|
||||
Kanten hat ein M IN -E DGE -C OVER des Graphen Gn ? Wie schlecht ist die Approximationsrate
|
||||
Kanten hat ein MIN -EDGE -COVER des Graphen Gn ? Wie schlecht ist die Approximationsrate
|
||||
des Algorithmus A für Gn im schlimmsten Fall? Geben Sie eine Reihenfolge der Knoten (für
|
||||
Schritt 1) und eine Auswahl der Kanten (für Schritt 2) an, die zu einer möglichst schlechten
|
||||
Approximationsrate führen.
|
||||
@@ -740,7 +740,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
|
||||
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 6 von 19
|
||||
Aufgabe 4 B EWEIS – S CHEDULING AUF IDENTISCHEN M ASCHINEN (10 Punkte)
|
||||
Aufgabe 4 BEWEIS – SCHEDULING AUF IDENTISCHEN MASCHINEN (10 Punkte)
|
||||
Betrachten Sie das Scheduling-Problem P||Cmax :
|
||||
Problem: P||Cmax
|
||||
Eingabe: Eine Liste L = (J1 , . . . , Jn ) von n Jobs mit Ausführungszeiten p1 , . . . , pn ∈ N, und m
|
||||
@@ -755,7 +755,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
LS(I) die Last Cmax eines Schedules des Algorithmus, und OPT(I) die Last Cmax eines optimalen
|
||||
Schedules zur Eingabe I. Zeigen Sie: Für alle Eingaben I = (L, m) gilt LS(I)/OPT(I) ≤ 2 − 1/m.
|
||||
|
||||
Algorithmus L IST S CHEDULING(L = (J1 , . . . , Jn ), m)
|
||||
Algorithmus LIST SCHEDULING(L = (J1 , . . . , Jn ), m)
|
||||
|
||||
1 for i = 1 to m do
|
||||
2 Ei = 0; Bi = 0/ ;
|
||||
@@ -774,19 +774,19 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 8 von 19
|
||||
Aufgabe 5 NP UND NP-VOLLSTÄNDIGKEIT (4+5+6 Punkte)
|
||||
|
||||
Problem: H AMILTONIAN PATH
|
||||
Problem: HAMILTONIAN PATH
|
||||
Eingabe: Ein ungerichteter Graph G = (V, E)
|
||||
Entscheide: Existiert ein Pfad in G, der jeden Knoten genau einmal besucht?
|
||||
Problem: H AMILTONIAN C YCLE
|
||||
Problem: HAMILTONIAN CYCLE
|
||||
Eingabe: Ein ungerichteter Graph G = (V, E)
|
||||
Entscheide: Existiert ein Kreis in G, der jeden Knoten genau einmal besucht?
|
||||
|
||||
(a) Sei L ⊆ S UBSET S UM die Menge der (positiven) SubsetSum-Instanzen, bei denen keine der
|
||||
(a) Sei L ⊆ SUBSET SUM die Menge der (positiven) SubsetSum-Instanzen, bei denen keine der
|
||||
Itemgrößen eine Zweierpotenz ist. Zeigen Sie, dass L NP-vollständig ist.
|
||||
(b) Geben Sie eine polynomielle Reduktion von H AMILTONIAN PATH auf H AMILTONIAN C Y-
|
||||
(b) Geben Sie eine polynomielle Reduktion von HAMILTONIAN PATH auf HAMILTONIAN C Y-
|
||||
CLE an.
|
||||
|
||||
(c) Geben Sie eine polynomielle Reduktion von H AMILTONIAN C YCLE auf H AMILTONIAN -
|
||||
(c) Geben Sie eine polynomielle Reduktion von HAMILTONIAN CYCLE auf HAMILTONIAN -
|
||||
PATH an.
|
||||
|
||||
|
||||
@@ -795,14 +795,14 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 9 von 19
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 10 von 19
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 11 von 19
|
||||
Aufgabe 6 U NTERE S CHRANKEN UND ETH (5+5 Punkte)
|
||||
Aufgabe 6 UNTERE SCHRANKEN UND ETH (5+5 Punkte)
|
||||
Im Folgenden bezeichne ⟨I⟩ die Kodierungslänge der Instanz.
|
||||
(a) Wir betrachten folgendes Problem:
|
||||
Problem: H ITTING S ET
|
||||
Problem: HITTING SET
|
||||
Eingabe: Eine Menge U ′ sowie r′ Teilmengen F1′ , . . . , Fr′′ ⊆ U ′ und eine Zahl k′ .
|
||||
Entscheide: Gibt es eine Menge S′ ⊆ U ′ mit |S′ | ≤ k′ und S′ ∩ Fi′ ̸= 0/ für alle i′ ∈ {1, . . . , r′ }?
|
||||
|
||||
Sie dürfen nutzen, dass sich H ITTING S ET unter Annahme der ETH nicht in Zeit 2o(r ) ⟨I⟩O(1)
|
||||
Sie dürfen nutzen, dass sich HITTING SET unter Annahme der ETH nicht in Zeit 2o(r ) ⟨I⟩O(1)
|
||||
′
|
||||
|
||||
|
||||
@@ -810,7 +810,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
′
|
||||
|
||||
|
||||
Problem: S ET C OVER
|
||||
Problem: SET COVER
|
||||
Eingabe: Eine Menge U sowie r Teilmengen F1 , . . . , Fr ⊆ U und eine Zahl k.
|
||||
Entscheide: Gibt es eine Menge S ⊆ {1, . . . , r} mit |S| ≤ k und i∈S Fi = U?
|
||||
S
|
||||
@@ -819,9 +819,9 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
Welche unteren Schranken ergeben sich
|
||||
(i) in Hinblick auf |U| sowie
|
||||
(ii) in Hinblick auf r
|
||||
für S ET C OVER unter Annahme der ETH aus den unteren Schranken für H ITTING S ET, wenn
|
||||
für SET COVER unter Annahme der ETH aus den unteren Schranken für HITTING SET, wenn
|
||||
man folgende Reduktion von SetCover auf HittingSet verwendet?
|
||||
Sei (F ,U, k) eine S ET C OVER-Instanz. Setze V1 := U und V2 := {1, . . . , r}. Sei V := V1 ∪V˙ 2.
|
||||
Sei (F ,U, k) eine SET COVER-Instanz. Setze V1 := U und V2 := {1, . . . , r}. Sei V := V1 ∪V˙ 2.
|
||||
Weiter sei E = { {v, w} | v ∈ V1 , w ∈ V2 , v ∈ Fw }. Damit ist G = (V, E) ein bipartiter Graph
|
||||
mit Partitionen V1 ,V2 . Setze U ′ := {1, . . . , r}. Erzeuge nun eine Menge Fw′ für jeden Knoten
|
||||
w ∈ V1 , indem Fw′ := { v ∈ V2 | {v, w} ∈ E } gesetzt wird. Sei F ′ := { Fw′ | w ∈ V1 }. Mit k′ = k
|
||||
@@ -833,14 +833,14 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)« Seite 12 von 19
|
||||
(b) Wir wollen uns mit einem Problem beschäftigen, das ein mächtiges Werkzeug im Bereich
|
||||
der Optimierung ist: Integrale Lineare Programme (ILPs).
|
||||
Problem: ILP-F EASIBILITY
|
||||
Problem: ILP-FEASIBILITY
|
||||
Eingabe: Eine Matrix A ∈ ZM×N , eine rechte Seite b ∈ ZM .
|
||||
Entscheide: Gibt es einen Vektor x ∈ ZN≥0 mit Ax ≤ b, d.h. Ai xi ≤ bi für alle i ∈ {1, . . . , M}?
|
||||
Welche unteren Schranken ergeben sich
|
||||
(i) in Hinblick auf M sowie
|
||||
(ii) im Hinblick auf N
|
||||
für ILP-F EASIBILITY unter Annahme der ETH, wenn man folgende Reduktion von 3-SAT
|
||||
auf ILP-F EASIBILITY verwendet?
|
||||
für ILP-FEASIBILITY unter Annahme der ETH, wenn man folgende Reduktion von 3-SAT
|
||||
auf ILP-FEASIBILITY verwendet?
|
||||
Sei φ eine 3-SAT Instanz mit m Klauseln (Ci )i∈m und n Variablen. Analog zu linearen Glei-
|
||||
chungssystemen (Ax = b), die aus M Gleichungen bestehen, haben wir bei Ax ≤ b einen
|
||||
Satz von M linearen Ungleichungen. Wir konstruieren diese wie folgt: Wir nutzen N := 2n
|
||||
@@ -850,7 +850,7 @@ Klausur »Analyse von Algorithmen und Komplexität (Komplexitätsteil)«
|
||||
chung − ∑ℓ∈Ci xℓ ≤ −1. Weiter erzeugen wir für jede SAT-Variable v zwei Ungleichungen:
|
||||
xv +xv̄ ≤ 1 und −xv −xv̄ ≤ −1. Durch das Eintragen der entsprechenden Koeffizienten in eine
|
||||
erweiterte Koeffizientenmatrix (bestehend aus Matrix A und rechter Seite b) erhalten wir die
|
||||
ILP-F EASIBILITY Instanz.
|
||||
ILP-FEASIBILITY Instanz.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Definition 1 (Knapsack (Entscheidungsvariante)). Gegeben ist eine Menge mit n Gegenständen
|
||||
mit Gewichten w1 , . . . , wn ∈ N≥0 und Profiten p1 , . . . , pn ∈ N≥0 . Weiter sind eine Kapazität
|
||||
K ∈ N≥0 und ein Zielprofit P ∈ N≥0 gegeben. Bei dem K NAPSACK Problem muss entschieden
|
||||
K ∈ N≥0 und ein Zielprofit P ∈ N≥0 gegeben. Bei dem KNAPSACK Problem muss entschieden
|
||||
werden, ob eine Teilmenge S ⊆ [n] existiert, sodass
|
||||
|
||||
∑ wi ≤ K und ∑ pi ≥ P
|
||||
@@ -19,8 +19,8 @@ werden, ob eine Teilmenge S ⊆ [n] existiert, sodass
|
||||
|
||||
gilt.
|
||||
|
||||
Präsenzaufgabe 9.1 (K NAPSACK in NP)
|
||||
Zeigen Sie K NAPSACK ∈ NP auf zwei verschiedene Wege.
|
||||
Präsenzaufgabe 9.1 (KNAPSACK in NP)
|
||||
Zeigen Sie KNAPSACK ∈ NP auf zwei verschiedene Wege.
|
||||
1. Geben Sie einen nicht deterministischen Algorithmus an, der das Problem in polynomi-
|
||||
eller Zeit löst.
|
||||
2. Geben Sie einen polynomiellen Verifizierer für das Problem an.
|
||||
@@ -34,7 +34,7 @@ Lösung. 1. Der Algorithmus arbeitet wie folgt: Für jeden Gegenstand entsche
|
||||
auch vom Algorithmus akzeptiert.
|
||||
Der Algorithmus macht polynomiell viele nichtdeterministische Entscheidungen und die
|
||||
Überprüfung der Eigenschaften ist ebenfalls in Polynomialzeit möglich, also ist dies ein
|
||||
polynomieller, nichtdeterministischer Algorithmus, der K NAPSACK löst.
|
||||
polynomieller, nichtdeterministischer Algorithmus, der KNAPSACK löst.
|
||||
2. Unser Zertifikat ist eine Teilmenge S ⊆ [n], die die Auswahl für den Rucksack darstellen
|
||||
soll. Dies kann zum Beispiel über ein Bit pro Gegenstand realisiert werden. Um dieses
|
||||
Zertifikat zu verifizieren, berechnen wir ∑i∈S wi und ∑i∈S pi und vergleichen die Werte
|
||||
@@ -88,7 +88,7 @@ Lösung. (i) Die Aussage gilt nur, wenn P = NP. Dann liegen alle Probleme in NP
|
||||
Falls P ̸= NP, dann nein, weil sonst folgender Widerspruch auftritt: ein Problem in NP \ P
|
||||
ist von einer NDTM, nicht aber von einer DTM in polynomieller Zeit lösbar. Könnte eine
|
||||
DTM die NDTM in polynomieller Zeit simulieren, wäre das Problem auch in P.
|
||||
(ii) Falsch. Es gibt NP-schwere Probleme, die nicht in NP liegen, z.B. das H ALTE-Problem.
|
||||
(ii) Falsch. Es gibt NP-schwere Probleme, die nicht in NP liegen, z.B. das HALTE-Problem.
|
||||
Diese liegen folglich auch definitiv nicht in P. NP-schwer ist eine untere Schranke, ∈ NP
|
||||
eine obere, und wenn beide erfüllt sind, ist ein Problem NP-vollständig.
|
||||
|
||||
@@ -97,27 +97,27 @@ Lösung. (i) Die Aussage gilt nur, wenn P = NP. Dann liegen alle Probleme in NP
|
||||
Definition 1 (Vertex Cover (VC)). Für einen (ungerichteten) Graphen G = (V, E) ist ein Vertex
|
||||
Cover eine Menge von Knoten C ⊆ V , sodass u ∈ C oder v ∈ C für alle { u, v } ∈ E gilt, also ist
|
||||
für alle Kanten mindestens ein Endpunkt in C.
|
||||
Für das V ERTEX C OVER Problem ist neben einem Graphen G eine Zahl k ∈ N≥0 gegeben und
|
||||
Für das VERTEX COVER Problem ist neben einem Graphen G eine Zahl k ∈ N≥0 gegeben und
|
||||
es wird gefragt, ob ein Vertex Cover mit Größe (Anzahl der Knoten) höchstens k in G existiert.
|
||||
|
||||
Präsenzaufgabe 10.2 (V ERTEX C OVER ist NP-vollständig)
|
||||
Zeigen Sie, dass V ERTEX C OVER NP-vollständig ist.
|
||||
Hinweis: Reduzieren Sie C LIQUE auf V ERTEX C OVER.
|
||||
Präsenzaufgabe 10.2 (VERTEX COVER ist NP-vollständig)
|
||||
Zeigen Sie, dass VERTEX COVER NP-vollständig ist.
|
||||
Hinweis: Reduzieren Sie CLIQUE auf VERTEX COVER.
|
||||
|
||||
Lösung. V ERTEX C OVER ∈ NP ist bekannt aus der letzten Hausaufgabe.
|
||||
Vorüberlegung zur Reduktion von C LIQUE auf V ERTEX C OVER: Sei eine Instanz mit G =
|
||||
(V, E) und k ∈ N≥0 von C LIQUE gegeben. Wir invertieren zunächst den Graphen G und erhal-
|
||||
Lösung. VERTEX COVER ∈ NP ist bekannt aus der letzten Hausaufgabe.
|
||||
Vorüberlegung zur Reduktion von CLIQUE auf VERTEX COVER: Sei eine Instanz mit G =
|
||||
(V, E) und k ∈ N≥0 von CLIQUE gegeben. Wir invertieren zunächst den Graphen G und erhal-
|
||||
ten damit G′ . Nun enthält G′ genau dann eine unabhängige Menge der Größe ℓ, wenn G eine
|
||||
Clique der Größe ℓ enthält. Wenn ein Graph mit n Knoten eine unabhängige Menge mit min-
|
||||
destens k Knoten hat, dann hat der Graph auch ein Vertex Cover mit maximal n − k Knoten
|
||||
(das Komplement zur unabhängigen Menge). Unsere Instanz für V ERTEX C OVER ist also G′
|
||||
(das Komplement zur unabhängigen Menge). Unsere Instanz für VERTEX COVER ist also G′
|
||||
und n − k. (k ≤ n können wir annehmen)
|
||||
Betrachte nun folgende Reduktion:
|
||||
Sei eine Instanz mit G = (V, E) und k ∈ N≥0 von C LIQUE gegeben. Invertiere den Graphen G
|
||||
und erhalte damit G′ . Gebe nun G′ und n − k als Instanz von V ERTEX C OVER zurück.
|
||||
Sei eine Instanz mit G = (V, E) und k ∈ N≥0 von CLIQUE gegeben. Invertiere den Graphen G
|
||||
und erhalte damit G′ . Gebe nun G′ und n − k als Instanz von VERTEX COVER zurück.
|
||||
Korrektheit:
|
||||
⇒ (Falls (G, k) eine Ja-Instanz von C LIQUE ist, dann ist (G′ , n − k) eine Ja-Instanz von V ER -
|
||||
TEX C OVER .):
|
||||
⇒ (Falls (G, k) eine Ja-Instanz von CLIQUE ist, dann ist (G′ , n − k) eine Ja-Instanz von VER -
|
||||
TEX COVER .):
|
||||
|
||||
G enthalte eine Clique C der Größe ≥ k. Dann ist V ′ := V \C ein Vertex Cover von G′ der Größe
|
||||
≤ n − k. Da |C| ≥ k, gilt |V ′ | ≤ n − k. Da C eine Clique ist, also alle Knoten aus C mit allen
|
||||
@@ -125,8 +125,8 @@ anderen Knoten aus C verbunden sind, gibt es im invertierten Graphen G′ keine
|
||||
Knoten aus C. Also gilt für jede Kante {u, v} ∈ E ′ , dass u ∈ / C und somit u ∈ V ′ oder
|
||||
/ C oder v ∈
|
||||
v ∈ V ′.
|
||||
⇐ (Falls (G′ , n − k) eine Ja-Instanz von V ERTEX C OVER ist, dann ist (G, k) eine Ja-Instanz von
|
||||
C LIQUE.):
|
||||
⇐ (Falls (G′ , n − k) eine Ja-Instanz von VERTEX COVER ist, dann ist (G, k) eine Ja-Instanz von
|
||||
CLIQUE.):
|
||||
G′ enthalte ein Vertex Cover V ′ der Größe ≤ n−k. Dann ist C := V \V ′ eine Clique der Größe ≥
|
||||
k in G. Da |V ′ | ≤ n − k, gilt |C| ≥ k. Für jede Menge {u, v} ⊆ C gilt, dass {u, v} ∈
|
||||
/ E ′ . Ansonsten
|
||||
@@ -134,8 +134,8 @@ wäre V ′ kein korrektes Vertex Cover. Da G′ der invertierte Graph von G ist
|
||||
für jede Menge {u, v} ⊆ C.
|
||||
Laufzeit: Das Invertieren des Graphen kann naiv in O(|V |2 · |E|) Zeit, also in polynomieller
|
||||
Zeit, erledigt werden.
|
||||
Also existiert eine polynomielle Reduktion von C LIQUE auf V ERTEX C OVER. Da C LIQUE
|
||||
NP-vollständig ist und V ERTEX C OVER ∈ NP gilt, ist somit V ERTEX C OVER NP-vollständig.
|
||||
Also existiert eine polynomielle Reduktion von CLIQUE auf VERTEX COVER. Da CLIQUE
|
||||
NP-vollständig ist und VERTEX COVER ∈ NP gilt, ist somit VERTEX COVER NP-vollständig.
|
||||
|
||||
|
||||
Präsenzaufgabe 10.3 (Turingmaschinen)
|
||||
@@ -187,7 +187,7 @@ Diskutieren Sie nachfolgende Aussagen. Sind diese wahr oder falsch?
|
||||
(a) Für L ∈ NP gilt, aus L ⪯ 3-SAT folgt, dass L NP-vollständig ist.
|
||||
(b) Gilt für eine Sprache L, dass sowohl L ⪯ 3-SAT als auch 3-SAT ⪯ L, dann ist L NP-vollständig.
|
||||
(c) Sei L NP-vollständig. Dann gilt L ∈ P genau dann, wenn P = NP.
|
||||
(d) Es ist möglich, dass 3-SAT ∈ P und C LIQUE ∈
|
||||
(d) Es ist möglich, dass 3-SAT ∈ P und CLIQUE ∈
|
||||
/ P.
|
||||
|
||||
Lösung.
|
||||
@@ -197,59 +197,59 @@ Lösung.
|
||||
(b) Das ist die Definition von NP-vollständig.
|
||||
(c) Korrekt, da sich jedes Problem auf L reduzieren lässt nach Def. NP-vollständig (und offen-
|
||||
sichtlich P ⊆ NP).
|
||||
(d) Nein, weil 3-SAT NP-vollständig ist, lässt sich C LIQUE (liegt bekannterweise in NP, ist
|
||||
(d) Nein, weil 3-SAT NP-vollständig ist, lässt sich CLIQUE (liegt bekannterweise in NP, ist
|
||||
sogar NP-vollständig) auf 3-SAT reduzieren.
|
||||
|
||||
|
||||
|
||||
Präsenzaufgabe 11.2 (NP-Vollständigkeit)
|
||||
In dem Problem C LIQUE -N OMEMBER ist ein Graph G = (V, E), ein Knoten v ∈ V sowie eine
|
||||
In dem Problem CLIQUE -NOMEMBER ist ein Graph G = (V, E), ein Knoten v ∈ V sowie eine
|
||||
Zahl k ∈ N gegeben und es soll entschieden werden, ob es eine Clique mit k Knoten gibt, die
|
||||
den Knoten v nicht enthält.
|
||||
Zeigen Sie, dass C LIQUE -N OMEMBER NP-vollständig ist, indem Sie eine Reduktion von C LI -
|
||||
QUE auf C LIQUE -N OMEMBER angeben. Sie können davon ausgehen, dass C LIQUE -N OMEMBER ∈
|
||||
Zeigen Sie, dass CLIQUE -NOMEMBER NP-vollständig ist, indem Sie eine Reduktion von CLI -
|
||||
QUE auf CLIQUE -NOMEMBER angeben. Sie können davon ausgehen, dass CLIQUE -NOMEMBER ∈
|
||||
NP ist.
|
||||
|
||||
Lösung. Clique-Nomember ist NP-schwer:
|
||||
Sei (G = (V, E), k) eine Eingabe für das Clique Problem.
|
||||
Idee: Füge einen neuen isolierten Knoten v zu G hinzu.
|
||||
Wir geben zuerst die Abbildung an: Sei v ̸∈ V . Wir definieren die Eingabe für das Problem
|
||||
C LIQUE -N OMEMBER als den Graphen G′ := (V ∪ {v}, E), den Knoten v als Knoten, der nicht
|
||||
CLIQUE -NOMEMBER als den Graphen G′ := (V ∪ {v}, E), den Knoten v als Knoten, der nicht
|
||||
benutzt werden darf, und k als gesuchte Cliquengröße. (G′ , v, k) ist eine gültige Eingabe für
|
||||
C LIQUE -N OMEMBER und in polynomieller Zeit berechenbar.
|
||||
CLIQUE -NOMEMBER und in polynomieller Zeit berechenbar.
|
||||
Wir zeigen nun: G hat eine k-Clique genau dann, wenn G′ eine k-Clique ohne v beinhaltet.
|
||||
⇒:
|
||||
Sei (G, k) eine Ja-Instanz des C LIQUE Problems. Dann enthält G eine k-Clique C. Dann ist C
|
||||
Sei (G, k) eine Ja-Instanz des CLIQUE Problems. Dann enthält G eine k-Clique C. Dann ist C
|
||||
auch eine k-Clique in G′ , da bei der Abbildung keine Kanten entfernt wurden. Da v ̸∈ V , enthält
|
||||
C den Knoten v nicht. Damit ist C eine k-Clique in G ohne den Knoten v. (G′ , v, k) ist also eine
|
||||
Ja-Instanz des C LIQUE -N OMEMBER Problems.
|
||||
Ja-Instanz des CLIQUE -NOMEMBER Problems.
|
||||
⇐:
|
||||
Sei (G′ , v, k) eine Ja-Instanz des C LIQUE -N OMEMBER Problems. Dann enthält G′ eine k-Clique
|
||||
Sei (G′ , v, k) eine Ja-Instanz des CLIQUE -NOMEMBER Problems. Dann enthält G′ eine k-Clique
|
||||
C mit v ̸∈ C. Da bei der Abbildung an dem Graphen nichts geändert wurde, außer der Knoten v
|
||||
hinzugefügt, muss C auch eine k-Clique in G sein. Damit ist (G, k) eine Ja-Instanz des C LIQUE
|
||||
hinzugefügt, muss C auch eine k-Clique in G sein. Damit ist (G, k) eine Ja-Instanz des CLIQUE
|
||||
Problems.
|
||||
Also existiert eine polynomielle Reduktion von C LIQUE auf C LIQUE -N OMEMBER. Da C LI -
|
||||
QUE NP-vollständig ist und C LIQUE -N OMEMBER ∈ NP nach Aufgabenstellung gilt, ist somit
|
||||
C LIQUE -N OMEMBER NP-vollständig.
|
||||
Also existiert eine polynomielle Reduktion von CLIQUE auf CLIQUE -NOMEMBER. Da CLI -
|
||||
QUE NP-vollständig ist und CLIQUE -NOMEMBER ∈ NP nach Aufgabenstellung gilt, ist somit
|
||||
CLIQUE -NOMEMBER NP-vollständig.
|
||||
|
||||
|
||||
|
||||
Präsenzaufgabe 11.3 (NP-Vollständigkeit)
|
||||
Für das Problem S UBSET S UM C ARDINALITY seien n ganze Zahlen c1 , . . . , cn ∈ N>0 (wobei n
|
||||
Für das Problem SUBSET SUM CARDINALITY seien n ganze Zahlen c1 , . . . , cn ∈ N>0 (wobei n
|
||||
gerade sei) sowie eine Zahl K ∈ N gegeben. Es soll entschieden werden, ob es eine Teilmenge
|
||||
S ⊂ {1, . . . , n} gibt mit |S| = n/2 und ∑i∈S ci = K.
|
||||
Zeigen Sie, dass das Problem S UBSET S UM C ARDINALITY NP-vollständig ist. Sie können
|
||||
davon ausgehen, dass S UBSET S UM C ARDINALITY ∈ NP ist.
|
||||
Zeigen Sie, dass das Problem SUBSET SUM CARDINALITY NP-vollständig ist. Sie können
|
||||
davon ausgehen, dass SUBSET SUM CARDINALITY ∈ NP ist.
|
||||
|
||||
Lösung. Subset Sum Cardinality ist NP-schwer:
|
||||
S UBSET S UM Problem: Gegeben: n ganze Zahlen c1 , . . . , cn ∈ N>0 und Zahl K ∈ N. Frage: Gibt
|
||||
SUBSET SUM Problem: Gegeben: n ganze Zahlen c1 , . . . , cn ∈ N>0 und Zahl K ∈ N. Frage: Gibt
|
||||
es eine Teilmenge S ⊆ {0, . . . , n} mit ∑i∈S ci = K?
|
||||
Reduktion: Sei I = (c1 , . . . , cn , K) eine Eingabe für das S UBSET S UM Problem. Definiere c′i :=
|
||||
Reduktion: Sei I = (c1 , . . . , cn , K) eine Eingabe für das SUBSET SUM Problem. Definiere c′i :=
|
||||
ci + 1 für i ≤ n und c′i := 1 für i ∈ {n + 1, . . . , 2n}. Wir definieren die Eingabe für das Problem
|
||||
S UBSET S UM C ARDINALITY als I ′ = (c′1 , . . . , c′2n , K + n).
|
||||
SUBSET SUM CARDINALITY als I ′ = (c′1 , . . . , c′2n , K + n).
|
||||
Wir zeigen nun: Es gibt eine Lösung für I genau dann, wenn es eine Lösung für I ′ gibt.
|
||||
⇒:
|
||||
Sei I eine Ja-Instanz des S UBSET S UM Problems. Dann gibt es eine Menge S ⊆ {1, . . . , n} mit
|
||||
Sei I eine Ja-Instanz des SUBSET SUM Problems. Dann gibt es eine Menge S ⊆ {1, . . . , n} mit
|
||||
∑i∈S ci = K. Es gilt also ∑i∈S c′i = ∑i∈S (ci + 1) = K + |S|. Sei nun S′ = {n + 1, . . . , 2n − |S|}. Es
|
||||
gilt |S ∪ S′ | = n und ∑i∈S∪S′ c′i = ∑i∈S c′i + ∑i∈S′ c′i = ∑i∈S (ci + 1) + ∑i∈S′ 1 = K + |S| + (n − |S|) =
|
||||
K + n. S ∪ S′ ist also eine Lösung für I ′ und I ′ damit eine Ja-Instanz.
|
||||
@@ -261,9 +261,9 @@ K + n − (n − |S|) = K + |S|, woraus folgt, dass ∑i∈S ci = K. Damit ist S
|
||||
somit I eine Ja-Instanz.
|
||||
Laufzeit: Die Reduktion kann naiv in einer Zeit von O(n) implementiert werden. Das ist eine
|
||||
polynomielle Laufzeit.
|
||||
Also existiert eine polynomielle Reduktion von S UBSET S UM auf S UBSET S UM C ARDINALI -
|
||||
TY . Da nach Skript S UBSET S UM NP-vollständig ist und S UBSET S UM C ARDINALITY ∈ NP
|
||||
nach Aufgabenstellung gilt, ist somit S UBSET S UM C ARDINALITY NP-vollständig.
|
||||
Also existiert eine polynomielle Reduktion von SUBSET SUM auf SUBSET SUM CARDINALI -
|
||||
TY . Da nach Skript SUBSET SUM NP-vollständig ist und SUBSET SUM CARDINALITY ∈ NP
|
||||
nach Aufgabenstellung gilt, ist somit SUBSET SUM CARDINALITY NP-vollständig.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Algorithmen und Komplexität
|
||||
Prof. Dr. K. Jansen, B. Schumacher, C. Wambsganz
|
||||
@@ -287,12 +287,12 @@ Vorlesung bekannten) Reduktionen (Laufzeitfaktoren, die polynomiell in der Einga
|
||||
sind, dürfen vernachlässigt werden)?
|
||||
Schätzen Sie dazu zunächst die verschiedenen Parameter der resultierenden Instanz mithilfe der
|
||||
Parameter der eingegebenen Instanz geeignet ab.
|
||||
1. 3-SAT ⪯ k-C LIQUE
|
||||
2. k-C LIQUE ⪯ k-I NDEPENDENT S ET (Problemdefinition für die Antwort nicht relevant; in
|
||||
1. 3-SAT ⪯ k-CLIQUE
|
||||
2. k-CLIQUE ⪯ k-INDEPENDENT SET (Problemdefinition für die Antwort nicht relevant; in
|
||||
der Reduktion wird der Graph G = (V, E) auf den Komplementgraph (V, E) abgebildet)
|
||||
3. SAT ⪯ 3-D IMENSIONAL M ATCHING (Hinweis: 3-SAT ⊆ SAT)
|
||||
4. 3-D IMENSIONAL M ATCHING ⪯ 3-E XACT C OVER
|
||||
5. 3-E XACT C OVER ⪯ S UBSET S UM
|
||||
3. SAT ⪯ 3-DIMENSIONAL MATCHING (Hinweis: 3-SAT ⊆ SAT)
|
||||
4. 3-DIMENSIONAL MATCHING ⪯ 3-EXACT COVER
|
||||
5. 3-EXACT COVER ⪯ SUBSET SUM
|
||||
|
||||
Lösung. Angenommen ETH gilt. Das Sparsification Lemma impliziert, dass man 3-SAT nicht
|
||||
in 2o(m) · |I|O(1) lösen kann.
|
||||
@@ -388,7 +388,7 @@ Gegeben ist der folgende approximative Algorithmus mit Güte 2 für VertexCover:
|
||||
Idee: Wähle eine beliebige Kante. Wenn einer der beiden Endknoten bereits in C ist, verwirf
|
||||
diese Kante. Sonst, füge beide Endknoten zur Menge C hinzu. Mache dies für jede Kante.
|
||||
|
||||
Algorithmus 2A PPROX CV(G = (V, E))
|
||||
Algorithmus 2APPROX CV(G = (V, E))
|
||||
|
||||
1 C = {}
|
||||
2 foreach {u, v} ∈ E do
|
||||
@@ -411,8 +411,8 @@ aus A. Da keine zwei Kanten aus A vom gleichen Knoten aus C∗ überdeckt werden
|
||||
so groß wie die optimale Lösung C∗ . Damit berechnet der Algorithmus eine 2-Approximation.
|
||||
Präsenzaufgabe 13.2 (TSP)
|
||||
In der Vorlesung haben Sie zwei approximative Algorithmen für das TSP-Problem kennen ge-
|
||||
lernt. Der erste Algorithmus ∆T SP1 hat eine Approximationsrate von 2, während der zweite
|
||||
Algorithmus ∆T SP2 von Christofides eine Approximationsrate von 3/2 besitzt. In dieser Übung
|
||||
lernt. Der erste Algorithmus ∆TSP1 hat eine Approximationsrate von 2, während der zweite
|
||||
Algorithmus ∆TSP2 von Christofides eine Approximationsrate von 3/2 besitzt. In dieser Übung
|
||||
soll gezeigt werden, dass die zweite Rate scharf ist. D.h., dass es eine Folge von Instanzen gibt,
|
||||
auf denen der Algorithmus immer näher an diese Rate heran kommt.
|
||||
Wir betrachten den vollständigen Graphen Kn = (V, E) für ein n ∈ N≥6 mit n mod 4 = 2 und
|
||||
@@ -472,7 +472,7 @@ Knoten 3 gehen, dann zu Knoten 5 und so weiter, bis Knoten n − 1 erreicht wurd
|
||||
folgen Knoten n, n − 2, n − 4 und so weiter. Diese Tour hat offensichtlich Kosten n, da wir n
|
||||
Kanten mit Gewicht 1 nutzen. Somit gilt OPT((Kn , d2 )) = n und wir haben eine Rate von
|
||||
|
||||
∆T SP2 ((Kn , d2 )) n − 1 + n/2 1
|
||||
∆TSP2 ((Kn , d2 )) n − 1 + n/2 1
|
||||
= = 3/2 − ≤ 3/2.
|
||||
OPT((Kn , d2 )) n n
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,848 @@
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Algorithmen und Komplexität
|
||||
Dr. Max Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
08.06.2026
|
||||
|
||||
Hausaufgaben zur Vorlesung »Analyse von Algorithmen und
|
||||
Komplexität«
|
||||
Blatt 9
|
||||
|
||||
|
||||
Definition 1 (Vertex Cover (VC)). Für einen (ungerichteten) Graphen G = (V, E) ist ein Vertex
|
||||
Cover eine Menge von Knoten C ⊆ V , sodass u ∈ C oder v ∈ C für alle { u, v } ∈ E gilt, also ist
|
||||
für alle Kanten mindestens ein Endpunkt in C.
|
||||
Für das VERTEX COVER Problem ist neben einem Graphen G eine Zahl k ∈ Z≥0 gegeben und
|
||||
es wird gefragt, ob ein Vertex Cover mit Größe (Anzahl der Knoten) höchstens k in G existiert.
|
||||
|
||||
Definition 2 (Feedback Vertex Set (FVS)). Für einen gerichteten Graphen G = (V, E) ist ein
|
||||
Feedback Vertex Set eine Menge von Knoten X ⊆ V , sodass G \ X kreisfrei ist.
|
||||
Für das FEEDBACK VERTEX SET Problem ist neben einem Graphen G eine Zahl k ∈ Z≥0 gege-
|
||||
ben und es wird gefragt, ob ein Feedback Vertex Set mit Größe (Anzahl der Knoten) höchstens
|
||||
k in G existiert.
|
||||
|
||||
1 6 2
|
||||
|
||||
|
||||
5
|
||||
8 7
|
||||
3
|
||||
4
|
||||
Abbildung 1: Beispiel Graph.
|
||||
|
||||
Hausaufgabe 9.1 (Nichtdeterministischer Algorithmus für VC) (5 Punkte)
|
||||
1. Finden Sie ein minimales Vertex Cover in Abbildung 1. Für ein Vertex Cover ist die
|
||||
Orientierung der Kanten nicht relevant.
|
||||
2. Zeigen Sie VERTEX COVER ∈ NP, indem Sie einen nichtdeterministischen Algorithmus
|
||||
angeben, der VERTEX COVER in Polynomialzeit löst. Begründen Sie die Korrektheit und
|
||||
die Laufzeit von Ihrem Algorithmus.
|
||||
Hausaufgabe 9.2 (Polynomieller Verifizierer für FVS) (5 Punkte)
|
||||
1. Finden Sie ein minimales Feedback Vertex Set in Abbildung 1.
|
||||
2. Zeigen Sie FEEDBACK VERTEX SET ∈ NP, indem Sie einen polynomiellen Verifizierer
|
||||
für FEEDBACK VERTEX SET angeben. Begründen Sie die Korrektheit und die Laufzeit
|
||||
von Ihrem Verifizierer.
|
||||
Abgabe: 15.06.2026, 10:00 Uhr in Moodle.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Algorithmen und Komplexität
|
||||
Dr. Max Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
15.06.2026
|
||||
|
||||
Hausaufgaben zur Vorlesung »Analyse von Algorithmen und
|
||||
Komplexität«
|
||||
Blatt 10
|
||||
|
||||
|
||||
Definition 1 (Feedback Vertex Set (FVS)). Für einen gerichteten Graphen G = (V, E) ist ein
|
||||
Feedback Vertex Set eine Menge von Knoten X ⊆ V , sodass G \ X kreisfrei ist.
|
||||
Für das FEEDBACK VERTEX SET Problem ist neben einem Graphen G eine Zahl k ∈ Z≥0 gege-
|
||||
ben und es wird gefragt, ob ein Feedback Vertex Set mit Größe (Anzahl der Knoten) höchstens
|
||||
k in G existiert.
|
||||
|
||||
Hausaufgabe 10.1 (FEEDBACK VERTEX SET ist NP-vollständig) (5 Punkte)
|
||||
Zeigen Sie, dass FEEDBACK VERTEX SET NP-vollständig ist.
|
||||
Hinweis: Reduzieren Sie VERTEX COVER auf FEEDBACK VERTEX SET. Wenn ein Vertex Cover
|
||||
aus einem Graphen entfernt wird, hat dieser keine Kanten mehr.
|
||||
Hausaufgabe 10.2 (k-CLIQUE mit universellem Knoten) (5 Punkte)
|
||||
Die folgende Aufgabe ist eine klassische Klausuraufgabe (da sie ein Problem, dessen NP-
|
||||
Vollständigkeit bereits bekannt ist, mit einer Zusatzeigenschaft versieht - und erneut nach NP-
|
||||
Vollständigkeit fragt).
|
||||
Wir definieren das Problem k-CLIQUE UNIVERSAL wie folgt:
|
||||
Gegeben: Ein ungerichteter Graph G = (V, E) sowie eine Zahl k ∈ Z≥1 , wobei ein u ∈ V so
|
||||
existiert, dass u mit allen anderen Knoten verbunden ist, i.e. { {u, v} | v ∈ V \ {u} } ⊆ E
|
||||
Entscheide: Existiert eine Clique C ⊆ V von G mit |C| ≥ k?
|
||||
Zeigen Sie, dass k-CLIQUE UNIVERSAL NP-vollständig ist.
|
||||
|
||||
|
||||
|
||||
|
||||
Abgabe: 22.06.2026, 10:00 Uhr in Moodle.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Algorithmen und Komplexität
|
||||
Dr. Max Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
22.06.2026
|
||||
|
||||
Hausaufgaben zur Vorlesung »Analyse von Algorithmen und
|
||||
Komplexität«
|
||||
Blatt 11
|
||||
|
||||
|
||||
Definition 1 (Dreiecksüberdeckung eines Graphen (∆ COVER)).
|
||||
|
||||
Gegeben Ein ungerichteter Graph G = (V, E) und eine Zahl k ∈ Z≥0 .
|
||||
Entscheide Besitzt G eine Dreiecksüberdeckung C∆ ⊆ V mit |C∆ | ≤ k,
|
||||
d.h. gilt D ∩C∆ ̸= 0/ für jede 3-Clique D ⊆ V in G?
|
||||
|
||||
|
||||
|
||||
|
||||
(a) Ein Vertex Cover der Größe 5 (b) Eine Dreiecksüberdeckung der Größe 5
|
||||
|
||||
Abbildung 1: Zwei Beispielgraphen
|
||||
|
||||
Hausaufgabe 11.1 (5 Punkte)
|
||||
Zeigen Sie: ∆ COVER ist NP-vollständig.
|
||||
|
||||
Definition 2 (Halteproblem auf DTMs). Eine DTM M hält auf dem Wort w, wenn die Be-
|
||||
rechnung von M auf w eine Stopkonfiguration erreicht. Für eine geeignete Kodierung ⟨•, •⟩
|
||||
definieren wir:
|
||||
|
||||
HALTTM := { ⟨M, w⟩ | M ist DTM und M hält auf dem Wort w }
|
||||
|
||||
Hausaufgabe 11.2 (5 Punkte)
|
||||
Zeigen Sie: HALTTM ist NP-schwer.
|
||||
|
||||
Abgabe: 30.06.2026, 10:00 Uhr in Moodle.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Algorithmen und Komplexität
|
||||
Dr. Max Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
29.06.2026
|
||||
|
||||
Hausaufgaben zur Vorlesung »Analyse von Algorithmen und
|
||||
Komplexität«
|
||||
Blatt 12
|
||||
|
||||
|
||||
Definition 1 (MAX -3-SAT). Bei dem Problem MAX -3-SAT ist eine Formel φ in konjunktiver
|
||||
Normalform gegeben, wobei jede Klausel 3 Literale enthält. Gesucht ist eine Belegung β der
|
||||
Variablen, die die Anzahl v(β ) der erfüllten Klauseln maximiert.
|
||||
|
||||
Hausaufgabe 12.1 (MAX -3-SAT, KLAUSURAUFGABE AUS S O S E 23, PZ1) (10 Punkte)
|
||||
Betrachten Sie folgenden Algorithmus A:
|
||||
• Sei β0 die Belegung, die alle Variablen auf false setzt.
|
||||
• Sei β1 die Belegung, die alle Variablen auf true setzt.
|
||||
• Falls v(β0 ) ≥ v(β1 ), gib β0 zurück.
|
||||
• Sonst gib β1 zurück.
|
||||
|
||||
(a) (6 Punkte) Zeigen Sie, dass obiger Algorithmus Güte 2 hat, d.h. v(A(φ )) ≥ 21 v(OPT (φ ))
|
||||
gilt für alle Eingaben φ .
|
||||
(b) (4 Punkte) Geben Sie eine Formel an, bei der der Algorithmus eine Belegung ausgibt, die
|
||||
genau die Hälfte der maximal erfüllbaren Klauseln erfüllt. Begründen Sie kurz, warum
|
||||
Ihre Formel geeignet ist.
|
||||
Hinweis: Sie brauchen nur höchstens zwei Klauseln und vier Variablen.
|
||||
|
||||
|
||||
|
||||
|
||||
Abgabe: 06.07.2026, 10:00 Uhr in Moodle.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Algorithmen und Komplexität
|
||||
Dr. Max Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
06.07.2026
|
||||
|
||||
Hausaufgaben zur Vorlesung »Analyse von Algorithmen und
|
||||
Komplexität«
|
||||
Blatt 13
|
||||
|
||||
|
||||
Hausaufgabe 13.1 (Lower Bound: VERTEX COVER) (5 Punkte)
|
||||
Betrachten Sie die Reduktion aus der Musterlösung zu Präsenzserie 10 von CLIQUE auf VER -
|
||||
TEX COVER .
|
||||
|
||||
1. Geben Sie die Anzahl der Knoten, die Anzahl der Kanten und den Wert k für die re-
|
||||
sultierende VERTEX COVER Instanz in Abhängigkeit von der originalen CLIQUE Instanz
|
||||
an.
|
||||
2. Zeigen Sie Lower Bounds für VERTEX COVER bezüglich Anzahl der Knoten, Anzahl
|
||||
der Kanten und k basierend auf der ETH und der Reduktion aus der Musterlösung zu
|
||||
Präsenzserie 10.
|
||||
|
||||
Definition 1 (HITTING SET). Bei diesem Problem ist ein Universum U, Teilmengen des Uni-
|
||||
versums F1 , . . . , Fr ⊆ U und eine Zahl k ∈ Z≥0 gegeben. Gibt es eine Menge S ⊆ U mit |U| ≤ k
|
||||
und S ∩ Fi ̸= 0/ für alle i ∈ [r]?
|
||||
Anschaulicher wird bei diesem Problem nach maximal k Elementen aus dem Universum ge-
|
||||
fragt, sodass diese Elemente alle gegebenen Mengen Fi „treffen“.
|
||||
|
||||
Hausaufgabe 13.2 (Lower Bound: HITTING SET) (5 Punkte)
|
||||
Betrachten Sie folgende Reduktion von 3-SAT auf HITTING SET.
|
||||
Für eine gegebene 3-SAT Instanz I mit n Variablen x1 , . . . , xn und m Klauseln C1 , . . . ,Cm : Als
|
||||
Universum wählen wir U = { x1 , x̄1 , . . . , xn , x̄n }. Für jede Variable xi fügen wir die Menge Fi :=
|
||||
{ xi , x̄i } hinzu. Für jede Klausel C j fügen wir die Menge Fn+ j := C j hinzu. Wir setzen k := n
|
||||
und erhalten damit eine HITTING SET Instanz I.
|
||||
1. Geben Sie die Größe des Universums, die Anzahl der Mengen und die Zahl k aus der
|
||||
resultierenden HITTING SET Instanz in Abhängigkeit von der Anzahl der Variablen n und
|
||||
der Anzahl der Klauseln m aus der originalen 3-SAT Instanz an.
|
||||
2. Beweisen Sie Lower Bounds für HITTING SET bezüglich Universumsgröße, Anzahl der
|
||||
Mengen und der Zahl k basierend auf der obigen Reduktion und der ETH.
|
||||
|
||||
|
||||
|
||||
|
||||
Abgabe: 13.07.2026, 10:00 Uhr in Moodle.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
08.06.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 9
|
||||
|
||||
|
||||
Definition 1 (Knapsack (Entscheidungsvariante)). Gegeben ist eine Menge mit n Gegenstän-
|
||||
den mit Gewichten w1 , . . . , wn ∈ Z≥0 und Profiten p1 , . . . , pn ∈ Z≥0 . Weiter sind eine Kapazität
|
||||
K ∈ Z≥0 und ein Zielprofit P ∈ Z≥0 gegeben. Bei dem KNAPSACK Problem muss entschieden
|
||||
werden, ob eine Teilmenge S ⊆ [n] existiert, sodass
|
||||
|
||||
∑ wi ≤ K und ∑ pi ≥ P
|
||||
i∈S i∈S
|
||||
|
||||
gilt.
|
||||
|
||||
Präsenzaufgabe 9.1 (KNAPSACK in NP)
|
||||
Zeigen Sie KNAPSACK ∈ NP auf zwei verschiedene Wege.
|
||||
1. Geben Sie einen nicht deterministischen Algorithmus an, der das Problem in polynomi-
|
||||
eller Zeit löst.
|
||||
2. Geben Sie einen polynomiellen Verifizierer für das Problem an.
|
||||
Wie hängen diese beiden Methoden zusammen?
|
||||
Präsenzaufgabe 9.2 (Transitivität von Polynomialzeitreduktionen)
|
||||
Zeigen Sie, dass Polynomialzeitreduktionen transitiv sind, d.h., dass für alle Entscheidungspro-
|
||||
bleme L1 , L2 , L3 ⊆ Σ∗ gilt, dass
|
||||
|
||||
L1 ≤ L2 ∧ L2 ≤ L3 =⇒ L1 ≤ L3 .
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
15.06.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 10
|
||||
|
||||
|
||||
Präsenzaufgabe 10.1 (Fragen über Fragen)
|
||||
Diskutieren Sie die folgenden Aussagen (Gelten sie allgemein? Gelten sie nicht? Gelten sie
|
||||
unter bestimmten Bedingungen? Welche?):
|
||||
(i) Für jede Sprache, die von einer NDTM in polynomieller Zeit akzeptiert werden kann,
|
||||
existiert eine DTM, die ebenfalls die Sprache in polynomieller Zeit akzeptiert.
|
||||
(ii) A ist NP-schwer =⇒ A ∈ P
|
||||
|
||||
Definition 1 (Vertex Cover (VC)). Für einen (ungerichteten) Graphen G = (V, E) ist ein Vertex
|
||||
Cover eine Menge von Knoten C ⊆ V , sodass u ∈ C oder v ∈ C für alle { u, v } ∈ E gilt, also ist
|
||||
für alle Kanten mindestens ein Endpunkt in C.
|
||||
Für das VERTEX COVER Problem ist neben einem Graphen G eine Zahl k ∈ Z≥0 gegeben und
|
||||
es wird gefragt, ob ein Vertex Cover mit Größe (Anzahl der Knoten) höchstens k in G existiert.
|
||||
|
||||
Präsenzaufgabe 10.2 (VERTEX COVER ist NP-vollständig)
|
||||
Zeigen Sie, dass VERTEX COVER NP-vollständig ist.
|
||||
Hinweis: Reduzieren Sie CLIQUE auf VERTEX COVER.
|
||||
Präsenzaufgabe 10.3 (Turingmaschinen)
|
||||
n
|
||||
Entwerfen Sie eine Turingmaschine, welche die Sprache L = { 02 | n ∈ Z≥0 } über dem Al-
|
||||
phabet Σ = {0} entscheidet. Geben Sie die Laufzeit Ihrer Turingmaschine an. Begründen Sie
|
||||
die Korrektheit und die Laufzeit Ihrer Turingmaschine. Diskutieren Sie die Laufzeit Ihrer Tu-
|
||||
ringmaschine im Vergleich zu einem Algorithmus (mit RAM) für diese Sprache.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
22.06.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 11
|
||||
|
||||
|
||||
Präsenzaufgabe 11.1 (Fragen über Fragen)
|
||||
Diskutieren Sie nachfolgende Aussagen. Sind diese wahr oder falsch?
|
||||
(a) Für L ∈ NP gilt, aus L ≤ 3-SAT folgt, dass L NP-vollständig ist.
|
||||
(b) Gilt für eine Sprache L, dass sowohl L ≤ 3-SAT als auch 3-SAT ≤ L, dann ist L NP-vollständig.
|
||||
(c) Sei L NP-vollständig. Dann gilt L ∈ P genau dann, wenn P = NP.
|
||||
(d) Es ist möglich, dass 3-SAT ∈ P und CLIQUE ∈
|
||||
/ P.
|
||||
Präsenzaufgabe 11.2 (NP-Vollständigkeit)
|
||||
In dem Problem CLIQUE -NOMEMBER ist ein Graph G = (V, E), ein Knoten v ∈ V sowie eine
|
||||
Zahl k ∈ Z≥0 gegeben und es soll entschieden werden, ob es eine Clique mit k Knoten gibt, die
|
||||
den Knoten v nicht enthält.
|
||||
Zeigen Sie, dass CLIQUE -NOMEMBER NP-vollständig ist, indem Sie eine Reduktion von CLI -
|
||||
QUE auf CLIQUE -NOMEMBER angeben. Sie können davon ausgehen, dass CLIQUE -NOMEMBER ∈
|
||||
NP ist.
|
||||
Präsenzaufgabe 11.3 (NP-Vollständigkeit)
|
||||
Für das Problem SUBSET SUM CARDINALITY seien n ganze Zahlen c1 , . . . , cn ∈ Z>0 (wobei n
|
||||
gerade sei) sowie eine Zahl K ∈ Z≥0 gegeben. Es soll entschieden werden, ob es eine Teilmenge
|
||||
S ⊂ {1, . . . , n} gibt mit |S| = n/2 und ∑i∈S ci = K.
|
||||
Zeigen Sie, dass das Problem SUBSET SUM CARDINALITY NP-vollständig ist. Sie können
|
||||
davon ausgehen, dass SUBSET SUM CARDINALITY ∈ NP ist.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
29.06.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 12
|
||||
|
||||
|
||||
Präsenzaufgabe 12.1 (Vertex Cover)
|
||||
Ein Vertex Cover in einem Graphen G = (V, E) ist eine Menge von Knoten C ⊆ V , so dass für
|
||||
jede Kante {v, w} ∈ E gilt, dass v ∈ C oder w ∈ C. Beim Optimierungsproblem Minimum Vertex
|
||||
Cover soll die kardinalitätskleinste Menge C ⊆ V gefunden werden, die ein Vertex Cover von
|
||||
G ist.
|
||||
Gegeben ist der folgende approximative Algorithmus mit Güte 2 für VertexCover:
|
||||
Idee: Wähle eine beliebige Kante. Wenn einer der beiden Endknoten bereits in C ist, verwirf
|
||||
diese Kante. Sonst, füge beide Endknoten zur Menge C hinzu. Mache dies für jede Kante.
|
||||
|
||||
Algorithmus 2APPROX VC(G = (V, E))
|
||||
|
||||
1 C = {}
|
||||
2 foreach {u, v} ∈ E do
|
||||
3 if u ̸∈ C and v ̸∈ C then
|
||||
4 C = C ∪ {u, v}
|
||||
5 fi
|
||||
6 od
|
||||
7 return C
|
||||
|
||||
Beweisen Sie die Güte.
|
||||
Präsenzaufgabe 12.2 (TSP)
|
||||
In der Vorlesung haben Sie zwei approximative Algorithmen für das TSP-Problem kennen ge-
|
||||
lernt. Der erste Algorithmus ∆TSP1 hat eine Approximationsrate von 2, während der zweite
|
||||
Algorithmus ∆TSP2 von Christofides eine Approximationsrate von 3/2 besitzt. In dieser Übung
|
||||
soll gezeigt werden, dass die zweite Rate scharf ist. D.h., dass es eine Folge von Instanzen gibt,
|
||||
auf denen der Algorithmus immer näher an diese Rate heran kommt.
|
||||
Wir betrachten den vollständigen Graphen Kn = (V, E) für ein n ∈ Z≥6 mit n mod 4 = 2 und die
|
||||
nachfolgende Gewichtsfunktion d2 : E → Z>0 .
|
||||
Wir definieren E2 := {{i, i + 2}|i ∈ {1, . . . , n − 2}} ∪ {{2i + 1, 2i + 2}|i ∈ {0, . . . , n/2 − 1}} und
|
||||
d2 , so dass alle Kanten aus E2 auf 1 abgebildet werden und alle Kanten aus E \ E2 auf den Wert
|
||||
des kürzesten Wegs zwischen diesen Knoten in dem Graphen G2 = (V, E2 ) abgebildet werden.
|
||||
Der Graph G2 ist im folgenden abgebildet.
|
||||
1 1 1 1
|
||||
1 3 5 7 9 n−1
|
||||
|
||||
|
||||
1 1 1 1 1 1
|
||||
|
||||
1 1 1 1
|
||||
2 4 6 8 10 n
|
||||
|
||||
|
||||
|
||||
Zeigen Sie, dass
|
||||
(a) OPT((Kn , d2 )) = n gilt und
|
||||
(b) eine mögliche Ausführung von ∆TSP2 existiert, die auf Eingabe (Kn , d2 ) eine Tour der
|
||||
Länge (n − 1) + n/2 berechnet.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
06.07.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 13
|
||||
|
||||
|
||||
Präsenzaufgabe 13.1
|
||||
Im Folgenden sollen Sie herausfinden (und begründen), welche Lower Bounds sich über ETH
|
||||
und die Reduktionen aus der Vorlesung ergeben. Nutzen Sie hierfür auch das Sparsification
|
||||
Lemma.
|
||||
Welche Schranken für die Laufzeit in Abhängigkeit von den natürlichen Parametern (Anzahl
|
||||
Knoten, Anzahl Kanten, Anzahl Items, Anzahl unterschiedlicher Items, Anzahl Tripel, Kardi-
|
||||
nalität der Grundmenge, Anzahl unterschiedlicher Tripel, Anzahl unterschiedlicher auftretender
|
||||
Zahlen, größte auftretende Zahl, . . . ) ergeben sich jeweils aus folgenden (zum Großteil aus der
|
||||
Vorlesung bekannten) Reduktionen (Laufzeitfaktoren, die polynomiell in der Eingabekodierung
|
||||
sind, dürfen vernachlässigt werden)?
|
||||
Schätzen Sie dazu zunächst die verschiedenen Parameter der resultierenden Instanz mithilfe der
|
||||
Parameter der eingegebenen Instanz geeignet ab.
|
||||
1. 3-SAT ≤ k-CLIQUE
|
||||
2. k-CLIQUE ≤ k-INDEPENDENT SET (Problemdefinition für die Antwort nicht relevant; in
|
||||
der Reduktion wird der Graph G = (V, E) auf den Komplementgraph (V, E) abgebildet)
|
||||
3. SAT ≤ 3-DIMENSIONAL MATCHING (Hinweis: 3-SAT ⊆ SAT)
|
||||
4. 3-DIMENSIONAL MATCHING ≤ 3-EXACT COVER
|
||||
5. 3-EXACT COVER ≤ SUBSET SUM
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
08.06.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 9
|
||||
|
||||
|
||||
Definition 1 (Knapsack (Entscheidungsvariante)). Gegeben ist eine Menge mit n Gegenstän-
|
||||
den mit Gewichten w1 , . . . , wn ∈ Z≥0 und Profiten p1 , . . . , pn ∈ Z≥0 . Weiter sind eine Kapazität
|
||||
K ∈ Z≥0 und ein Zielprofit P ∈ Z≥0 gegeben. Bei dem KNAPSACK Problem muss entschieden
|
||||
werden, ob eine Teilmenge S ⊆ [n] existiert, sodass
|
||||
|
||||
∑ wi ≤ K und ∑ pi ≥ P
|
||||
i∈S i∈S
|
||||
|
||||
gilt.
|
||||
|
||||
Präsenzaufgabe 9.1 (KNAPSACK in NP)
|
||||
Zeigen Sie KNAPSACK ∈ NP auf zwei verschiedene Wege.
|
||||
1. Geben Sie einen nicht deterministischen Algorithmus an, der das Problem in polynomi-
|
||||
eller Zeit löst.
|
||||
2. Geben Sie einen polynomiellen Verifizierer für das Problem an.
|
||||
Wie hängen diese beiden Methoden zusammen?
|
||||
|
||||
Lösung. 1. Der Algorithmus arbeitet wie folgt: Für jeden Gegenstand entscheiden wir nicht-
|
||||
deterministisch, ob er in den Rucksack soll. Anschließend akzeptieren wir, falls die Kapazitäts-
|
||||
und Profitbedingung erfüllt sind.
|
||||
Existiert eine valide Rucksackfüllung mit genügend Profit, dann gibt es auch eine Reihe
|
||||
von nichtdeterministischen Entscheidungen, die diese Füllung findet. Diese wird dann
|
||||
auch vom Algorithmus akzeptiert.
|
||||
Der Algorithmus macht polynomiell viele nichtdeterministische Entscheidungen und die
|
||||
Überprüfung der Eigenschaften ist ebenfalls in Polynomialzeit möglich, also ist dies ein
|
||||
polynomieller, nichtdeterministischer Algorithmus, der KNAPSACK löst.
|
||||
2. Unser Zertifikat ist eine Teilmenge S ⊆ [n], die die Auswahl für den Rucksack darstellen
|
||||
soll. Dies kann zum Beispiel über ein Bit pro Gegenstand realisiert werden. Um dieses
|
||||
Zertifikat zu verifizieren, berechnen wir ∑i∈S wi und ∑i∈S pi und vergleichen die Werte
|
||||
mit K und P und akzeptieren entsprechend.
|
||||
Existiert eine valide Rucksackfüllung mit genügend Profit, dann gibt es auch ein Zertifi-
|
||||
kat, das diese Füllung beschreibt. Dieses wird dann auch vom Verifizierer akzeptiert.
|
||||
Das Ausrechnen der Summen und das Überprüfen ist in polynomieller Zeit möglich, also
|
||||
ist dies ein polynomieller Verifizierer.
|
||||
In beiden Wegen wird die extra Ressource, die wir zur Verfügung haben, genutzt, um eine
|
||||
Teilmenge (einen möglichen Rucksack) zu bestimmen und zu überprüfen. In der Vorlesung
|
||||
wird noch genau gezeigt, dass diese beiden Wege tatsächlich äquivalent sind.
|
||||
|
||||
Präsenzaufgabe 9.2 (Transitivität von Polynomialzeitreduktionen)
|
||||
Zeigen Sie, dass Polynomialzeitreduktionen transitiv sind, d.h., dass für alle Entscheidungspro-
|
||||
bleme L1 , L2 , L3 ⊆ Σ∗ gilt, dass
|
||||
|
||||
L1 ≤ L2 ∧ L2 ≤ L3 =⇒ L1 ≤ L3 .
|
||||
|
||||
Lösung. Seien L1 , L2 , L3 Sprachen. Zudem sei R1 ein Algorithmus, der L1 nach L2 in O(na )
|
||||
reduziert, und R2 ein Algorithmus, der L2 nach L3 in O(nb ) reduziert.
|
||||
Sei x eine Instanz von L1 der Größe n. Dann benötigt der Aufruf von R1 auf x höchstens O(na )
|
||||
Zeit.
|
||||
Sei x′ die Ausgabe von R1 . Dann hat x′ eine Größe von höchstens O(na ). Diese Schranke für
|
||||
die Ausgabe ergibt sich aus der Laufzeit der Reduktion. Der Aufruf von R2 auf x′ benötigt nun
|
||||
höchstens O((na )b ) = O(nab ) Zeit. Damit ist die Reduktion von L1 auf L3 in polynomieller Zeit
|
||||
möglich.
|
||||
Die Korrektheit ergibt sich aus der Transitivität der Äquivalenz: Sei x′′ die Ausgabe von R2 auf
|
||||
x′ , dann gilt
|
||||
x ∈ L1 ⇐⇒ x′ ∈ L2 und x′ ∈ L2 ⇐⇒ x′′ ∈ L3 ,
|
||||
also auch x ∈ L1 ⇐⇒ x′′ ∈ L3 .
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
15.06.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 10
|
||||
|
||||
|
||||
Präsenzaufgabe 10.1 (Fragen über Fragen)
|
||||
Diskutieren Sie die folgenden Aussagen (Gelten sie allgemein? Gelten sie nicht? Gelten sie
|
||||
unter bestimmten Bedingungen? Welche?):
|
||||
(i) Für jede Sprache, die von einer NDTM in polynomieller Zeit akzeptiert werden kann,
|
||||
existiert eine DTM, die ebenfalls die Sprache in polynomieller Zeit akzeptiert.
|
||||
(ii) A ist NP-schwer =⇒ A ∈ P
|
||||
|
||||
Lösung. (i) Die Aussage gilt nur, wenn P = NP. Dann liegen alle Probleme in NP (von
|
||||
NDTM in polynomieller Zeit lösbar) auch in P (von DTM in polynomieller Zeit lösbar).
|
||||
Falls P ̸= NP, dann nein, weil sonst folgender Widerspruch auftritt: ein Problem in NP \ P
|
||||
ist von einer NDTM, nicht aber von einer DTM in polynomieller Zeit lösbar. Könnte eine
|
||||
DTM die NDTM in polynomieller Zeit simulieren, wäre das Problem auch in P.
|
||||
(ii) Falsch. Es gibt NP-schwere Probleme, die nicht in NP liegen, z.B. das HALTE-Problem.
|
||||
Diese liegen folglich auch definitiv nicht in P. NP-schwer ist eine untere Schranke, ∈ NP
|
||||
eine obere, und wenn beide erfüllt sind, ist ein Problem NP-vollständig.
|
||||
|
||||
|
||||
|
||||
Definition 1 (Vertex Cover (VC)). Für einen (ungerichteten) Graphen G = (V, E) ist ein Vertex
|
||||
Cover eine Menge von Knoten C ⊆ V , sodass u ∈ C oder v ∈ C für alle { u, v } ∈ E gilt, also ist
|
||||
für alle Kanten mindestens ein Endpunkt in C.
|
||||
Für das VERTEX COVER Problem ist neben einem Graphen G eine Zahl k ∈ Z≥0 gegeben und
|
||||
es wird gefragt, ob ein Vertex Cover mit Größe (Anzahl der Knoten) höchstens k in G existiert.
|
||||
|
||||
Präsenzaufgabe 10.2 (VERTEX COVER ist NP-vollständig)
|
||||
Zeigen Sie, dass VERTEX COVER NP-vollständig ist.
|
||||
Hinweis: Reduzieren Sie CLIQUE auf VERTEX COVER.
|
||||
|
||||
Lösung. VERTEX COVER ∈ NP ist bekannt aus der letzten Hausaufgabe.
|
||||
Vorüberlegung zur Reduktion von CLIQUE auf VERTEX COVER: Sei eine Instanz mit G =
|
||||
(V, E) und k ∈ Z≥0 von CLIQUE gegeben. Wir invertieren zunächst den Graphen G und erhal-
|
||||
ten damit G′ . Nun enthält G′ genau dann eine unabhängige Menge der Größe ℓ, wenn G eine
|
||||
Clique der Größe ℓ enthält. Wenn ein Graph mit n Knoten eine unabhängige Menge mit min-
|
||||
destens k Knoten hat, dann hat der Graph auch ein Vertex Cover mit maximal n − k Knoten
|
||||
(das Komplement zur unabhängigen Menge). Unsere Instanz für VERTEX COVER ist also G′
|
||||
und n − k. (k ≤ n können wir annehmen)
|
||||
Betrachte nun folgende Reduktion:
|
||||
Sei eine Instanz mit G = (V, E) und k ∈ Z≥0 von CLIQUE gegeben. Invertiere den Graphen G
|
||||
und erhalte damit G′ . Gebe nun G′ und n − k als Instanz von VERTEX COVER zurück.
|
||||
Korrektheit:
|
||||
⇒ (Falls (G, k) eine Ja-Instanz von CLIQUE ist, dann ist (G′ , n − k) eine Ja-Instanz von VER -
|
||||
TEX COVER .):
|
||||
|
||||
G enthalte eine Clique C der Größe ≥ k. Dann ist V ′ := V \C ein Vertex Cover von G′ der Größe
|
||||
≤ n − k. Da |C| ≥ k, gilt |V ′ | ≤ n − k. Da C eine Clique ist, also alle Knoten aus C mit allen
|
||||
anderen Knoten aus C verbunden sind, gibt es im invertierten Graphen G′ keine Kante zwischen
|
||||
Knoten aus C. Also gilt für jede Kante {u, v} ∈ E ′ , dass u ∈ / C und somit u ∈ V ′ oder
|
||||
/ C oder v ∈
|
||||
v ∈ V ′.
|
||||
⇐ (Falls (G′ , n − k) eine Ja-Instanz von VERTEX COVER ist, dann ist (G, k) eine Ja-Instanz von
|
||||
CLIQUE.):
|
||||
G′ enthalte ein Vertex Cover V ′ der Größe ≤ n−k. Dann ist C := V \V ′ eine Clique der Größe ≥
|
||||
k in G. Da |V ′ | ≤ n − k, gilt |C| ≥ k. Für jede Menge {u, v} ⊆ C gilt, dass {u, v} ∈
|
||||
/ E ′ . Ansonsten
|
||||
wäre V ′ kein korrektes Vertex Cover. Da G′ der invertierte Graph von G ist, gilt somit {u, v} ∈ E
|
||||
für jede Menge {u, v} ⊆ C.
|
||||
Laufzeit: Das Invertieren des Graphen kann naiv in O(|V |2 · |E|) Zeit, also in polynomieller
|
||||
Zeit, erledigt werden.
|
||||
Also existiert eine polynomielle Reduktion von CLIQUE auf VERTEX COVER. Da CLIQUE
|
||||
NP-vollständig ist und VERTEX COVER ∈ NP gilt, ist somit VERTEX COVER NP-vollständig.
|
||||
|
||||
|
||||
Präsenzaufgabe 10.3 (Turingmaschinen)
|
||||
n
|
||||
Entwerfen Sie eine Turingmaschine, welche die Sprache L = { 02 | n ∈ Z≥0 } über dem Al-
|
||||
phabet Σ = {0} entscheidet. Geben Sie die Laufzeit Ihrer Turingmaschine an. Begründen Sie
|
||||
die Korrektheit und die Laufzeit Ihrer Turingmaschine. Diskutieren Sie die Laufzeit Ihrer Tu-
|
||||
ringmaschine im Vergleich zu einem Algorithmus (mit RAM) für diese Sprache.
|
||||
|
||||
Lösung. Die Turingmaschine sieht folgendermaßen aus:
|
||||
(1) Falls genau eine 0 auf dem Band steht, akzeptiere.
|
||||
(2) Bewege den Kopf von links nach rechts über die Eingabe und ersetze jede zweite 0 durch
|
||||
ein neues Symbol x.
|
||||
(3) Falls das letzte Symbol der Eingabe nun eine 0 ist, verwerfe.
|
||||
(4) Gehe zu Schritt (1).
|
||||
Korrektheit: Die Sprache L besteht aus allen Wörtern, die nur Nullen enthalten und deren Länge
|
||||
eine Zweierpotenz ist. Eine Zahl ist genau dann eine Zweierpotenz, wenn sie nach wiederholtem
|
||||
Teilen durch 2 irgendwann eine 1 ergibt. Genau das überprüft die Turingmaschine. Wenn bei
|
||||
diesem wiederholten Teilen eine ungerade Zahl auftritt, ist die Zahl keine Zweierpotenz. In
|
||||
diesem Fall verwirft die Turingmaschine die Eingabe (siehe Schritt (3)).
|
||||
Laufzeit: Die angegebene Turingmaschine hat eine Laufzeit von O(n log(n)), wobei n hier die
|
||||
Länge der Eingabe bezeichnet. Schritt (1) und Schritt (2) benötigen jeweils eine Laufzeit von
|
||||
O(n). Schritt (3) geht in konstanter Laufzeit O(1). Schritt (4) wiederholt die Schritte (1) bis (3)
|
||||
bis nur noch eine 0 auf dem Band steht oder vorher abgebrochen wird. Da in jedem Durchlauf
|
||||
der Schritte (1) bis (3) die Hälfte der Nullen (abgerundet) durch x Symbole ersetzt wird, steht
|
||||
nach log n Durchläufen nur noch eine Null auf dem Band und es wird nach Schritt (1) akzeptiert
|
||||
oder es steht nach ⌊log n⌋ − 1 Durchläufen eine ungerade Anzahl von Nullen auf dem Band und
|
||||
es wird nach Schritt (3) verworfen. Also gibt es maximal log n + 1 Durchläufe.
|
||||
Diskussion: Ein Algorithmus mit RAM könnte folgendermaßen aussehen:
|
||||
1. Zähle die Buchstaben und erhalte Anzahl m.
|
||||
2. Falls log m ganzzahlig ist, akzeptiere. Sonst verwerfe.
|
||||
Dieser Algorithmus hat eine Laufzeit von O(n), wobei n hier die Länge der Eingabe bezeichnet.
|
||||
Ein Algorithmus mit RAM kann also den zusätzlichen Speicher nutzen, um wesentlich schneller
|
||||
eine Aufgabe zu erledigen, im Vergleich zu einem Algorithmus, der auf einer Turingmaschine
|
||||
läuft.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
22.06.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 11
|
||||
|
||||
|
||||
Präsenzaufgabe 11.1 (Fragen über Fragen)
|
||||
Diskutieren Sie nachfolgende Aussagen. Sind diese wahr oder falsch?
|
||||
(a) Für L ∈ NP gilt, aus L ≤ 3-SAT folgt, dass L NP-vollständig ist.
|
||||
(b) Gilt für eine Sprache L, dass sowohl L ≤ 3-SAT als auch 3-SAT ≤ L, dann ist L NP-vollständig.
|
||||
(c) Sei L NP-vollständig. Dann gilt L ∈ P genau dann, wenn P = NP.
|
||||
(d) Es ist möglich, dass 3-SAT ∈ P und CLIQUE ∈
|
||||
/ P.
|
||||
Lösung.
|
||||
/ Offensichtlich gilt L ∈ NP. Aus L ≤ 3-SAT folgt nur,
|
||||
(a) Nein, ein Gegenbeispiel ist L = 0.
|
||||
dass sich L auf 3-SAT reduzieren lässt, nicht anders herum.
|
||||
(b) Das ist die Definition von NP-vollständig.
|
||||
(c) Korrekt, da sich jedes Problem auf L reduzieren lässt nach Def. NP-vollständig (und offen-
|
||||
sichtlich P ⊆ NP).
|
||||
(d) Nein, weil 3-SAT NP-vollständig ist, lässt sich CLIQUE (liegt bekannterweise in NP, ist
|
||||
sogar NP-vollständig) auf 3-SAT reduzieren.
|
||||
Präsenzaufgabe 11.2 (NP-Vollständigkeit)
|
||||
In dem Problem CLIQUE -NOMEMBER ist ein Graph G = (V, E), ein Knoten v ∈ V sowie eine
|
||||
Zahl k ∈ Z≥0 gegeben und es soll entschieden werden, ob es eine Clique mit k Knoten gibt, die
|
||||
den Knoten v nicht enthält.
|
||||
Zeigen Sie, dass CLIQUE -NOMEMBER NP-vollständig ist, indem Sie eine Reduktion von CLI -
|
||||
QUE auf CLIQUE -NOMEMBER angeben. Sie können davon ausgehen, dass CLIQUE -NOMEMBER ∈
|
||||
NP ist.
|
||||
Lösung. Clique-Nomember ist NP-schwer:
|
||||
Sei (G = (V, E), k) eine Eingabe für das Clique Problem.
|
||||
Idee: Füge einen neuen isolierten Knoten v zu G hinzu.
|
||||
Wir geben zuerst die Abbildung an: Sei v ̸∈ V . Wir definieren die Eingabe für das Problem
|
||||
CLIQUE -NOMEMBER als den Graphen G′ := (V ∪ {v}, E), den Knoten v als Knoten, der nicht
|
||||
benutzt werden darf, und k als gesuchte Cliquengröße. (G′ , v, k) ist eine gültige Eingabe für
|
||||
CLIQUE -NOMEMBER und in polynomieller Zeit berechenbar.
|
||||
Wir zeigen nun: G hat eine k-Clique genau dann, wenn G′ eine k-Clique ohne v beinhaltet.
|
||||
⇒:
|
||||
Sei (G, k) eine Ja-Instanz des CLIQUE Problems. Dann enthält G eine k-Clique C. Dann ist C
|
||||
auch eine k-Clique in G′ , da bei der Abbildung keine Kanten entfernt wurden. Da v ̸∈ V , enthält
|
||||
C den Knoten v nicht. Damit ist C eine k-Clique in G ohne den Knoten v. (G′ , v, k) ist also eine
|
||||
Ja-Instanz des CLIQUE -NOMEMBER Problems.
|
||||
⇐:
|
||||
Sei (G′ , v, k) eine Ja-Instanz des CLIQUE -NOMEMBER Problems. Dann enthält G′ eine k-Clique
|
||||
C mit v ̸∈ C. Da bei der Abbildung an dem Graphen nichts geändert wurde, außer der Knoten v
|
||||
hinzugefügt, muss C auch eine k-Clique in G sein. Damit ist (G, k) eine Ja-Instanz des CLIQUE
|
||||
Problems.
|
||||
Also existiert eine polynomielle Reduktion von CLIQUE auf CLIQUE -NOMEMBER. Da CLI -
|
||||
QUE NP-vollständig ist und CLIQUE -NOMEMBER ∈ NP nach Aufgabenstellung gilt, ist somit
|
||||
CLIQUE -NOMEMBER NP-vollständig.
|
||||
Präsenzaufgabe 11.3 (NP-Vollständigkeit)
|
||||
Für das Problem SUBSET SUM CARDINALITY seien n ganze Zahlen c1 , . . . , cn ∈ Z>0 (wobei n
|
||||
gerade sei) sowie eine Zahl K ∈ Z≥0 gegeben. Es soll entschieden werden, ob es eine Teilmenge
|
||||
S ⊂ {1, . . . , n} gibt mit |S| = n/2 und ∑i∈S ci = K.
|
||||
Zeigen Sie, dass das Problem SUBSET SUM CARDINALITY NP-vollständig ist. Sie können
|
||||
davon ausgehen, dass SUBSET SUM CARDINALITY ∈ NP ist.
|
||||
Lösung. Subset Sum Cardinality ist NP-schwer:
|
||||
SUBSET SUM Problem: Gegeben: n ganze Zahlen c1 , . . . , cn ∈ Z>0 und Zahl K ∈ Z≥0 . Frage:
|
||||
Gibt es eine Teilmenge S ⊆ {0, . . . , n} mit ∑i∈S ci = K?
|
||||
Reduktion: Sei I = (c1 , . . . , cn , K) eine Eingabe für das SUBSET SUM Problem. Definiere c′i :=
|
||||
ci + 1 für i ≤ n und c′i := 1 für i ∈ {n + 1, . . . , 2n}. Wir definieren die Eingabe für das Problem
|
||||
SUBSET SUM CARDINALITY als I ′ = (c′1 , . . . , c′2n , K + n).
|
||||
Wir zeigen nun: Es gibt eine Lösung für I genau dann, wenn es eine Lösung für I ′ gibt.
|
||||
⇒:
|
||||
Sei I eine Ja-Instanz des SUBSET SUM Problems. Dann gibt es eine Menge S ⊆ {1, . . . , n} mit
|
||||
∑i∈S ci = K. Es gilt also ∑i∈S c′i = ∑i∈S (ci + 1) = K + |S|. Sei nun S′ = {n + 1, . . . , 2n − |S|}. Es
|
||||
gilt |S ∪ S′ | = n und ∑i∈S∪S′ c′i = ∑i∈S c′i + ∑i∈S′ c′i = ∑i∈S (ci + 1) + ∑i∈S′ 1 = K + |S| + (n − |S|) =
|
||||
K + n. S ∪ S′ ist also eine Lösung für I ′ und I ′ damit eine Ja-Instanz.
|
||||
⇐:
|
||||
Es sei I ′ eine Ja-Instanz. Dann gibt es also eine Menge S′ ⊆ {1, . . . , 2n} mit ∑i∈S′ c′i = K + n und
|
||||
|S′ | = n. Sei S := S′ ∩{1, . . . , n}. Es gilt ∑i∈S′ c′i − ∑i∈S c′i = n−|S|, da die Items in {n+1, . . . , 2n}
|
||||
das Gewicht 1 haben, und genau n − |S| Items übrig sind. Damit gilt ∑i∈S (ci + 1) = ∑i∈S c′i =
|
||||
K + n − (n − |S|) = K + |S|, woraus folgt, dass ∑i∈S ci = K. Damit ist S eine Lösung für I und
|
||||
somit I eine Ja-Instanz.
|
||||
Laufzeit: Die Reduktion kann naiv in einer Zeit von O(n) implementiert werden. Das ist eine
|
||||
polynomielle Laufzeit.
|
||||
Also existiert eine polynomielle Reduktion von SUBSET SUM auf SUBSET SUM CARDINALI -
|
||||
TY . Da nach Skript SUBSET SUM NP-vollständig ist und SUBSET SUM CARDINALITY ∈ NP
|
||||
nach Aufgabenstellung gilt, ist somit SUBSET SUM CARDINALITY NP-vollständig.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
29.06.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 12
|
||||
|
||||
|
||||
Präsenzaufgabe 12.1 (Vertex Cover)
|
||||
Ein Vertex Cover in einem Graphen G = (V, E) ist eine Menge von Knoten C ⊆ V , so dass für
|
||||
jede Kante {v, w} ∈ E gilt, dass v ∈ C oder w ∈ C. Beim Optimierungsproblem Minimum Vertex
|
||||
Cover soll die kardinalitätskleinste Menge C ⊆ V gefunden werden, die ein Vertex Cover von
|
||||
G ist.
|
||||
Gegeben ist der folgende approximative Algorithmus mit Güte 2 für VertexCover:
|
||||
Idee: Wähle eine beliebige Kante. Wenn einer der beiden Endknoten bereits in C ist, verwirf
|
||||
diese Kante. Sonst, füge beide Endknoten zur Menge C hinzu. Mache dies für jede Kante.
|
||||
|
||||
Algorithmus 2APPROX VC(G = (V, E))
|
||||
|
||||
1 C = {}
|
||||
2 foreach {u, v} ∈ E do
|
||||
3 if u ̸∈ C and v ̸∈ C then
|
||||
4 C = C ∪ {u, v}
|
||||
5 fi
|
||||
6 od
|
||||
7 return C
|
||||
|
||||
Beweisen Sie die Güte.
|
||||
|
||||
Lösung. Beweis: Sei C∗ ein minimales Vertex Cover. Wir zeigen |C| ≤ 2|C∗ |.
|
||||
Sei A die Menge der Kanten, für die beide Endknoten zu C hinzugefügt wurden. Es gilt |C| =
|
||||
2|A|, da beide Knoten jeder ausgewählten Kante zu C hinzugefügt wurden und alle anderen
|
||||
Kanten nicht weiter betrachtet werden. Damit folgt auch, dass keine zwei Kanten aus A einen
|
||||
Knoten gemeinsam haben können. Sei nun C∗ ein minimales Vertex Cover. C∗ enthält nach
|
||||
Definition mindestens einen Knoten jeder Kante, also insbesondere einen Knoten jeder Kante
|
||||
aus A. Da keine zwei Kanten aus A vom gleichen Knoten aus C∗ überdeckt werden können, gilt
|
||||
|C∗ | ≥ |A|. Es folgt |C| = 2|A| ≤ 2|C∗ |. Die Lösung C von 2ApproxVC ist also maximal doppelt
|
||||
so groß wie die optimale Lösung C∗ . Damit berechnet der Algorithmus eine 2-Approximation.
|
||||
Präsenzaufgabe 12.2 (TSP)
|
||||
In der Vorlesung haben Sie zwei approximative Algorithmen für das TSP-Problem kennen ge-
|
||||
lernt. Der erste Algorithmus ∆TSP1 hat eine Approximationsrate von 2, während der zweite
|
||||
Algorithmus ∆TSP2 von Christofides eine Approximationsrate von 3/2 besitzt. In dieser Übung
|
||||
soll gezeigt werden, dass die zweite Rate scharf ist. D.h., dass es eine Folge von Instanzen gibt,
|
||||
auf denen der Algorithmus immer näher an diese Rate heran kommt.
|
||||
Wir betrachten den vollständigen Graphen Kn = (V, E) für ein n ∈ Z≥6 mit n mod 4 = 2 und die
|
||||
nachfolgende Gewichtsfunktion d2 : E → Z>0 .
|
||||
Wir definieren E2 := {{i, i + 2}|i ∈ {1, . . . , n − 2}} ∪ {{2i + 1, 2i + 2}|i ∈ {0, . . . , n/2 − 1}} und
|
||||
d2 , so dass alle Kanten aus E2 auf 1 abgebildet werden und alle Kanten aus E \ E2 auf den Wert
|
||||
des kürzesten Wegs zwischen diesen Knoten in dem Graphen G2 = (V, E2 ) abgebildet werden.
|
||||
Der Graph G2 ist im folgenden abgebildet.
|
||||
|
||||
|
||||
1 1 1 1
|
||||
1 3 5 7 9 n−1
|
||||
|
||||
|
||||
1 1 1 1 1 1
|
||||
|
||||
1 1 1 1
|
||||
2 4 6 8 10 n
|
||||
|
||||
|
||||
|
||||
Zeigen Sie, dass
|
||||
(a) OPT((Kn , d2 )) = n gilt und
|
||||
(b) eine mögliche Ausführung von ∆TSP2 existiert, die auf Eingabe (Kn , d2 ) eine Tour der
|
||||
Länge (n − 1) + n/2 berechnet.
|
||||
|
||||
Lösung. Wichtig bei dieser Aufgabe ist der folgende Fakt: Bei beiden Algorithmen wird ir-
|
||||
gendein MST genommen und dann irgendeine Reihenfolge der Knoten. Typischerweise wird
|
||||
diese Reihenfolge durch bestimmte Knoten- oder Kanten-Labels festgelegt (wird zum Beispiel
|
||||
der Kruskal-Algorithmus zur Konstruktion des MST genutzt, so werden dort die Kanten nach
|
||||
Gewicht sortiert. Kanten gleichen Gewichts müssen also auch noch durch irgendeine Reihen-
|
||||
folge festgelegt werden.) Wir gehen also davon aus, dass wir diese Reihenfolge auch wählen
|
||||
können. Sollte der Algorithmus also einen “besseren” MST oder eine “bessere” Knotenreihen-
|
||||
folge nutzen, passen wir die Labels dementsprechend an.
|
||||
Ein möglicher MST ist durch die unten angegebene Zick-Zack-Tour gegeben oder formal durch
|
||||
h [ i
|
||||
ET = {{ j, j + 1}, { j + 1, j + 3}, { j + 3, j + 2}, { j + 2, j + 4}}} ∪ {{n − 1, n}}.
|
||||
1≤ j≤n−5
|
||||
j mod 4=1
|
||||
|
||||
|
||||
1 1 1 1
|
||||
1 3 5 7 n−3 n−1
|
||||
|
||||
|
||||
1 1 1 1 1 1
|
||||
|
||||
1 1 1 1
|
||||
2 4 6 8 n−2 n
|
||||
Es gilt d(T ) = n − 1 für die Kosten d(T ) des MST T , da nur Kanten mit Kosten 1 gewählt
|
||||
werden und n − 1 Kanten gewählt werden müssen, um den Zusammenhang sicher zu stellen.
|
||||
Da jeder Knoten im MST außer 1 und n geraden Grad hat, ist das Matching K sehr simpel,
|
||||
nämlich K = {{1, n}}. Die Kosten der Kante {1, n} sind n/2 (der kürzeste Pfad von 1 zu n)
|
||||
und somit gilt d(K) = n/2 für die Kosten von K. Die Rundreise R entspricht einfach dem MST
|
||||
vereinigt mit der Matchingkante und hat somit Kosten: d(R) = n − 1 + n/2.
|
||||
Die optimale Tour würde erst “oben” und dann “unten” entlang laufen, also von Knoten 1 zu
|
||||
Knoten 3 gehen, dann zu Knoten 5 und so weiter, bis Knoten n − 1 erreicht wurde. Danach
|
||||
folgen Knoten n, n − 2, n − 4 und so weiter. Diese Tour hat offensichtlich Kosten n, da wir n
|
||||
Kanten mit Gewicht 1 nutzen. Somit gilt OPT((Kn , d2 )) = n und wir haben eine Rate von
|
||||
|
||||
∆TSP2 ((Kn , d2 )) n − 1 + n/2 1
|
||||
= = 3/2 − ≤ 3/2.
|
||||
OPT((Kn , d2 )) n n
|
||||
|
||||
Da limn→∞ 3/2 − n1 = 3/2, kommen wir der Rate von 3/2 also beliebig nahe.
|
||||
CHRISTIAN-ALBRECHTS-UNIVERSITÄT ZU KIEL
|
||||
Institut für Informatik, Arbeitsgruppe Theoretische Informatik
|
||||
Dr. Max A. Deppert, Janina Reuter, Annika Huch
|
||||
|
||||
06.07.2026
|
||||
|
||||
Präsenzaufgaben zur Vorlesung
|
||||
»Analyse von Algorithmen und Komplexität«
|
||||
Blatt 13
|
||||
|
||||
|
||||
Präsenzaufgabe 13.1
|
||||
Im Folgenden sollen Sie herausfinden (und begründen), welche Lower Bounds sich über ETH
|
||||
und die Reduktionen aus der Vorlesung ergeben. Nutzen Sie hierfür auch das Sparsification
|
||||
Lemma.
|
||||
Welche Schranken für die Laufzeit in Abhängigkeit von den natürlichen Parametern (Anzahl
|
||||
Knoten, Anzahl Kanten, Anzahl Items, Anzahl unterschiedlicher Items, Anzahl Tripel, Kardi-
|
||||
nalität der Grundmenge, Anzahl unterschiedlicher Tripel, Anzahl unterschiedlicher auftretender
|
||||
Zahlen, größte auftretende Zahl, . . . ) ergeben sich jeweils aus folgenden (zum Großteil aus der
|
||||
Vorlesung bekannten) Reduktionen (Laufzeitfaktoren, die polynomiell in der Eingabekodierung
|
||||
sind, dürfen vernachlässigt werden)?
|
||||
Schätzen Sie dazu zunächst die verschiedenen Parameter der resultierenden Instanz mithilfe der
|
||||
Parameter der eingegebenen Instanz geeignet ab.
|
||||
1. 3-SAT ≤ k-CLIQUE
|
||||
2. k-CLIQUE ≤ k-INDEPENDENT SET (Problemdefinition für die Antwort nicht relevant; in
|
||||
der Reduktion wird der Graph G = (V, E) auf den Komplementgraph (V, E) abgebildet)
|
||||
3. SAT ≤ 3-DIMENSIONAL MATCHING (Hinweis: 3-SAT ⊆ SAT)
|
||||
4. 3-DIMENSIONAL MATCHING ≤ 3-EXACT COVER
|
||||
5. 3-EXACT COVER ≤ SUBSET SUM
|
||||
|
||||
Lösung. Angenommen ETH gilt. Das Sparsification Lemma impliziert, dass man 3-SAT nicht
|
||||
in 2o(m) · |I|O(1) lösen kann.
|
||||
|
||||
1. In der Reduktion werden |V | = O(m) Knoten und |E| = O(m2 ) Kanten erstellt und k = m.
|
||||
Außerdem gilt n ≤ 3m ∈ O(m).
|
||||
• Lower bound bzgl. der Anzahl an Knoten: 2o(|V |) · |I|O(1)
|
||||
Angenommen, es existiert ein Algorithmus, der k-Clique in 2o(|V |) ·|I|O(1) löst. Dann
|
||||
gibt es auch einen Algorithmus, der 3-SAT in 2o(m) · |I|O(1) löst (Reduktion auf k-
|
||||
Clique + k-Cliquen Algorithmus). Nach dem Sparsification Lemma gibt es dies nur,
|
||||
wenn die ETH nicht gilt.
|
||||
√
|
||||
• Lower bound bzgl. der Anzahl an Kanten: 2o( |E|) · |I|O(1) √
|
||||
Angenommen, es existiert ein Algorithmus, der k-Clique in 2o( |E|) · |I|O(1) löst.
|
||||
Da |E| ∈ O(m2 ), gibt es dann auch einen Algorithmus, der 3-SAT in 2o(m) · |I|O(1)
|
||||
löst (Reduktion auf k-Clique + k-Cliquen Algorithmus). Nach dem Sparsification
|
||||
Lemma gibt es dies nur, wenn die ETH nicht gilt.
|
||||
• Lower bound bzgl. k: 2o(k) · |I|O(1)
|
||||
Angenommen, es existiert ein Algorithmus, der k-Clique in 2o(k) · |I|O(1) löst. Dann
|
||||
gibt es auch einen Algorithmus, der 3-SAT in 2o(m) · |I|O(1) löst (Reduktion auf k-
|
||||
Clique + k-Cliquen Algorithmus). Nach dem Sparsification Lemma gibt es dies nur,
|
||||
wenn die ETH nicht gilt.
|
||||
2. In der Reduktion werden genau |V | Knoten und maximal |E| = O(|V |2 ) Kanten erstellt.
|
||||
• Lower bound bzgl. der Anzahl an Knoten: 2o(|V |) · |I|O(1)
|
||||
Angenommen, es existiert ein Algorithmus, der k-Independent-Set in 2o(|V |) · |I|O(1)
|
||||
löst. Dann gibt es auch einen Algorithmus, der k-Clique in 2o(|V |) · |I|O(1) löst (Re-
|
||||
duktion auf k-Independent-Set + k-Independent-Set Algorithmus). Nach Aufgaben-
|
||||
teil 1, gibt es diesen nur, wenn die ETH nicht gilt.
|
||||
√
|
||||
o( |E|)
|
||||
• Lower bound bzgl. der Anzahl an Kanten: 2 · |I|O(1) √
|
||||
Angenommen, es existiert ein Algorithmus, der k-Independen-Set in 2o( |E|) ·|I|O(1)
|
||||
löst. Da |E| ≤ |V |2 , gibt es dann auch einen Algorithmus, der k-Clique in 2o(|V |) ·
|
||||
|I|O(1) löst (Reduktion auf k-Independent-Set + k-Independent-Set Algorithmus).
|
||||
Nach Aufgabenteil 1, gibt es diesen nur, wenn die ETH nicht gilt.
|
||||
3. In der Reduktion werden Mengen mit |U| = |V | = |W | = O(mn) Elementen erstellt und
|
||||
|T | = O(m2 n2 ) Tupel. Es gilt n ≤ 3m ∈ O(m) für 3-SAT.
|
||||
√
|
||||
• Lower bound bzgl. der Anzahl an Elementen: 2o( |V |) · |I|O(1) √
|
||||
Angenommen, es existiert ein Algorithmus, der 3-Dimensional-Matching in 2o( |V |) ·
|
||||
|I|O(1) löst. Da |V | ∈ O(mn) ≤ O(m2 ), gibt es dann auch einen Algorithmus, der
|
||||
3-SAT in 2o(m) · |I|O(1) löst (Reduktion von SAT auf 3-Dimensional-Matching an-
|
||||
gewandt auf 3-SAT Instanzen + 3-Dimensional-Matching Algorithmus). Nach dem
|
||||
Sparsification Lemma geht dies nur, wenn die ETH nicht gilt.
|
||||
√
|
||||
4
|
||||
• Lower bound bzgl. der Kardinalität von T : 2o( |T |) · |I|O(1) √
|
||||
4
|
||||
Angenommen, es existiert ein Algorithmus, der 3-Dimensional-Matching in 2o( |T |) ·
|
||||
|I|O(1) löst. Da |T | ∈ O(m2 n2 ) ≤ O(m4 ), gibt es dann auch einen Algorithmus, der
|
||||
3-SAT in 2o(m) · |I|O(1) löst (Reduktion von SAT auf 3-Dimensional-Matching an-
|
||||
gewandt auf 3-SAT Instanzen + 3-Dimensional-Matching Algorithmus). Nach dem
|
||||
Sparsification Lemma geht dies nur, wenn die ETH nicht gilt.
|
||||
4. Das 3-dimensionale Matching ist ein Spezialfall vom 3-Exact Cover. Daher gelten die
|
||||
Lower Bounds von 3-dimensionalem Matching auch für 3-Exact Cover. Da |U| = 3m ∈
|
||||
O(|V |), ergibt sich, dass es für 3-Exact Cover unter der ETH keinen Algorithmus √ mit
|
||||
√
|
||||
o( m) O(1) o( |V |)
|
||||
Laufzeit 2 · |I| geben kann, da sonst 3-dimensionales Matching in 2 ·
|
||||
|I|O(1) lösbar wäre. Und da |F| = |T |, √ ergibt sich, dass es für 3-Exact Cover unter der ETH
|
||||
o( 4 |F|)
|
||||
keinen Algorithmus
|
||||
√ mit Laufzeit 2 · |I|O(1) geben kann, da sonst 3-dimensionales
|
||||
4
|
||||
Matching in 2o( |T |) · |I|O(1) lösbar wäre.
|
||||
√
|
||||
4
|
||||
5. In der Reduktion wird n = |F| gesetzt. Lower bound bzgl. n: 2o( n)√· |I|O(1)
|
||||
4
|
||||
Angenommen, es existiert ein Algorithmus, der SubsetSum in 2o( n) · |I|O(1) löst. Dann
|
||||
√
|
||||
4
|
||||
|F|)
|
||||
gibt es auch einen Algorithmus, der 3-Exact Cover in 2o( · |I|O(1) löst. Nach Aufga-
|
||||
benteil 4, gibt es diesen nur, wenn die ETH nicht gilt.
|
||||
|
||||
Reference in new issue
Block a user