update
This commit is contained in:
@@ -5,3 +5,10 @@ CLAUDE_CODE_OAUTH_TOKEN=
|
|||||||
|
|
||||||
# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan).
|
# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan).
|
||||||
MINIMAX_API_KEY=
|
MINIMAX_API_KEY=
|
||||||
|
|
||||||
|
# Optional — Rollen-Mixing über Anbieter-Grenzen (Standard: die UI-Auswahl gilt für alles).
|
||||||
|
# Wert: Provider-Name ("claude"/"minimax"/"lokal") oder "provider:modell".
|
||||||
|
#ROLE_QUICK=
|
||||||
|
#ROLE_JUDGE=
|
||||||
|
#ROLE_GUIDE=
|
||||||
|
#ROLE_FAST=
|
||||||
|
|||||||
@@ -29,13 +29,14 @@ log = logging.getLogger("creator.agents")
|
|||||||
|
|
||||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||||
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
|
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
|
||||||
|
_active_labels: dict[str, str] = {} # agent_key → human-readable label (for display + events)
|
||||||
|
|
||||||
|
|
||||||
def active_agents(scope_prefix: str | None = None) -> list[dict]:
|
def active_agents(scope_prefix: str | None = None) -> list[dict]:
|
||||||
"""Currently running agents and how long they've been running. Filter by key prefix
|
"""Currently running agents and how long they've been running. Filter by key prefix
|
||||||
(e.g. f"blocks-{topic}-") for one topic. → [{key, runtime}] sorted longest-first."""
|
(e.g. f"blocks-{topic}-") for one topic. → [{key, label, runtime}] sorted longest-first."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
out = [{"key": k, "runtime": round(now - t, 1)}
|
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
|
||||||
for k, t in list(_active_started.items())
|
for k, t in list(_active_started.items())
|
||||||
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
|
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
|
||||||
return sorted(out, key=lambda a: -a["runtime"])
|
return sorted(out, key=lambda a: -a["runtime"])
|
||||||
@@ -101,15 +102,19 @@ _interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
|||||||
# per-topic queue can't undo the global priority when one topic is the only load.
|
# per-topic queue can't undo the global priority when one topic is the only load.
|
||||||
_topic_sems: dict[str, _PrioritySemaphore] = {}
|
_topic_sems: dict[str, _PrioritySemaphore] = {}
|
||||||
|
|
||||||
# Earlier kanban columns get the scarce global slot first (smaller = higher priority).
|
# Smaller index = higher priority. Board 1 (inventory) first — it feeds everything.
|
||||||
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter", "grouping")
|
# Within board 2 the LATE stages win (outline → artefacts → … → subblocks): finish cards
|
||||||
|
# instead of opening new WIP, so the makespan tail block gets slots before fresh work.
|
||||||
|
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter",
|
||||||
|
"grouping", "supplement", "outline", "artifact", "question", "relevance",
|
||||||
|
"level", "facts", "subblock")
|
||||||
|
|
||||||
|
|
||||||
def _agent_priority(key: str) -> int:
|
def _agent_priority(key: str) -> int:
|
||||||
for i, tag in enumerate(_STAGE_PRIORITY):
|
for i, tag in enumerate(_STAGE_PRIORITY):
|
||||||
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
|
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
|
||||||
return i
|
return i
|
||||||
return len(_STAGE_PRIORITY) # downstream agents (subblocks/facts/…) after the inventory columns
|
return len(_STAGE_PRIORITY) # unmatched keys (guide board, …) after everything
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -145,6 +150,8 @@ async def _opencode_slot() -> None:
|
|||||||
_opencode_next_start = start_at + _OPENCODE_START_DELAY
|
_opencode_next_start = start_at + _OPENCODE_START_DELAY
|
||||||
await asyncio.sleep(max(0.0, start_at - now))
|
await asyncio.sleep(max(0.0, start_at - now))
|
||||||
|
|
||||||
|
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
|
||||||
|
|
||||||
# Capability → Claude --allowedTools
|
# Capability → Claude --allowedTools
|
||||||
_CLAUDE_TOOLS = {
|
_CLAUDE_TOOLS = {
|
||||||
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
||||||
@@ -220,6 +227,11 @@ def kill_process(agent_key_prefix: str) -> None:
|
|||||||
_kill(process)
|
_kill(process)
|
||||||
|
|
||||||
|
|
||||||
|
# Event sink for the pipeline history (injected by main.py lifespan as database.add_event —
|
||||||
|
# agents.py stays DB-free). Called fire-and-forget for every finished BATCH agent.
|
||||||
|
on_event = None
|
||||||
|
|
||||||
|
|
||||||
async def run_agent(
|
async def run_agent(
|
||||||
agent_key: str,
|
agent_key: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -230,6 +242,7 @@ async def run_agent(
|
|||||||
lane: str = "batch",
|
lane: str = "batch",
|
||||||
scope: str | None = None,
|
scope: str | None = None,
|
||||||
on_line=None,
|
on_line=None,
|
||||||
|
label: str = "",
|
||||||
) -> tuple[int, str, str]:
|
) -> tuple[int, str, str]:
|
||||||
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
|
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
|
||||||
return 1, "", "cancelled"
|
return 1, "", "cancelled"
|
||||||
@@ -243,17 +256,47 @@ async def run_agent(
|
|||||||
return 1, "", f"No model for role '{role}' (provider: {provider})"
|
return 1, "", f"No model for role '{role}' (provider: {provider})"
|
||||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
|
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
|
||||||
|
queued = time.monotonic()
|
||||||
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
|
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
|
||||||
async with gate:
|
async with gate:
|
||||||
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
||||||
return 1, "", "cancelled"
|
return 1, "", "cancelled"
|
||||||
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
|
wait_ms = int((time.monotonic() - queued) * 1000)
|
||||||
if PROVIDERS[provider]["cli"] == "opencode":
|
start = time.monotonic()
|
||||||
return await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line)
|
status = "error"
|
||||||
return await _run_claude_cli(agent_key, prompt, timeout, model, capabilities)
|
rc = None
|
||||||
|
err_tail = ""
|
||||||
|
try:
|
||||||
|
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
|
||||||
|
if PROVIDERS[provider]["cli"] == "opencode":
|
||||||
|
res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label)
|
||||||
|
else:
|
||||||
|
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
|
||||||
|
rc = res[0]
|
||||||
|
status = "ok" if rc == 0 else ("killed" if rc is not None and rc < 0 else "error")
|
||||||
|
if rc not in (0, None) and rc >= 0:
|
||||||
|
err_tail = (res[2] or res[1] or "").strip()[-300:] # diagnosis: rc=1 without stderr is opaque
|
||||||
|
return res
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
status = "timeout"
|
||||||
|
raise
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
status = "cancelled"
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
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 err_tail:
|
||||||
|
meta["stderr"] = err_tail
|
||||||
|
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
|
||||||
|
status=status, dur_ms=int((time.monotonic() - start) * 1000),
|
||||||
|
wait_ms=wait_ms, meta=meta)
|
||||||
|
except Exception:
|
||||||
|
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) -> 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) -> tuple[int, str, str]:
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
|
||||||
async def spawn():
|
async def spawn():
|
||||||
@@ -263,6 +306,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
|||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
start_new_session=True, # own process group → killpg also kills child processes
|
start_new_session=True, # own process group → killpg also kills child processes
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
|
|
||||||
if stagger:
|
if stagger:
|
||||||
@@ -277,6 +321,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
|||||||
n += 1
|
n += 1
|
||||||
_active_processes[track_key] = process
|
_active_processes[track_key] = process
|
||||||
_active_started[track_key] = time.time()
|
_active_started[track_key] = time.time()
|
||||||
|
_active_labels[track_key] = label
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
if on_line is not None:
|
if on_line is not None:
|
||||||
@@ -319,19 +364,20 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
|||||||
if _active_processes.get(track_key) is process:
|
if _active_processes.get(track_key) is process:
|
||||||
del _active_processes[track_key]
|
del _active_processes[track_key]
|
||||||
_active_started.pop(track_key, None)
|
_active_started.pop(track_key, None)
|
||||||
|
_active_labels.pop(track_key, None)
|
||||||
|
|
||||||
|
|
||||||
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str) -> tuple[int, str, str]:
|
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str, label: str = "") -> tuple[int, str, str]:
|
||||||
cfg = PROVIDERS["claude"]
|
cfg = PROVIDERS["claude"]
|
||||||
cmd = [cfg["cli"], "-p", "--model", model]
|
cmd = [cfg["cli"], "-p", "--model", model]
|
||||||
tools = _CLAUDE_TOOLS.get(capabilities)
|
tools = _CLAUDE_TOOLS.get(capabilities)
|
||||||
if tools:
|
if tools:
|
||||||
cmd += ["--allowedTools", tools]
|
cmd += ["--allowedTools", tools]
|
||||||
cmd += ["--dangerously-skip-permissions"]
|
cmd += ["--dangerously-skip-permissions"]
|
||||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
|
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, label=label)
|
||||||
|
|
||||||
|
|
||||||
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None) -> tuple[int, str, str]:
|
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None, label: str = "") -> tuple[int, str, str]:
|
||||||
cfg = PROVIDERS[provider]
|
cfg = PROVIDERS[provider]
|
||||||
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
|
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
|
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
|
||||||
@@ -349,8 +395,14 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
|||||||
]
|
]
|
||||||
if on_line is not None:
|
if on_line is not None:
|
||||||
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line
|
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line
|
||||||
|
env = None
|
||||||
|
if capabilities != "full":
|
||||||
|
# Batch agents (files/readonly/text) never use the web MCPs, but opencode starts
|
||||||
|
# every configured MCP server PER PROCESS (~3 procs / ~300 MB each). Point them
|
||||||
|
# at the mcp-free config copy; only `full` (research/supplement) keeps the servers.
|
||||||
|
env = {**os.environ, "OPENCODE_CONFIG": str(_SLIM_CONFIG)}
|
||||||
try:
|
try:
|
||||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line)
|
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env)
|
||||||
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
|
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
|
||||||
finally:
|
finally:
|
||||||
prompt_path.unlink(missing_ok=True)
|
prompt_path.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
|||||||
import database as db
|
import database as db
|
||||||
import embedding
|
import embedding
|
||||||
from agents import kill_process, cancel_scope, clear_scope, run_agent
|
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
|
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
|
||||||
from fsutil import atomic_write_text, atomic_write_json
|
from fsutil import atomic_write_text, atomic_write_json
|
||||||
from jsonio import read_json_file as _json_file
|
from jsonio import 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 paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
|
||||||
@@ -53,6 +53,10 @@ RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema", no crawl folder)
|
|||||||
RESEARCH_SECTION_CHARS = 12000
|
RESEARCH_SECTION_CHARS = 12000
|
||||||
# Triage (content/noise) is now a deterministic rule filter (config.CRAWL_*).
|
# Triage (content/noise) is now a deterministic rule filter (config.CRAWL_*).
|
||||||
SUBBLOCK_CAP = 900 # subblock find loop per chunk (15 min)
|
SUBBLOCK_CAP = 900 # subblock find loop per chunk (15 min)
|
||||||
|
SUBBLOCK_MIN = 5 # below this consensus count a block gets focused catch-up rounds
|
||||||
|
SUBBLOCK_EXTRA_ROUNDS = 2 # max catch-up rounds (saturation stop still applies — thin stays thin)
|
||||||
|
SUBBLOCK_MAX_ROUNDS = 5 # hard round cap: measured runs hit 5–9 rounds purely on paraphrases
|
||||||
|
# before the variant-robust `new` count converges — never search longer
|
||||||
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (dedups everything); above that chunked + merge pass — fallback path only
|
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (dedups everything); above that chunked + merge pass — fallback path only
|
||||||
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair (complete-link aggregates → no chaining)
|
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair (complete-link aggregates → no chaining)
|
||||||
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package (pairwise verification instead of a block mixer)
|
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package (pairwise verification instead of a block mixer)
|
||||||
@@ -61,7 +65,10 @@ FILTER_CHUNK = 35 # blocks to assess per judge in the degrade pass (fu
|
|||||||
# Balance question-pattern chunks by sub load via LPT (makespan), not by block count.
|
# Balance question-pattern chunks by sub load via LPT (makespan), not by block count.
|
||||||
QUESTION_CHUNK_SUBS = 50 # target sum of relevant subs per chunk
|
QUESTION_CHUNK_SUBS = 50 # target sum of relevant subs per chunk
|
||||||
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern (the LLM omits ~18 % per chunk)
|
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern (the LLM omits ~18 % per chunk)
|
||||||
FACTS_CHUNK_SUBS = 25 # facts extraction: smaller chunks (facts are bulkier than patterns)
|
FACTS_CHUNK_SUBS = 10 # facts extraction: small chunks — the 4 phases (find/erg/check/fix) are
|
||||||
|
# serial PER CHUNK, so chunk count = parallelism; the makespan tail of a
|
||||||
|
# late block is bounded by ONE chunk's phase chain, not the whole block
|
||||||
|
ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples: bulk generation, phases are cheap → bigger packages
|
||||||
FACTS_CHECK_PANEL = 3 # judges per chunk in the facts check (majority objects)
|
FACTS_CHECK_PANEL = 3 # judges per chunk in the facts check (majority objects)
|
||||||
CONSOLIDATION_PANEL = 3 # mapping judges per chunk (panel → reconcile instead of a single judge)
|
CONSOLIDATION_PANEL = 3 # mapping judges per chunk (panel → reconcile instead of a single judge)
|
||||||
SUBBLOCK_PANEL = 3 # source judges in the subblock clarification (majority instead of a single judge)
|
SUBBLOCK_PANEL = 3 # source judges in the subblock clarification (majority instead of a single judge)
|
||||||
@@ -556,10 +563,51 @@ def _lpt_chunks(weights: list[int], target: int) -> list[list[int]]:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_NEG_TOKENS = {"nicht", "kein", "keine", "keinen", "keiner", "ohne", "nie"}
|
||||||
|
|
||||||
|
|
||||||
|
def _neg_set(title: str) -> frozenset:
|
||||||
|
"""Negation tokens of a title — antonym statements measure cos 0.91–0.95 (above any usable
|
||||||
|
variant threshold), so equal negation sets are a hard merge precondition."""
|
||||||
|
return frozenset(t for t in re.findall(r"\w+", _norm_title(title)) if t in _NEG_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
def _sub_tokens(title: str) -> set:
|
||||||
|
return set(re.findall(r"\w+", _norm_title(title)))
|
||||||
|
|
||||||
|
|
||||||
|
def _variant_clusters(titles: list[str], mentions: list[int], sims) -> list[dict]:
|
||||||
|
"""Fold phrasing VARIANTS of one concept BEFORE the consensus count: finders rephrase per
|
||||||
|
round, so exact-norm counting starves real concepts. Union-find over cos ≥ SUB_VARIANT_COS
|
||||||
|
with the negation guard. → [{"rep": idx, "members": [idx…], "mentions": sum}]."""
|
||||||
|
n = len(titles)
|
||||||
|
parent = list(range(n))
|
||||||
|
|
||||||
|
def find(x):
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
negs = [_neg_set(t) for t in titles]
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
if float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j]:
|
||||||
|
parent[find(i)] = find(j)
|
||||||
|
groups: dict[int, list[int]] = {}
|
||||||
|
for i in range(n):
|
||||||
|
groups.setdefault(find(i), []).append(i)
|
||||||
|
return [{"rep": max(g, key=lambda k: (len(titles[k]), -k)), "members": g,
|
||||||
|
"mentions": sum(mentions[k] for k in g)} for g in groups.values()]
|
||||||
|
|
||||||
|
|
||||||
async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str,
|
async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str,
|
||||||
wipe: bool = True, ns: str = "") -> dict | None:
|
wipe: bool = True, ns: str = "", seeds: list[str] | None = None,
|
||||||
|
lbl: str = "") -> dict | None:
|
||||||
"""Block B (DB + loop): per package, find subblocks in rounds (3 finders, until 0 new/cap),
|
"""Block B (DB + loop): per package, find subblocks in rounds (3 finders, until 0 new/cap),
|
||||||
collect in the DB (≥2 mentions = consensus, 1× discarded), a judge cleans up per package.
|
collect in the DB (variant-clustered mentions ≥2 = consensus), a judge panel cleans up per
|
||||||
|
package; blocks below SUBBLOCK_MIN get focused catch-up rounds; `seeds` (demoted fragment
|
||||||
|
titles, single-block kanban calls) are guaranteed to reach the facts evidence gate.
|
||||||
→ {block title: [subblock, …]} (consensus) or None. Fills DB table `subblocks`.
|
→ {block title: [subblock, …]} (consensus) or None. Fills DB table `subblocks`.
|
||||||
wipe=False (kanban board: one call per block) keeps the other blocks' rows."""
|
wipe=False (kanban board: one call per block) keeps the other blocks' rows."""
|
||||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||||
@@ -574,8 +622,12 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
|||||||
n = len(chunks)
|
n = len(chunks)
|
||||||
title_by_num = {num: _title(entries[num]) for num in nums}
|
title_by_num = {num: _title(entries[num]) for num in nums}
|
||||||
norm_by_num = {num: _norm_title(title_by_num[num]) for num in nums}
|
norm_by_num = {num: _norm_title(title_by_num[num]) for num in nums}
|
||||||
|
emb_on = EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available)
|
||||||
if wipe:
|
if wipe:
|
||||||
await db.delete_subblocks(topic) # fresh start of the block (idempotent counter)
|
await db.delete_subblocks(topic) # fresh start of the block (idempotent counter)
|
||||||
|
else:
|
||||||
|
for num in nums: # per-block wipe: a re-spawned card must not accumulate mentions
|
||||||
|
await db.delete_subblocks(topic, norm_by_num[num])
|
||||||
|
|
||||||
async def _known_block(chunk):
|
async def _known_block(chunk):
|
||||||
known = []
|
known = []
|
||||||
@@ -589,48 +641,82 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
|||||||
# self-bias/echo) — only add what's missing. This keeps the counter an honest consensus signal.
|
# self-bias/echo) — only add what's missing. This keeps the counter an honest consensus signal.
|
||||||
return ("\n\nBEREITS ERFASST — liste diese NICHT erneut. Finde nur, was FEHLT:\n" + "\n".join(known))
|
return ("\n\nBEREITS ERFASST — liste diese NICHT erneut. Finde nur, was FEHLT:\n" + "\n".join(known))
|
||||||
|
|
||||||
|
# ONE finder round (3 slots, quorum 2) → count of NEW sub norms; None = no result/cancel.
|
||||||
|
async def _one_round(label, subset, assignment, paths, keys, known, extra_instr):
|
||||||
|
chunk_idx = _title_index({num: title_by_num[num] for num in subset})
|
||||||
|
for p in paths:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
slots = [{
|
||||||
|
"key": k,
|
||||||
|
"prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=known, out_path=p, extra=_extra(extra_instr)),
|
||||||
|
"role": "quick", "capabilities": caps,
|
||||||
|
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
|
||||||
|
} for k, p in zip(keys, paths)]
|
||||||
|
agent_texts = await _race(topic, label, slots, 2, _timeout("subblock", len(subset)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||||
|
if is_cancelled() or not agent_texts:
|
||||||
|
return None
|
||||||
|
rows_before = {num: await db.list_subblocks(topic, norm_by_num[num]) for num in subset}
|
||||||
|
existing = {num: {s["sub_norm"] for s in rows_before[num]} for num in subset}
|
||||||
|
fresh: dict[int, list[str]] = {}
|
||||||
|
for d in agent_texts:
|
||||||
|
for marker, subs in d.items():
|
||||||
|
num = _resolve_title(chunk_idx, marker)
|
||||||
|
if num is None:
|
||||||
|
continue
|
||||||
|
seen_set = set()
|
||||||
|
for sub in subs:
|
||||||
|
sn = _norm_title(sub)
|
||||||
|
if not sn or sn in seen_set:
|
||||||
|
continue
|
||||||
|
seen_set.add(sn)
|
||||||
|
if sn not in existing[num]:
|
||||||
|
existing[num].add(sn)
|
||||||
|
fresh.setdefault(num, []).append(sub)
|
||||||
|
await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub)
|
||||||
|
# "New" is variant-robust: a paraphrase of an existing sub (or of another fresh find)
|
||||||
|
# still gets stored above (its mention feeds the cluster consensus), but it must not
|
||||||
|
# keep the saturation loop spinning — finders rephrase every round (measured: 5–9
|
||||||
|
# rounds without this fold). Model off → exact counting (status quo).
|
||||||
|
new = 0
|
||||||
|
for num, cands in fresh.items():
|
||||||
|
sims = None
|
||||||
|
base = [s["sub_title"] for s in rows_before[num]]
|
||||||
|
if emb_on and base + cands:
|
||||||
|
sims = await asyncio.to_thread(embedding.embed_sims, base + cands)
|
||||||
|
if sims is None:
|
||||||
|
new += len(cands)
|
||||||
|
continue
|
||||||
|
negs = [_neg_set(t) for t in base + cands]
|
||||||
|
nb = len(base)
|
||||||
|
kept: list[int] = []
|
||||||
|
for i in range(nb, nb + len(cands)):
|
||||||
|
dup = any(float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j]
|
||||||
|
for j in [*range(nb), *kept])
|
||||||
|
if not dup:
|
||||||
|
kept.append(i)
|
||||||
|
new += len(kept)
|
||||||
|
return new
|
||||||
|
|
||||||
# Phase "Subblocks find": per package loop until 0 new subs / time cap.
|
# Phase "Subblocks find": per package loop until 0 new subs / time cap.
|
||||||
async def _find(c, chunk):
|
async def _find(c, chunk):
|
||||||
assignment = "\n".join(f"- {entries[num]}" for num in chunk)
|
assignment = "\n".join(f"- {entries[num]}" for num in chunk)
|
||||||
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
round_n = 0
|
round_n = 0
|
||||||
while not is_cancelled():
|
while not is_cancelled():
|
||||||
round_n += 1
|
round_n += 1
|
||||||
bekannt = await _known_block(chunk) if round_n > 1 else ""
|
bekannt = await _known_block(chunk) if round_n > 1 else ""
|
||||||
paths = [work_dir / f"subblock-c{c}-r{round_n}-{i}.md" for i in (1, 2, 3)]
|
paths = [work_dir / f"subblock-c{c}-r{round_n}-{i}.md" for i in (1, 2, 3)]
|
||||||
for p in paths:
|
keys = [f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}" for i in (1, 2, 3)]
|
||||||
p.unlink(missing_ok=True)
|
new = await _one_round(f"{lbl}Subblocks package {c} R{round_n}", chunk, assignment, paths, keys, bekannt, instructions)
|
||||||
slots = [{
|
if new is None:
|
||||||
"key": f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}",
|
if is_cancelled():
|
||||||
"prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=bekannt, out_path=p, extra=_extra(instructions)),
|
return False
|
||||||
"role": "quick", "capabilities": caps,
|
|
||||||
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
|
|
||||||
} for i, p in enumerate(paths, 1)]
|
|
||||||
agent_texts = await _race(topic, f"Subblocks package {c} R{round_n}", slots, 2, _timeout("subblock", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
|
||||||
if is_cancelled():
|
|
||||||
return False
|
|
||||||
if not agent_texts:
|
|
||||||
return round_n > 1 # round 1 without result = error; later = simply the end
|
return round_n > 1 # round 1 without result = error; later = simply the end
|
||||||
existing = {num: {s["sub_norm"] for s in await db.list_subblocks(topic, norm_by_num[num])} for num in chunk}
|
|
||||||
new = 0
|
|
||||||
for d in agent_texts:
|
|
||||||
for marker, subs in d.items():
|
|
||||||
num = _resolve_title(chunk_idx, marker)
|
|
||||||
if num is None:
|
|
||||||
continue
|
|
||||||
seen_set = set()
|
|
||||||
for sub in subs:
|
|
||||||
sn = _norm_title(sub)
|
|
||||||
if not sn or sn in seen_set:
|
|
||||||
continue
|
|
||||||
seen_set.add(sn)
|
|
||||||
if sn not in existing[num]:
|
|
||||||
new += 1
|
|
||||||
existing[num].add(sn)
|
|
||||||
await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub)
|
|
||||||
if new == 0:
|
if new == 0:
|
||||||
break
|
break
|
||||||
|
if round_n >= SUBBLOCK_MAX_ROUNDS:
|
||||||
|
_log(topic, f"Subblocks package {c}: round cap reached ({round_n})")
|
||||||
|
break
|
||||||
if time.monotonic() - start > SUBBLOCK_CAP:
|
if time.monotonic() - start > SUBBLOCK_CAP:
|
||||||
_log(topic, f"Subblocks package {c}: time cap reached (round {round_n})")
|
_log(topic, f"Subblocks package {c}: time cap reached (round {round_n})")
|
||||||
break
|
break
|
||||||
@@ -643,49 +729,117 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
|||||||
_blocks_errors[topic] = "Subblocks failed (research)"
|
_blocks_errors[topic] = "Subblocks failed (research)"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Phase "Subblocks select": ≥2 mentions = consensus, 1× discarded (code).
|
# Phase "Subblocks select": variant-clustered mentions ≥2 = consensus — the cluster
|
||||||
|
# representative carries the status, folded members become `variant` (NOT discarded:
|
||||||
|
# the clarify panel's uncertain group must not re-list them). Model off → exact counter.
|
||||||
|
async def _select(subset, keep_consensus=False):
|
||||||
|
for num in subset:
|
||||||
|
rows = await db.list_subblocks(topic, norm_by_num[num])
|
||||||
|
clusters = None
|
||||||
|
if emb_on and len(rows) >= 2:
|
||||||
|
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
|
||||||
|
if sims is not None:
|
||||||
|
clusters = _variant_clusters([r["sub_title"] for r in rows],
|
||||||
|
[r["mentions"] for r in rows], sims)
|
||||||
|
if clusters is None:
|
||||||
|
for r in rows:
|
||||||
|
if keep_consensus and r["status"] == "consensus":
|
||||||
|
continue
|
||||||
|
await db.set_subblock_fields(topic, norm_by_num[num], r["sub_norm"],
|
||||||
|
status=("consensus" if r["mentions"] >= 2 else "discarded"))
|
||||||
|
continue
|
||||||
|
for cl in clusters:
|
||||||
|
# a re-select (catch-up) never demotes panel-confirmed subs — an existing
|
||||||
|
# consensus member stays the representative, new variants fold under it.
|
||||||
|
kept = [k for k in cl["members"] if keep_consensus and rows[k]["status"] == "consensus"]
|
||||||
|
for k in cl["members"]:
|
||||||
|
if kept:
|
||||||
|
st = "consensus" if k in kept else "variant"
|
||||||
|
elif cl["mentions"] >= 2:
|
||||||
|
st = "consensus" if k == cl["rep"] else "variant"
|
||||||
|
else:
|
||||||
|
st = "discarded"
|
||||||
|
if keep_consensus and rows[k]["status"] == "consensus" and st != "consensus":
|
||||||
|
continue
|
||||||
|
await db.set_subblock_fields(topic, norm_by_num[num], rows[k]["sub_norm"], status=st)
|
||||||
|
|
||||||
set_p(f"Subblocks select ({n} packages)…", step=_step_idx(topic, "Subblocks select"))
|
set_p(f"Subblocks select ({n} packages)…", step=_step_idx(topic, "Subblocks select"))
|
||||||
for num in nums:
|
await _select(nums)
|
||||||
for s in await db.list_subblocks(topic, norm_by_num[num]):
|
|
||||||
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"],
|
# Judge formulation → shown candidate (best cos ≥ SUB_VARIANT_COS, negation-guarded).
|
||||||
status=("consensus" if s["mentions"] >= 2 else "discarded"))
|
# Judges demonstrably paraphrase; without canonicalizing, the exact-norm majority vote
|
||||||
|
# splinters across formulations (measured: 0.993-duplicates in a final list).
|
||||||
|
async def _canon_map(shown: list[str], judge_titles: list[str]) -> dict[str, tuple[str, str]]:
|
||||||
|
if not emb_on or not shown or not judge_titles:
|
||||||
|
return {}
|
||||||
|
texts = shown + list(judge_titles)
|
||||||
|
sims = await asyncio.to_thread(embedding.embed_sims, texts)
|
||||||
|
if sims is None:
|
||||||
|
return {}
|
||||||
|
negs = [_neg_set(t) for t in texts]
|
||||||
|
m: dict[str, tuple[str, str]] = {}
|
||||||
|
for a in range(len(shown), len(texts)):
|
||||||
|
best, bv = None, 0.0
|
||||||
|
for b in range(len(shown)):
|
||||||
|
v = float(sims[a][b])
|
||||||
|
if v >= SUB_VARIANT_COS and v > bv and negs[a] == negs[b]:
|
||||||
|
best, bv = b, v
|
||||||
|
if best is not None:
|
||||||
|
m[_norm_title(texts[a])] = (_norm_title(shown[best]), shown[best])
|
||||||
|
return m
|
||||||
|
|
||||||
# Phase "Subblocks clarify": source panel (SUBBAUSTEIN_PANEL judges) checks consensus + uncertain (1×)
|
# Phase "Subblocks clarify": source panel (SUBBAUSTEIN_PANEL judges) checks consensus + uncertain (1×)
|
||||||
# against the source; code majority per sub. External, multi-voice gate against single-judge bias + echo.
|
# against the source; code majority per sub. External, multi-voice gate against single-judge bias + echo.
|
||||||
async def _clarify(c, chunk):
|
async def _clarify(c, chunk, tag=""):
|
||||||
fp = work_dir / f"subblock-final-c{c}.md"
|
fp = work_dir / f"subblock-final-c{c}{tag}.md"
|
||||||
if _parse_subblocks(_read(fp)):
|
if _parse_subblocks(_read(fp)):
|
||||||
return
|
return
|
||||||
block_texts, has_any = [], False
|
block_texts, has_any = [], False
|
||||||
consensus_by_num: dict[int, list[str]] = {}
|
consensus_by_num: dict[int, list[str]] = {}
|
||||||
|
shown_by_num: dict[int, list[str]] = {}
|
||||||
for num in chunk:
|
for num in chunk:
|
||||||
rows = await db.list_subblocks(topic, norm_by_num[num])
|
rows = await db.list_subblocks(topic, norm_by_num[num])
|
||||||
consensus_subs = [s["sub_title"] for s in rows if s["status"] == "consensus"]
|
consensus_subs = [s["sub_title"] for s in rows if s["status"] == "consensus"]
|
||||||
uncertain = [s["sub_title"] for s in rows if s["status"] != "consensus" and s["mentions"] == 1]
|
# folded variants (status `variant`) are already counted — only true singles are uncertain
|
||||||
|
uncertain = [s["sub_title"] for s in rows if s["status"] == "discarded" and s["mentions"] == 1]
|
||||||
consensus_by_num[num] = consensus_subs
|
consensus_by_num[num] = consensus_subs
|
||||||
|
shown_by_num[num] = consensus_subs + uncertain
|
||||||
if not consensus_subs and not uncertain:
|
if not consensus_subs and not uncertain:
|
||||||
continue
|
continue
|
||||||
has_any = True
|
has_any = True
|
||||||
k_lines = "\n".join(f"- {s}" for s in consensus_subs) if consensus_subs else "- (keiner)"
|
k_lines = "\n".join(f"- {s}" for s in consensus_subs) if consensus_subs else "- (keiner)"
|
||||||
u_lines = "\n".join(f"- {s}" for s in uncertain) if uncertain else "- (keiner)"
|
u_lines = "\n".join(f"- {s}" for s in uncertain) if uncertain else "- (keiner)"
|
||||||
block_texts.append(f"BLOCK: {title_by_num[num]}\nKonsens (≥2 finders):\n{k_lines}\nUnsicher (1× — streng gegen Source check):\n{u_lines}")
|
band = ""
|
||||||
|
shown = shown_by_num[num]
|
||||||
|
if emb_on and len(shown) >= 2: # near-dup pairs BELOW the fold threshold → explicit panel hint
|
||||||
|
sims = await asyncio.to_thread(embedding.embed_sims, shown)
|
||||||
|
if sims is not None:
|
||||||
|
pairs = [f"- „{shown[i]}“ ↔ „{shown[j]}“"
|
||||||
|
for i in range(len(shown)) for j in range(i + 1, len(shown))
|
||||||
|
if 0.75 <= float(sims[i][j]) < SUB_VARIANT_COS]
|
||||||
|
if pairs:
|
||||||
|
band = ("\nMögliche Duplikate — prüfen und ggf. zu EINEM Eintrag zusammenführen:\n"
|
||||||
|
+ "\n".join(pairs[:12]))
|
||||||
|
block_texts.append(f"BLOCK: {title_by_num[num]}\nKonsens (≥2 finders):\n{k_lines}\nUnsicher (1× — streng gegen Source check):\n{u_lines}{band}")
|
||||||
if not has_any:
|
if not has_any:
|
||||||
return
|
return
|
||||||
|
|
||||||
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
|
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
|
||||||
paths = [work_dir / f"subblock-final-c{c}-j{j}.md" for j in range(1, SUBBLOCK_PANEL + 1)]
|
paths = [work_dir / f"subblock-final-c{c}{tag}-j{j}.md" for j in range(1, SUBBLOCK_PANEL + 1)]
|
||||||
pending = [(j, p) for j, p in enumerate(paths, 1) if _parse_subblocks(_read(p)) is None]
|
# truthiness, NOT `is None`: a missing file parses to {} — with `is None` the whole
|
||||||
|
# panel silently never ran (fallback adopted the raw consensus unchecked).
|
||||||
|
pending = [(j, p) for j, p in enumerate(paths, 1) if not _parse_subblocks(_read(p))]
|
||||||
for _, p in pending:
|
for _, p in pending:
|
||||||
p.unlink(missing_ok=True)
|
p.unlink(missing_ok=True)
|
||||||
if pending:
|
if pending:
|
||||||
slots = [{
|
slots = [{
|
||||||
"key": f"blocks-{topic}-{ns}subblock-final-c{c}-j{j}",
|
"key": f"blocks-{topic}-{ns}subblock-final-c{c}{tag}-j{j}",
|
||||||
"prompt": _prompt("Subblock-Mapping", topic=topic, source=source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)),
|
"prompt": _prompt("Subblock-Mapping", topic=topic, source=source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)),
|
||||||
"role": "judge", "capabilities": caps,
|
"role": "judge", "capabilities": caps,
|
||||||
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
|
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
|
||||||
} for j, p in pending]
|
} for j, p in pending]
|
||||||
existing = SUBBLOCK_PANEL - len(pending)
|
existing = SUBBLOCK_PANEL - len(pending)
|
||||||
await _race(topic, f"Subblock-Clarification {c}", slots, max(1, 2 - existing),
|
await _race(topic, f"{lbl}Subblock-Clarification {c}", slots, max(1, 2 - existing),
|
||||||
_timeout("subblock_check", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
_timeout("subblock_check", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||||
if is_cancelled():
|
if is_cancelled():
|
||||||
return
|
return
|
||||||
@@ -698,22 +852,34 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
|||||||
return
|
return
|
||||||
|
|
||||||
# code majority per block/sub-norm: keep if a majority of judges list it (tie → keep).
|
# code majority per block/sub-norm: keep if a majority of judges list it (tie → keep).
|
||||||
|
# Votes are canonicalized onto the shown candidates first (paraphrase-robust).
|
||||||
block_texts_out = []
|
block_texts_out = []
|
||||||
for num in chunk:
|
for num in chunk:
|
||||||
|
raw_votes: list[list[str]] = []
|
||||||
|
for d in outs:
|
||||||
|
subs_of_num: list[str] = []
|
||||||
|
for marker, subs in d.items():
|
||||||
|
if _resolve_title(chunk_idx, marker) == num:
|
||||||
|
subs_of_num.extend(subs)
|
||||||
|
raw_votes.append(subs_of_num)
|
||||||
|
judge_titles = list(dict.fromkeys(s for subs in raw_votes for s in subs
|
||||||
|
if _norm_title(s) not in {_norm_title(t) for t in shown_by_num[num]}))
|
||||||
|
canon = await _canon_map(shown_by_num[num], judge_titles)
|
||||||
votes: dict[str, int] = {}
|
votes: dict[str, int] = {}
|
||||||
form: dict[str, str] = {}
|
form: dict[str, str] = {}
|
||||||
for d in outs:
|
for subs_of_num in raw_votes:
|
||||||
seen = set()
|
seen = set()
|
||||||
for marker, subs in d.items():
|
for sub in subs_of_num:
|
||||||
if _resolve_title(chunk_idx, marker) != num:
|
sn = _norm_title(sub)
|
||||||
|
if not sn:
|
||||||
continue
|
continue
|
||||||
for sub in subs:
|
if sn in canon:
|
||||||
sn = _norm_title(sub)
|
sn, sub = canon[sn]
|
||||||
if not sn or sn in seen:
|
if sn in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(sn)
|
seen.add(sn)
|
||||||
form.setdefault(sn, sub)
|
form.setdefault(sn, sub)
|
||||||
votes[sn] = votes.get(sn, 0) + 1
|
votes[sn] = votes.get(sn, 0) + 1
|
||||||
kept = [form[sn] for sn in form if votes[sn] * 2 >= len(outs)]
|
kept = [form[sn] for sn in form if votes[sn] * 2 >= len(outs)]
|
||||||
if kept:
|
if kept:
|
||||||
block_texts_out.append(f"<!-- block: {title_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in kept))
|
block_texts_out.append(f"<!-- block: {title_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in kept))
|
||||||
@@ -725,8 +891,9 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
|||||||
|
|
||||||
# Final list per block: judge output, otherwise consensus fallback. Reconcile DB + build raw.
|
# Final list per block: judge output, otherwise consensus fallback. Reconcile DB + build raw.
|
||||||
raw: dict[str, list[str]] = {}
|
raw: dict[str, list[str]] = {}
|
||||||
for c, chunk in enumerate(chunks, 1):
|
|
||||||
final = _parse_subblocks(_read(work_dir / f"subblock-final-c{c}.md")) or {}
|
async def _align(c, chunk, tag=""):
|
||||||
|
final = _parse_subblocks(_read(work_dir / f"subblock-final-c{c}{tag}.md")) or {}
|
||||||
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
|
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
|
||||||
final_by_num = {_resolve_title(chunk_idx, m): subs for m, subs in final.items() if _resolve_title(chunk_idx, m) is not None}
|
final_by_num = {_resolve_title(chunk_idx, m): subs for m, subs in final.items() if _resolve_title(chunk_idx, m) is not None}
|
||||||
for num in chunk:
|
for num in chunk:
|
||||||
@@ -740,14 +907,83 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
|||||||
final_norms = {_norm_title(s) for s in subs}
|
final_norms = {_norm_title(s) for s in subs}
|
||||||
have = {s["sub_norm"] for s in await db.list_subblocks(topic, norm_by_num[num])}
|
have = {s["sub_norm"] for s in await db.list_subblocks(topic, norm_by_num[num])}
|
||||||
for s in await db.list_subblocks(topic, norm_by_num[num]):
|
for s in await db.list_subblocks(topic, norm_by_num[num]):
|
||||||
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"],
|
if s["sub_norm"] in final_norms:
|
||||||
status=("consensus" if s["sub_norm"] in final_norms else "discarded"))
|
st = "consensus"
|
||||||
|
elif s["status"] == "variant":
|
||||||
|
st = "variant" # folded members stay marked — a catch-up clarify must not
|
||||||
|
else: # re-list them as "uncertain singles"
|
||||||
|
st = "discarded"
|
||||||
|
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"], status=st)
|
||||||
for s in subs:
|
for s in subs:
|
||||||
sn = _norm_title(s)
|
sn = _norm_title(s)
|
||||||
if sn and sn not in have:
|
if sn and sn not in have:
|
||||||
await db.upsert_subblock(topic, norm_by_num[num], sn, title, s)
|
await db.upsert_subblock(topic, norm_by_num[num], sn, title, s)
|
||||||
await db.set_subblock_fields(topic, norm_by_num[num], sn, status="consensus")
|
await db.set_subblock_fields(topic, norm_by_num[num], sn, status="consensus")
|
||||||
|
|
||||||
|
for c, chunk in enumerate(chunks, 1):
|
||||||
|
await _align(c, chunk)
|
||||||
|
|
||||||
|
# Minimum catch-up: a block below SUBBLOCK_MIN gets up to SUBBLOCK_EXTRA_ROUNDS focused
|
||||||
|
# finder rounds. Saturation stop stays — a thin block REMAINS thin if nothing verifiable.
|
||||||
|
async def _catchup(c, chunk):
|
||||||
|
for k in range(1, SUBBLOCK_EXTRA_ROUNDS + 1):
|
||||||
|
lacking = [num for num in chunk if len(raw.get(title_by_num[num]) or []) < SUBBLOCK_MIN]
|
||||||
|
if not lacking or is_cancelled():
|
||||||
|
return
|
||||||
|
assignment = "\n".join(f"- {entries[num]}" for num in lacking)
|
||||||
|
known = await _known_block(lacking) # ALL rows of the block, incl. variants/discarded
|
||||||
|
focus = (instructions + "\n\nDieser Block hat bisher nur sehr wenige belegte "
|
||||||
|
"Subbausteine. Suche gezielt nach WEITEREN belegbaren Kernaspekten, die "
|
||||||
|
"oben fehlen. Nimm NUR auf, was die Quellen wirklich hergeben — nicht aufblähen.")
|
||||||
|
paths = [work_dir / f"subblock-x{k}-c{c}-{i}.md" for i in (1, 2, 3)]
|
||||||
|
keys = [f"blocks-{topic}-{ns}subblock-x{k}-c{c}-{i}" for i in (1, 2, 3)]
|
||||||
|
new = await _one_round(f"{lbl}Subblocks catch-up {c} X{k}", lacking, assignment, paths, keys, known, focus)
|
||||||
|
if not new:
|
||||||
|
return
|
||||||
|
await _select(lacking, keep_consensus=True)
|
||||||
|
await _clarify(c, chunk, tag=f"-x{k}")
|
||||||
|
if is_cancelled():
|
||||||
|
return
|
||||||
|
await _align(c, chunk, tag=f"-x{k}")
|
||||||
|
|
||||||
|
# progress reuses the clarify step label — catch-up has no own registry entry
|
||||||
|
await _gather_progress([_catchup(c, chunk) for c, chunk in enumerate(chunks, 1)], n,
|
||||||
|
_report_p(set_p, topic, "Subblocks clarify"))
|
||||||
|
if is_cancelled():
|
||||||
|
return None
|
||||||
await _dedup_subblocks(topic, raw) # near-dup filter per block (deterministic, no LLM)
|
await _dedup_subblocks(topic, raw) # near-dup filter per block (deterministic, no LLM)
|
||||||
|
|
||||||
|
# Seed guarantee (single-block kanban calls): every demoted-fragment seed must reach the
|
||||||
|
# facts evidence gate — covered by a consensus sub, promoted from a single find, or
|
||||||
|
# inserted as its own sub. Unverifiable seeds die at the facts discard, not silently here.
|
||||||
|
for num in (nums if seeds else []):
|
||||||
|
title = title_by_num[num]
|
||||||
|
for seed in dict.fromkeys(s for s in seeds if s):
|
||||||
|
st = _sub_tokens(seed)
|
||||||
|
have = raw.get(title) or []
|
||||||
|
if not st or any(st <= _sub_tokens(s) for s in have):
|
||||||
|
continue
|
||||||
|
if emb_on and have: # embedding backup for rephrased covers (lexical is primary)
|
||||||
|
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + have)
|
||||||
|
if sims is not None and max(float(sims[0][j]) for j in range(1, len(have) + 1)) >= SEED_COVER_COS:
|
||||||
|
continue
|
||||||
|
rows = [r for r in await db.list_subblocks(topic, norm_by_num[num]) if r["status"] != "consensus"]
|
||||||
|
cand = next((r for r in rows if st <= _sub_tokens(r["sub_title"])), None)
|
||||||
|
if cand is None and emb_on and rows:
|
||||||
|
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + [r["sub_title"] for r in rows])
|
||||||
|
if sims is not None:
|
||||||
|
j = max(range(1, len(rows) + 1), key=lambda x: float(sims[0][x]))
|
||||||
|
if float(sims[0][j]) >= SEED_COVER_COS and _neg_set(seed) == _neg_set(rows[j - 1]["sub_title"]):
|
||||||
|
cand = rows[j - 1]
|
||||||
|
if cand is not None:
|
||||||
|
await db.set_subblock_fields(topic, norm_by_num[num], cand["sub_norm"], status="consensus")
|
||||||
|
raw.setdefault(title, []).append(cand["sub_title"])
|
||||||
|
_log(topic, f"Seed „{seed}“: Einzelfund „{cand['sub_title']}“ übernommen ({title})")
|
||||||
|
elif (sn := _norm_title(seed)):
|
||||||
|
await db.put_subblock(topic, norm_by_num[num], sn, title, seed, status="consensus")
|
||||||
|
raw.setdefault(title, []).append(seed)
|
||||||
|
_log(topic, f"Seed „{seed}“ als Subbaustein eingefügt ({title}) — Facts-Gate prüft")
|
||||||
|
|
||||||
if not raw:
|
if not raw:
|
||||||
# Finders ran but nothing survived the consensus/evidence gates: a legitimately
|
# Finders ran but nothing survived the consensus/evidence gates: a legitimately
|
||||||
# thin block (e.g. a bare named reduction). {} = done-without-subs — the guide
|
# thin block (e.g. a bare named reduction). {} = done-without-subs — the guide
|
||||||
@@ -772,8 +1008,9 @@ async def _dedup_subblocks(topic: str, raw: dict[str, list[str]]) -> None:
|
|||||||
return
|
return
|
||||||
keepers: list[int] = []
|
keepers: list[int] = []
|
||||||
discarded: list[int] = []
|
discarded: list[int] = []
|
||||||
|
negs = [_neg_set(s) for s in subs]
|
||||||
for i in sorted(range(len(subs)), key=lambda x: (-len(subs[x]), x)): # most informative first
|
for i in sorted(range(len(subs)), key=lambda x: (-len(subs[x]), x)): # most informative first
|
||||||
if any(float(sims[i][j]) >= EMBEDDING_SUB_DUP for j in keepers):
|
if any(float(sims[i][j]) >= EMBEDDING_SUB_DUP and negs[i] == negs[j] for j in keepers):
|
||||||
discarded.append(i)
|
discarded.append(i)
|
||||||
else:
|
else:
|
||||||
keepers.append(i)
|
keepers.append(i)
|
||||||
@@ -812,7 +1049,7 @@ def _disputed_lines(items, item_idxs, disputed: dict) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instructions: str, ns: str = "") -> dict | None:
|
async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||||
"""Block C: three phases with a barrier — find (classify), select (vote), clarify.
|
"""Block C: three phases with a barrier — find (classify), select (vote), clarify.
|
||||||
Local IDs 1..n per package, mapped to global gid afterwards.
|
Local IDs 1..n per package, mapped to global gid afterwards.
|
||||||
→ {block title: [{title, level}, …]} or None."""
|
→ {block title: [{title, level}, …]} or None."""
|
||||||
@@ -868,7 +1105,7 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
|
|||||||
"role": "quick", "capabilities": "files",
|
"role": "quick", "capabilities": "files",
|
||||||
"payload": (lambda result, p=p, ids=local_set: _levels_schema(_json_file(p), ids)),
|
"payload": (lambda result, p=p, ids=local_set: _levels_schema(_json_file(p), ids)),
|
||||||
} for i, p in pending]
|
} for i, p in pending]
|
||||||
new = await _race(topic, f"Levels package {c}", slots, 2 - existing, _timeout("level", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
new = await _race(topic, f"{lbl}Levels package {c}", slots, 2 - existing, _timeout("level", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||||
return not is_cancelled() and new is not None
|
return not is_cancelled() and new is not None
|
||||||
|
|
||||||
oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Levels find"))
|
oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Levels find"))
|
||||||
@@ -895,7 +1132,7 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
|
|||||||
if decision is None:
|
if decision is None:
|
||||||
disputed_block = _disputed_lines(items, item_idxs, strittig)
|
disputed_block = _disputed_lines(items, item_idxs, strittig)
|
||||||
status, decision = await run_single_slot(
|
status, decision = await run_single_slot(
|
||||||
ctx, f"Levels-Clarification {c}",
|
ctx, f"{lbl}Levels-Clarification {c}",
|
||||||
key=f"blocks-{topic}-{ns}level-final-c{c}",
|
key=f"blocks-{topic}-{ns}level-final-c{c}",
|
||||||
prompt=_prompt("Levels-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
|
prompt=_prompt("Levels-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
|
||||||
role="judge", capabilities="files",
|
role="judge", capabilities="files",
|
||||||
@@ -1008,7 +1245,7 @@ def _facts_complete(files: dict) -> bool:
|
|||||||
return isinstance(d, dict) and bool(d)
|
return isinstance(d, dict) and bool(d)
|
||||||
|
|
||||||
|
|
||||||
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "") -> tuple | None:
|
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "") -> tuple | None:
|
||||||
"""Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
|
"""Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
|
||||||
Extract-once grounding: the result feeds level/relevance/questions/guide.
|
Extract-once grounding: the result feeds level/relevance/questions/guide.
|
||||||
→ (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map
|
→ (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map
|
||||||
@@ -1081,7 +1318,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
return True
|
return True
|
||||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||||
status, _r = await run_single_slot(
|
status, _r = await run_single_slot(
|
||||||
ctx, f"Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}",
|
ctx, f"{lbl}Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}",
|
||||||
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks=block_text(idxs), out_path=fp, extra=_extra(instructions)),
|
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks=block_text(idxs), out_path=fp, extra=_extra(instructions)),
|
||||||
role="quick", capabilities=caps,
|
role="quick", capabilities=caps,
|
||||||
payload=lambda result, p=fp: _facts_schema(_json_file(p)),
|
payload=lambda result, p=fp: _facts_schema(_json_file(p)),
|
||||||
@@ -1112,7 +1349,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
for bt, fm in per.items())
|
for bt, fm in per.items())
|
||||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||||
await run_single_slot(
|
await run_single_slot(
|
||||||
ctx, f"Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}",
|
ctx, f"{lbl}Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}",
|
||||||
prompt=_prompt("Facts-Supplement", topic=topic, source=source, blocks=block, out_path=ep, extra=_extra(instructions)),
|
prompt=_prompt("Facts-Supplement", topic=topic, source=source, blocks=block, out_path=ep, extra=_extra(instructions)),
|
||||||
role="quick", capabilities=caps,
|
role="quick", capabilities=caps,
|
||||||
payload=lambda result, p=ep: _facts_schema(_json_file(p)),
|
payload=lambda result, p=ep: _facts_schema(_json_file(p)),
|
||||||
@@ -1134,7 +1371,8 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
await asyncio.gather(*[
|
await asyncio.gather(*[
|
||||||
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}",
|
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}",
|
||||||
_prompt("Facts-Check", topic=topic, source=source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
|
_prompt("Facts-Check", topic=topic, source=source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
|
||||||
_timeout("content_check", len(per)), provider=provider, role="judge", capabilities=caps)
|
_timeout("content_check", len(per)), provider=provider, role="judge", capabilities=caps,
|
||||||
|
scope=topic, label=f"{lbl}Facts check {ci}/{j}")
|
||||||
for j in pending], return_exceptions=True)
|
for j in pending], return_exceptions=True)
|
||||||
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
|
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
|
||||||
bvotes: dict[str, int] = {}
|
bvotes: dict[str, int] = {}
|
||||||
@@ -1183,7 +1421,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
if not goal:
|
if not goal:
|
||||||
return
|
return
|
||||||
await run_single_slot(
|
await run_single_slot(
|
||||||
ctx, f"Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}",
|
ctx, f"{lbl}Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}",
|
||||||
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks="\n\n".join(goal), out_path=fix_path(ci), extra=_extra(instructions)),
|
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks="\n\n".join(goal), out_path=fix_path(ci), extra=_extra(instructions)),
|
||||||
role="quick", capabilities=caps,
|
role="quick", capabilities=caps,
|
||||||
payload=lambda result, p=fix_path(ci): _facts_schema(_json_file(p)),
|
payload=lambda result, p=fix_path(ci): _facts_schema(_json_file(p)),
|
||||||
@@ -1211,7 +1449,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
return outcome, discarded_map
|
return outcome, discarded_map
|
||||||
|
|
||||||
|
|
||||||
async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
|
async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||||
"""Block D: three phases with a barrier — find (relevant/peripheral), select (vote), clarify.
|
"""Block D: three phases with a barrier — find (relevant/peripheral), select (vote), clarify.
|
||||||
Items from the sidecar; local IDs 1..n per package → global gid.
|
Items from the sidecar; local IDs 1..n per package → global gid.
|
||||||
→ {gid: relevance} or None on cancel/research error. Default on gap/dispute: 'relevant'."""
|
→ {gid: relevance} or None on cancel/research error. Default on gap/dispute: 'relevant'."""
|
||||||
@@ -1249,7 +1487,7 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
|||||||
"role": "quick", "capabilities": "files",
|
"role": "quick", "capabilities": "files",
|
||||||
"payload": (lambda result, p=p, ids=local_set: _relevance_schema(_json_file(p), ids)),
|
"payload": (lambda result, p=p, ids=local_set: _relevance_schema(_json_file(p), ids)),
|
||||||
} for i, p in pending]
|
} for i, p in pending]
|
||||||
new = await _race(topic, f"Relevance package {c}", slots, 2 - existing, _timeout("relevance", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
new = await _race(topic, f"{lbl}Relevance package {c}", slots, 2 - existing, _timeout("relevance", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||||
return not is_cancelled() and new is not None
|
return not is_cancelled() and new is not None
|
||||||
|
|
||||||
oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Relevance find"))
|
oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Relevance find"))
|
||||||
@@ -1276,7 +1514,7 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
|||||||
if decision is None:
|
if decision is None:
|
||||||
disputed_block = _disputed_lines(items, item_idxs, strittig)
|
disputed_block = _disputed_lines(items, item_idxs, strittig)
|
||||||
status, decision = await run_single_slot(
|
status, decision = await run_single_slot(
|
||||||
ctx, f"Relevance-Clarification {c}",
|
ctx, f"{lbl}Relevance-Clarification {c}",
|
||||||
key=f"blocks-{topic}-{ns}relevance-final-c{c}",
|
key=f"blocks-{topic}-{ns}relevance-final-c{c}",
|
||||||
prompt=_prompt("Relevance-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
|
prompt=_prompt("Relevance-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
|
||||||
role="judge", capabilities="files",
|
role="judge", capabilities="files",
|
||||||
@@ -1321,7 +1559,7 @@ def _match_sub(agent_sub: str, rel: list[str]) -> str:
|
|||||||
return agent_sub
|
return agent_sub
|
||||||
|
|
||||||
|
|
||||||
async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
|
async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||||
"""Block E (chunks of 10): find (1 generator per ~10 blocks, parallel), select (code:
|
"""Block E (chunks of 10): find (1 generator per ~10 blocks, parallel), select (code:
|
||||||
group per block + dedup), clarify (1 critic per chunk), check (catch-up round).
|
group per block + dedup), clarify (1 critic per chunk), check (catch-up round).
|
||||||
Assignment per entry via the `block` field (a chunk file carries several blocks).
|
Assignment per entry via the `block` field (a chunk file carries several blocks).
|
||||||
@@ -1371,7 +1609,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
|||||||
)
|
)
|
||||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||||
status, _ = await run_single_slot(
|
status, _ = await run_single_slot(
|
||||||
ctx, f"Question-Pattern {ci}",
|
ctx, f"{lbl}Question-Pattern {ci}",
|
||||||
key=f"blocks-{topic}-{ns}question-pattern-c{ci}",
|
key=f"blocks-{topic}-{ns}question-pattern-c{ci}",
|
||||||
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=block,
|
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=block,
|
||||||
out_path=fp, extra=_extra(instructions)),
|
out_path=fp, extra=_extra(instructions)),
|
||||||
@@ -1436,7 +1674,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
|||||||
return # nothing to clarify in this chunk
|
return # nothing to clarify in this chunk
|
||||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||||
status, _ = await run_single_slot(
|
status, _ = await run_single_slot(
|
||||||
ctx, f"Question-Pattern-Clarification {ci}",
|
ctx, f"{lbl}Question-Pattern-Clarification {ci}",
|
||||||
key=f"blocks-{topic}-{ns}question-pattern-final-c{ci}",
|
key=f"blocks-{topic}-{ns}question-pattern-final-c{ci}",
|
||||||
prompt=_prompt("Question-Pattern-Critique", topic=topic, table="\n\n".join(block_texts), out_path=fp, extra=_extra(instructions)),
|
prompt=_prompt("Question-Pattern-Critique", topic=topic, table="\n\n".join(block_texts), out_path=fp, extra=_extra(instructions)),
|
||||||
role="judge", capabilities="files",
|
role="judge", capabilities="files",
|
||||||
@@ -1505,7 +1743,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
|||||||
return # resume
|
return # resume
|
||||||
subs_total = sum(len(s) for _, s in items)
|
subs_total = sum(len(s) for _, s in items)
|
||||||
await run_single_slot(
|
await run_single_slot(
|
||||||
ctx, f"Question pattern catch-up R{round_n}/{pi}",
|
ctx, f"{lbl}Question pattern catch-up R{round_n}/{pi}",
|
||||||
key=f"blocks-{topic}-{ns}question-pattern-nach{round_n}-c{pi}",
|
key=f"blocks-{topic}-{ns}question-pattern-nach{round_n}-c{pi}",
|
||||||
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=_followup_block(items),
|
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=_followup_block(items),
|
||||||
out_path=fp, extra=_extra(instructions)),
|
out_path=fp, extra=_extra(instructions)),
|
||||||
@@ -2144,6 +2382,26 @@ def _outline_complete(files: dict) -> bool:
|
|||||||
return isinstance(d, dict) and isinstance(d.get("chapters"), list) and bool(d.get("chapters"))
|
return isinstance(d, dict) and isinstance(d.get("chapters"), list) and bool(d.get("chapters"))
|
||||||
|
|
||||||
|
|
||||||
|
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int):
|
||||||
|
"""{"moves": {"<blocknr>": <chapter-idx>}} → {nr: idx} (may be {}) · None if broken/invalid.
|
||||||
|
A mass rewrite (more than a third of all blocks) is rejected — the reviewer's job is
|
||||||
|
spotting misplacements, not re-designing the outline."""
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("moves"), dict):
|
||||||
|
return None
|
||||||
|
out: dict[int, int] = {}
|
||||||
|
for k, v in data["moves"].items():
|
||||||
|
try:
|
||||||
|
nr, ch = int(k), int(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if nr not in valid or not (1 <= ch <= n_chapters):
|
||||||
|
return None
|
||||||
|
out[nr] = ch
|
||||||
|
if len(out) * 3 > n_blocks:
|
||||||
|
return None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _outline_schema(data, valid: set[int]):
|
def _outline_schema(data, valid: set[int]):
|
||||||
"""{"chapters":[{title,numbers}]} → cleaned (valid numbers, each exactly once) ·
|
"""{"chapters":[{title,numbers}]} → cleaned (valid numbers, each exactly once) ·
|
||||||
None at <80 % coverage (agent/judge omitted too much)."""
|
None at <80 % coverage (agent/judge omitted too much)."""
|
||||||
@@ -2302,6 +2560,34 @@ async def _outline_block(ctx: GenContext, set_p, files: dict, entries: dict, ins
|
|||||||
timeout=_timeout("plan_judge", len(entries)))
|
timeout=_timeout("plan_judge", len(entries)))
|
||||||
plan = _outline_schema(_json_file(files["outline"]), valid) or proposals[0]
|
plan = _outline_schema(_json_file(files["outline"]), valid) or proposals[0]
|
||||||
|
|
||||||
|
# Placement review (best-effort): ONE judge checks every block→chapter assignment and
|
||||||
|
# reports ONLY misplacements as moves. Invalid/mass output → plan unchanged.
|
||||||
|
if proposals and len(plan["chapters"]) >= 2 and not is_cancelled():
|
||||||
|
rp = files["arbeit"] / "outline-review.json"
|
||||||
|
moves = _outline_review_schema(_json_file(rp), valid, len(plan["chapters"]), len(entries))
|
||||||
|
if moves is None:
|
||||||
|
chapter_text = "\n\n".join(
|
||||||
|
f"KAPITEL {k}: {ch['title']}\n" + "\n".join(f" {n}. {_title(entries[n])}" for n in ch["numbers"])
|
||||||
|
for k, ch in enumerate(plan["chapters"], 1))
|
||||||
|
set_p("Outline review…", step=step)
|
||||||
|
await run_single_slot(
|
||||||
|
ctx, "Outline-Review", key=f"blocks-{topic}-outline-review",
|
||||||
|
prompt=_prompt("Guide-Outline-Review", topic=topic, chapters=chapter_text,
|
||||||
|
out_path=rp, extra=_extra(instructions)),
|
||||||
|
role="judge", capabilities="files",
|
||||||
|
payload=lambda result: _outline_review_schema(
|
||||||
|
_json_file(rp), valid, len(plan["chapters"]), len(entries)),
|
||||||
|
timeout=_timeout("plan_judge", len(entries)))
|
||||||
|
moves = _outline_review_schema(_json_file(rp), valid, len(plan["chapters"]), len(entries))
|
||||||
|
for nr, target in (moves or {}).items():
|
||||||
|
for ch in plan["chapters"]:
|
||||||
|
if nr in ch["numbers"]:
|
||||||
|
ch["numbers"].remove(nr)
|
||||||
|
plan["chapters"][target - 1]["numbers"].append(nr)
|
||||||
|
if moves:
|
||||||
|
plan["chapters"] = [ch for ch in plan["chapters"] if ch["numbers"]]
|
||||||
|
_log(topic, f"Outline-Review: {len(moves)} Block/Blöcke umsortiert")
|
||||||
|
|
||||||
# Completeness: every block appears — missing in "Other" (against omitting agents/judge).
|
# Completeness: every block appears — missing in "Other" (against omitting agents/judge).
|
||||||
included = {n for ch in plan["chapters"] for n in ch["numbers"]}
|
included = {n for ch in plan["chapters"] for n in ch["numbers"]}
|
||||||
missing = [n for n in entries if n not in included]
|
missing = [n for n in entries if n not in included]
|
||||||
@@ -2372,7 +2658,7 @@ def _artefacts_complete(files: dict) -> bool:
|
|||||||
return isinstance(d, dict) and all(t in d for t in ARTEFACT_TYPES)
|
return isinstance(d, dict) and all(t in d for t in ARTEFACT_TYPES)
|
||||||
|
|
||||||
|
|
||||||
async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
|
async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||||
"""Generate learning artefacts per type from the stored facts — one generation pass
|
"""Generate learning artefacts per type from the stored facts — one generation pass
|
||||||
per type over chunks. Worked examples are verified against the facts (wrong ones discarded);
|
per type over chunks. Worked examples are verified against the facts (wrong ones discarded);
|
||||||
flashcards are low-risk and stay unchecked. → {type: [entries]} (also in files)."""
|
flashcards are low-risk and stay unchecked. → {type: [entries]} (also in files)."""
|
||||||
@@ -2400,7 +2686,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
|||||||
atomic_write_json(files["artefakte"], empty_map, indent=1)
|
atomic_write_json(files["artefakte"], empty_map, indent=1)
|
||||||
return empty_map
|
return empty_map
|
||||||
|
|
||||||
chunks = _lpt_chunks([len(z) for _, z in blocks], FACTS_CHUNK_SUBS)
|
chunks = _lpt_chunks([len(z) for _, z in blocks], ARTEFACT_CHUNK_SUBS)
|
||||||
def block_text(idxs):
|
def block_text(idxs):
|
||||||
return "\n\n".join(f"BLOCK: {blocks[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(blocks[i][1]) for i in idxs)
|
return "\n\n".join(f"BLOCK: {blocks[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(blocks[i][1]) for i in idxs)
|
||||||
|
|
||||||
@@ -2419,7 +2705,8 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
|||||||
await asyncio.gather(*[
|
await asyncio.gather(*[
|
||||||
run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}",
|
run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}",
|
||||||
_prompt("Artifact-Example-Check", topic=topic, facts=block_text(idxs), examples=examples_txt, out_path=cpath(j), extra=_extra(instructions)),
|
_prompt("Artifact-Example-Check", topic=topic, facts=block_text(idxs), examples=examples_txt, out_path=cpath(j), extra=_extra(instructions)),
|
||||||
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities=caps)
|
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities=caps,
|
||||||
|
scope=topic, label=f"{lbl}Beispiel-Check {ci}/{j}")
|
||||||
for j in pending], return_exceptions=True)
|
for j in pending], return_exceptions=True)
|
||||||
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _example_check_schema(_json_file(cpath(j)))) is not None]
|
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _example_check_schema(_json_file(cpath(j)))) is not None]
|
||||||
if not outs:
|
if not outs:
|
||||||
@@ -2446,7 +2733,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
|||||||
if schema(_json_file(p)) is not None:
|
if schema(_json_file(p)) is not None:
|
||||||
return True
|
return True
|
||||||
await run_single_slot(
|
await run_single_slot(
|
||||||
ctx, f"{_ARTEFACT_STEP[typ]} {ci}", key=f"blocks-{topic}-{ns}artifact-{typ}-c{ci}",
|
ctx, f"{lbl}{_ARTEFACT_STEP[typ]} {ci}", key=f"blocks-{topic}-{ns}artifact-{typ}-c{ci}",
|
||||||
prompt=_prompt(_ARTEFACT_PROMPT[typ], topic=topic, blocks=block_text(idxs), out_path=p, extra=_extra(instructions)),
|
prompt=_prompt(_ARTEFACT_PROMPT[typ], topic=topic, blocks=block_text(idxs), out_path=p, extra=_extra(instructions)),
|
||||||
role="guide", capabilities="files",
|
role="guide", capabilities="files",
|
||||||
payload=lambda result, p=p: schema(_json_file(p)),
|
payload=lambda result, p=p: schema(_json_file(p)),
|
||||||
|
|||||||
@@ -42,11 +42,20 @@ def _safe(norm: str) -> str:
|
|||||||
|
|
||||||
def _card_set_p(flow: Flow, norm: str):
|
def _card_set_p(flow: Flow, norm: str):
|
||||||
"""Per-card progress: the inner step messages land in-memory on the flow —
|
"""Per-card progress: the inner step messages land in-memory on the flow —
|
||||||
board_snapshot shows them as the card's info line while it is active."""
|
board_snapshot shows them as the card's info line + phase stepper while active.
|
||||||
|
The step INDEX is resolved to its NAME at write time (indices shift with the
|
||||||
|
source type, names are stable)."""
|
||||||
info = flow.state.setdefault("card_info", {})
|
info = flow.state.setdefault("card_info", {})
|
||||||
|
|
||||||
def set_p(msg: str, step: int | None = None) -> None:
|
def set_p(msg: str, step: int | None = None) -> None:
|
||||||
info[f"{BOARD}:{norm}"] = msg
|
name = ""
|
||||||
|
if step is not None:
|
||||||
|
steps = flow.state.get("blocks_steps")
|
||||||
|
if steps is None:
|
||||||
|
steps = flow.state["blocks_steps"] = blocks._blocks_steps(flow.topic)
|
||||||
|
if 0 <= step < len(steps):
|
||||||
|
name = steps[step]
|
||||||
|
info[f"{BOARD}:{norm}"] = {"msg": msg, "step": name}
|
||||||
return set_p
|
return set_p
|
||||||
|
|
||||||
|
|
||||||
@@ -94,28 +103,73 @@ def _fail_or_cancel(ctx: GenContext, what: str):
|
|||||||
|
|
||||||
|
|
||||||
# ── Stage processors (one call per card, all parallel) ─────────────────────────────
|
# ── Stage processors (one call per card, all parallel) ─────────────────────────────
|
||||||
|
async def _seed_map(topic: str) -> dict[str, list[str]]:
|
||||||
|
"""Demoted fragments become seed candidates of their SURVIVING parent block.
|
||||||
|
parent_norm may point at a block that itself got grouped/merged/renamed — follow the
|
||||||
|
redirect chain (grouped → merged_into, rejected → parent_norm, done → mirrored_norm)
|
||||||
|
to the living board-2 card id (= mirrored_norm). A dead end drops the seed (as before)."""
|
||||||
|
alive: set[str] = set()
|
||||||
|
redirect: dict[str, str] = {}
|
||||||
|
rejected: list[dict] = []
|
||||||
|
for r in await db.kanban_cards(topic, board="inventory", kind="block"):
|
||||||
|
p = r["payload"]
|
||||||
|
tn = _norm_title(p.get("title", ""))
|
||||||
|
if not tn:
|
||||||
|
continue
|
||||||
|
if r["stage"] in ("done", "done_block"):
|
||||||
|
mn = p.get("mirrored_norm") or tn
|
||||||
|
alive.add(mn)
|
||||||
|
if tn != mn:
|
||||||
|
redirect.setdefault(tn, mn)
|
||||||
|
elif r["stage"] == "grouped" and p.get("merged_into"):
|
||||||
|
redirect.setdefault(tn, _norm_title(p["merged_into"]))
|
||||||
|
# umbrella members are absorbed WHOLE topics ("Aufgabenlisten" → "Listen") —
|
||||||
|
# without a seed the umbrella's finders may simply miss them (measured).
|
||||||
|
rejected.append({"title": p.get("title", ""), "parent_norm": _norm_title(p["merged_into"])})
|
||||||
|
elif r["stage"] == "rejected":
|
||||||
|
if p.get("parent_norm"):
|
||||||
|
redirect.setdefault(tn, p["parent_norm"])
|
||||||
|
rejected.append(p)
|
||||||
|
|
||||||
|
def _resolve(norm: str) -> str | None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
cur = norm
|
||||||
|
while cur and cur not in seen:
|
||||||
|
if cur in alive: # alive check BEFORE following (self-edges like „Listen"→„Listen")
|
||||||
|
return cur
|
||||||
|
seen.add(cur)
|
||||||
|
cur = redirect.get(cur, "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
seeds: dict[str, list[str]] = {}
|
||||||
|
for p in rejected:
|
||||||
|
pn = p.get("parent_norm")
|
||||||
|
if pn and (target := _resolve(pn)):
|
||||||
|
seeds.setdefault(target, []).append(p.get("title", ""))
|
||||||
|
return seeds
|
||||||
|
|
||||||
|
|
||||||
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||||
topic = flow.topic
|
topic = flow.topic
|
||||||
# Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks.
|
# Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks.
|
||||||
seeds: dict[str, list[str]] = {}
|
seeds = await _seed_map(topic)
|
||||||
for r in await db.kanban_cards(topic, board="inventory", stage="rejected"):
|
|
||||||
pn = r["payload"].get("parent_norm")
|
|
||||||
if pn:
|
|
||||||
seeds.setdefault(pn, []).append(r["payload"].get("title", ""))
|
|
||||||
|
|
||||||
async def one(c):
|
async def one(c):
|
||||||
p = c["payload"]
|
p = c["payload"]
|
||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
instr = instructions
|
instr = instructions
|
||||||
if (sd := [s for s in seeds.get(norm, []) if s]):
|
sd = [s for s in seeds.get(norm, []) if s]
|
||||||
|
if sd:
|
||||||
instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
|
instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
|
||||||
"Blocks (unbedingt prüfen und, wenn belegt, aufnehmen):\n"
|
"Blocks (unbedingt prüfen und, wenn belegt, aufnehmen):\n"
|
||||||
+ "\n".join(f"- {s}" for s in sd))
|
+ "\n".join(f"- {s}" for s in sd))
|
||||||
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||||
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-")
|
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-",
|
||||||
|
seeds=sd or None, lbl=f"{p.get('title', norm)} · ")
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
|
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
|
||||||
p["raw"] = raw
|
p["raw"] = raw
|
||||||
|
p["subs_n"] = sum(len(v) for v in raw.values()) # LPT: bigger blocks pull first
|
||||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||||
await db.kanban_advance(topic, BOARD, norm, "facts")
|
await db.kanban_advance(topic, BOARD, norm, "facts")
|
||||||
|
|
||||||
@@ -131,7 +185,8 @@ async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
|||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
raw = p.get("raw") or {}
|
raw = p.get("raw") or {}
|
||||||
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q,
|
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q,
|
||||||
folder, instructions, ns=f"{_safe(norm)}-")
|
folder, instructions, ns=f"{_safe(norm)}-",
|
||||||
|
lbl=f"{p.get('title', norm)} · ")
|
||||||
if res is None:
|
if res is None:
|
||||||
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
|
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
|
||||||
facts_map, discarded = res
|
facts_map, discarded = res
|
||||||
@@ -154,7 +209,8 @@ async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: s
|
|||||||
p = c["payload"]
|
p = c["payload"]
|
||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||||
p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-")
|
p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-",
|
||||||
|
lbl=f"{p.get('title', norm)} · ")
|
||||||
if sidecar is None:
|
if sidecar is None:
|
||||||
return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}")
|
return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}")
|
||||||
facts_map = p.get("facts") or {}
|
facts_map = p.get("facts") or {}
|
||||||
@@ -178,7 +234,8 @@ async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions
|
|||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
sidecar = p.get("sidecar") or {}
|
sidecar = p.get("sidecar") or {}
|
||||||
rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||||
sidecar, instructions, ns=f"{_safe(norm)}-")
|
sidecar, instructions, ns=f"{_safe(norm)}-",
|
||||||
|
lbl=f"{p.get('title', norm)} · ")
|
||||||
if rel is None:
|
if rel is None:
|
||||||
return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}")
|
return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}")
|
||||||
gid = 0
|
gid = 0
|
||||||
@@ -201,7 +258,7 @@ async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instr
|
|||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||||
p.get("sidecar") or {}, instructions,
|
p.get("sidecar") or {}, instructions,
|
||||||
ns=f"{_safe(norm)}-")
|
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
|
||||||
if pattern is None:
|
if pattern is None:
|
||||||
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
|
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
|
||||||
p["pattern"] = pattern
|
p["pattern"] = pattern
|
||||||
@@ -219,7 +276,7 @@ async def _proc_artefacts(ctx: GenContext, flow: Flow, files: dict, instructions
|
|||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
artefacts = await _artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
artefacts = await _artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||||
p.get("sidecar") or {}, instructions,
|
p.get("sidecar") or {}, instructions,
|
||||||
ns=f"{_safe(norm)}-")
|
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
|
||||||
if artefacts is None and ctx.is_cancelled():
|
if artefacts is None and ctx.is_cancelled():
|
||||||
return None
|
return None
|
||||||
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
|
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
|
||||||
@@ -301,7 +358,15 @@ async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions:
|
|||||||
entries = {i: _entry_line(c["payload"]) for i, c in enumerate(done, 1)
|
entries = {i: _entry_line(c["payload"]) for i, c in enumerate(done, 1)
|
||||||
if c["payload"].get("title")}
|
if c["payload"].get("title")}
|
||||||
if entries:
|
if entries:
|
||||||
plan = await _outline_block(ctx, _nset, files, entries, instructions)
|
# The outline may run BEFORE finalize has merged the global facts.json — feed the
|
||||||
|
# prereq hints of _learning_order from the card payloads instead (complete as soon
|
||||||
|
# as every block passed the facts stage, which the trimmed barrier guarantees).
|
||||||
|
facts_map: dict = {}
|
||||||
|
for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
|
||||||
|
facts_map.update(bc["payload"].get("facts") or {})
|
||||||
|
fp = flow.work_dir / "outline-facts.json"
|
||||||
|
atomic_write_json(fp, facts_map, indent=1)
|
||||||
|
plan = await _outline_block(ctx, _nset, {**files, "facts": fp}, entries, instructions)
|
||||||
if ctx.is_cancelled():
|
if ctx.is_cancelled():
|
||||||
return
|
return
|
||||||
if isinstance(plan, dict) and plan.get("chapters"):
|
if isinstance(plan, dict) and plan.get("chapters"):
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ from blocks import (
|
|||||||
)
|
)
|
||||||
from config import (
|
from config import (
|
||||||
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
||||||
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, GROUP_MIN_COS_FLOOR,
|
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
|
||||||
GROUP_RECONCILE_FLOOR,
|
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
|
||||||
)
|
)
|
||||||
from fsutil import atomic_write_json, atomic_write_text
|
from fsutil import atomic_write_json, atomic_write_text
|
||||||
from jsonio import read_json_file as _json_file
|
from jsonio import read_json_file as _json_file
|
||||||
@@ -716,7 +716,7 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
|||||||
raise errs[0]
|
raise errs[0]
|
||||||
if ctx.is_cancelled():
|
if ctx.is_cancelled():
|
||||||
return
|
return
|
||||||
fragments: dict[int, int] = {}
|
proposals: dict[int, int] = {} # judge demotes are PROPOSALS — panel/containment confirm
|
||||||
drops: set[int] = set()
|
drops: set[int] = set()
|
||||||
for ci, numbers in enumerate(chunks):
|
for ci, numbers in enumerate(chunks):
|
||||||
raw = _json_file(work_dir / f"filter-{h}-c{ci}.json")
|
raw = _json_file(work_dir / f"filter-{h}-c{ci}.json")
|
||||||
@@ -724,7 +724,7 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
|||||||
nset = set(numbers)
|
nset = set(numbers)
|
||||||
for nr, parent in verdict.items():
|
for nr, parent in verdict.items():
|
||||||
if 1 <= parent <= n_all and nr in nset:
|
if 1 <= parent <= n_all and nr in nset:
|
||||||
fragments[nr] = parent
|
proposals[nr] = parent
|
||||||
for x in (raw.get("drop", []) if isinstance(raw, dict) else []):
|
for x in (raw.get("drop", []) if isinstance(raw, dict) else []):
|
||||||
try:
|
try:
|
||||||
dnr = int(x)
|
dnr = int(x)
|
||||||
@@ -735,34 +735,43 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
|||||||
# hard-drop double gate
|
# hard-drop double gate
|
||||||
honored = {nr for nr in drops if _is_artifact(allrows[nr - 1]["title"])}
|
honored = {nr for nr in drops if _is_artifact(allrows[nr - 1]["title"])}
|
||||||
for nr in honored:
|
for nr in honored:
|
||||||
fragments.pop(nr, None)
|
proposals.pop(nr, None)
|
||||||
# containment demote + parentless noise (deterministic)
|
# containment demote (deterministic — auto-confirms proposals whose parent name is
|
||||||
|
# literally contained in the title, and keeps the legacy ⚠-only pass) + parentless noise
|
||||||
norms = [(i, allrows[i - 1]["title_norm"]) for i in range(1, n_all + 1)]
|
norms = [(i, allrows[i - 1]["title_norm"]) for i in range(1, n_all + 1)]
|
||||||
|
fragments: dict[int, int] = {}
|
||||||
|
contained: set[int] = set()
|
||||||
for i in range(1, n_dem + 1):
|
for i in range(1, n_dem + 1):
|
||||||
if i in fragments or i in honored or not _filter_suspect(allrows[i - 1]):
|
if i in honored or (i not in proposals and not _filter_suspect(allrows[i - 1])):
|
||||||
continue
|
continue
|
||||||
parent = _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i])
|
parent = _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i])
|
||||||
if parent is not None and parent != i and parent not in honored:
|
if parent is not None and parent != i and parent not in honored:
|
||||||
fragments[i] = parent
|
fragments[i] = parent
|
||||||
|
contained.add(i)
|
||||||
|
proposals.pop(i, None)
|
||||||
for i in range(1, n_dem + 1):
|
for i in range(1, n_dem + 1):
|
||||||
if i in fragments or i in honored or not _is_parentless_noise(allrows[i - 1]["title"]):
|
if i in fragments or i in honored or i in proposals or not _is_parentless_noise(allrows[i - 1]["title"]):
|
||||||
continue
|
continue
|
||||||
if _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i]) is None:
|
if _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i]) is None:
|
||||||
honored.add(i)
|
honored.add(i)
|
||||||
# recheck panel over still-⚠ survivors (rare-positive recall, majority ≥2).
|
# recheck panel = second opinion: unconfirmed judge proposals (WITHOUT the suggested
|
||||||
# ONE wave over ALL (chunk, judge) slots; a single failed judge is tolerated
|
# parent — no anchoring) plus still-⚠ survivors. Majority ≥2 demotes/drops; a proposal
|
||||||
# (panel votes over whatever answered — legacy semantics). Voting afterwards.
|
# the panel does not confirm survives. The panel is load-bearing now: fewer than 2
|
||||||
|
# valid judge files per chunk is an error (backoff), not a silent keep.
|
||||||
survivors = [i for i in range(1, n_dem + 1)
|
survivors = [i for i in range(1, n_dem + 1)
|
||||||
if i not in fragments and i not in honored and _filter_suspect(allrows[i - 1])]
|
if i not in fragments and i not in honored
|
||||||
|
and (i in proposals or _filter_suspect(allrows[i - 1]))]
|
||||||
|
overruled: list[int] = []
|
||||||
if survivors:
|
if survivors:
|
||||||
|
ph = _h(",".join(map(str, survivors))) # panel-input hash: stale pre-change files never match
|
||||||
rchunks = [survivors[k:k + FILTER_CHUNK] for k in range(0, len(survivors), FILTER_CHUNK)]
|
rchunks = [survivors[k:k + FILTER_CHUNK] for k in range(0, len(survivors), FILTER_CHUNK)]
|
||||||
|
|
||||||
async def _recheck_judge(ci, nums, j):
|
async def _recheck_judge(ci, nums, j):
|
||||||
path = work_dir / f"filter-recheck-{h}-c{ci}-j{j}.json"
|
path = work_dir / f"filter-recheck-{h}-{ph}-c{ci}-j{j}.json"
|
||||||
if _filter_schema(_json_file(path)) is not None:
|
if _filter_schema(_json_file(path)) is not None:
|
||||||
return # resume
|
return # resume
|
||||||
await run_single_slot(
|
await run_single_slot(
|
||||||
ctx, f"Filter-Recheck {ci}/{j}", key=f"blocks-{topic}-filter-recheck-{h}-c{ci}-j{j}",
|
ctx, f"Filter-Recheck {ci}/{j}", key=f"blocks-{topic}-filter-recheck-{h}-{ph}-c{ci}-j{j}",
|
||||||
prompt=_prompt("Blocks-Filter-Recheck", topic=topic,
|
prompt=_prompt("Blocks-Filter-Recheck", topic=topic,
|
||||||
survivors="\n".join(_fline(i) for i in nums),
|
survivors="\n".join(_fline(i) for i in nums),
|
||||||
list=full_list, out_path=path),
|
list=full_list, out_path=path),
|
||||||
@@ -780,11 +789,13 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
|||||||
dem: dict[int, list[int]] = {}
|
dem: dict[int, list[int]] = {}
|
||||||
drp: dict[int, int] = {}
|
drp: dict[int, int] = {}
|
||||||
nset = set(nums)
|
nset = set(nums)
|
||||||
|
valid = 0
|
||||||
for j in range(1, FILTER_RECHECK_PANEL + 1):
|
for j in range(1, FILTER_RECHECK_PANEL + 1):
|
||||||
raw = _json_file(work_dir / f"filter-recheck-{h}-c{ci}-j{j}.json")
|
raw = _json_file(work_dir / f"filter-recheck-{h}-{ph}-c{ci}-j{j}.json")
|
||||||
v = _filter_schema(raw)
|
v = _filter_schema(raw)
|
||||||
if v is None:
|
if v is None:
|
||||||
continue
|
continue
|
||||||
|
valid += 1
|
||||||
for nr, parent in v.items():
|
for nr, parent in v.items():
|
||||||
if nr in nset and 1 <= parent <= n_all and nr != parent:
|
if nr in nset and 1 <= parent <= n_all and nr != parent:
|
||||||
dem.setdefault(nr, []).append(parent)
|
dem.setdefault(nr, []).append(parent)
|
||||||
@@ -795,6 +806,8 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
|||||||
continue
|
continue
|
||||||
if dnr in nset:
|
if dnr in nset:
|
||||||
drp[dnr] = drp.get(dnr, 0) + 1
|
drp[dnr] = drp.get(dnr, 0) + 1
|
||||||
|
if valid < 2:
|
||||||
|
raise RuntimeError(f"Filter-Recheck chunk {ci}: nur {valid} Judge(s) mit Ergebnis")
|
||||||
for nr in nums:
|
for nr in nums:
|
||||||
if nr in fragments or nr in honored:
|
if nr in fragments or nr in honored:
|
||||||
continue
|
continue
|
||||||
@@ -803,6 +816,18 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
|||||||
honored.add(nr)
|
honored.add(nr)
|
||||||
elif len(dem.get(nr, [])) >= 2:
|
elif len(dem.get(nr, [])) >= 2:
|
||||||
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
||||||
|
elif nr in proposals:
|
||||||
|
overruled.append(nr)
|
||||||
|
# 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] = []
|
||||||
|
if (cand := [nr for nr in fragments if nr not in contained]):
|
||||||
|
va = await _vec_rows(flow, [r["title"] for r in allrows])
|
||||||
|
if va is not None:
|
||||||
|
for nr in cand:
|
||||||
|
if float(va[nr - 1] @ va[fragments[nr] - 1]) < FRAGMENT_MIN_COS:
|
||||||
|
fragments.pop(nr)
|
||||||
|
floor_veto.append(nr)
|
||||||
# statement-gate rescue (final override)
|
# statement-gate rescue (final override)
|
||||||
def _protected(nr):
|
def _protected(nr):
|
||||||
return _is_named_statement(allrows[nr - 1]["title"], allrows[nr - 1]["description"])
|
return _is_named_statement(allrows[nr - 1]["title"], allrows[nr - 1]["description"])
|
||||||
@@ -831,8 +856,12 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
|||||||
await db.kanban_set_payload(topic, BOARD, r["card_id"], r["payload"])
|
await db.kanban_set_payload(topic, BOARD, r["card_id"], r["payload"])
|
||||||
moves.append((r["card_id"], "rejected"))
|
moves.append((r["card_id"], "rejected"))
|
||||||
journal.append({"fragment": r["title"], "eltern": None, "grund": "drop"})
|
journal.append({"fragment": r["title"], "eltern": None, "grund": "drop"})
|
||||||
atomic_write_json(work_dir / "inventar-filter.json",
|
# one journal file per pass (h) — the supplement feedback pass must not overwrite
|
||||||
{"vorher": n_dem, "degradiert": len(journal), "fragments": journal}, indent=1)
|
# the main pass's journal (it is the evaluation instrument).
|
||||||
|
atomic_write_json(work_dir / f"inventar-filter-{h}.json",
|
||||||
|
{"vorher": n_dem, "degradiert": len(journal), "fragments": journal,
|
||||||
|
"ueberstimmt": [allrows[nr - 1]["title"] for nr in overruled],
|
||||||
|
"floor_veto": [allrows[nr - 1]["title"] for nr in floor_veto]}, indent=1)
|
||||||
_log(topic, f"Fragment-Filter: {n_dem} → {n_dem - len(journal)} (−{len(journal)})")
|
_log(topic, f"Fragment-Filter: {n_dem} → {n_dem - len(journal)} (−{len(journal)})")
|
||||||
demoted = {cid for cid, _ in moves}
|
demoted = {cid for cid, _ in moves}
|
||||||
moves += [(r["card_id"], "grouping") for r in rows if r["card_id"] not in demoted]
|
moves += [(r["card_id"], "grouping") for r in rows if r["card_id"] not in demoted]
|
||||||
@@ -1057,21 +1086,59 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
|||||||
if status != OK:
|
if status != OK:
|
||||||
_log(topic, "Supplement fehlgeschlagen — übersprungen (optional)")
|
_log(topic, "Supplement fehlgeschlagen — übersprungen (optional)")
|
||||||
supplements = []
|
supplements = []
|
||||||
known_norms = set()
|
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||||
known_keys = set()
|
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
|
||||||
for t in await db.kanban_cards(topic, board=BOARD):
|
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
|
||||||
tt = t["payload"].get("title", "")
|
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
|
||||||
if tt:
|
# in the dedup: those were rejected as non-blocks, not lost as content.
|
||||||
known_norms.add(_norm_title(tt))
|
dead_reasons = {"fragment", "drop-collateral", "drop"}
|
||||||
if (k := _canonical_key(tt)):
|
cards = await db.kanban_cards(topic, board=BOARD)
|
||||||
known_keys.add(k)
|
dead_clusters = {c["payload"].get("cluster") for c in cards
|
||||||
new = 0
|
if c["kind"] == "block" and c["stage"] == "rejected"
|
||||||
|
and c["payload"].get("reason") in dead_reasons}
|
||||||
|
dead_clusters.discard(None)
|
||||||
|
membership = await db.kanban_membership(topic)
|
||||||
|
dead_titles = {nm for nm, cid in membership.items() if cid in dead_clusters}
|
||||||
|
|
||||||
|
def _is_dead(c) -> bool:
|
||||||
|
if c["kind"] == "title":
|
||||||
|
return c["card_id"] in dead_titles
|
||||||
|
if c["kind"] == "cluster":
|
||||||
|
return c["card_id"] in dead_clusters
|
||||||
|
return c["stage"] == "rejected" and c["payload"].get("reason") in dead_reasons
|
||||||
|
|
||||||
|
known_norms, known_keys = set(), set()
|
||||||
|
dead_by_norm: dict[str, str] = {} # title norm/key → requeue-able title card_id
|
||||||
|
dead_by_key: dict[str, str] = {}
|
||||||
|
for c in cards:
|
||||||
|
tt = c["payload"].get("title", "")
|
||||||
|
if not tt:
|
||||||
|
continue
|
||||||
|
if _is_dead(c):
|
||||||
|
if c["kind"] == "title":
|
||||||
|
dead_by_norm.setdefault(c["card_id"], c["card_id"])
|
||||||
|
if (k := _canonical_key(tt)):
|
||||||
|
dead_by_key.setdefault(k, c["card_id"])
|
||||||
|
continue
|
||||||
|
known_norms.add(_norm_title(tt))
|
||||||
|
if (k := _canonical_key(tt)):
|
||||||
|
known_keys.add(k)
|
||||||
|
new = reopened = 0
|
||||||
for t, d in (supplements or []):
|
for t, d in (supplements or []):
|
||||||
norm = _norm_title(t)
|
norm = _norm_title(t)
|
||||||
key = _canonical_key(t)
|
key = _canonical_key(t)
|
||||||
if not norm or norm in known_norms or (key and key in known_keys):
|
if not norm or norm in known_norms or (key and key in known_keys):
|
||||||
continue
|
continue
|
||||||
known_norms.add(norm)
|
known_norms.add(norm)
|
||||||
|
dead_id = dead_by_norm.get(norm) or (dead_by_key.get(key) if key else None)
|
||||||
|
if dead_id:
|
||||||
|
card = await db.kanban_get_card(topic, BOARD, dead_id)
|
||||||
|
if card:
|
||||||
|
card["payload"]["supplement"] = True
|
||||||
|
await db.kanban_set_payload(topic, BOARD, dead_id, card["payload"])
|
||||||
|
await db.kanban_advance(topic, BOARD, dead_id, "cluster")
|
||||||
|
reopened += 1
|
||||||
|
continue
|
||||||
desc = f"{d} [Supplement]".strip()
|
desc = f"{d} [Supplement]".strip()
|
||||||
async with _ingest_lock:
|
async with _ingest_lock:
|
||||||
await db.kanban_add_title(topic, BOARD, norm, t, desc, "supplement", "supplement")
|
await db.kanban_add_title(topic, BOARD, norm, t, desc, "supplement", "supplement")
|
||||||
@@ -1080,8 +1147,8 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
|||||||
card["payload"]["supplement"] = True
|
card["payload"]["supplement"] = True
|
||||||
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
|
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
|
||||||
new += 1
|
new += 1
|
||||||
if new:
|
if new or reopened:
|
||||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest")
|
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||||
flow.wake.set()
|
flow.wake.set()
|
||||||
|
|
||||||
|
|
||||||
@@ -1175,6 +1242,13 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
|||||||
await board_artefacts.ensure_outline_card(topic)
|
await board_artefacts.ensure_outline_card(topic)
|
||||||
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
|
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
|
||||||
stages = chain_stages(stages)
|
stages = chain_stages(stages)
|
||||||
|
if artefacts:
|
||||||
|
# Outline needs every block's TITLE + FACTS, nothing later: cut the post-facts
|
||||||
|
# artefact stages from its barrier so it runs parallel to levels…finalize of the
|
||||||
|
# slowest block (makespan tail). Inventory stages all stay — no late blocks.
|
||||||
|
outline = next(s for s in stages if s.stage == "outline")
|
||||||
|
outline.upstream = [u for u in outline.upstream if u not in
|
||||||
|
("levels", "relevance", "question_pattern", "artefacts", "finalize")]
|
||||||
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
|
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
|
||||||
|
|
||||||
async def _as_producer(coro):
|
async def _as_producer(coro):
|
||||||
@@ -1260,6 +1334,12 @@ _VERDICT_KEYS = ("reason", "votes", "judges", "merged_into", "parent_norm", "mir
|
|||||||
DONE_ART = "done_artefact"
|
DONE_ART = "done_artefact"
|
||||||
|
|
||||||
|
|
||||||
|
# Board-2 stage → its fine-step group in blocks.PHASEN (drives the per-card stepper).
|
||||||
|
_STAGE_PHASE = {"subblocks": "Subblocks", "facts": "Facts", "levels": "Levels",
|
||||||
|
"relevance": "Relevance", "question_pattern": "Questions", "artefacts": "Artefacts"}
|
||||||
|
_PHASE_STEPS = {name: steps for name, steps in blocks.PHASEN}
|
||||||
|
|
||||||
|
|
||||||
def _card_view(r: dict, active: set[str], live_info: dict) -> dict:
|
def _card_view(r: dict, active: set[str], live_info: dict) -> dict:
|
||||||
p = r["payload"]
|
p = r["payload"]
|
||||||
key = f"{r['board']}:{r['card_id']}"
|
key = f"{r['board']}:{r['card_id']}"
|
||||||
@@ -1269,11 +1349,18 @@ def _card_view(r: dict, active: set[str], live_info: dict) -> dict:
|
|||||||
info = f"→ {p['merged_into']}"
|
info = f"→ {p['merged_into']}"
|
||||||
elif p.get("parent_norm"):
|
elif p.get("parent_norm"):
|
||||||
info = f"Fragment von: {p['parent_norm']}"
|
info = f"Fragment von: {p['parent_norm']}"
|
||||||
if is_active and live_info.get(key): # live step message wins while the card is worked
|
out = {"title": p.get("title") or r["card_id"], "retries": r["retries"], "card_id": r["card_id"],
|
||||||
info = live_info[key]
|
"kind": r.get("kind", ""), "board": r.get("board", ""),
|
||||||
status = "error" if r["retries"] else ("active" if is_active else "open")
|
"status": "error" if r["retries"] else ("active" if is_active else "open")}
|
||||||
return {"title": p.get("title") or r["card_id"], "status": status,
|
live = live_info.get(key)
|
||||||
"info": info, "retries": r["retries"]}
|
if is_active and live: # live step message wins while the card is worked
|
||||||
|
info = live["msg"] if isinstance(live, dict) else live # dict since the stepper, str before
|
||||||
|
step = live.get("step") if isinstance(live, dict) else ""
|
||||||
|
steps = _PHASE_STEPS.get(_STAGE_PHASE.get(r.get("stage", ""), ""), ())
|
||||||
|
if step in steps: # phase stepper: which fine step of the card's stage runs (1-based)
|
||||||
|
out.update(step_i=steps.index(step) + 1, step_n=len(steps), steps=list(steps))
|
||||||
|
out["info"] = info
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def board_snapshot(topic: str, limit: int = 20) -> dict:
|
async def board_snapshot(topic: str, limit: int = 20) -> dict:
|
||||||
@@ -1361,9 +1448,27 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
|||||||
await db.delete_subblocks(topic)
|
await db.delete_subblocks(topic)
|
||||||
await db.delete_question_pattern(topic)
|
await db.delete_question_pattern(topic)
|
||||||
await db.delete_sub_artefakte(topic)
|
await db.delete_sub_artefakte(topic)
|
||||||
|
await db.add_event(topic, "reset", key=f"{board}:from-{stage}", status=str(moved))
|
||||||
return moved
|
return moved
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_artefact_card(topic: str, card_id: str) -> bool:
|
||||||
|
"""Restart ONE artefacts card from `subblocks` — 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, "subblocks")
|
||||||
|
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def requeue_dead(topic: str) -> int:
|
async def requeue_dead(topic: str) -> int:
|
||||||
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
|
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
|
||||||
n = 0
|
n = 0
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ UNI_DIR = PROJECT_ROOT / "uni"
|
|||||||
|
|
||||||
|
|
||||||
def _load_env(path: Path) -> None:
|
def _load_env(path: Path) -> None:
|
||||||
"""Mini .env loader (no dependency): KEY=VALUE lines; existing env always wins
|
"""Mini .env loader (no dependency): KEY=VALUE lines. The FILE wins over inherited
|
||||||
(`make dev` already exports .env — this covers bare `uvicorn`/pytest starts)."""
|
env: a --reload master keeps its startup environment forever, so "existing env wins"
|
||||||
|
silently pinned stale values across .env edits (measured: file said 24, workers
|
||||||
|
inherited 15 for hours). Trade-off: ad-hoc shell overrides lose against the file."""
|
||||||
try:
|
try:
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -23,7 +25,7 @@ def _load_env(path: Path) -> None:
|
|||||||
continue
|
continue
|
||||||
key, _, value = line.partition("=")
|
key, _, value = line.partition("=")
|
||||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||||
if key and key not in os.environ:
|
if key:
|
||||||
os.environ[key] = value
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +61,15 @@ EMBEDDING_BLOCK_CAP = 25 # max. titles per block (keep the LLM list short/s
|
|||||||
# block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
|
# block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
|
||||||
# without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
|
# without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
|
||||||
EMBEDDING_SUB_DUP = 0.90
|
EMBEDDING_SUB_DUP = 0.90
|
||||||
|
# Variant folding BEFORE the subblock consensus count: finders rephrase the same concept each
|
||||||
|
# round, so exact-norm counting starves real concepts (measured Markdown run: 623/965 mentions
|
||||||
|
# discarded, „Zeichenkodierung" 73/74). 0.90 folds true paraphrases at ~0 false folds (0.85/0.88
|
||||||
|
# fold distinct aspects like ** vs ***). Antonym pairs measure 0.91–0.95 → negation guard required.
|
||||||
|
SUB_VARIANT_COS = 0.90
|
||||||
|
# Seed coverage check is LEXICAL first (token containment) — seeds are short fragment NAMES,
|
||||||
|
# subs are statements: true covers measure 0.27–0.38 while a wrong hit measured 0.76. The
|
||||||
|
# embedding stage only backs up the lexical one (catches „Line Breaks (Soft)" 0.888).
|
||||||
|
SEED_COVER_COS = 0.80
|
||||||
|
|
||||||
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
|
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
|
||||||
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
|
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
|
||||||
@@ -83,6 +94,12 @@ GROUP_RECONCILE_FLOOR = 0.75
|
|||||||
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless
|
# 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.
|
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
|
||||||
GROUP_MIN_COS_FLOOR = 0.15
|
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
|
||||||
|
# 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).
|
||||||
|
FRAGMENT_MIN_COS = 0.15
|
||||||
|
|
||||||
# Caps for concurrent CLI agent processes (env-overridable). Two nested limits, both always active:
|
# Caps for concurrent CLI agent processes (env-overridable). Two nested limits, both always active:
|
||||||
# a per-topic cap and a global cap across all topics. Defaults 10/10 = previous behavior (global
|
# a per-topic cap and a global cap across all topics. Defaults 10/10 = previous behavior (global
|
||||||
@@ -125,15 +142,15 @@ QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL i
|
|||||||
# Timeouts per agent step: (base seconds, seconds per block/section).
|
# Timeouts per agent step: (base seconds, seconds per block/section).
|
||||||
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
|
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
|
||||||
TIMEOUTS = {
|
TIMEOUTS = {
|
||||||
"research": (1800, 0), # fixed 30 min
|
"research": (900, 0), # p95 measured 125 s (web mode); uni/link sections need headroom
|
||||||
"research_mapping": (600, 3), # n = pre-merged entries
|
"research_mapping": (600, 3), # n = pre-merged entries
|
||||||
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
|
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
|
||||||
"ergaenzung": (900, 0), # subject-field extension for projects (web research)
|
"ergaenzung": (600, 0), # subject-field extension for projects (web research)
|
||||||
"plan": (300, 5),
|
"plan": (300, 5),
|
||||||
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
|
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
|
||||||
"content": (600, 90), # identify content per block in the chunk (web search)
|
"content": (450, 30), # facts find/erg/fix — p95 measured 241 s (was 600+90n)
|
||||||
"content_check": (300, 10), # content exam per block in the package
|
"content_check": (300, 10), # content exam per block in the package
|
||||||
"subblock": (900, 45), # find subblocks per block in the chunk (web search)
|
"subblock": (400, 15), # finder round — p95 measured 124 s (was 900+45n)
|
||||||
"subblock_check": (300, 15), # judge decides contested subblocks in the chunk
|
"subblock_check": (300, 15), # judge decides contested subblocks in the chunk
|
||||||
"level": (300, 10), # classify subblocks per chunk
|
"level": (300, 10), # classify subblocks per chunk
|
||||||
"level_check": (300, 10), # judge decides contested levels in the chunk
|
"level_check": (300, 10), # judge decides contested levels in the chunk
|
||||||
@@ -141,7 +158,7 @@ TIMEOUTS = {
|
|||||||
"relevance_check": (300, 10), # judge decides contested relevance in the chunk
|
"relevance_check": (300, 10), # judge decides contested relevance in the chunk
|
||||||
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
||||||
"question_pattern_check": (300, 10), # critic cleans up the pattern table per block
|
"question_pattern_check": (300, 10), # critic cleans up the pattern table per block
|
||||||
"writer": (600, 120), # per section in the chunk
|
"writer": (450, 60), # per section — split keeps sections ≤30 subs
|
||||||
"lese_check": (300, 10), # per section in the package
|
"lese_check": (300, 10), # per section in the package
|
||||||
# guide board (per card = one block)
|
# guide board (per card = one block)
|
||||||
"lernziele": (300, 5), # backward-design objectives per block
|
"lernziele": (300, 5), # backward-design objectives per block
|
||||||
@@ -194,15 +211,14 @@ PROVIDERS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Role routing ACROSS provider stacks: generation (quick/guide) and judging (judge)
|
# Role routing: by DEFAULT the run's provider (the UI choice) handles ALL roles —
|
||||||
# may run on different providers within ONE run — judge model ≠ generator model
|
# the role only picks the model WITHIN that stack (PROVIDERS[stack][role]).
|
||||||
# (research-backed: cross-model judging avoids self-preference bias).
|
# Opt-in cross-provider mixing via env: ROLE_JUDGE=claude routes every judge call
|
||||||
# Value: "" = provider of the run; "minimax" = that stack's role model;
|
# to the claude stack regardless of the UI choice ("provider:model" pins a model).
|
||||||
# "provider:model" = explicit model override.
|
|
||||||
ROLE_ROUTING = {
|
ROLE_ROUTING = {
|
||||||
"quick": os.getenv("ROLE_QUICK", "minimax"),
|
"quick": os.getenv("ROLE_QUICK", ""),
|
||||||
"judge": os.getenv("ROLE_JUDGE", "claude"),
|
"judge": os.getenv("ROLE_JUDGE", ""),
|
||||||
"guide": os.getenv("ROLE_GUIDE", "minimax"),
|
"guide": os.getenv("ROLE_GUIDE", ""),
|
||||||
"fast": os.getenv("ROLE_FAST", ""),
|
"fast": os.getenv("ROLE_FAST", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,19 +34,6 @@ CREATE TABLE IF NOT EXISTS topics (
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CREATE_ELEMENTS = """
|
|
||||||
CREATE TABLE IF NOT EXISTS elements (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
topic TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
examples TEXT NOT NULL DEFAULT '[]',
|
|
||||||
hints TEXT NOT NULL DEFAULT '[]',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
CREATE_BLOCK_TEXTE = """
|
CREATE_BLOCK_TEXTE = """
|
||||||
CREATE TABLE IF NOT EXISTS block_texte (
|
CREATE TABLE IF NOT EXISTS block_texte (
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
@@ -219,6 +206,28 @@ CREATE TABLE IF NOT EXISTS kanban_cards (
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Pipeline history (agents, stage moves, failures) — the raw data for quality/perf
|
||||||
|
# analysis. Written fire-and-forget from the hooks in kanban_advance_many / kanban_fail_card /
|
||||||
|
# set_guide_card and agents.run_agent (injected via agents.on_event); never load-bearing.
|
||||||
|
CREATE_EVENTS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL DEFAULT '',
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
dur_ms INTEGER,
|
||||||
|
wait_ms INTEGER,
|
||||||
|
meta TEXT NOT NULL DEFAULT '{}'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATE_EVENTS_INDEX = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events ON events(topic, ts)
|
||||||
|
"""
|
||||||
|
|
||||||
CREATE_KANBAN_PULL_INDEX = """
|
CREATE_KANBAN_PULL_INDEX = """
|
||||||
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at)
|
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at)
|
||||||
"""
|
"""
|
||||||
@@ -287,7 +296,6 @@ async def init_db():
|
|||||||
await db.execute(CREATE_GUIDES)
|
await db.execute(CREATE_GUIDES)
|
||||||
await db.execute(CREATE_PROGRESS)
|
await db.execute(CREATE_PROGRESS)
|
||||||
await db.execute(CREATE_TOPICS)
|
await db.execute(CREATE_TOPICS)
|
||||||
await db.execute(CREATE_ELEMENTS)
|
|
||||||
await db.execute(CREATE_BLOCK_TEXTE)
|
await db.execute(CREATE_BLOCK_TEXTE)
|
||||||
await db.execute(CREATE_BLOCK_PROGRESS)
|
await db.execute(CREATE_BLOCK_PROGRESS)
|
||||||
await db.execute(CREATE_BLOCKS)
|
await db.execute(CREATE_BLOCKS)
|
||||||
@@ -300,6 +308,8 @@ async def init_db():
|
|||||||
await db.execute(CREATE_GUIDE_OUTLINE)
|
await db.execute(CREATE_GUIDE_OUTLINE)
|
||||||
await db.execute(CREATE_SUB_ARTEFAKTE)
|
await db.execute(CREATE_SUB_ARTEFAKTE)
|
||||||
await db.execute(CREATE_KANBAN_CARDS)
|
await db.execute(CREATE_KANBAN_CARDS)
|
||||||
|
await db.execute(CREATE_EVENTS)
|
||||||
|
await db.execute(CREATE_EVENTS_INDEX)
|
||||||
await db.execute(CREATE_KANBAN_PULL_INDEX)
|
await db.execute(CREATE_KANBAN_PULL_INDEX)
|
||||||
await db.execute(CREATE_KANBAN_MEMBERS)
|
await db.execute(CREATE_KANBAN_MEMBERS)
|
||||||
await db.execute(CREATE_GUIDE_CARDS)
|
await db.execute(CREATE_GUIDE_CARDS)
|
||||||
@@ -353,6 +363,8 @@ async def init_db():
|
|||||||
"SELECT topic, block, 'deepdive', md, created_at, updated_at FROM vertiefungen"
|
"SELECT topic, block, 'deepdive', md, created_at, updated_at FROM vertiefungen"
|
||||||
)
|
)
|
||||||
await db.execute("DROP TABLE vertiefungen")
|
await db.execute("DROP TABLE vertiefungen")
|
||||||
|
# Migration: the elements feature was removed entirely — drop its orphaned table.
|
||||||
|
await db.execute("DROP TABLE IF EXISTS elements")
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
|
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
|
||||||
"WHERE status IN ('queued', 'generating')"
|
"WHERE status IN ('queued', 'generating')"
|
||||||
@@ -446,59 +458,6 @@ async def delete_topic(name: str) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
# --- Elements ---
|
|
||||||
|
|
||||||
def _element_row(row, cursor) -> dict:
|
|
||||||
el = _row_to_dict(row, cursor)
|
|
||||||
el["examples"] = json.loads(el["examples"] or "[]")
|
|
||||||
el["hints"] = json.loads(el["hints"] or "[]")
|
|
||||||
return el
|
|
||||||
|
|
||||||
|
|
||||||
async def create_element(element: dict) -> dict:
|
|
||||||
db = await get_db()
|
|
||||||
await db.execute(
|
|
||||||
"""INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at)
|
|
||||||
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
|
|
||||||
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
|
|
||||||
"hints": json.dumps(element["hints"], ensure_ascii=False)},
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return element
|
|
||||||
|
|
||||||
|
|
||||||
async def list_elements(topic: str) -> list[dict]:
|
|
||||||
db = await get_db()
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT * FROM elements WHERE topic = ? ORDER BY updated_at DESC", (topic,)
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
return [_element_row(row, cursor) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_element(element_id: str) -> dict | None:
|
|
||||||
db = await get_db()
|
|
||||||
cursor = await db.execute("SELECT * FROM elements WHERE id = ?", (element_id,))
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return _element_row(row, cursor)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_element(element_id: str, **fields) -> None:
|
|
||||||
for key in ("examples", "hints"):
|
|
||||||
if key in fields:
|
|
||||||
fields[key] = json.dumps(fields[key], ensure_ascii=False)
|
|
||||||
await _update("elements", fields, {"id": element_id})
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_element(element_id: str) -> bool:
|
|
||||||
db = await get_db()
|
|
||||||
cursor = await db.execute("DELETE FROM elements WHERE id = ?", (element_id,))
|
|
||||||
await db.commit()
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
|
|
||||||
# --- Chapter progress ---
|
# --- Chapter progress ---
|
||||||
|
|
||||||
async def list_progress_all() -> dict[str, set[str]]:
|
async def list_progress_all() -> dict[str, set[str]]:
|
||||||
@@ -613,23 +572,6 @@ async def delete_block_progress(topic: str, block: str) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def set_block_completed(topic: str, block: str) -> bool:
|
|
||||||
"""Marks completed; True only the first time (drives the element task)."""
|
|
||||||
db = await get_db()
|
|
||||||
now = _now()
|
|
||||||
await db.execute(
|
|
||||||
"INSERT OR IGNORE INTO block_progress (topic, block, good_answers, updated_at) VALUES (?, ?, 0, ?)",
|
|
||||||
(topic, block, now),
|
|
||||||
)
|
|
||||||
cursor = await db.execute(
|
|
||||||
"UPDATE block_progress SET completed = ?, updated_at = ? "
|
|
||||||
"WHERE topic = ? AND block = ? AND completed IS NULL",
|
|
||||||
(now, now, topic, block),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
|
|
||||||
# Sub-level from the two orthogonal columns: peripheral → 4 (V), otherwise level (learning-path position):
|
# Sub-level from the two orthogonal columns: peripheral → 4 (V), otherwise level (learning-path position):
|
||||||
# beginner/NULL → 1, advanced → 2, expert → 3. Old values (einfach/mittel/schwer) are
|
# beginner/NULL → 1, advanced → 2, expert → 3. Old values (einfach/mittel/schwer) are
|
||||||
# mapped in backward-compatibly. Drives the guide view A/F/E/V + cap (unlocked subs × 25).
|
# mapped in backward-compatibly. Drives the guide view A/F/E/V + cap (unlocked subs × 25).
|
||||||
@@ -797,11 +739,13 @@ def _card(row, cursor) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]:
|
async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]:
|
||||||
"""Oldest `limit` cards sitting in `stage` whose backoff has expired (FIFO via updated_at)."""
|
"""`limit` ready cards of `stage` (backoff expired). LPT: cards carrying a `subs_n`
|
||||||
|
payload field (board 2, set after subblocks) are pulled BIGGEST first — the longest
|
||||||
|
block starts earliest and stops dominating the makespan tail. Others stay FIFO."""
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ?
|
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ?
|
||||||
ORDER BY updated_at LIMIT ?""",
|
ORDER BY COALESCE(json_extract(payload, '$.subs_n'), 0) DESC, updated_at LIMIT ?""",
|
||||||
(topic, board, stage, _now(), limit))
|
(topic, board, stage, _now(), limit))
|
||||||
return [_card(row, cursor) for row in await cursor.fetchall()]
|
return [_card(row, cursor) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
@@ -829,6 +773,27 @@ async def kanban_advance(topic: str, board: str, card_id: str, stage: str) -> No
|
|||||||
await kanban_advance_many(topic, board, [(card_id, stage)])
|
await kanban_advance_many(topic, board, [(card_id, stage)])
|
||||||
|
|
||||||
|
|
||||||
|
async def add_event(topic: str, kind: str, key: str = "", label: str = "", status: str = "",
|
||||||
|
dur_ms: int | None = None, wait_ms: int | None = None, meta: dict | None = None) -> None:
|
||||||
|
"""One pipeline-history row, own commit. Callers treat this as fire-and-forget."""
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
(topic, _now(), kind, key, label, status, dur_ms, wait_ms,
|
||||||
|
json.dumps(meta or {}, ensure_ascii=False)))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_events_many(db, topic: str, rows: list[tuple]) -> None:
|
||||||
|
"""Batch insert WITHOUT commit — must run inside the caller's transaction
|
||||||
|
(kanban_advance_many) so the event batch stays atomic with the moves."""
|
||||||
|
now = _now()
|
||||||
|
await db.executemany(
|
||||||
|
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
[(topic, now, kind, key, label, status, None, None, "{}")
|
||||||
|
for kind, key, label, status in rows])
|
||||||
|
|
||||||
|
|
||||||
async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str]]) -> None:
|
async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str]]) -> None:
|
||||||
"""Batch stage moves in ONE commit (the flow advances whole packages)."""
|
"""Batch stage moves in ONE commit (the flow advances whole packages)."""
|
||||||
if not moves:
|
if not moves:
|
||||||
@@ -839,6 +804,7 @@ async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str
|
|||||||
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
||||||
WHERE topic = ? AND board = ? AND card_id = ?""",
|
WHERE topic = ? AND board = ? AND card_id = ?""",
|
||||||
[(stage, now, topic, board, cid) for cid, stage in moves])
|
[(stage, now, topic, board, cid) for cid, stage in moves])
|
||||||
|
await _add_events_many(db, topic, [("stage", f"{board}:{cid}", "", stage) for cid, stage in moves])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -912,6 +878,10 @@ async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
|
|||||||
WHERE topic = ? AND board = ? AND card_id = ?""",
|
WHERE topic = ? AND board = ? AND card_id = ?""",
|
||||||
(retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(),
|
(retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(),
|
||||||
topic, board, card_id))
|
topic, board, card_id))
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
(topic, _now(), "fail", f"{board}:{card_id}", "", "dead" if dead else f"retry{retries}",
|
||||||
|
None, None, json.dumps({"error": error[:200]}, ensure_ascii=False)))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return dead
|
return dead
|
||||||
|
|
||||||
@@ -928,6 +898,7 @@ async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int:
|
|||||||
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
||||||
WHERE topic = ? AND board = ? AND stage = 'dead'""",
|
WHERE topic = ? AND board = ? AND stage = 'dead'""",
|
||||||
(stage, _now(), topic, board))
|
(stage, _now(), topic, board))
|
||||||
|
await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
@@ -1032,6 +1003,11 @@ async def set_guide_card(topic: str, format: str, block_norm: str, **fields) ->
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?",
|
f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?",
|
||||||
(*fields.values(), _now(), topic, format, block_norm))
|
(*fields.values(), _now(), topic, format, block_norm))
|
||||||
|
if "stage" in fields: # the guide board moves stages here, not via kanban_advance_many
|
||||||
|
await _add_events_many(db, topic, [("stage", f"guide:{format}:{block_norm}", "", fields["stage"])])
|
||||||
|
elif fields.get("status") == "error": # guide cards fail here, not via kanban_fail_card
|
||||||
|
await _add_events_many(db, topic, [("fail", f"guide:{format}:{block_norm}", "",
|
||||||
|
str(fields.get("gate_info", ""))[:200])])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -1202,9 +1178,12 @@ async def set_subblock_fields(topic: str, block_norm: str, sub_norm: str, **fiel
|
|||||||
await _update("subblocks", fields, {"topic": topic, "block_norm": block_norm, "sub_norm": sub_norm})
|
await _update("subblocks", fields, {"topic": topic, "block_norm": block_norm, "sub_norm": sub_norm})
|
||||||
|
|
||||||
|
|
||||||
async def delete_subblocks(topic: str) -> None:
|
async def delete_subblocks(topic: str, block_norm: str | None = None) -> None:
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,))
|
if block_norm is None:
|
||||||
|
await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,))
|
||||||
|
else:
|
||||||
|
await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -1232,9 +1211,12 @@ async def list_question_pattern(topic: str, block_norm: str | None = None) -> li
|
|||||||
return [_row_to_dict(row, cursor) for row in rows]
|
return [_row_to_dict(row, cursor) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def delete_question_pattern(topic: str) -> None:
|
async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None:
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,))
|
if block_norm is None:
|
||||||
|
await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,))
|
||||||
|
else:
|
||||||
|
await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -1408,9 +1390,12 @@ async def get_sub_artefakte(topic: str, type: str | None = None) -> list[dict]:
|
|||||||
return [_row_to_dict(row, cursor) for row in rows]
|
return [_row_to_dict(row, cursor) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def delete_sub_artefakte(topic: str) -> None:
|
async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None:
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
|
if block_norm is None:
|
||||||
|
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
|
||||||
|
else:
|
||||||
|
await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -1441,6 +1426,6 @@ async def delete_topic_pipeline(topic: str) -> None:
|
|||||||
NOT the topic config `source` — that is managed separately (delete_source)."""
|
NOT the topic config `source` — that is managed separately (delete_source)."""
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage",
|
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage",
|
||||||
"pipeline_state", "guide_outline", "sub_artefakte"):
|
"pipeline_state", "guide_outline", "sub_artefakte", "events"):
|
||||||
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
|
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
"""Elements (personal summary) and tutor chat for the guide."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from agents import run_agent
|
|
||||||
from config import DEFAULT_PROVIDER
|
|
||||||
from jsonio import parse_json_text as _parse_json_text, read_json_file as _read_json_file
|
|
||||||
from paths import blocks_path, guide_content_path
|
|
||||||
from pipeline import _prompt
|
|
||||||
|
|
||||||
log = logging.getLogger("creator.elements")
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tutor chat ---
|
|
||||||
|
|
||||||
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
|
|
||||||
transcript = "\n".join(
|
|
||||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
|
||||||
for m in messages
|
|
||||||
)
|
|
||||||
return _prompt(
|
|
||||||
"Chat",
|
|
||||||
topic=topic, format_name=format_name,
|
|
||||||
outline_block=outline.strip() or "(none)",
|
|
||||||
section_block=section.strip() or "(no section detected)",
|
|
||||||
transcript=transcript,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
|
||||||
try:
|
|
||||||
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
|
||||||
returncode, stdout, stderr = await run_agent(
|
|
||||||
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
return "Sorry, that didn't work. Please try again."
|
|
||||||
reply = stdout.strip()
|
|
||||||
return reply or "Sorry, I didn't get a response."
|
|
||||||
except Exception:
|
|
||||||
log.warning("[%s] Guide chat failed", topic, exc_info=True)
|
|
||||||
return "Sorry, that didn't work. Please try again."
|
|
||||||
|
|
||||||
|
|
||||||
# --- Elements ---
|
|
||||||
|
|
||||||
def _element_fields(data: dict) -> dict | None:
|
|
||||||
"""Validate AI element JSON and normalize it onto the DB fields."""
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
title = str(data.get("title", "")).strip()
|
|
||||||
if not title:
|
|
||||||
return None
|
|
||||||
lists = {}
|
|
||||||
for key in ("examples", "hints"):
|
|
||||||
raw = data.get(key, [])
|
|
||||||
lists[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
|
|
||||||
return {
|
|
||||||
"title": title[:200],
|
|
||||||
"description": str(data.get("description", "")).strip(),
|
|
||||||
"examples": lists["examples"],
|
|
||||||
"hints": lists["hints"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _topic_context(topic: str, limit: int = 12000) -> str:
|
|
||||||
"""Blocks + guide content of the topic as context text (truncated)."""
|
|
||||||
parts: list[str] = []
|
|
||||||
bp = blocks_path(topic)
|
|
||||||
if bp.exists():
|
|
||||||
parts.append(bp.read_text(encoding="utf-8"))
|
|
||||||
for fmt in ("Guide", "FullGuide"): # best available prose guide as chat context
|
|
||||||
content = _read_json_file(guide_content_path(topic, fmt))
|
|
||||||
if content:
|
|
||||||
for ch in content.get("chapters", []):
|
|
||||||
for sec in ch.get("sections", []):
|
|
||||||
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
|
|
||||||
break # the best available guide is enough
|
|
||||||
text = "\n\n".join(parts).strip()
|
|
||||||
return text[:limit] if text else "(no material available)"
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict:
|
|
||||||
"""Create element fields via AI. Fallback: only the title from the keyword."""
|
|
||||||
fallback = {"title": hint.strip() or "New element", "description": "", "examples": [], "hints": []}
|
|
||||||
try:
|
|
||||||
context = _topic_context(topic)
|
|
||||||
if extra_context.strip():
|
|
||||||
context = (extra_context.strip() + "\n\n" + context)[:12000]
|
|
||||||
prompt = _prompt(
|
|
||||||
"Element-Create",
|
|
||||||
topic=topic, hint=hint.strip() or "(none — pick a core concept yourself)",
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
returncode, stdout, _ = await run_agent(
|
|
||||||
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
return fallback
|
|
||||||
return _element_fields(_parse_json_text(stdout)) or fallback
|
|
||||||
except Exception:
|
|
||||||
log.warning("[%s] Element creation failed", topic, exc_info=True)
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_suggestions(stdout: str) -> list[dict] | None:
|
|
||||||
"""Validate suggestion JSON from AI output. None on invalid JSON."""
|
|
||||||
data = _parse_json_text(stdout)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
suggestions = []
|
|
||||||
for s in data.get("suggestions", []):
|
|
||||||
if not isinstance(s, dict):
|
|
||||||
continue
|
|
||||||
text = str(s.get("text", "")).strip()
|
|
||||||
target = s.get("target")
|
|
||||||
content = str(s.get("content", "")).strip()
|
|
||||||
if text and content and target in ("description", "examples", "hints"):
|
|
||||||
suggestions.append({"text": text, "target": target, "content": content})
|
|
||||||
return suggestions
|
|
||||||
|
|
||||||
|
|
||||||
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
|
||||||
"""Two-step check for missing info: research → verify. None on error."""
|
|
||||||
try:
|
|
||||||
element_json = json.dumps(
|
|
||||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
|
||||||
ensure_ascii=False, indent=1,
|
|
||||||
)
|
|
||||||
context = _topic_context(element["topic"])
|
|
||||||
|
|
||||||
# Step 1: research — collect candidates broadly
|
|
||||||
prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
|
|
||||||
returncode, stdout, _ = await run_agent(
|
|
||||||
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
return None
|
|
||||||
candidates = _parse_suggestions(stdout)
|
|
||||||
if candidates is None:
|
|
||||||
return None
|
|
||||||
if not candidates:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Step 2: verify — only let important, non-redundant items through
|
|
||||||
prompt = _prompt(
|
|
||||||
"Element-Verify",
|
|
||||||
topic=element["topic"], element_json=element_json,
|
|
||||||
candidates_json=json.dumps({"suggestions": candidates}, ensure_ascii=False, indent=1),
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
returncode, stdout, _ = await run_agent(
|
|
||||||
"element-verify-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
return None
|
|
||||||
return _parse_suggestions(stdout)
|
|
||||||
except Exception:
|
|
||||||
log.warning("[%s] Element check failed", element.get("topic", "?"), exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _element_json(element: dict) -> str:
|
|
||||||
return json.dumps(
|
|
||||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
|
||||||
ensure_ascii=False, indent=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_change(c, element: dict) -> dict | None:
|
|
||||||
"""Validate a change suggestion from AI output against the element."""
|
|
||||||
if not isinstance(c, dict):
|
|
||||||
return None
|
|
||||||
text = str(c.get("text", "")).strip()
|
|
||||||
action = c.get("action")
|
|
||||||
target = c.get("target")
|
|
||||||
index = c.get("index")
|
|
||||||
content = str(c.get("content", "")).strip()
|
|
||||||
if not text or action not in ("remove", "adjust", "add"):
|
|
||||||
return None
|
|
||||||
if target not in ("title", "description", "examples", "hints"):
|
|
||||||
return None
|
|
||||||
if action in ("adjust", "add") and not content:
|
|
||||||
return None
|
|
||||||
if action == "remove" and target not in ("examples", "hints"):
|
|
||||||
return None
|
|
||||||
# Index only for adjust/remove on list fields; must exist
|
|
||||||
if target in ("examples", "hints") and action in ("adjust", "remove"):
|
|
||||||
if not isinstance(index, int) or not (0 <= index < len(element[target])):
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
index = None
|
|
||||||
return {"text": text, "action": action, "target": target, "index": index, "content": content}
|
|
||||||
|
|
||||||
|
|
||||||
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, list[dict]]:
|
|
||||||
"""Chat about the element. Returns (reply, change suggestions) — changes nothing directly."""
|
|
||||||
error = "Sorry, that didn't work. Please try again."
|
|
||||||
try:
|
|
||||||
transcript = "\n".join(
|
|
||||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
|
||||||
for m in messages
|
|
||||||
)
|
|
||||||
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript)
|
|
||||||
returncode, stdout, _ = await run_agent(
|
|
||||||
"element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
return error, []
|
|
||||||
data = _parse_json_text(stdout)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return error, []
|
|
||||||
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
|
||||||
reply = str(data.get("reply", "")).strip() or ("Suggestions created." if changes else error)
|
|
||||||
return reply, changes
|
|
||||||
except Exception:
|
|
||||||
log.warning("[%s] Element chat failed", element.get("topic", "?"), exc_info=True)
|
|
||||||
return error, []
|
|
||||||
|
|
||||||
|
|
||||||
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
|
||||||
"""Check an element against the style rules and suggest changes. None on error."""
|
|
||||||
try:
|
|
||||||
prompt = _prompt("Element-Style", topic=element["topic"], element_json=_element_json(element))
|
|
||||||
returncode, stdout, _ = await run_agent(
|
|
||||||
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
return None
|
|
||||||
data = _parse_json_text(stdout)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
return [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
|
||||||
except Exception:
|
|
||||||
log.warning("[%s] Style check failed", element.get("topic", "?"), exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None:
|
|
||||||
"""Revise a single suggestion per user instruction. None on error."""
|
|
||||||
try:
|
|
||||||
prompt = _prompt(
|
|
||||||
"Element-Refine",
|
|
||||||
topic=element["topic"], element_json=_element_json(element),
|
|
||||||
suggestion_json=json.dumps(suggestion, ensure_ascii=False, indent=1),
|
|
||||||
instruction=instruction,
|
|
||||||
)
|
|
||||||
returncode, stdout, _ = await run_agent(
|
|
||||||
"element-refine-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
return None
|
|
||||||
data = _parse_json_text(stdout)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
return _validate_change(data.get("change"), element)
|
|
||||||
except Exception:
|
|
||||||
log.warning("[%s] Suggestion revision failed", element.get("topic", "?"), exc_info=True)
|
|
||||||
return None
|
|
||||||
@@ -396,3 +396,34 @@ async def block_adopt(topic: str, format_name: str, block: str, spot: str, old:
|
|||||||
await set_guide_content(topic, format_name, js)
|
await set_guide_content(topic, format_name, js)
|
||||||
atomic_write_json(guide_content_path(topic, format_name), content, indent=1)
|
atomic_write_json(guide_content_path(topic, format_name), content, indent=1)
|
||||||
return {"compact": sec.get("compact", ""), "md": sec.get("md", ""), "found": found}
|
return {"compact": sec.get("compact", ""), "md": sec.get("md", ""), "found": found}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tutor chat (moved from the removed elements module) ---
|
||||||
|
|
||||||
|
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
|
||||||
|
transcript = "\n".join(
|
||||||
|
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||||
|
for m in messages
|
||||||
|
)
|
||||||
|
return _prompt(
|
||||||
|
"Chat",
|
||||||
|
topic=topic, format_name=format_name,
|
||||||
|
outline_block=outline.strip() or "(none)",
|
||||||
|
section_block=section.strip() or "(no section detected)",
|
||||||
|
transcript=transcript,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
||||||
|
try:
|
||||||
|
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
||||||
|
returncode, stdout, stderr = await run_agent(
|
||||||
|
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||||
|
)
|
||||||
|
if returncode != 0:
|
||||||
|
return "Sorry, that didn't work. Please try again."
|
||||||
|
reply = stdout.strip()
|
||||||
|
return reply or "Sorry, I didn't get a response."
|
||||||
|
except Exception:
|
||||||
|
log.warning("[%s] Guide chat failed", topic, exc_info=True)
|
||||||
|
return "Sorry, that didn't work. Please try again."
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Board 3 „Guide": one card per block, linear stages with gates between them.
|
"""Board 3 „Guide": one card per block, linear stages with gates between them.
|
||||||
|
|
||||||
lernziele judge Backward Design — objectives BEFORE writing
|
lernziele judge-Rolle Backward Design — objectives BEFORE writing
|
||||||
zuweisung code chapter/order from the outline artefact + facts grounding
|
zuweisung code chapter/order from the outline artefact + facts grounding
|
||||||
writer guide ONE coherent per-block text, only from VERIFIED FACTS
|
writer guide-Rolle ONE coherent per-block text, only from VERIFIED FACTS
|
||||||
fakten_gate judge CoVe: atomic claims, each binary against the facts → minimal fix
|
fakten_gate judge-Rolle CoVe: atomic claims, each binary against the facts → minimal fix
|
||||||
coverage judge objective↔section mapping; gap → back to writer (max 2 rounds)
|
coverage judge-Rolle objective↔section mapping; gap → back to writer (max 2 rounds)
|
||||||
lesbarkeit judge Lese-Check + deterministic readability gate → fix → done
|
lesbarkeit judge-Rolle Lese-Check + deterministic readability gate → fix → done
|
||||||
|
|
||||||
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
|
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
|
||||||
needed); stage transitions are persisted in guide_cards, so the board is live and
|
needed); stage transitions are persisted in guide_cards, so the board is live and
|
||||||
@@ -20,7 +20,7 @@ import re
|
|||||||
|
|
||||||
import database as db
|
import database as db
|
||||||
import readability
|
import readability
|
||||||
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR
|
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||||
from fsutil import atomic_write_json
|
from fsutil import atomic_write_json
|
||||||
from jsonio import read_json_file as _json_file
|
from jsonio import read_json_file as _json_file
|
||||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||||
@@ -34,7 +34,10 @@ STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "W
|
|||||||
"fakten_gate": "Fakten-Gate", "coverage": "Coverage",
|
"fakten_gate": "Fakten-Gate", "coverage": "Coverage",
|
||||||
"lesbarkeit": "Lesbarkeit", "done": "Fertig"}
|
"lesbarkeit": "Lesbarkeit", "done": "Fertig"}
|
||||||
MAX_WRITER_ROUNDS = 2 # coverage → writer feedback loop cap (gains die after round 1–2)
|
MAX_WRITER_ROUNDS = 2 # coverage → writer feedback loop cap (gains die after round 1–2)
|
||||||
CARD_CONCURRENCY = 10 # simultaneous cards (the per-topic agent semaphore is the hard cap)
|
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
|
||||||
|
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
|
||||||
|
# from the old 10-slot era while the .env already allowed 24).
|
||||||
|
CARD_CONCURRENCY = MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||||
|
|
||||||
|
|
||||||
def _safe(norm: str) -> str:
|
def _safe(norm: str) -> str:
|
||||||
@@ -142,6 +145,15 @@ def _card_assignment(env: _Env, card: dict) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# Live info per active card (in-memory): what the card is doing RIGHT NOW —
|
||||||
|
# board_snapshot shows it as the info line while status == active.
|
||||||
|
_live_info: dict[tuple[str, str, str], str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _live(env: _Env, card: dict, msg: str) -> None:
|
||||||
|
_live_info[(env.topic, env.format, card["block_norm"])] = msg
|
||||||
|
|
||||||
|
|
||||||
async def _set(env: _Env, card: dict, **fields):
|
async def _set(env: _Env, card: dict, **fields):
|
||||||
card.update(fields)
|
card.update(fields)
|
||||||
await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)
|
await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)
|
||||||
@@ -179,6 +191,82 @@ async def _stage_zuweisung(env: _Env, card: dict) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# A single section over ~45 subs measurably breaks the writer/coverage (Front Matter:
|
||||||
|
# 4/6 objectives open after 2 rounds). First drafts of oversized cards are written in two
|
||||||
|
# halves and merged back into ONE canonical section (all gates/assembly read one section).
|
||||||
|
WRITER_SPLIT_SUBS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
|
||||||
|
"""Rebuild ONE canonical fragment from two half-sections: header + anchor from part A,
|
||||||
|
sub blocks of both parts in order, both layers. Part B's framing is dropped — its
|
||||||
|
prompt forbids an intro; keeping it would inject a second lead-in mid-section."""
|
||||||
|
lines = []
|
||||||
|
if sec_a.get("chapters"):
|
||||||
|
lines.append(f"<!-- kapitel: {sec_a['chapters']} -->")
|
||||||
|
lines.append(f"<!-- section: {sec_a['title']} -->")
|
||||||
|
lines.append("<!-- compact -->")
|
||||||
|
if sec_a.get("anker_compact"):
|
||||||
|
lines.append(sec_a["anker_compact"])
|
||||||
|
for sub in [*sec_a["subs"], *sec_b["subs"]]:
|
||||||
|
if sub.get("compact"):
|
||||||
|
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
|
||||||
|
lines.append(sub["compact"])
|
||||||
|
lines.append("<!-- ausführlich -->")
|
||||||
|
if sec_a.get("anchor"):
|
||||||
|
lines.append(sec_a["anchor"])
|
||||||
|
for sub in [*sec_a["subs"], *sec_b["subs"]]:
|
||||||
|
if sub.get("md"):
|
||||||
|
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
|
||||||
|
lines.append(sub["md"])
|
||||||
|
return "\n\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_split(env: _Env, card: dict, ziele_text: str):
|
||||||
|
"""First draft in two halves (parallel), merged into one section.
|
||||||
|
→ merged text | None (failed) | False (cancelled)."""
|
||||||
|
from guide import _level_label
|
||||||
|
norm = card["block_norm"]
|
||||||
|
subs = env.subs_by_title.get(card["block"], [])
|
||||||
|
half = (len(subs) + 1) // 2
|
||||||
|
parts = (subs[:half], subs[half:])
|
||||||
|
hints = (
|
||||||
|
"TEIL 1/2: Schreibe den Abschnitts-EINSTIEG und die folgenden Unterpunkte. "
|
||||||
|
"Weitere Unterpunkte folgen in Teil 2 — KEIN Fazit, KEIN Ausblick am Ende.",
|
||||||
|
"TEIL 2/2: FORTSETZUNG desselben Abschnitts. KEIN neuer Einstieg, KEINE "
|
||||||
|
"Wiederholung von Teil 1 — direkt mit den Unterpunkten weitermachen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _one(i):
|
||||||
|
assignment = "\n".join([f"- {card['block']}"]
|
||||||
|
+ [f" [{_level_label(s)}] {s['title']}" for s in parts[i]])
|
||||||
|
path = env.slot(f"card-{_safe(norm)}-r0-{'ab'[i]}.md")
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _payload(result, p=path):
|
||||||
|
t = p.read_text(encoding="utf-8") if p.exists() else ""
|
||||||
|
sec = _first_section(t)
|
||||||
|
return t if sec and sec.get("md", "").strip() else None
|
||||||
|
|
||||||
|
return await run_single_slot(
|
||||||
|
env.ctx, f"Writer {card['block']} ({i + 1}/2)",
|
||||||
|
key=f"{env.guide_id}-w-{_safe(norm)}-r0-{'ab'[i]}",
|
||||||
|
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
|
||||||
|
chapter=card.get("chapter") or "Inhalte",
|
||||||
|
assignment=assignment, ziele=ziele_text,
|
||||||
|
facts=_card_facts(env, card["block"]), gaps="\n" + hints[i] + "\n",
|
||||||
|
spec=env.spec, out_path=path, extra=_extra(env.instructions)),
|
||||||
|
role="guide", capabilities="files", payload=_payload,
|
||||||
|
timeout=_timeout("writer", 1))
|
||||||
|
|
||||||
|
results = await asyncio.gather(_one(0), _one(1))
|
||||||
|
if any(s == CANCELLED for s, _ in results):
|
||||||
|
return False
|
||||||
|
if any(s == FAILED for s, _ in results):
|
||||||
|
return None
|
||||||
|
return _merge_split_sections(_first_section(results[0][1]), _first_section(results[1][1]))
|
||||||
|
|
||||||
|
|
||||||
async def _stage_writer(env: _Env, card: dict) -> bool:
|
async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||||
norm = card["block_norm"]
|
norm = card["block_norm"]
|
||||||
ziele = await db.list_lernziele(env.topic, norm)
|
ziele = await db.list_lernziele(env.topic, norm)
|
||||||
@@ -188,6 +276,17 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
|
|||||||
gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
|
gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
|
||||||
"below, cut the listed ballast, keep everything else as-is.\n"
|
"below, cut the listed ballast, keep everything else as-is.\n"
|
||||||
f"PREVIOUS VERSION:\n{card.get('md', '')}\n\nGAPS/BALLAST:\n{card['gate_info']}\n")
|
f"PREVIOUS VERSION:\n{card.get('md', '')}\n\nGAPS/BALLAST:\n{card['gate_info']}\n")
|
||||||
|
# oversized first drafts: two halves, merged into one canonical section
|
||||||
|
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
|
||||||
|
text = await _write_split(env, card, ziele_text)
|
||||||
|
if text is False:
|
||||||
|
return False
|
||||||
|
if text is None:
|
||||||
|
await _set(env, card, status="error", gate_info="Writer (Split) ohne Ergebnis")
|
||||||
|
return False
|
||||||
|
await _set(env, card, md=text, stage="fakten_gate", status="open")
|
||||||
|
return True
|
||||||
|
|
||||||
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
|
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
@@ -251,7 +350,7 @@ async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
|
|||||||
prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
|
prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
|
||||||
section=card["md"], claims=claims_text, facts=facts,
|
section=card["md"], claims=claims_text, facts=facts,
|
||||||
out_path=fixp, extra=_extra(env.instructions)),
|
out_path=fixp, extra=_extra(env.instructions)),
|
||||||
role="guide", capabilities="files", payload=_fixload, # Fix ≠ Gate-Modell (kein Selbst-Check)
|
role="guide", capabilities="files", payload=_fixload, # Fix auf der Schreib-Rolle, Gate auf der Judge-Rolle
|
||||||
timeout=_timeout("fakten_gate", 1))
|
timeout=_timeout("fakten_gate", 1))
|
||||||
if fstatus == CANCELLED:
|
if fstatus == CANCELLED:
|
||||||
return False
|
return False
|
||||||
@@ -372,23 +471,31 @@ _STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
|
|||||||
|
|
||||||
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
|
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
|
||||||
async with sem:
|
async with sem:
|
||||||
while card["stage"] != "done":
|
try:
|
||||||
if is_guide_cancelled(env.guide_id):
|
await _run_card_inner(env, card)
|
||||||
await _set(env, card, status="open") # no longer being worked
|
finally:
|
||||||
return
|
_live_info.pop((env.topic, env.format, card["block_norm"]), None)
|
||||||
fn = _STAGE_FN.get(card["stage"])
|
|
||||||
if fn is None: # unknown stage → park as error
|
|
||||||
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
|
async def _run_card_inner(env: _Env, card: dict) -> None:
|
||||||
return
|
while card["stage"] != "done":
|
||||||
if card["status"] != "active":
|
if is_guide_cancelled(env.guide_id):
|
||||||
await _set(env, card, status="active") # live board: this card is being worked
|
await _set(env, card, status="open") # no longer being worked
|
||||||
try:
|
return
|
||||||
if not await fn(env, card):
|
fn = _STAGE_FN.get(card["stage"])
|
||||||
return
|
if fn is None: # unknown stage → park as error
|
||||||
except Exception as e:
|
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
|
||||||
log.exception("[%s] guide card %s failed", env.topic, card["block"])
|
return
|
||||||
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
|
if card["status"] != "active":
|
||||||
|
await _set(env, card, status="active") # live board: this card is being worked
|
||||||
|
_live(env, card, STAGE_LABELS.get(card["stage"], card["stage"]) + "…")
|
||||||
|
try:
|
||||||
|
if not await fn(env, card):
|
||||||
return
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("[%s] guide card %s failed", env.topic, card["block"])
|
||||||
|
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
# ── Orchestration ──────────────────────────────────────────────────────────────────
|
# ── Orchestration ──────────────────────────────────────────────────────────────────
|
||||||
@@ -498,16 +605,35 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
|
|||||||
views = []
|
views = []
|
||||||
for c in in_stage[:limit]:
|
for c in in_stage[:limit]:
|
||||||
zc = ziele.get(c["block_norm"])
|
zc = ziele.get(c["block_norm"])
|
||||||
views.append({"title": c["block"],
|
info = c["gate_info"][:200] if c["status"] == "error" else ""
|
||||||
|
if c["status"] == "active":
|
||||||
|
info = _live_info.get((topic, format_name, c["block_norm"]), "") or info
|
||||||
|
views.append({"title": c["block"], "card_id": c["block_norm"],
|
||||||
"status": c["status"] if c["status"] in ("error", "active") else "open",
|
"status": c["status"] if c["status"] in ("error", "active") else "open",
|
||||||
"rounds": c["writer_rounds"],
|
"rounds": c["writer_rounds"],
|
||||||
"info": c["gate_info"][:200] if c["status"] == "error" else "",
|
"info": info,
|
||||||
"ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
|
"ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
|
||||||
columns.append({"key": stage, "label": STAGE_LABELS[stage],
|
columns.append({"key": stage, "label": STAGE_LABELS[stage],
|
||||||
"total": len(in_stage), "cards": views})
|
"total": len(in_stage), "cards": views})
|
||||||
return {"columns": columns}
|
return {"columns": columns}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
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]."""
|
"""Cards in stages ≥ ab_stage (incl. done) back to GUIDE_STAGES[ab_stage]."""
|
||||||
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
|
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from agents import run_agent
|
from agents import run_agent
|
||||||
from config import DEFAULT_PROVIDER
|
from config import DEFAULT_PROVIDER
|
||||||
from database import create_element, list_elements, get_block_hurdles
|
from database import get_block_hurdles
|
||||||
from elements import generate_element
|
|
||||||
from jsonio import parse_json_text as _parse_json_text
|
from jsonio import parse_json_text as _parse_json_text
|
||||||
from pipeline import _prompt, _problems_schema
|
from pipeline import _prompt, _problems_schema
|
||||||
from textkit import _norm_title
|
from textkit import _norm_title
|
||||||
@@ -637,21 +636,3 @@ async def block_discussion(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def create_block_element(topic: str, block: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
|
|
||||||
"""Background task after completion: register the block as an element.
|
|
||||||
|
|
||||||
Dedup via normalized title — if an element for the block already exists,
|
|
||||||
nothing happens. Must never raise an exception to the outside.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
existing = {_norm_title(e["title"]) for e in await list_elements(topic)}
|
|
||||||
if _norm_title(block) in existing:
|
|
||||||
return
|
|
||||||
fields = await generate_element(topic, hint=block, provider=provider, extra_context=section)
|
|
||||||
if _norm_title(fields["title"]) in existing:
|
|
||||||
return
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now})
|
|
||||||
log.info("[%s] Block registered as element: %s", topic, fields["title"])
|
|
||||||
except Exception:
|
|
||||||
log.warning("[%s] Element registration after exam failed (%s)", topic, block, exc_info=True)
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from logsetup import setup_logging
|
|||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
from config import FRONTEND_DIST, STORAGE_DIR
|
from config import FRONTEND_DIST, STORAGE_DIR
|
||||||
|
import agents
|
||||||
|
import database
|
||||||
from database import init_db, close_db
|
from database import init_db, close_db
|
||||||
from guide import reconcile_guides
|
from guide import reconcile_guides
|
||||||
from routes import router
|
from routes import router
|
||||||
@@ -18,6 +20,7 @@ from routes import router
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
(STORAGE_DIR / "topics").mkdir(parents=True, exist_ok=True)
|
(STORAGE_DIR / "topics").mkdir(parents=True, exist_ok=True)
|
||||||
await init_db()
|
await init_db()
|
||||||
|
agents.on_event = database.add_event # pipeline history sink (agents.py stays DB-free)
|
||||||
await reconcile_guides()
|
await reconcile_guides()
|
||||||
yield
|
yield
|
||||||
await close_db()
|
await close_db()
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ class BlocksCreateRequest(BaseModel):
|
|||||||
research: bool = True # False = Continue: drain the existing kanban queue, no new search
|
research: bool = True # False = Continue: drain the existing kanban queue, no new search
|
||||||
|
|
||||||
|
|
||||||
|
class BlocksCardRestartRequest(BaseModel):
|
||||||
|
topic: str = Field(min_length=1)
|
||||||
|
card_id: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class BlocksResetStageRequest(BaseModel):
|
||||||
topic: str = Field(min_length=1, max_length=100)
|
topic: str = Field(min_length=1, max_length=100)
|
||||||
board: Literal["inventory", "artefacts"]
|
board: Literal["inventory", "artefacts"]
|
||||||
@@ -134,76 +146,6 @@ class GuideChatResponse(BaseModel):
|
|||||||
reply: str
|
reply: str
|
||||||
|
|
||||||
|
|
||||||
class ElementResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
topic: str
|
|
||||||
title: str
|
|
||||||
description: str = ""
|
|
||||||
examples: list[str] = []
|
|
||||||
hints: list[str] = []
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ElementCreateRequest(BaseModel):
|
|
||||||
topic: str = Field(min_length=1, max_length=100)
|
|
||||||
hint: str = Field(default="", max_length=500)
|
|
||||||
provider: ProviderType = "claude"
|
|
||||||
|
|
||||||
|
|
||||||
class ElementUpdateRequest(BaseModel):
|
|
||||||
title: str | None = Field(default=None, max_length=200)
|
|
||||||
description: str | None = None
|
|
||||||
examples: list[str] | None = None
|
|
||||||
hints: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ElementCheckRequest(BaseModel):
|
|
||||||
provider: ProviderType = "claude"
|
|
||||||
|
|
||||||
|
|
||||||
class ElementSuggestion(BaseModel):
|
|
||||||
text: str
|
|
||||||
target: Literal["description", "examples", "hints"]
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class ElementCheckResponse(BaseModel):
|
|
||||||
suggestions: list[ElementSuggestion]
|
|
||||||
|
|
||||||
|
|
||||||
class ElementStyleChange(BaseModel):
|
|
||||||
text: str
|
|
||||||
action: Literal["remove", "adjust", "add"]
|
|
||||||
target: Literal["title", "description", "examples", "hints"]
|
|
||||||
index: int | None = None
|
|
||||||
content: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ElementStyleResponse(BaseModel):
|
|
||||||
changes: list[ElementStyleChange]
|
|
||||||
|
|
||||||
|
|
||||||
class ElementChatRequest(BaseModel):
|
|
||||||
messages: list[ChatMessage] = Field(min_length=1)
|
|
||||||
provider: ProviderType = "claude"
|
|
||||||
|
|
||||||
|
|
||||||
class ElementChatResponse(BaseModel):
|
|
||||||
reply: str
|
|
||||||
changes: list[ElementStyleChange] = []
|
|
||||||
|
|
||||||
|
|
||||||
class ElementRefineRequest(BaseModel):
|
|
||||||
suggestion: ElementStyleChange
|
|
||||||
instruction: str = Field(min_length=1, max_length=2000)
|
|
||||||
provider: ProviderType = "claude"
|
|
||||||
|
|
||||||
|
|
||||||
class ElementRefineResponse(BaseModel):
|
|
||||||
change: ElementStyleChange
|
|
||||||
|
|
||||||
|
|
||||||
class ProgressUpdate(BaseModel):
|
class ProgressUpdate(BaseModel):
|
||||||
chapter: str = Field(min_length=1, max_length=100)
|
chapter: str = Field(min_length=1, max_length=100)
|
||||||
done: bool
|
done: bool
|
||||||
|
|||||||
@@ -196,10 +196,11 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
|||||||
|
|
||||||
def spawn(i: int) -> None:
|
def spawn(i: int) -> None:
|
||||||
slot = slots[i]
|
slot = slots[i]
|
||||||
|
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
|
||||||
task = asyncio.create_task(run_agent(
|
task = asyncio.create_task(run_agent(
|
||||||
slot["key"], slot["prompt"], timeout,
|
slot["key"], slot["prompt"], timeout,
|
||||||
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
||||||
scope=topic, on_line=slot.get("on_line"),
|
scope=topic, on_line=slot.get("on_line"), label=lbl,
|
||||||
))
|
))
|
||||||
tasks[task] = i
|
tasks[task] = i
|
||||||
|
|
||||||
|
|||||||
@@ -13,28 +13,24 @@ from database import (
|
|||||||
create_guide, delete_guide, get_guide, list_guides,
|
create_guide, delete_guide, get_guide, list_guides,
|
||||||
create_topic, list_topics as db_list_topics, delete_topic,
|
create_topic, list_topics as db_list_topics, delete_topic,
|
||||||
list_progress, set_progress, delete_progress,
|
list_progress, set_progress, delete_progress,
|
||||||
create_element, list_elements, get_element, update_element, delete_element,
|
|
||||||
list_block_progress, get_block_progress, set_open_question,
|
list_block_progress, get_block_progress, set_open_question,
|
||||||
set_block_score_and_streak, set_block_completed,
|
set_block_score_and_streak,
|
||||||
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
|
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
|
||||||
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
|
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
|
||||||
get_sub_artefakte, kanban_reset, delete_guide_board,
|
get_sub_artefakte, kanban_reset, delete_guide_board,
|
||||||
)
|
)
|
||||||
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 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
|
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
|
||||||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
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
|
||||||
from learning import block_chat, block_discussion, create_block_element, 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
|
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
|
||||||
from guide import generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
|
|
||||||
from pipeline import cancel_guide
|
from pipeline import cancel_guide
|
||||||
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
|
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
|
||||||
from models import (
|
from models import (
|
||||||
GuideCreateRequest, GuideResponse,
|
GuideCreateRequest, GuideResponse,
|
||||||
TopicCreateRequest,
|
TopicCreateRequest,
|
||||||
BlocksCreateRequest, BlocksResetStageRequest, BlocksStatusResponse,
|
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse,
|
||||||
|
GuideCardResetRequest,
|
||||||
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
|
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
|
||||||
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
|
||||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
|
||||||
ElementRefineRequest, ElementRefineResponse,
|
|
||||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||||
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
||||||
BlockChatRequest, BlockChatResponse,
|
BlockChatRequest, BlockChatResponse,
|
||||||
@@ -177,14 +173,14 @@ async def get_blocks_board(topic: str):
|
|||||||
snap["generating"] = status["generating"]
|
snap["generating"] = status["generating"]
|
||||||
snap["progress"] = status["progress"]
|
snap["progress"] = status["progress"]
|
||||||
snap["error"] = status["error"]
|
snap["error"] = status["error"]
|
||||||
snap["agents"] = [{"label": 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"]}
|
||||||
for a in active_agents(f"blocks-{topic}-")]
|
for a in active_agents(f"blocks-{topic}-")]
|
||||||
return snap
|
return snap
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blocks/agents")
|
@router.get("/blocks/agents")
|
||||||
async def get_blocks_agents(topic: str):
|
async def get_blocks_agents(topic: str):
|
||||||
return [{"label": a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
return [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
||||||
for a in active_agents(f"blocks-{topic}-")]
|
for a in active_agents(f"blocks-{topic}-")]
|
||||||
|
|
||||||
|
|
||||||
@@ -214,6 +210,28 @@ async def requeue_blocks_dead(topic: str):
|
|||||||
return {"ok": True, "requeued": await requeue_dead(topic)}
|
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")
|
@router.post("/blocks/cancel")
|
||||||
async def cancel_blocks_route(topic: str):
|
async def cancel_blocks_route(topic: str):
|
||||||
if not cancel_blocks(topic):
|
if not cancel_blocks(topic):
|
||||||
@@ -361,11 +379,10 @@ def _color(points: int) -> str:
|
|||||||
async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
|
async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
|
||||||
"""Book score+streak drift-free (lock + open-question/open-streak anchor). Tier →
|
"""Book score+streak drift-free (lock + open-question/open-streak anchor). Tier →
|
||||||
points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
|
points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
|
||||||
from the base (delayed unlock at the level threshold); element once from beginner level.
|
from the base (delayed unlock at the level threshold).
|
||||||
Re-rating of the same question uses the open streak anchor → idempotent."""
|
Re-rating of the same question uses the open streak anchor → idempotent."""
|
||||||
async with _check_lock(req.topic, req.block):
|
async with _check_lock(req.topic, req.block):
|
||||||
state = await get_block_progress(req.topic, req.block)
|
state = await get_block_progress(req.topic, req.block)
|
||||||
was_level = state["completed"] is not None # element guard: ever created already?
|
|
||||||
basis, re_rating = _basis(state, question)
|
basis, re_rating = _basis(state, question)
|
||||||
streak_basis = state["offene_streak"] if re_rating else state["streak"]
|
streak_basis = state["offene_streak"] if re_rating else state["streak"]
|
||||||
if not re_rating:
|
if not re_rating:
|
||||||
@@ -378,10 +395,6 @@ async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int])
|
|||||||
score = compute_score(basis, d, floor, ca, cf)
|
score = compute_score(basis, d, floor, ca, cf)
|
||||||
points = score - basis
|
points = score - basis
|
||||||
good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
|
good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
|
||||||
# Create the learning element once, as soon as the first level (beginner) is reached.
|
|
||||||
if not was_level and level_from_score(score, cf) is not None:
|
|
||||||
if await set_block_completed(req.topic, req.block):
|
|
||||||
asyncio.create_task(create_block_element(req.topic, req.block, req.section, req.provider))
|
|
||||||
return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
|
return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
|
||||||
|
|
||||||
|
|
||||||
@@ -571,7 +584,7 @@ async def get_guide_board(topic: str, format: str = "Guide"):
|
|||||||
snap["progress"] = guide.get("progress") if guide else None
|
snap["progress"] = guide.get("progress") if guide else None
|
||||||
snap["error"] = guide.get("error_msg") if guide else None
|
snap["error"] = guide.get("error_msg") if guide else None
|
||||||
prefix = f"{guide['id']}-" if guide else "-"
|
prefix = f"{guide['id']}-" if guide else "-"
|
||||||
snap["agents"] = [{"label": a["key"].removeprefix(prefix), "runtime": a["runtime"]}
|
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(prefix), "runtime": a["runtime"]}
|
||||||
for a in active_agents(prefix)]
|
for a in active_agents(prefix)]
|
||||||
return snap
|
return snap
|
||||||
|
|
||||||
@@ -658,82 +671,6 @@ async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
# --- Elements (personal summary) ---
|
|
||||||
|
|
||||||
@router.get("/elements", response_model=list[ElementResponse])
|
|
||||||
async def get_elements(topic: str):
|
|
||||||
return await list_elements(topic)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/elements", response_model=ElementResponse)
|
|
||||||
async def post_element(req: ElementCreateRequest):
|
|
||||||
fields = await generate_element(req.topic, req.hint, provider=req.provider)
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now}
|
|
||||||
await create_element(element)
|
|
||||||
return element
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/elements/{element_id}/chat", response_model=ElementChatResponse)
|
|
||||||
async def element_chat(element_id: str, req: ElementChatRequest):
|
|
||||||
element = await get_element(element_id)
|
|
||||||
if element is None:
|
|
||||||
raise HTTPException(404, "Element not found")
|
|
||||||
reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
|
|
||||||
return {"reply": reply, "changes": changes}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/elements/{element_id}/refine", response_model=ElementRefineResponse)
|
|
||||||
async def element_refine(element_id: str, req: ElementRefineRequest):
|
|
||||||
element = await get_element(element_id)
|
|
||||||
if element is None:
|
|
||||||
raise HTTPException(404, "Element not found")
|
|
||||||
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
|
|
||||||
if change is None:
|
|
||||||
raise HTTPException(502, "Revision failed — please try again")
|
|
||||||
return {"change": change}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/elements/{element_id}", response_model=ElementResponse)
|
|
||||||
async def put_element(element_id: str, req: ElementUpdateRequest):
|
|
||||||
if await get_element(element_id) is None:
|
|
||||||
raise HTTPException(404, "Element not found")
|
|
||||||
fields = req.model_dump(exclude_unset=True, exclude_none=True)
|
|
||||||
if fields:
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
await update_element(element_id, **fields, updated_at=now)
|
|
||||||
return await get_element(element_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/elements/{element_id}/style", response_model=ElementStyleResponse)
|
|
||||||
async def element_style(element_id: str, req: ElementCheckRequest):
|
|
||||||
element = await get_element(element_id)
|
|
||||||
if element is None:
|
|
||||||
raise HTTPException(404, "Element not found")
|
|
||||||
changes = await style_element(element, provider=req.provider)
|
|
||||||
if changes is None:
|
|
||||||
raise HTTPException(502, "Style check failed — please try again")
|
|
||||||
return {"changes": changes}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/elements/{element_id}/check", response_model=ElementCheckResponse)
|
|
||||||
async def element_check(element_id: str, req: ElementCheckRequest):
|
|
||||||
element = await get_element(element_id)
|
|
||||||
if element is None:
|
|
||||||
raise HTTPException(404, "Element not found")
|
|
||||||
suggestions = await check_element(element, provider=req.provider)
|
|
||||||
if suggestions is None:
|
|
||||||
raise HTTPException(502, "Check failed — please try again")
|
|
||||||
return {"suggestions": suggestions}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/elements/{element_id}")
|
|
||||||
async def remove_element(element_id: str):
|
|
||||||
if not await delete_element(element_id):
|
|
||||||
raise HTTPException(404, "Element not found")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/guides/{guide_id}/cancel")
|
@router.post("/guides/{guide_id}/cancel")
|
||||||
async def cancel(guide_id: str):
|
async def cancel(guide_id: str):
|
||||||
cancelled = await cancel_guide(guide_id)
|
cancelled = await cancel_guide(guide_id)
|
||||||
|
|||||||
@@ -57,26 +57,26 @@ async def board_env(testdb, tmp_path, monkeypatch):
|
|||||||
return False
|
return False
|
||||||
monkeypatch.setattr(bi, "_emb_ok", no_emb)
|
monkeypatch.setattr(bi, "_emb_ok", no_emb)
|
||||||
|
|
||||||
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns=""):
|
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
|
||||||
title = list(entries.values())[0].split(" — ")[0]
|
title = list(entries.values())[0].split(" — ")[0]
|
||||||
return {title: ["Sub Eins", "Sub Zwei"]}
|
return {title: ["Sub Eins", "Sub Zwei"]}
|
||||||
|
|
||||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns=""):
|
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl=""):
|
||||||
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
|
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
|
||||||
for s in subs} for t, subs in raw.items()}
|
for s in subs} for t, subs in raw.items()}
|
||||||
return facts, {}
|
return facts, {}
|
||||||
|
|
||||||
async def fake_levels(ctx, set_p, files, raw, instructions, ns=""):
|
async def fake_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||||
return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()}
|
return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()}
|
||||||
|
|
||||||
async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns=""):
|
async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||||
return {1: "relevant", 2: "peripheral"}
|
return {1: "relevant", 2: "peripheral"}
|
||||||
|
|
||||||
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns=""):
|
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||||
return {t: [{"subblock": subs[0]["title"], "question": f"Was ist {t}?"}]
|
return {t: [{"subblock": subs[0]["title"], "question": f"Was ist {t}?"}]
|
||||||
for t, subs in sidecar.items()}
|
for t, subs in sidecar.items()}
|
||||||
|
|
||||||
async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns=""):
|
async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||||
return {"flashcard": [{"block": t, "subblock": subs[0]["title"], "front": "F", "back": "B"}
|
return {"flashcard": [{"block": t, "subblock": subs[0]["title"], "front": "F", "back": "B"}
|
||||||
for t, subs in sidecar.items()], "example": []}
|
for t, subs in sidecar.items()], "example": []}
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypat
|
|||||||
import blocks as blx
|
import blocks as blx
|
||||||
db, ctx, files = board_env
|
db, ctx, files = board_env
|
||||||
|
|
||||||
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns=""):
|
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
|
||||||
return {}
|
return {}
|
||||||
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
|
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
|
||||||
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
|
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
|
||||||
@@ -208,3 +208,304 @@ async def test_reader_union_folds_exact_dupes(testdb):
|
|||||||
assert set(card["payload"]["readers"]) == {"r1", "r2"}
|
assert set(card["payload"]["readers"]) == {"r1", "r2"}
|
||||||
assert set(card["payload"]["sources"]) == {"s1", "s2"}
|
assert set(card["payload"]["sources"]) == {"s1", "s2"}
|
||||||
assert card["payload"]["description"] == "d länger"
|
assert card["payload"]["description"] == "d länger"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fragment-Filter: Zweitmeinung, Containment, Floor, Supplement-Reopen ────────────
|
||||||
|
|
||||||
|
def _slot_router(handlers, counter=None):
|
||||||
|
"""Fully scripted judge: first matching key-substring wins, its JSON lands at out_path."""
|
||||||
|
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||||
|
m = _PATH_RE.search(prompt)
|
||||||
|
out = None
|
||||||
|
for pat, h in handlers:
|
||||||
|
if pat in key:
|
||||||
|
if counter is not None:
|
||||||
|
counter[pat] = counter.get(pat, 0) + 1
|
||||||
|
out = h(key) if callable(h) else h
|
||||||
|
break
|
||||||
|
if m and out is not None:
|
||||||
|
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(out, f)
|
||||||
|
return "ok", payload(None)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_flow(tmp_path):
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
return SimpleNamespace(topic=TOPIC, work_dir=tmp_path, state={}, wake=asyncio.Event())
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_filter(db, ctx, tmp_path, cards):
|
||||||
|
"""Seed block cards into fragment_filter and run ONE barrier pass over them."""
|
||||||
|
for cid, p in cards:
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, cid, "block", "fragment_filter", p)
|
||||||
|
rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards]
|
||||||
|
await bi._proc_fragment_filter(ctx, _mk_flow(tmp_path), rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_votes(votes, verdict):
|
||||||
|
"""Recheck judge j∈votes returns `verdict`, the rest keep everything."""
|
||||||
|
return lambda key: verdict if key.rsplit("-j", 1)[1] in votes else {"fragments": {}, "drop": []}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
|
||||||
|
"""Judge-Demote ist nur Vorschlag — 2 Panel-Stimmen bestätigen → rejected."""
|
||||||
|
db, ctx, files = board_env
|
||||||
|
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||||
|
("-filter-recheck-", _confirm_votes({"1", "2"}, {"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"}),
|
||||||
|
])
|
||||||
|
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"
|
||||||
|
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
|
||||||
|
|
||||||
|
|
||||||
|
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)."""
|
||||||
|
db, ctx, files = board_env
|
||||||
|
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||||
|
("-filter-recheck-", _confirm_votes({"1"}, {"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"] == "grouping"
|
||||||
|
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
|
||||||
|
assert journal["ueberstimmt"] == ["Blockzitat"]
|
||||||
|
assert journal["degradiert"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_containment_autoconfirm_skips_panel(board_env, tmp_path, monkeypatch):
|
||||||
|
"""Proposal mit Namens-Containment wird deterministisch committet — ohne Recheck-Call."""
|
||||||
|
db, ctx, files = board_env
|
||||||
|
counter = {}
|
||||||
|
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||||
|
("-filter-recheck-", {"fragments": {}, "drop": []}),
|
||||||
|
("-filter-", {"fragments": {"1": 2}, "drop": []}),
|
||||||
|
], counter))
|
||||||
|
await _run_filter(db, ctx, tmp_path, [
|
||||||
|
("b-1", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
|
||||||
|
("b-2", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
|
||||||
|
])
|
||||||
|
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
|
||||||
|
assert c1["stage"] == "rejected"
|
||||||
|
assert c1["payload"]["parent_norm"] == "aufgabenlisten"
|
||||||
|
assert "-filter-recheck-" not in counter # no panel needed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_floor_vetoes_structureless_demote(board_env, tmp_path, monkeypatch):
|
||||||
|
"""Orthogonale Titel-Vektoren: bestätigter Judge-Demote ohne Containment wird vetot,
|
||||||
|
der Containment-Demote nicht."""
|
||||||
|
import numpy as np
|
||||||
|
db, ctx, files = board_env
|
||||||
|
|
||||||
|
async def emb_on(flow):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def ortho_vecs(flow, texts):
|
||||||
|
uniq = list(dict.fromkeys(texts))
|
||||||
|
eye = np.eye(max(2, len(uniq)))
|
||||||
|
pos = {t: eye[i] for i, t in enumerate(uniq)}
|
||||||
|
return np.vstack([pos[t] for t in texts])
|
||||||
|
|
||||||
|
monkeypatch.setattr(bi, "_emb_ok", emb_on)
|
||||||
|
monkeypatch.setattr(bi, "_vec_rows", ortho_vecs)
|
||||||
|
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||||
|
("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
|
||||||
|
("-filter-", {"fragments": {"1": 2, "3": 4}, "drop": []}),
|
||||||
|
]))
|
||||||
|
await _run_filter(db, ctx, tmp_path, [
|
||||||
|
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||||
|
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
|
||||||
|
("b-3", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
|
||||||
|
("b-4", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
|
||||||
|
])
|
||||||
|
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" # floor veto
|
||||||
|
assert (await db.kanban_get_card(TOPIC, B, "b-3"))["stage"] == "rejected" # containment holds
|
||||||
|
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
|
||||||
|
assert journal["floor_veto"] == ["Blockzitat"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filter_resume_no_new_calls(board_env, tmp_path, monkeypatch):
|
||||||
|
"""Zweiter Lauf über identischem Zustand resumed alle Judge-Dateien: 0 neue Calls."""
|
||||||
|
db, ctx, files = board_env
|
||||||
|
counter = {}
|
||||||
|
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||||
|
("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
|
||||||
|
("-filter-", {"fragments": {"1": 2}, "drop": []}),
|
||||||
|
], counter))
|
||||||
|
cards = [("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||||
|
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"})]
|
||||||
|
await _run_filter(db, ctx, tmp_path, cards)
|
||||||
|
first = dict(counter)
|
||||||
|
assert first["-filter-"] == 1 and first["-filter-recheck-"] == 3
|
||||||
|
await _run_filter(db, ctx, tmp_path, cards)
|
||||||
|
assert counter == first
|
||||||
|
|
||||||
|
|
||||||
|
async def test_supplement_reopens_dead_lineage(board_env, tmp_path, monkeypatch):
|
||||||
|
"""Vorschlag trifft einen wegdegradierten Titel → Lineage wird wiedereröffnet;
|
||||||
|
failed-quorum bleibt dedupt; frische Titel landen normal im ingest."""
|
||||||
|
db, ctx, files = board_env
|
||||||
|
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||||
|
("-supplement", {"blocks": [
|
||||||
|
{"title": "Blockzitate", "description": "Zitat-Syntax"},
|
||||||
|
{"title": "Leerzeilen", "description": "Trenner"},
|
||||||
|
{"title": "Neu-Konzept", "description": "fehlt kanonisch"},
|
||||||
|
]}),
|
||||||
|
]))
|
||||||
|
# dead lineage: title → cluster cl-1 → block b-cl-1 demoted as fragment
|
||||||
|
await db.kanban_add_title(TOPIC, B, "blockzitate", "Blockzitate", "d", "s1", "r1")
|
||||||
|
await db.kanban_advance(TOPIC, B, "blockzitate", "clustered")
|
||||||
|
await db.kanban_set_member(TOPIC, "blockzitate", "cl-1")
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "cl-1", "cluster", "done_cluster", {"title": "Blockzitate"})
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-cl-1", "block", "rejected",
|
||||||
|
{"title": "Blockzitate (Blockquotes)", "reason": "fragment",
|
||||||
|
"cluster": "cl-1", "parent_norm": "codeblöcke"})
|
||||||
|
# failed-quorum lineage: deliberately rejected as non-block → must stay deduped
|
||||||
|
await db.kanban_add_title(TOPIC, B, "leerzeilen", "Leerzeilen", "d", "s1", "r1")
|
||||||
|
await db.kanban_advance(TOPIC, B, "leerzeilen", "clustered")
|
||||||
|
await db.kanban_set_member(TOPIC, "leerzeilen", "cl-2")
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "cl-2", "cluster", "rejected",
|
||||||
|
{"title": "Leerzeilen", "reason": "failed-quorum"})
|
||||||
|
flow = _mk_flow(tmp_path)
|
||||||
|
flow.state["instructions"] = ""
|
||||||
|
await bi._supplement_producer(ctx, flow, ["Codeblöcke"])
|
||||||
|
reopened = await db.kanban_get_card(TOPIC, B, "blockzitate")
|
||||||
|
assert reopened["stage"] == "cluster"
|
||||||
|
assert reopened["payload"]["supplement"] is True
|
||||||
|
assert (await db.kanban_get_card(TOPIC, B, "leerzeilen"))["stage"] == "clustered"
|
||||||
|
fresh = await db.kanban_get_card(TOPIC, B, "neu-konzept")
|
||||||
|
assert fresh and fresh["stage"] == "ingest" and fresh["payload"]["supplement"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Makespan: Slot-Priorität, vorgezogene Gliederung ────────────────────────────────
|
||||||
|
|
||||||
|
def test_agent_priority_order():
|
||||||
|
"""Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub)."""
|
||||||
|
from agents import _agent_priority as p
|
||||||
|
t = "blocks-Markdown"
|
||||||
|
assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-supplement")
|
||||||
|
assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0")
|
||||||
|
< p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0")
|
||||||
|
< p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0")
|
||||||
|
< p(f"{t}-ns-subblock-c1-r2-1"))
|
||||||
|
assert p(f"{t}-supplement") < p(f"{t}-outline-1")
|
||||||
|
assert p("guide-t-writer-k1") == 16 # unmatched → after everything
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
||||||
|
"""Gliederung startet, sobald alle Karten die facts-Stage passiert haben —
|
||||||
|
parallel zu den restlichen Artefakt-Stages des langsamsten Blocks."""
|
||||||
|
import asyncio
|
||||||
|
import board_artefacts as ba
|
||||||
|
db, ctx, files = board_env
|
||||||
|
await _seed(db)
|
||||||
|
base_levels = ba._levels_block
|
||||||
|
snapshot = {}
|
||||||
|
|
||||||
|
async def slow_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||||
|
await asyncio.sleep(0.8) # keeps one card in `levels` while the outline fires
|
||||||
|
return await base_levels(ctx, set_p, files, raw, instructions, ns=ns, lbl=lbl)
|
||||||
|
|
||||||
|
base_outline = ba._outline_block
|
||||||
|
|
||||||
|
async def spy_outline(ctx, set_p, files, entries, instructions):
|
||||||
|
cards = await db.kanban_cards(TOPIC, board="artefacts", kind="ablock")
|
||||||
|
snapshot["unfinished"] = sum(1 for c in cards if c["stage"] != "done_artefact")
|
||||||
|
return await base_outline(ctx, set_p, files, entries, instructions)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ba, "_levels_block", slow_levels)
|
||||||
|
monkeypatch.setattr(ba, "_outline_block", spy_outline)
|
||||||
|
ok = await asyncio.wait_for(
|
||||||
|
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||||
|
timeout=30)
|
||||||
|
assert ok
|
||||||
|
assert snapshot["unfinished"] > 0 # outline ran while blocks were still in levels+
|
||||||
|
outline = await db.get_outline(TOPIC)
|
||||||
|
assert outline and "Kapitel 1" in outline
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outline_facts_from_payloads(board_env, tmp_path):
|
||||||
|
"""_proc_outline speist die Prereq-Hints aus den Karten-Payloads —
|
||||||
|
unabhängig vom globalen facts.json (das erst finalize schreibt)."""
|
||||||
|
import board_artefacts as ba
|
||||||
|
db, ctx, files = board_env
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block",
|
||||||
|
{"title": "Alpha", "description": "d"})
|
||||||
|
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels",
|
||||||
|
{"title": "Alpha", "facts": {"Alpha": {"sub eins": {
|
||||||
|
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
|
||||||
|
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
|
||||||
|
{"title": "Gliederung"})
|
||||||
|
flow = _mk_flow(tmp_path)
|
||||||
|
await ba._proc_outline(ctx, flow, files, "", [{"card_id": "outline", "payload": {}}])
|
||||||
|
merged = json.loads((tmp_path / "outline-facts.json").read_text(encoding="utf-8"))
|
||||||
|
assert merged["Alpha"]["sub eins"]["prerequisites"] == "Beta zuerst"
|
||||||
|
assert (await db.kanban_get_card(TOPIC, "artefacts", "outline"))["stage"] == "done_artefact"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_card_view_stepper(testdb):
|
||||||
|
"""Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar."""
|
||||||
|
r = {"board": "artefacts", "card_id": "alpha", "stage": "facts", "retries": 0,
|
||||||
|
"payload": {"title": "Alpha"}}
|
||||||
|
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Facts check 1/2…", "step": "Facts check"}})
|
||||||
|
assert v["status"] == "active" and v["info"] == "Facts check 1/2…"
|
||||||
|
assert v["step_i"] == 2 and v["step_n"] == 3 and v["steps"][0] == "Facts find"
|
||||||
|
# legacy plain-string live info → no stepper, no crash
|
||||||
|
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"})
|
||||||
|
assert v2["info"] == "Facts find 0/1…" and "step_n" not in v2
|
||||||
|
# step outside the card's stage group (e.g. supplement note) → no stepper
|
||||||
|
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Subblocks find"}})
|
||||||
|
assert "step_n" not in v3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_map_resolves_cascade(testdb):
|
||||||
|
"""Seeds folgen der Redirect-Kette bis zum lebenden Block; Zyklen/Dead-Ends verfallen."""
|
||||||
|
import board_artefacts as ba
|
||||||
|
db = testdb
|
||||||
|
# chain: fragment → grouped member → living umbrella (with self-edge Listen→Listen)
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "rejected",
|
||||||
|
{"title": "Blockzitate (Blockquotes)", "reason": "fragment",
|
||||||
|
"parent_norm": "eingerückte codeblöcke"})
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-2", "block", "grouped",
|
||||||
|
{"title": "Eingerückte Codeblöcke", "merged_into": "Codeblöcke"})
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-3", "block", "done_block",
|
||||||
|
{"title": "Codeblöcke", "mirrored_norm": "codeblöcke"})
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-4", "block", "grouped",
|
||||||
|
{"title": "Listen", "merged_into": "Listen"})
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-5", "block", "done_block",
|
||||||
|
{"title": "Listen", "mirrored_norm": "listen"})
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-6", "block", "rejected",
|
||||||
|
{"title": "Aufgabenlisten", "reason": "fragment", "parent_norm": "listen"})
|
||||||
|
# cycle: a → b → a, neither alive
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-7", "block", "rejected",
|
||||||
|
{"title": "A-Ding", "reason": "fragment", "parent_norm": "b-ding"})
|
||||||
|
await db.kanban_upsert_card(TOPIC, B, "b-8", "block", "rejected",
|
||||||
|
{"title": "B-Ding", "reason": "fragment", "parent_norm": "a-ding"})
|
||||||
|
seeds = await ba._seed_map(TOPIC)
|
||||||
|
# grouped umbrella members become seeds of their living target too (whole absorbed topics)
|
||||||
|
assert {k: sorted(v) for k, v in seeds.items()} == {
|
||||||
|
"codeblöcke": ["Blockzitate (Blockquotes)", "Eingerückte Codeblöcke"],
|
||||||
|
"listen": ["Aufgabenlisten", "Listen"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_block_functions_accept_wrapper_kwargs():
|
||||||
|
"""Die board_artefacts-Wrapper übergeben ns/lbl (subblocks auch seeds) — ein fehlender
|
||||||
|
Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur)."""
|
||||||
|
import inspect
|
||||||
|
import blocks as blx
|
||||||
|
for fn in ("_subblocks_block", "_facts_block", "_levels_block", "_relevance_block",
|
||||||
|
"_question_pattern_block", "_artefacts_block"):
|
||||||
|
params = inspect.signature(getattr(blx, fn)).parameters
|
||||||
|
assert "ns" in params and "lbl" in params, fn
|
||||||
|
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
|
||||||
|
|||||||
174
backend/tests/test_events.py
Normal file
174
backend/tests/test_events.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""Event-Tracking (events-Tabelle) + Agenten-Labels."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import agents
|
||||||
|
from pipeline import GenContext
|
||||||
|
|
||||||
|
TOPIC = "t"
|
||||||
|
|
||||||
|
|
||||||
|
async def _events(db, kind=None):
|
||||||
|
conn = await db.get_db()
|
||||||
|
q = "SELECT topic, kind, key, label, status, dur_ms, wait_ms FROM events WHERE topic = ?"
|
||||||
|
args = [TOPIC]
|
||||||
|
if kind:
|
||||||
|
q += " AND kind = ?"
|
||||||
|
args.append(kind)
|
||||||
|
cur = await conn.execute(q, args)
|
||||||
|
return [dict(zip(("topic", "kind", "key", "label", "status", "dur_ms", "wait_ms"), r))
|
||||||
|
for r in await cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_advance_many_writes_stage_events(testdb):
|
||||||
|
db = testdb
|
||||||
|
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||||
|
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
|
||||||
|
await db.kanban_advance_many(TOPIC, "inventory", [("a", "s2"), ("b", "s2")])
|
||||||
|
evs = await _events(db, "stage")
|
||||||
|
assert {(e["key"], e["status"]) for e in evs} == {("inventory:a", "s2"), ("inventory:b", "s2")}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fail_card_events_retry_then_dead(testdb):
|
||||||
|
db = testdb
|
||||||
|
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||||
|
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is False
|
||||||
|
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is True
|
||||||
|
evs = await _events(db, "fail")
|
||||||
|
assert [e["status"] for e in evs] == ["retry1", "dead"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guide_stage_event(testdb):
|
||||||
|
db = testdb
|
||||||
|
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
|
||||||
|
await db.set_guide_card(TOPIC, "Guide", "alpha", stage="writer")
|
||||||
|
evs = await _events(db, "stage")
|
||||||
|
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and evs[-1]["status"] == "writer"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_agent_emits_event_and_survives_broken_sink(testdb, monkeypatch):
|
||||||
|
recorded = []
|
||||||
|
|
||||||
|
async def sink(**kw):
|
||||||
|
recorded.append(kw)
|
||||||
|
|
||||||
|
async def fake_cli(agent_key, prompt, timeout, model, capabilities, label=""):
|
||||||
|
return 0, "out", ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(agents, "on_event", sink)
|
||||||
|
monkeypatch.setattr(agents, "_run_claude_cli", fake_cli)
|
||||||
|
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
|
||||||
|
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("claude", "test-model"))
|
||||||
|
rc, out, err = await agents.run_agent("blocks-t-x", "p", 5, provider="claude",
|
||||||
|
role="judge", scope=TOPIC, label="Alpha · Judge")
|
||||||
|
assert rc == 0
|
||||||
|
assert recorded and recorded[0]["kind"] == "agent"
|
||||||
|
assert recorded[0]["label"] == "Alpha · Judge" and recorded[0]["status"] == "ok"
|
||||||
|
assert isinstance(recorded[0]["wait_ms"], int) and isinstance(recorded[0]["dur_ms"], int)
|
||||||
|
|
||||||
|
# broken sink never breaks the call; interactive/scope-less calls don't log
|
||||||
|
async def broken(**kw):
|
||||||
|
raise RuntimeError("sink down")
|
||||||
|
monkeypatch.setattr(agents, "on_event", broken)
|
||||||
|
rc, _, _ = await agents.run_agent("blocks-t-y", "p", 5, provider="claude", scope=TOPIC)
|
||||||
|
assert rc == 0
|
||||||
|
monkeypatch.setattr(agents, "on_event", sink)
|
||||||
|
recorded.clear()
|
||||||
|
await agents.run_agent("chat-1", "p", 5, provider="claude", lane="interactive")
|
||||||
|
assert recorded == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_active_agents_carry_labels():
|
||||||
|
async def run(key, label):
|
||||||
|
return await agents._communicate(key, ["sleep", "0.4"], None, 5, label=label)
|
||||||
|
|
||||||
|
t1 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 1"))
|
||||||
|
t2 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 2")) # key collision → ~2
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
agents_now = agents.active_agents("blocks-t-")
|
||||||
|
assert sorted(a["label"] for a in agents_now) == ["Alpha · Facts 1", "Alpha · Facts 2"]
|
||||||
|
assert {a["key"] for a in agents_now} == {"blocks-t-x", "blocks-t-x~2"}
|
||||||
|
await asyncio.gather(t1, t2)
|
||||||
|
assert agents.active_agents("blocks-t-") == []
|
||||||
|
|
||||||
|
async def test_pull_prefers_bigger_blocks(testdb):
|
||||||
|
"""LPT: Karten mit größerem subs_n werden zuerst gezogen; ohne Feld bleibt FIFO."""
|
||||||
|
db = testdb
|
||||||
|
await db.kanban_upsert_card(TOPIC, "artefacts", "klein", "ablock", "facts", {"subs_n": 5})
|
||||||
|
await db.kanban_upsert_card(TOPIC, "artefacts", "gross", "ablock", "facts", {"subs_n": 40})
|
||||||
|
await db.kanban_upsert_card(TOPIC, "artefacts", "mittel", "ablock", "facts", {"subs_n": 15})
|
||||||
|
pulled = await db.kanban_pull(TOPIC, "artefacts", "facts", 10)
|
||||||
|
assert [c["card_id"] for c in pulled] == ["gross", "mittel", "klein"]
|
||||||
|
# ohne subs_n: FIFO nach updated_at
|
||||||
|
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||||
|
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
|
||||||
|
pulled = await db.kanban_pull(TOPIC, "inventory", "s1", 10)
|
||||||
|
assert [c["card_id"] for c in pulled] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_learnstate_smoke(testdb):
|
||||||
|
"""Regression: P5-Ausbau hatte die _LEVEL_CASE-Konstante mitgerissen —
|
||||||
|
load_learnstate (Guide-Start-Pfad) muss ohne NameError laufen."""
|
||||||
|
from rules import load_learnstate
|
||||||
|
guides, progress, levels = await load_learnstate()
|
||||||
|
assert isinstance(levels, dict)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guide_error_event(testdb):
|
||||||
|
db = testdb
|
||||||
|
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
|
||||||
|
await db.set_guide_card(TOPIC, "Guide", "alpha", status="error", gate_info="Writer ohne Ergebnis")
|
||||||
|
evs = await _events(db, "fail")
|
||||||
|
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and "Writer" in evs[-1]["status"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_calibration_smoke():
|
||||||
|
from pipeline import _timeout
|
||||||
|
assert _timeout("subblock", 10) == 400 + 150
|
||||||
|
assert _timeout("content", 10) == 450 + 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_file_wins(tmp_path, monkeypatch):
|
||||||
|
"""Regression: geerbte (veraltete) Env-Werte dürfen die .env nicht mehr überstimmen."""
|
||||||
|
import config
|
||||||
|
monkeypatch.setenv("X_CREATOR_TESTKEY", "alt")
|
||||||
|
p = tmp_path / ".env"
|
||||||
|
p.write_text("X_CREATOR_TESTKEY=neu\n", encoding="utf-8")
|
||||||
|
config._load_env(p)
|
||||||
|
import os
|
||||||
|
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"] == "subblocks"
|
||||||
|
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 (fakten_gate) behält md
|
||||||
|
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
|
||||||
|
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||||
|
assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"]
|
||||||
@@ -66,3 +66,96 @@ async def test_done_step(testdb):
|
|||||||
assert await gb.done_step(TOPIC, FMT) == 3 # bis fakten_gate fertig
|
assert await gb.done_step(TOPIC, FMT) == 3 # bis fakten_gate fertig
|
||||||
await db.set_guide_card(TOPIC, FMT, "a", stage="done")
|
await db.set_guide_card(TOPIC, FMT, "a", stage="done")
|
||||||
assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES)
|
assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_card_sets_and_clears_live_info(testdb, monkeypatch):
|
||||||
|
"""Regression: _live nutzte env.format_name (existiert nicht) → AttributeError beim
|
||||||
|
ersten Stage-Start. Treibt eine Karte durch _run_card mit Fake-Stage."""
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import guide_board as gb
|
||||||
|
db = testdb
|
||||||
|
await db.upsert_guide_card("t", "Guide", "alpha", "Alpha")
|
||||||
|
env = SimpleNamespace(ctx=None, guide_id="g-live", topic="t", format="Guide")
|
||||||
|
card = {"block_norm": "alpha", "block": "Alpha", "stage": "lernziele", "status": "open"}
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_stage(env2, card2):
|
||||||
|
seen.update(dict(gb._live_info))
|
||||||
|
card2["stage"] = "done"
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(gb, "_STAGE_FN", {"lernziele": fake_stage})
|
||||||
|
await gb._run_card(env, card, asyncio.Semaphore(1))
|
||||||
|
assert card["stage"] == "done"
|
||||||
|
assert ("t", "Guide", "alpha") in seen # live info stand während der Stage
|
||||||
|
assert ("t", "Guide", "alpha") not in gb._live_info # und wurde aufgeräumt
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_split_sections_one_section_all_markers():
|
||||||
|
import guide_board as gb
|
||||||
|
from textkit import _parse_fragment
|
||||||
|
a = _parse_fragment("""<!-- section: Front Matter -->
|
||||||
|
<!-- compact -->
|
||||||
|
Kurzer Einstieg kompakt.
|
||||||
|
<!-- sub: beginner | YAML-Basics -->
|
||||||
|
YAML kompakt.
|
||||||
|
<!-- ausführlich -->
|
||||||
|
Einstieg ausführlich.
|
||||||
|
<!-- sub: beginner | YAML-Basics -->
|
||||||
|
YAML ausführlich.""")[0]
|
||||||
|
b = _parse_fragment("""<!-- section: Front Matter (Teil 2) -->
|
||||||
|
<!-- compact -->
|
||||||
|
<!-- sub: advanced | TOML-Sektionen -->
|
||||||
|
TOML kompakt.
|
||||||
|
<!-- ausführlich -->
|
||||||
|
Unerwünschter zweiter Einstieg.
|
||||||
|
<!-- sub: advanced | TOML-Sektionen -->
|
||||||
|
TOML ausführlich.""")[0]
|
||||||
|
merged = gb._merge_split_sections(a, b)
|
||||||
|
secs = _parse_fragment(merged)
|
||||||
|
assert len(secs) == 1
|
||||||
|
sec = secs[0]
|
||||||
|
assert sec["title"] == "Front Matter"
|
||||||
|
assert [s["title"] for s in sec["subs"]] == ["YAML-Basics", "TOML-Sektionen"]
|
||||||
|
assert sec["anchor"] == "Einstieg ausführlich." # Teil-B-Einstieg verworfen
|
||||||
|
assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path):
|
||||||
|
import guide_board as gb
|
||||||
|
from types import SimpleNamespace
|
||||||
|
db = testdb
|
||||||
|
await db.upsert_guide_card("t", "Guide", "gross", "Gross")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||||
|
calls.append(label)
|
||||||
|
part = "2" if key.endswith("-b") else "1"
|
||||||
|
p = tmp_path / f"out-{key[-1]}.md"
|
||||||
|
p.write_text(f"<!-- section: Gross -->\n<!-- ausführlich -->\n"
|
||||||
|
+ ("Einstieg.\n" if part == "1" else "")
|
||||||
|
+ f"<!-- sub: beginner | Sub {part} -->\nText {part}.", encoding="utf-8")
|
||||||
|
# payload liest die ECHTE Slot-Datei — wir schreiben direkt an deren Pfad
|
||||||
|
import re as _re
|
||||||
|
m = _re.search(r"(/\S+\.md)", prompt)
|
||||||
|
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||||
|
f.write(p.read_text(encoding="utf-8"))
|
||||||
|
return "ok", payload(None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||||
|
subs = [{"title": f"Sub {i}", "level": "beginner", "relevance": "relevant"} for i in range(31)]
|
||||||
|
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
|
||||||
|
guide_id="g", topic="t", format="Guide", instructions="",
|
||||||
|
subs_by_title={"Gross": subs}, spec="",
|
||||||
|
slot=lambda name: tmp_path / name)
|
||||||
|
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "")
|
||||||
|
card = {"block_norm": "gross", "block": "Gross", "stage": "writer", "status": "open",
|
||||||
|
"writer_rounds": 0, "gate_info": "", "md": "", "chapter": "K1"}
|
||||||
|
ok = await gb._stage_writer(env, card)
|
||||||
|
assert ok is True
|
||||||
|
assert [c for c in calls if "(1/2)" in c] and [c for c in calls if "(2/2)" in c]
|
||||||
|
from textkit import _parse_fragment
|
||||||
|
secs = _parse_fragment(card["md"])
|
||||||
|
assert len(secs) == 1 and [s["title"] for s in secs[0]["subs"]] == ["Sub 1", "Sub 2"]
|
||||||
|
assert card["stage"] == "fakten_gate"
|
||||||
|
|||||||
295
backend/tests/test_subblocks.py
Normal file
295
backend/tests/test_subblocks.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import blocks as blx
|
||||||
|
from pipeline import GenContext
|
||||||
|
|
||||||
|
TOPIC = "t"
|
||||||
|
_MD_PATH = re.compile(r"(/\S+\.md)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _sims(pairs, n):
|
||||||
|
m = np.eye(n)
|
||||||
|
for i, j, v in pairs:
|
||||||
|
m[i][j] = m[j][i] = v
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def test_variant_clusters_folds_paraphrases():
|
||||||
|
titles = ["Harte Umbrüche brauchen Marker", "Harte Umbrüche erfordern explizite Marker!",
|
||||||
|
"Tabs werden expandiert"]
|
||||||
|
cl = blx._variant_clusters(titles, [1, 1, 1], _sims([(0, 1, 0.95)], 3))
|
||||||
|
by_rep = {c["rep"]: c for c in cl}
|
||||||
|
assert by_rep[1]["mentions"] == 2 and sorted(by_rep[1]["members"]) == [0, 1] # longest wins
|
||||||
|
assert by_rep[2]["mentions"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_variant_clusters_negation_guard():
|
||||||
|
titles = ["Fenced können Absätze unterbrechen", "Fenced können Absätze nicht unterbrechen"]
|
||||||
|
cl = blx._variant_clusters(titles, [1, 1], _sims([(0, 1, 0.95)], 2))
|
||||||
|
assert len(cl) == 2 # antonyms never merge, no matter the cosine
|
||||||
|
|
||||||
|
|
||||||
|
# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────
|
||||||
|
|
||||||
|
def _fake_sims(texts):
|
||||||
|
"""Markertoken matrix: same first word → 0.95, else 0."""
|
||||||
|
n = len(texts)
|
||||||
|
m = np.eye(n)
|
||||||
|
key = lambda t: t.split()[0].casefold()
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(n):
|
||||||
|
if i != j and key(texts[i]) == key(texts[j]):
|
||||||
|
m[i][j] = 0.95
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_race(finder_by_agent):
|
||||||
|
"""Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
|
||||||
|
catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
|
||||||
|
prompts = []
|
||||||
|
|
||||||
|
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
||||||
|
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
|
||||||
|
outs = []
|
||||||
|
for slot in slots:
|
||||||
|
key, prompt = slot["key"], slot["prompt"]
|
||||||
|
prompts.append((key, prompt))
|
||||||
|
text = None
|
||||||
|
if "-subblock-final-" in key:
|
||||||
|
kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
|
||||||
|
subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
|
||||||
|
if l.startswith("- ") and l != "- (keiner)"]
|
||||||
|
if subs:
|
||||||
|
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||||
|
elif "-r1-" in key:
|
||||||
|
agent = int(key.rsplit("-", 1)[1])
|
||||||
|
subs = finder_by_agent.get(agent) or []
|
||||||
|
if subs:
|
||||||
|
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||||
|
if text is not None and (m := _MD_PATH.search(prompt)):
|
||||||
|
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
outs.append(slot["payload"](None))
|
||||||
|
outs = [o for o in outs if o]
|
||||||
|
return outs or None
|
||||||
|
return fake_race, prompts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sub_env(testdb, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
|
||||||
|
monkeypatch.setattr(blx.embedding, "available", lambda: True)
|
||||||
|
monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
|
||||||
|
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||||
|
files = {"arbeit": tmp_path}
|
||||||
|
return testdb, ctx, files
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
|
||||||
|
fake, prompts = _mk_race(finder_by_agent)
|
||||||
|
monkeypatch.setattr(blx, "_race", fake)
|
||||||
|
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||||
|
"", wipe=False, ns="x-", seeds=seeds)
|
||||||
|
return raw, prompts
|
||||||
|
|
||||||
|
|
||||||
|
async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
|
||||||
|
"""3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
|
||||||
|
nicht als „Unsicher" ins Panel."""
|
||||||
|
db, ctx, files = sub_env
|
||||||
|
raw, prompts = await _run(ctx, files, monkeypatch, {
|
||||||
|
1: ["Umbruch braucht Marker"],
|
||||||
|
2: ["Umbruch erfordert explizite Marker!"],
|
||||||
|
3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
|
||||||
|
})
|
||||||
|
assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]} # longest = rep
|
||||||
|
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||||
|
status = sorted(r["status"] for r in rows)
|
||||||
|
assert status == ["consensus", "variant", "variant"]
|
||||||
|
clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
|
||||||
|
assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_promotes_single_find(sub_env, monkeypatch):
|
||||||
|
"""Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
|
||||||
|
db, ctx, files = sub_env
|
||||||
|
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||||
|
1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
|
||||||
|
2: ["Alpha Grundlagen"],
|
||||||
|
}, seeds=["Zeilenumbruch Regeln"])
|
||||||
|
assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
|
||||||
|
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||||
|
if r["sub_norm"] == "zeilenumbruch regeln im detail")
|
||||||
|
assert row["status"] == "consensus"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
|
||||||
|
"""Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
|
||||||
|
db, ctx, files = sub_env
|
||||||
|
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||||
|
1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
|
||||||
|
}, seeds=["Fußnoten Syntax"])
|
||||||
|
assert "Fußnoten Syntax" in raw["Alpha"]
|
||||||
|
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||||
|
if r["sub_title"] == "Fußnoten Syntax")
|
||||||
|
assert row["status"] == "consensus"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
|
||||||
|
"""Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
|
||||||
|
db, ctx, files = sub_env
|
||||||
|
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||||
|
1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
|
||||||
|
}, seeds=["Tabs"])
|
||||||
|
assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
|
||||||
|
"""Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
|
||||||
|
db, ctx, files = sub_env
|
||||||
|
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||||
|
first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||||
|
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||||
|
second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
|
||||||
|
"""Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
|
||||||
|
Konsens wächst; zweite Runde ohne Neues → Ende."""
|
||||||
|
db, ctx, files = sub_env
|
||||||
|
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||||
|
base = fake
|
||||||
|
hit = {"n": 0}
|
||||||
|
|
||||||
|
async def with_catchup(topic, label, slots, *a, **k):
|
||||||
|
if any("-subblock-x" in s["key"] for s in slots):
|
||||||
|
hit["n"] += 1
|
||||||
|
if hit["n"] == 1: # first catch-up round: both agents agree on one new sub
|
||||||
|
for slot in slots[:2]:
|
||||||
|
m = _MD_PATH.search(slot["prompt"])
|
||||||
|
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||||
|
f.write("<!-- block: Alpha -->\n- Vertiefung der Konzepte")
|
||||||
|
return [slot["payload"](None) for slot in slots[:2]]
|
||||||
|
return None
|
||||||
|
return await base(topic, label, slots, *a, **k)
|
||||||
|
|
||||||
|
monkeypatch.setattr(blx, "_race", with_catchup)
|
||||||
|
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||||
|
"", wipe=False, ns="x-")
|
||||||
|
assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
|
||||||
|
assert (tmp_path / "subblock-final-c1-x1.md").exists()
|
||||||
|
assert hit["n"] == 2 # round 2 ran, found nothing, loop ended
|
||||||
|
|
||||||
|
|
||||||
|
# ── Outline-Review ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_outline_review_schema():
|
||||||
|
valid = {1, 2, 3, 4, 5, 6}
|
||||||
|
ok = blx._outline_review_schema({"moves": {"3": 2}}, valid, 2, 6)
|
||||||
|
assert ok == {3: 2}
|
||||||
|
assert blx._outline_review_schema({"moves": {}}, valid, 2, 6) == {}
|
||||||
|
assert blx._outline_review_schema({"moves": {"9": 1}}, valid, 2, 6) is None # unknown block
|
||||||
|
assert blx._outline_review_schema({"moves": {"1": 5}}, valid, 2, 6) is None # chapter range
|
||||||
|
assert blx._outline_review_schema({"moves": {"1": 2, "2": 2, "3": 2}}, valid, 2, 6) is None # mass move
|
||||||
|
assert blx._outline_review_schema({"chapters": []}, valid, 2, 6) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
|
||||||
|
"""Review verschiebt einen fehlplatzierten Block; kaputtes Review lässt den Plan unverändert."""
|
||||||
|
entries = {i: f"Block {i} — d" for i in range(1, 7)}
|
||||||
|
slots = [tmp_path / f"outline-{i}.json" for i in (1, 2, 3)]
|
||||||
|
plan_a = {"chapters": [{"title": "K1", "numbers": [1, 2, 6]}, {"title": "K2", "numbers": [3, 4, 5]}]}
|
||||||
|
for p in slots[:2]:
|
||||||
|
p.write_text(json.dumps(plan_a), encoding="utf-8")
|
||||||
|
files = {"arbeit": tmp_path, "outline": tmp_path / "outline.json", "outline_slots": slots,
|
||||||
|
"facts": tmp_path / "facts.json"}
|
||||||
|
|
||||||
|
review_out = {"val": {"moves": {"6": 2}}}
|
||||||
|
|
||||||
|
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||||
|
out = None
|
||||||
|
if key.endswith("outline-prereqs"):
|
||||||
|
out = {"prereqs": {}}
|
||||||
|
elif key.endswith("outline-judge"):
|
||||||
|
out = plan_a
|
||||||
|
elif key.endswith("outline-review"):
|
||||||
|
out = review_out["val"]
|
||||||
|
if out is not None:
|
||||||
|
m = re.search(r"(/\S+\.json)", prompt)
|
||||||
|
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(out, f)
|
||||||
|
return "ok", payload(None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
|
||||||
|
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||||
|
plan = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||||
|
assert plan["chapters"][0]["numbers"] == [1, 2]
|
||||||
|
assert plan["chapters"][1]["numbers"] == [3, 4, 5, 6]
|
||||||
|
|
||||||
|
# broken review (mass move) → schema rejects, plan unchanged
|
||||||
|
review_out["val"] = {"moves": {"1": 2, "2": 2, "3": 1}}
|
||||||
|
(tmp_path / "outline-review.json").unlink()
|
||||||
|
(tmp_path / "outline.json").unlink()
|
||||||
|
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||||
|
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
|
||||||
|
|
||||||
|
async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
|
||||||
|
"""Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
|
||||||
|
Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
|
||||||
|
db, ctx, files = sub_env
|
||||||
|
base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})
|
||||||
|
|
||||||
|
async def with_r2(topic, label, slots, *a, **k):
|
||||||
|
if any("-r2-" in s["key"] for s in slots):
|
||||||
|
outs = []
|
||||||
|
for slot in slots[:2]:
|
||||||
|
m = _MD_PATH.search(slot["prompt"])
|
||||||
|
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||||
|
f.write("<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!")
|
||||||
|
outs.append(slot["payload"](None))
|
||||||
|
return outs
|
||||||
|
return await base_fake(topic, label, slots, *a, **k)
|
||||||
|
|
||||||
|
monkeypatch.setattr(blx, "_race", with_r2)
|
||||||
|
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||||
|
"", wipe=False, ns="x-")
|
||||||
|
assert raw["Alpha"] # Konsens steht
|
||||||
|
assert not any("-r3-" in k for k, _ in prompts) # Paraphrase hielt die Schleife NICHT am Leben
|
||||||
|
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||||
|
assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
|
||||||
|
"""Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
|
||||||
|
db, ctx, files = sub_env
|
||||||
|
_, prompts = _mk_race({})
|
||||||
|
|
||||||
|
async def endless(topic, label, slots, *a, **k):
|
||||||
|
if "-subblock-final-" in slots[0]["key"]:
|
||||||
|
return None # panel fails → consensus fallback
|
||||||
|
outs = []
|
||||||
|
import re as _re
|
||||||
|
rn = _re.search(r"-r(\d+)-", slots[0]["key"])
|
||||||
|
n = rn.group(1) if rn else "x"
|
||||||
|
for slot in slots[:2]:
|
||||||
|
m = _MD_PATH.search(slot["prompt"])
|
||||||
|
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig")
|
||||||
|
prompts.append((slot["key"], slot["prompt"]))
|
||||||
|
outs.append(slot["payload"](None))
|
||||||
|
return outs
|
||||||
|
|
||||||
|
monkeypatch.setattr(blx, "_race", endless)
|
||||||
|
await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||||
|
"", wipe=False, ns="x-")
|
||||||
|
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
|
||||||
|
assert max_round == blx.SUBBLOCK_MAX_ROUNDS
|
||||||
113
dev-ops/opencode-slim.json
Normal file
113
dev-ops/opencode-slim.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Auto-Ableitung von opencode.json OHNE mcp-Server: Batch-Agenten (files/readonly/text)
|
||||||
|
// brauchen keine Web-MCPs — jeder opencode-Prozess startet sonst ~3 MCP-Prozesse (~300 MB).
|
||||||
|
// Bei Änderungen an opencode.json hier nachziehen (nur der mcp-Block fehlt).
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"minimax": {
|
||||||
|
"options": {
|
||||||
|
"apiKey": "{env:MINIMAX_API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"MiniMax-M3": {
|
||||||
|
"name": "MiniMax M3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimax-kalt": {
|
||||||
|
"npm": "@ai-sdk/anthropic",
|
||||||
|
"name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "https://api.minimax.io/anthropic/v1",
|
||||||
|
"apiKey": "{env:MINIMAX_API_KEY}"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"MiniMax-M3": {
|
||||||
|
"name": "MiniMax M3 (kalt)",
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.2,
|
||||||
|
"thinking": {
|
||||||
|
"type": "disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MiniMax-M2.7-highspeed": {
|
||||||
|
"name": "MiniMax M2.7 highspeed (kalt)",
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "Ollama (lokal)",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "http://localhost:11434/v1"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"qwen3.6:27b": {
|
||||||
|
"name": "Qwen3.6 27B"
|
||||||
|
},
|
||||||
|
"qwen3.5:9b": {
|
||||||
|
"name": "Qwen3.5 9B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"full": {
|
||||||
|
"description": "Alle Tools: Dateien, Bash, Websuche",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"bash": "allow",
|
||||||
|
"webfetch": "allow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"description": "Dateien lesen/schreiben + Bash, keine Websuche",
|
||||||
|
"permission": {
|
||||||
|
"edit": "allow",
|
||||||
|
"bash": "allow",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"minimax-search*": false,
|
||||||
|
"searxng*": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"readonly": {
|
||||||
|
"description": "Nur Dateien lesen",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"bash": false,
|
||||||
|
"minimax-search*": false,
|
||||||
|
"searxng*": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"description": "Reine Textantwort, keine Tools",
|
||||||
|
"permission": {
|
||||||
|
"edit": "deny",
|
||||||
|
"bash": "deny",
|
||||||
|
"webfetch": "deny"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"write": false,
|
||||||
|
"edit": false,
|
||||||
|
"bash": false,
|
||||||
|
"read": false,
|
||||||
|
"glob": false,
|
||||||
|
"grep": false,
|
||||||
|
"minimax-search*": false,
|
||||||
|
"searxng*": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
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, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchGuideSteps, 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, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
||||||
import { usePolling } from './composables/usePolling.js'
|
import { usePolling } from './composables/usePolling.js'
|
||||||
import TopicSidebar from './components/TopicSidebar.vue'
|
import TopicSidebar from './components/TopicSidebar.vue'
|
||||||
import TopicDetail from './components/TopicDetail.vue'
|
import TopicDetail from './components/TopicDetail.vue'
|
||||||
import BlocksOverview from './components/BlocksOverview.vue'
|
import BlocksOverview from './components/BlocksOverview.vue'
|
||||||
import GuideBoard from './components/GuideBoard.vue'
|
import GenerationView from './components/GenerationView.vue'
|
||||||
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
|
|
||||||
import ElementsOverview from './components/ElementsOverview.vue'
|
|
||||||
import GeneralExamPanel from './components/GeneralExamPanel.vue'
|
import GeneralExamPanel from './components/GeneralExamPanel.vue'
|
||||||
|
|
||||||
const guides = ref([])
|
const guides = ref([])
|
||||||
@@ -28,19 +26,14 @@ const activeBlocks = ref([])
|
|||||||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||||
const providers = ref([])
|
const providers = ref([])
|
||||||
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
|
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
|
||||||
const mainView = ref('blocks') // blocks | guideboard | elements | general | detail — exclusive main-area view
|
const mainView = ref('blocks') // blocks | generation | general | detail — exclusive main-area view
|
||||||
const guideBoardFormat = ref('Guide')
|
const guideBoardFormat = ref('Guide')
|
||||||
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
|
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
|
||||||
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
|
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const progress = ref({})
|
const progress = ref({})
|
||||||
const locks = ref({}) // lock reasons per format (backend = single rule source)
|
const locks = ref({}) // lock reasons per format (backend = single rule source)
|
||||||
const guideStepsDone = ref({}) // highest finished step index per format (artifact-based)
|
|
||||||
const uiError = ref(null) // surface rejected actions (409/400)
|
const uiError = ref(null) // surface rejected actions (409/400)
|
||||||
const elementsOpen = ref(false) // right sidebar
|
|
||||||
const elementsVersion = ref(0) // increment = reload overview
|
|
||||||
const elementOpenId = ref(null) // open element from overview in sidebar
|
|
||||||
const elementOpenTick = ref(0)
|
|
||||||
|
|
||||||
// Run a loader, log + swallow its error (a failed background load must not break the UI).
|
// Run a loader, log + swallow its error (a failed background load must not break the UI).
|
||||||
async function guard(label, fn) {
|
async function guard(label, fn) {
|
||||||
@@ -172,12 +165,10 @@ async function loadBlocks() {
|
|||||||
blocks.value = await fetchBlocksStatus(selectedTopic.value)
|
blocks.value = await fetchBlocksStatus(selectedTopic.value)
|
||||||
progress.value = await fetchTopicProgress(selectedTopic.value)
|
progress.value = await fetchTopicProgress(selectedTopic.value)
|
||||||
locks.value = await fetchGuideLocks(selectedTopic.value)
|
locks.value = await fetchGuideLocks(selectedTopic.value)
|
||||||
guideStepsDone.value = await fetchGuideSteps(selectedTopic.value)
|
|
||||||
} else {
|
} else {
|
||||||
blocks.value = { ...EMPTY_BLOCKS }
|
blocks.value = { ...EMPTY_BLOCKS }
|
||||||
progress.value = {}
|
progress.value = {}
|
||||||
locks.value = {}
|
locks.value = {}
|
||||||
guideStepsDone.value = {}
|
|
||||||
}
|
}
|
||||||
if (activeBlocks.value.length && !polling.running()) startPolling()
|
if (activeBlocks.value.length && !polling.running()) startPolling()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -189,9 +180,7 @@ function selectTopic(topic) {
|
|||||||
selectedTopic.value = topic
|
selectedTopic.value = topic
|
||||||
previewGuide.value = null
|
previewGuide.value = null
|
||||||
sidebarSticky.value = false
|
sidebarSticky.value = false
|
||||||
elementsOpen.value = false
|
|
||||||
mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click)
|
mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click)
|
||||||
elementOpenId.value = null
|
|
||||||
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
|
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
|
||||||
localStorage.setItem('lastTopic', topic)
|
localStorage.setItem('lastTopic', topic)
|
||||||
loadBlocks()
|
loadBlocks()
|
||||||
@@ -273,6 +262,7 @@ async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt })
|
|||||||
}
|
}
|
||||||
await loadTopics()
|
await loadTopics()
|
||||||
selectTopic(topic)
|
selectTopic(topic)
|
||||||
|
mainView.value = 'generation' // frisches Topic: die Boards sind das Einzige, was passiert
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,10 +316,35 @@ async function handleFormatClick({ format, instructions = '', abStep = null }) {
|
|||||||
function handleOpenGuideBoard(format = 'Guide') {
|
function handleOpenGuideBoard(format = 'Guide') {
|
||||||
if (!selectedTopic.value) return
|
if (!selectedTopic.value) return
|
||||||
guideBoardFormat.value = format
|
guideBoardFormat.value = format
|
||||||
mainView.value = 'guideboard'
|
mainView.value = 'generation'
|
||||||
previewGuide.value = null
|
previewGuide.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenGeneration() {
|
||||||
|
if (!selectedTopic.value) return
|
||||||
|
mainView.value = 'generation'
|
||||||
|
previewGuide.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
async function handleGuideBoardReset({ format, abStage }) {
|
||||||
uiError.value = null
|
uiError.value = null
|
||||||
try {
|
try {
|
||||||
@@ -355,18 +370,6 @@ function handleGeneralExam() {
|
|||||||
previewGuide.value = null
|
previewGuide.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenElements() {
|
|
||||||
if (!selectedTopic.value) return
|
|
||||||
mainView.value = 'elements'
|
|
||||||
// Right sidebar stays closed — it opens only when an element is clicked.
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOpenElementDetail(el) {
|
|
||||||
elementOpenId.value = el.id
|
|
||||||
elementOpenTick.value++
|
|
||||||
elementsOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteGuide(guideId, slots = false) {
|
async function handleDeleteGuide(guideId, slots = false) {
|
||||||
await deleteGuide(guideId, slots)
|
await deleteGuide(guideId, slots)
|
||||||
if (previewGuide.value?.id === guideId) {
|
if (previewGuide.value?.id === guideId) {
|
||||||
@@ -422,7 +425,6 @@ onMounted(async () => {
|
|||||||
:stats="stats"
|
:stats="stats"
|
||||||
:fortschritt="progress"
|
:fortschritt="progress"
|
||||||
:locks="locks"
|
:locks="locks"
|
||||||
:guideStepsDone="guideStepsDone"
|
|
||||||
:uiError="uiError"
|
:uiError="uiError"
|
||||||
:doneByFormat="doneByFormat"
|
:doneByFormat="doneByFormat"
|
||||||
:latestByFormat="latestByFormat"
|
:latestByFormat="latestByFormat"
|
||||||
@@ -447,17 +449,12 @@ onMounted(async () => {
|
|||||||
@updateSource="handleUpdateSource"
|
@updateSource="handleUpdateSource"
|
||||||
@openBausteineView="handleOpenBlocksView"
|
@openBausteineView="handleOpenBlocksView"
|
||||||
@openGuideBoard="handleOpenGuideBoard"
|
@openGuideBoard="handleOpenGuideBoard"
|
||||||
@formatClick="handleFormatClick"
|
|
||||||
@bausteineClick="handleBlocksClick"
|
@bausteineClick="handleBlocksClick"
|
||||||
@cancelBlocks="handleCancelBlocks"
|
|
||||||
@resetBausteine="handleResetBlocks"
|
|
||||||
@deleteTopic="handleDeleteTopic"
|
@deleteTopic="handleDeleteTopic"
|
||||||
@cancelGuide="handleCancel"
|
|
||||||
@deleteGuide="handleDeleteGuide"
|
|
||||||
@dismissError="handleDismissError"
|
@dismissError="handleDismissError"
|
||||||
@dismissUiError="uiError = null"
|
@dismissUiError="uiError = null"
|
||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
@openElements="handleOpenElements"
|
@openGeneration="handleOpenGeneration"
|
||||||
@togglePin="toggleSidebarPin"
|
@togglePin="toggleSidebarPin"
|
||||||
@sidebarLeave="onSidebarLeave"
|
@sidebarLeave="onSidebarLeave"
|
||||||
/>
|
/>
|
||||||
@@ -469,6 +466,17 @@ onMounted(async () => {
|
|||||||
:ready="blocks.ready"
|
:ready="blocks.ready"
|
||||||
:partial="blocks.partial"
|
:partial="blocks.partial"
|
||||||
@close="mainView = 'detail'"
|
@close="mainView = 'detail'"
|
||||||
|
@openGeneration="handleOpenGeneration"
|
||||||
|
/>
|
||||||
|
<GenerationView
|
||||||
|
v-else-if="selectedTopic && mainView === 'generation'"
|
||||||
|
:topic="selectedTopic"
|
||||||
|
:generating="blocks.generating"
|
||||||
|
:progress="blocks.progress"
|
||||||
|
:ready="blocks.ready"
|
||||||
|
:partial="blocks.partial"
|
||||||
|
:guideFormat="guideBoardFormat"
|
||||||
|
@close="mainView = 'blocks'"
|
||||||
@resetStage="handleResetStage"
|
@resetStage="handleResetStage"
|
||||||
@restartAll="() => handleBlocksClick({ research: true })"
|
@restartAll="() => handleBlocksClick({ research: true })"
|
||||||
@continueAll="() => handleBlocksClick({ research: false })"
|
@continueAll="() => handleBlocksClick({ research: false })"
|
||||||
@@ -476,22 +484,13 @@ onMounted(async () => {
|
|||||||
@requeueDead="handleRequeueDead"
|
@requeueDead="handleRequeueDead"
|
||||||
@removeAll="handleResetBlocks"
|
@removeAll="handleResetBlocks"
|
||||||
@cancel="handleCancelBlocks"
|
@cancel="handleCancelBlocks"
|
||||||
/>
|
|
||||||
<GuideBoard
|
|
||||||
v-else-if="selectedTopic && mainView === 'guideboard'"
|
|
||||||
:topic="selectedTopic"
|
|
||||||
:format="guideBoardFormat"
|
|
||||||
@close="mainView = 'blocks'"
|
|
||||||
@cancelGuide="handleCancel"
|
@cancelGuide="handleCancel"
|
||||||
@startGuide="handleFormatClick"
|
@startGuide="handleFormatClick"
|
||||||
@resetStage="handleGuideBoardReset"
|
@resetGuideStage="handleGuideBoardReset"
|
||||||
@preview="handleGuideBoardPreview"
|
@preview="handleGuideBoardPreview"
|
||||||
/>
|
@deleteGuide="handleDeleteGuide"
|
||||||
<ElementsOverview
|
@restartCard="handleRestartCard"
|
||||||
v-else-if="selectedTopic && mainView === 'elements'"
|
@resetGuideCard="handleResetGuideCard"
|
||||||
:topic="selectedTopic"
|
|
||||||
:version="elementsVersion"
|
|
||||||
@open="handleOpenElementDetail"
|
|
||||||
/>
|
/>
|
||||||
<GeneralExamPanel
|
<GeneralExamPanel
|
||||||
v-else-if="selectedTopic && mainView === 'general'"
|
v-else-if="selectedTopic && mainView === 'general'"
|
||||||
@@ -505,7 +504,6 @@ onMounted(async () => {
|
|||||||
:previewGuide="previewGuide"
|
:previewGuide="previewGuide"
|
||||||
:dark="darkMode"
|
:dark="darkMode"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
:elementsOpen="elementsOpen"
|
|
||||||
:doneByFormat="doneByFormat"
|
:doneByFormat="doneByFormat"
|
||||||
:themaAbgeschlossen="!!progress.completed"
|
:themaAbgeschlossen="!!progress.completed"
|
||||||
:ansichtModus="viewMode"
|
:ansichtModus="viewMode"
|
||||||
@@ -518,20 +516,6 @@ onMounted(async () => {
|
|||||||
<div v-else class="empty-main">
|
<div v-else class="empty-main">
|
||||||
<p>Create or select a topic in the sidebar.</p>
|
<p>Create or select a topic in the sidebar.</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
v-if="elementsOpen && selectedTopic"
|
|
||||||
class="elements-backdrop"
|
|
||||||
@click="elementsOpen = false"
|
|
||||||
></div>
|
|
||||||
<ElementsSidebar
|
|
||||||
v-if="elementsOpen && selectedTopic"
|
|
||||||
:topic="selectedTopic"
|
|
||||||
:provider="provider"
|
|
||||||
:openId="elementOpenId"
|
|
||||||
:openTick="elementOpenTick"
|
|
||||||
@close="elementsOpen = false"
|
|
||||||
@changed="elementsVersion++"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -685,19 +669,4 @@ textarea::placeholder {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Only visible when the elements sidebar sits as an overlay on mobile.
|
|
||||||
A tap next to it closes it. */
|
|
||||||
.elements-backdrop {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.elements-backdrop {
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 29;
|
|
||||||
background: var(--shadow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,6 +78,24 @@ export async function addBlocksResearch(topic, provider = 'claude') {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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) {
|
export async function requeueBlocksDead(topic) {
|
||||||
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
@@ -253,68 +271,3 @@ export async function chatGuide(id, { section, outline, messages, provider = 'cl
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchElements(topic) {
|
|
||||||
const res = await fetch(`${BASE}/elements?topic=${encodeURIComponent(topic)}`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createElement(topic, hint = '', provider = 'claude') {
|
|
||||||
const res = await fetch(`${BASE}/elements`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ topic, hint, provider }),
|
|
||||||
})
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function chatElement(id, messages, provider = 'claude') {
|
|
||||||
const res = await fetch(`${BASE}/elements/${id}/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ messages, provider }),
|
|
||||||
})
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteElement(id) {
|
|
||||||
await fetch(`${BASE}/elements/${id}`, { method: 'DELETE' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateElement(id, fields) {
|
|
||||||
const res = await fetch(`${BASE}/elements/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(fields),
|
|
||||||
})
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function styleElement(id, provider = 'claude') {
|
|
||||||
const res = await fetch(`${BASE}/elements/${id}/style`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ provider }),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Stil-Exam fehlgeschlagen (${res.status})`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refineSuggestion(id, suggestion, instruction, provider = 'claude') {
|
|
||||||
const res = await fetch(`${BASE}/elements/${id}/refine`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ suggestion, instruction, provider }),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Überarbeitung fehlgeschlagen (${res.status})`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkElement(id, provider = 'claude') {
|
|
||||||
const res = await fetch(`${BASE}/elements/${id}/check`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ provider }),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Exam fehlgeschlagen (${res.status})`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
import { fetchBlocksOverview, fetchBlocksBoard } from '../api.js'
|
import { fetchBlocksOverview } from '../api.js'
|
||||||
import KanbanBoard from './KanbanBoard.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: { type: String, required: true },
|
topic: { type: String, required: true },
|
||||||
@@ -10,71 +9,21 @@ const props = defineProps({
|
|||||||
ready: { type: Boolean, default: false },
|
ready: { type: Boolean, default: false },
|
||||||
partial: { type: Boolean, default: false },
|
partial: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch', 'requeueDead', 'removeAll', 'cancel'])
|
const emit = defineEmits(['close', 'openGeneration'])
|
||||||
|
|
||||||
// ── Live-Kanban-Board (Poll 1.2s solange generiert) ────────────────────────────
|
|
||||||
// State ZUERST deklarieren: die immediate-Watches unten rufen load() synchron beim
|
|
||||||
// Setup — spätere const-Deklarationen wären dort noch TDZ (ReferenceError).
|
|
||||||
const board = ref(null)
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
|
// 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
|
let timer = null
|
||||||
let lastDone = -1
|
function startPoll() { stopPoll(); timer = setInterval(load, 5000) }
|
||||||
|
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||||
async function pollBoard() {
|
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
|
||||||
try {
|
watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true })
|
||||||
board.value = await fetchBlocksBoard(props.topic)
|
|
||||||
if (board.value.done !== lastDone) { // neue fertige Blöcke → Grid live nachladen
|
|
||||||
lastDone = board.value.done
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
} catch { /* Board noch leer */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPoll() {
|
|
||||||
stopPoll()
|
|
||||||
timer = setInterval(pollBoard, 1200)
|
|
||||||
}
|
|
||||||
function stopPoll() {
|
|
||||||
if (timer) { clearInterval(timer); timer = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.topic, () => { board.value = null; lastDone = -1; pollBoard(); load() }, { immediate: true })
|
|
||||||
watch(() => props.generating, (g) => {
|
|
||||||
if (g) startPoll()
|
|
||||||
else { stopPoll(); pollBoard(); load() } // Endstand + fertige Blöcke nachladen
|
|
||||||
}, { immediate: true })
|
|
||||||
onUnmounted(stopPoll)
|
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 boardEmpty = computed(() => !(board.value?.columns || []).some((c) => c.total > 0))
|
|
||||||
const dead = computed(() => board.value?.dead || [])
|
|
||||||
|
|
||||||
// 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', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
|
|
||||||
const sel = ref(null) // gewählte Spalte {board, key, label}
|
|
||||||
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
|
|
||||||
|
|
||||||
function stageClick(c) {
|
|
||||||
if (props.generating || !RESETTABLE.has(c.key)) return
|
|
||||||
confirm.value = null
|
|
||||||
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
|
|
||||||
}
|
|
||||||
function arm(action, fn) {
|
|
||||||
if (confirm.value === action) { confirm.value = null; fn() }
|
|
||||||
else confirm.value = action
|
|
||||||
}
|
|
||||||
function resetHere(restart) {
|
|
||||||
const s = sel.value
|
|
||||||
sel.value = null
|
|
||||||
confirm.value = null
|
|
||||||
emit('resetStage', { board: s.board, stage: s.key, restart })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
|
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
{ key: 'beginner', label: 'Beginner' },
|
{ key: 'beginner', label: 'Beginner' },
|
||||||
@@ -120,59 +69,13 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
|||||||
<button class="bk-close" title="Close" @click="emit('close')">✕</button>
|
<button class="bk-close" title="Close" @click="emit('close')">✕</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="bk-board">
|
<button v-if="generating" class="bk-banner" @click="emit('openGeneration')">
|
||||||
<div class="bk-steps-top">
|
<span class="bk-progress-dot"></span>
|
||||||
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
|
Generierung läuft{{ progress ? ' · ' + progress : '' }} — Board öffnen →
|
||||||
<div v-if="!generating" class="bk-global-actions">
|
</button>
|
||||||
<button class="bk-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
|
<button v-else-if="!ready && !partial && !items.length && !loading" class="bk-banner idle" @click="emit('openGeneration')">
|
||||||
<button v-if="partial" class="bk-act" @click="emit('continueAll')">Continue</button>
|
Noch keine Bausteine — zur Generierung →
|
||||||
<button
|
</button>
|
||||||
v-if="dead.length"
|
|
||||||
class="bk-act"
|
|
||||||
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
|
|
||||||
@click="emit('requeueDead')"
|
|
||||||
>⟳ {{ dead.length }} dead</button>
|
|
||||||
<button
|
|
||||||
v-if="ready || partial"
|
|
||||||
class="bk-act danger"
|
|
||||||
:class="{ armed: confirm === 'remove' }"
|
|
||||||
@click="arm('remove', () => emit('removeAll'))"
|
|
||||||
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="bk-global-actions">
|
|
||||||
<button class="bk-act" @click="emit('addResearch')">+ Research</button>
|
|
||||||
<button class="bk-act danger" @click="emit('cancel')">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="boardEmpty && !generating" class="bk-board-empty">No board yet — generation streams live cards through the columns here.</div>
|
|
||||||
<template v-else>
|
|
||||||
<div class="bk-board-label">Inventar</div>
|
|
||||||
<KanbanBoard
|
|
||||||
:columns="inventoryCols"
|
|
||||||
:agents="board?.agents || []"
|
|
||||||
:generating="generating"
|
|
||||||
:selectable="!generating"
|
|
||||||
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
|
|
||||||
@stageClick="stageClick"
|
|
||||||
/>
|
|
||||||
<div class="bk-board-label">Artefakte</div>
|
|
||||||
<KanbanBoard
|
|
||||||
:columns="artefactCols"
|
|
||||||
:generating="generating"
|
|
||||||
:selectable="!generating"
|
|
||||||
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
|
|
||||||
@stageClick="stageClick"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="sel && !generating" class="bk-step-actions">
|
|
||||||
<span class="bk-step-actions-label">Ab «{{ sel.label }}»:</span>
|
|
||||||
<button class="bk-act play" @click="resetHere(true)">↻ neu generieren</button>
|
|
||||||
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : '✕ nur zurücksetzen' }}</button>
|
|
||||||
<button class="bk-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div v-if="loading" class="bk-empty-state">Loading…</div>
|
<div v-if="loading" class="bk-empty-state">Loading…</div>
|
||||||
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
|
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
|
||||||
@@ -242,29 +145,6 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
|||||||
}
|
}
|
||||||
.bk-close:hover { border-color: var(--accent); }
|
.bk-close:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
/* Live board above the blocks */
|
|
||||||
.bk-board {
|
|
||||||
padding: 0.85rem 2rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
.bk-board-label {
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin: 0.5rem 0 0.3rem;
|
|
||||||
}
|
|
||||||
.bk-board-empty { color: var(--text-faint); font-size: 0.82rem; padding: 0.4rem 0; }
|
|
||||||
.bk-progress {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.bk-progress-dot {
|
.bk-progress-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -274,35 +154,27 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
|||||||
}
|
}
|
||||||
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||||
|
|
||||||
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
|
|
||||||
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
|
||||||
|
|
||||||
.bk-step-actions {
|
|
||||||
|
|
||||||
|
|
||||||
|
.bk-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.75rem;
|
margin: 0.85rem 2rem 0;
|
||||||
padding-top: 0.7rem;
|
padding: 0.55rem 0.9rem;
|
||||||
border-top: 1px dashed var(--border-strong);
|
border: 1px solid var(--accent);
|
||||||
}
|
border-radius: 8px;
|
||||||
.bk-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
|
||||||
|
|
||||||
.bk-act {
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--text);
|
color: var(--accent);
|
||||||
font-size: 0.8rem;
|
font-size: 0.84rem;
|
||||||
padding: 0.3rem 0.7rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
.bk-act:hover { border-color: var(--accent); }
|
.bk-banner.idle { border-color: var(--border-strong); color: var(--text-muted); }
|
||||||
.bk-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
.bk-banner:hover { background: var(--panel-soft); }
|
||||||
.bk-act.play:hover { background: var(--accent-hover); }
|
|
||||||
.bk-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
|
||||||
.bk-act.danger.armed { background: var(--danger); color: #fff; }
|
|
||||||
.bk-act.ghost { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.bk-empty-state {
|
.bk-empty-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { fetchElements } from '../api.js'
|
|
||||||
import { renderMarkdown, plainText } from '../markdown.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
topic: { type: String, required: true },
|
|
||||||
version: { type: Number, default: 0 }, // increment = reload elements
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['open'])
|
|
||||||
|
|
||||||
const elements = ref([])
|
|
||||||
|
|
||||||
watch([() => props.topic, () => props.version], load, { immediate: true })
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
elements.value = await fetchElements(props.topic)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load elements:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="elements-overview">
|
|
||||||
<div class="overview-scroll">
|
|
||||||
<div class="overview-content">
|
|
||||||
<header class="overview-head">
|
|
||||||
<h1>{{ topic }}</h1>
|
|
||||||
<span class="overview-format">Elements</span>
|
|
||||||
</header>
|
|
||||||
<p v-if="!elements.length" class="overview-empty">
|
|
||||||
No elements yet. Enter a keyword in the sidebar on the right and click +.
|
|
||||||
</p>
|
|
||||||
<div class="element-grid">
|
|
||||||
<article
|
|
||||||
v-for="el in elements"
|
|
||||||
:key="el.id"
|
|
||||||
class="element-card"
|
|
||||||
@click="emit('open', el)"
|
|
||||||
>
|
|
||||||
<h3>{{ plainText(el.title) }}</h3>
|
|
||||||
<div class="markdown" v-html="renderMarkdown(el.description)"></div>
|
|
||||||
<div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div>
|
|
||||||
<div v-if="el.hints.length" class="el-hints-block">
|
|
||||||
<h4>Hints</h4>
|
|
||||||
<ul>
|
|
||||||
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.elements-overview {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-preview);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-scroll {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-content {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2.5rem 2rem 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.8rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-head h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 2.2rem;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-format {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview-empty {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.element-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.element-card {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-top: 3px solid var(--accent);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: box-shadow 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.element-card:hover {
|
|
||||||
box-shadow: 0 4px 16px var(--shadow);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.element-card h3 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-example {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints-block {
|
|
||||||
margin-top: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints-block h4 {
|
|
||||||
margin: 0 0 0.3rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints-block ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints-block li {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown: base styles global (assets/markdown.css), here only the card base font */
|
|
||||||
.markdown {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
265
frontend/src/components/GenerationView.vue
Normal file
265
frontend/src/components/GenerationView.vue
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { fetchBlocksBoard } from '../api.js'
|
||||||
|
import KanbanBoard from './KanbanBoard.vue'
|
||||||
|
import GuideBoardSection from './GuideBoardSection.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
topic: { type: String, required: true },
|
||||||
|
generating: { type: Boolean, default: false },
|
||||||
|
progress: { type: String, default: null },
|
||||||
|
ready: { type: Boolean, default: false },
|
||||||
|
partial: { type: Boolean, default: false },
|
||||||
|
guideFormat: { type: String, default: 'Guide' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
|
||||||
|
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'deleteGuide', 'restartCard', 'resetGuideCard'])
|
||||||
|
|
||||||
|
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
|
||||||
|
const board = ref(null)
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
async function pollBoard() {
|
||||||
|
try {
|
||||||
|
board.value = await fetchBlocksBoard(props.topic)
|
||||||
|
} catch { /* Board noch leer */ }
|
||||||
|
}
|
||||||
|
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) }
|
||||||
|
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||||
|
|
||||||
|
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
|
||||||
|
watch(() => props.generating, (g) => {
|
||||||
|
if (g) startPoll()
|
||||||
|
else { stopPoll(); 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 boardEmpty = computed(() => !(board.value?.columns || []).some((c) => c.total > 0))
|
||||||
|
const dead = computed(() => board.value?.dead || [])
|
||||||
|
|
||||||
|
// 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', '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
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartCard() {
|
||||||
|
const k = selCard.value
|
||||||
|
selCard.value = null
|
||||||
|
confirm.value = null
|
||||||
|
emit('restartCard', k.card_id)
|
||||||
|
}
|
||||||
|
function arm(action, fn) {
|
||||||
|
if (confirm.value === action) { confirm.value = null; fn() }
|
||||||
|
else confirm.value = action
|
||||||
|
}
|
||||||
|
function resetHere(restart) {
|
||||||
|
const s = sel.value
|
||||||
|
sel.value = null
|
||||||
|
confirm.value = null
|
||||||
|
emit('resetStage', { board: s.board, stage: s.key, restart })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="gen-view">
|
||||||
|
<header class="gen-head">
|
||||||
|
<h1>{{ topic }}</h1>
|
||||||
|
<span class="gen-sub">Generierung</span>
|
||||||
|
<span class="gen-spacer"></span>
|
||||||
|
<button class="gen-close" title="Close" @click="emit('close')">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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 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', () => 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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="dead.length"
|
||||||
|
class="gen-act"
|
||||||
|
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
|
||||||
|
@click="emit('requeueDead')"
|
||||||
|
>⟳ {{ dead.length }} dead</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="boardEmpty && !generating" class="gen-empty">No board yet — generation streams live cards through the columns here.</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="gen-board-label">Inventar</div>
|
||||||
|
<KanbanBoard
|
||||||
|
:columns="inventoryCols"
|
||||||
|
:agents="board?.agents || []"
|
||||||
|
:generating="generating"
|
||||||
|
:selectable="!generating"
|
||||||
|
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
|
||||||
|
@stageClick="stageClick"
|
||||||
|
/>
|
||||||
|
<div class="gen-board-label">Artefakte</div>
|
||||||
|
<KanbanBoard
|
||||||
|
:columns="artefactCols"
|
||||||
|
:generating="generating"
|
||||||
|
:selectable="!generating"
|
||||||
|
:cardSelectable="!generating"
|
||||||
|
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
|
||||||
|
@stageClick="stageClick"
|
||||||
|
@cardClick="cardClick"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="gen-section">
|
||||||
|
<GuideBoardSection
|
||||||
|
:topic="topic"
|
||||||
|
:format="guideFormat"
|
||||||
|
@cancelGuide="(id) => emit('cancelGuide', id)"
|
||||||
|
@startGuide="(p) => emit('startGuide', p)"
|
||||||
|
@resetStage="(p) => emit('resetGuideStage', p)"
|
||||||
|
@preview="emit('preview')"
|
||||||
|
@deleteGuide="(id) => emit('deleteGuide', id)"
|
||||||
|
@resetCard="(p) => emit('resetGuideCard', p)"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gen-view {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-preview);
|
||||||
|
}
|
||||||
|
.gen-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.25rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.gen-head h1 { font-size: 1.5rem; }
|
||||||
|
.gen-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
|
||||||
|
.gen-spacer { flex: 1; }
|
||||||
|
.gen-close {
|
||||||
|
align-self: center;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.gen-close:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.gen-section {
|
||||||
|
padding: 0.85rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
.gen-title { font-size: 0.9rem; font-weight: 700; }
|
||||||
|
.gen-board-label {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin: 0.5rem 0 0.3rem;
|
||||||
|
}
|
||||||
|
.gen-empty { color: var(--text-faint); font-size: 0.82rem; padding: 0.4rem 0; }
|
||||||
|
.gen-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.gen-progress-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: gen-pulse 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-step-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.7rem;
|
||||||
|
border-top: 1px dashed var(--border-strong);
|
||||||
|
}
|
||||||
|
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.gen-act {
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.gen-act:hover { border-color: var(--accent); }
|
||||||
|
.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); }
|
||||||
|
</style>
|
||||||
@@ -7,7 +7,7 @@ const props = defineProps({
|
|||||||
topic: { type: String, required: true },
|
topic: { type: String, required: true },
|
||||||
format: { type: String, default: 'Guide' },
|
format: { type: String, default: 'Guide' },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['close', 'cancelGuide', 'startGuide', 'resetStage', 'preview'])
|
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'deleteGuide', 'resetCard'])
|
||||||
|
|
||||||
const board = ref(null)
|
const board = ref(null)
|
||||||
let timer = null
|
let timer = null
|
||||||
@@ -32,13 +32,31 @@ const done = computed(() => columns.value.find((c) => c.key === 'done')?.total |
|
|||||||
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
|
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
|
||||||
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
|
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
|
||||||
const sel = ref(null)
|
const sel = ref(null)
|
||||||
|
const selCard = ref(null)
|
||||||
const confirm = ref(null)
|
const confirm = ref(null)
|
||||||
|
|
||||||
function stageClick(c) {
|
function stageClick(c) {
|
||||||
if (generating.value || !STAGES.includes(c.key)) return
|
if (generating.value || !STAGES.includes(c.key)) return
|
||||||
confirm.value = null
|
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) }
|
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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) {
|
function arm(action, fn) {
|
||||||
if (confirm.value === action) { confirm.value = null; fn() }
|
if (confirm.value === action) { confirm.value = null; fn() }
|
||||||
else confirm.value = action
|
else confirm.value = action
|
||||||
@@ -58,86 +76,56 @@ function resetHere() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="gb-view">
|
<section class="gb-board">
|
||||||
<header class="gb-head">
|
<div class="gb-top">
|
||||||
<h1>{{ topic }}</h1>
|
<span class="gb-title">Guide · {{ format }}</span>
|
||||||
<span class="gb-sub">Guide-Board · {{ format }}</span>
|
|
||||||
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
|
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
|
||||||
<span class="gb-spacer"></span>
|
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
|
||||||
<button class="gb-close" title="Close" @click="emit('close')">✕</button>
|
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
|
||||||
</header>
|
<div class="gb-actions">
|
||||||
|
<template v-if="generating">
|
||||||
<section class="gb-board">
|
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
|
||||||
<div class="gb-top">
|
</template>
|
||||||
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
|
<template v-else>
|
||||||
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
|
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
|
||||||
<div class="gb-actions">
|
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
|
||||||
<template v-if="generating">
|
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => emit('deleteGuide', board?.guide_id))">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
|
||||||
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
|
</template>
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:agents="board?.agents || []"
|
:agents="board?.agents || []"
|
||||||
:generating="generating"
|
:generating="generating"
|
||||||
:selectable="!generating"
|
:selectable="!generating"
|
||||||
:selectedKey="sel?.key || null"
|
:cardSelectable="!generating"
|
||||||
@stageClick="stageClick"
|
:selectedKey="sel?.key || null"
|
||||||
/>
|
@stageClick="stageClick"
|
||||||
|
@cardClick="cardClick"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="sel && !generating" class="gb-stage-actions">
|
<div v-if="selCard && !generating" class="gb-stage-actions">
|
||||||
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
|
<span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
|
||||||
<button class="gb-act play" @click="restartHere">↻ neu generieren</button>
|
<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 danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : '✕ nur zurücksetzen' }}</button>
|
<button class="gb-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
|
||||||
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<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>
|
<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>
|
</section>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.gb-view {
|
.gb-board { padding: 0.85rem 0 0; }
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: 100dvh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-preview);
|
|
||||||
}
|
|
||||||
.gb-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1.25rem 2rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
.gb-head h1 { font-size: 1.5rem; }
|
|
||||||
.gb-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
|
|
||||||
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
|
|
||||||
.gb-spacer { flex: 1; }
|
|
||||||
.gb-close {
|
|
||||||
align-self: center;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.gb-close:hover { border-color: var(--accent); }
|
|
||||||
|
|
||||||
.gb-board { padding: 0.85rem 2rem; }
|
|
||||||
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; }
|
.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-progress {
|
.gb-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6,10 +6,11 @@ const props = defineProps({
|
|||||||
agents: { type: Array, default: () => [] }, // [{label, runtime}]
|
agents: { type: Array, default: () => [] }, // [{label, runtime}]
|
||||||
generating: { type: Boolean, default: false },
|
generating: { type: Boolean, default: false },
|
||||||
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
|
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
|
||||||
|
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
|
||||||
selectedKey: { type: String, default: null },
|
selectedKey: { type: String, default: null },
|
||||||
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
|
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['stageClick'])
|
const emit = defineEmits(['stageClick', 'cardClick'])
|
||||||
|
|
||||||
function visible(c) {
|
function visible(c) {
|
||||||
return !props.hideEmpty || c.total > 0
|
return !props.hideEmpty || c.total > 0
|
||||||
@@ -24,14 +25,14 @@ function fmtRuntime(s) {
|
|||||||
<div class="kb">
|
<div class="kb">
|
||||||
<div v-if="agents.length" class="kb-agents">
|
<div v-if="agents.length" class="kb-agents">
|
||||||
<span class="kb-agents-label">{{ agents.length }} Agent(en):</span>
|
<span class="kb-agents-label">{{ agents.length }} Agent(en):</span>
|
||||||
<span v-for="a in agents" :key="a.label" class="kb-agent">{{ a.label }} · {{ fmtRuntime(a.runtime) }}</span>
|
<span v-for="(a, i) in agents" :key="a.key || i" class="kb-agent" :title="a.key">{{ a.label }} · {{ fmtRuntime(a.runtime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kb-cols">
|
<div class="kb-cols">
|
||||||
<div
|
<div
|
||||||
v-for="c in columns.filter(visible)"
|
v-for="c in columns.filter(visible)"
|
||||||
:key="(c.board || '') + c.key"
|
:key="(c.board || '') + c.key"
|
||||||
class="kb-col"
|
class="kb-col"
|
||||||
:class="{ active: c.total > 0, sel: selectedKey === c.key }"
|
:class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="kb-col-head"
|
class="kb-col-head"
|
||||||
@@ -43,7 +44,12 @@ function fmtRuntime(s) {
|
|||||||
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
|
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<ul v-if="c.cards && c.cards.length" class="kb-cards">
|
<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" :title="k.info || k.title">
|
<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 })"
|
||||||
|
>
|
||||||
<div class="kb-card-row">
|
<div class="kb-card-row">
|
||||||
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
|
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
|
||||||
<span class="kb-card-title">{{ k.title }}</span>
|
<span class="kb-card-title">{{ k.title }}</span>
|
||||||
@@ -51,6 +57,13 @@ function fmtRuntime(s) {
|
|||||||
<span v-if="k.ziele" class="kb-badge ziele" title="Lernziele abgedeckt">{{ k.ziele }}</span>
|
<span v-if="k.ziele" class="kb-badge ziele" title="Lernziele abgedeckt">{{ k.ziele }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="k.info && k.status === 'active'" class="kb-card-info">{{ k.info }}</div>
|
<div v-if="k.info && k.status === 'active'" class="kb-card-info">{{ k.info }}</div>
|
||||||
|
<div v-if="k.step_n && k.status === 'active'" class="kb-steps" :title="k.steps ? k.steps.join(' → ') : ''">
|
||||||
|
<span
|
||||||
|
v-for="s in k.step_n" :key="s" class="kb-step"
|
||||||
|
:class="{ done: s < k.step_i, act: s === k.step_i }"
|
||||||
|
:title="k.steps ? k.steps[s - 1] : ''"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="c.total > c.cards.length" class="kb-more">+{{ c.total - c.cards.length }} weitere</li>
|
<li v-if="c.total > c.cards.length" class="kb-more">+{{ c.total - c.cards.length }} weitere</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -81,12 +94,15 @@ function fmtRuntime(s) {
|
|||||||
.kb-cols {
|
.kb-cols {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto; /* Fallback (schmale Screens) — am Desktop passt alles dank Kollaps */
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
padding-bottom: 0.3rem;
|
padding-bottom: 0.3rem;
|
||||||
}
|
}
|
||||||
.kb-col {
|
.kb-col {
|
||||||
flex: 0 0 150px;
|
flex: 1 1 150px;
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: 230px;
|
||||||
|
align-self: flex-start;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -95,6 +111,36 @@ function fmtRuntime(s) {
|
|||||||
.kb-col.active { opacity: 1; border-color: var(--border-strong); }
|
.kb-col.active { opacity: 1; border-color: var(--border-strong); }
|
||||||
.kb-col.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
|
.kb-col.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||||
|
|
||||||
|
/* Leere Spalten kollabieren zu schmalen Säulen (Jira-Muster) — der Kopf bleibt
|
||||||
|
klickbar (Reset ab Spalte), das Label läuft vertikal. */
|
||||||
|
.kb-col.collapsed {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: 32px;
|
||||||
|
align-self: stretch;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.kb-col.collapsed:hover { opacity: 0.85; }
|
||||||
|
.kb-col.collapsed .kb-col-head {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 130px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
}
|
||||||
|
.kb-col.collapsed .kb-col-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
.kb-col-head {
|
.kb-col-head {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -151,6 +197,9 @@ function fmtRuntime(s) {
|
|||||||
.kb-card.error { border-color: var(--danger); }
|
.kb-card.error { border-color: var(--danger); }
|
||||||
.kb-card.active { border-color: var(--accent-border); }
|
.kb-card.active { border-color: var(--accent-border); }
|
||||||
.kb-card-row { display: flex; align-items: center; gap: 0.35rem; }
|
.kb-card-row { display: flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.kb-card.klickbar { cursor: pointer; }
|
||||||
|
.kb-card.klickbar:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
.kb-card-info {
|
.kb-card-info {
|
||||||
margin: 0.15rem 0 0 1rem;
|
margin: 0.15rem 0 0 1rem;
|
||||||
font-size: 0.66rem;
|
font-size: 0.66rem;
|
||||||
@@ -159,6 +208,29 @@ function fmtRuntime(s) {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase stepper: one segment per fine step of the card's stage */
|
||||||
|
.kb-steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
margin: 0.25rem 0 0.05rem 1rem;
|
||||||
|
}
|
||||||
|
.kb-step {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 26px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--border, #444);
|
||||||
|
}
|
||||||
|
.kb-step.done { background: var(--accent, #7aa2f7); opacity: 0.55; }
|
||||||
|
.kb-step.act {
|
||||||
|
background: var(--accent, #7aa2f7);
|
||||||
|
animation: kbStepPulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes kbStepPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
.kb-card-title {
|
.kb-card-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const props = defineProps({
|
|||||||
previewGuide: { type: Object, default: null },
|
previewGuide: { type: Object, default: null },
|
||||||
dark: { type: Boolean, default: false },
|
dark: { type: Boolean, default: false },
|
||||||
provider: { type: String, default: 'claude' },
|
provider: { type: String, default: 'claude' },
|
||||||
elementsOpen: { type: Boolean, default: false }, // element sidebar open → chat to the left
|
|
||||||
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
|
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
|
||||||
themaAbgeschlossen: { type: Boolean, default: false },
|
themaAbgeschlossen: { type: Boolean, default: false },
|
||||||
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
|
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
|
||||||
@@ -209,12 +208,6 @@ function closeChat() {
|
|||||||
chat.reset()
|
chat.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// On mobile, chat and element sidebar are mutually exclusive —
|
|
||||||
// there is no room side by side, the sidebar would cover the chat.
|
|
||||||
watch(() => props.elementsOpen, (open) => {
|
|
||||||
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
|
|
||||||
})
|
|
||||||
|
|
||||||
function onDocMouseDown(e) {
|
function onDocMouseDown(e) {
|
||||||
if (!chatOpen.value) return
|
if (!chatOpen.value) return
|
||||||
if (panelEl.value && panelEl.value.contains(e.target)) return
|
if (panelEl.value && panelEl.value.contains(e.target)) return
|
||||||
@@ -354,9 +347,9 @@ function extractContext() {
|
|||||||
@section-updated="onSectionUpdated"
|
@section-updated="onSectionUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Questions about the guide" @click="openChat">💬</button>
|
<button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" title="Questions about the guide" @click="openChat">💬</button>
|
||||||
|
|
||||||
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
|
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel">
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<span>Questions about the guide</span>
|
<span>Questions about the guide</span>
|
||||||
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
|
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
|
||||||
@@ -640,23 +633,6 @@ function extractContext() {
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Element sidebar (320px) open → show chat to its left */
|
|
||||||
.chat-fab.shifted {
|
|
||||||
right: calc(1.5rem + 320px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-panel.shifted {
|
|
||||||
right: calc(1.5rem + 320px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* On mobile the element sidebar overlays the chat — hide FAB/panel */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.chat-fab.shifted,
|
|
||||||
.chat-panel.shifted {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 1.5rem;
|
right: 1.5rem;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const props = defineProps({
|
|||||||
stats: { type: Object, default: null },
|
stats: { type: Object, default: null },
|
||||||
fortschritt: { type: Object, default: () => ({}) },
|
fortschritt: { type: Object, default: () => ({}) },
|
||||||
locks: { type: Object, default: () => ({}) },
|
locks: { type: Object, default: () => ({}) },
|
||||||
guideStepsDone: { type: Object, default: () => ({}) }, // highest finished step per format (-1 = none)
|
|
||||||
uiError: { type: String, default: null },
|
uiError: { type: String, default: null },
|
||||||
doneByFormat: { type: Object, default: () => ({}) },
|
doneByFormat: { type: Object, default: () => ({}) },
|
||||||
latestByFormat: { type: Object, default: () => ({}) },
|
latestByFormat: { type: Object, default: () => ({}) },
|
||||||
@@ -26,7 +25,7 @@ const props = defineProps({
|
|||||||
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'createThema', 'updateSource', 'formatClick', 'bausteineClick', 'cancelBlocks', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
|
const emit = defineEmits(['select', 'createThema', 'updateSource', 'bausteineClick', 'deleteTopic', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openGeneration', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
|
||||||
|
|
||||||
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
|
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
|
||||||
const openPanel = ref(null)
|
const openPanel = ref(null)
|
||||||
@@ -85,14 +84,6 @@ const activeGenerations = computed(() => {
|
|||||||
|
|
||||||
const { pending: pendingConfirm, armOrRun } = useConfirm()
|
const { pending: pendingConfirm, armOrRun } = useConfirm()
|
||||||
|
|
||||||
function confirmCancelBlocks() {
|
|
||||||
armOrRun('blocks', () => emit('cancelBlocks'))
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmResetBlocks() {
|
|
||||||
armOrRun('blocks', () => emit('resetBausteine'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name click = open the block overview (generate/resume/remove all live there now).
|
// Name click = open the block overview (generate/resume/remove all live there now).
|
||||||
function onBlocksName() {
|
function onBlocksName() {
|
||||||
emit('openBausteineView')
|
emit('openBausteineView')
|
||||||
@@ -108,26 +99,6 @@ function guideStatus(format) {
|
|||||||
return latest.status
|
return latest.status
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage dots of the guide board (display only — restart/reset lives on the board)
|
|
||||||
const GUIDE_STEPS = ['Lernziele', 'Zuweisung', 'Writer', 'Fakten', 'Coverage', 'Lesbarkeit']
|
|
||||||
|
|
||||||
// Dots from the card-based "done" marker: ≤ done = done. Running → done+1 active.
|
|
||||||
function guideSteps(format) {
|
|
||||||
const labels = GUIDE_STEPS
|
|
||||||
const done = props.guideStepsDone[format] ?? -1
|
|
||||||
const st = guideStatus(format)
|
|
||||||
const active = st === 'generating' || st === 'queued' ? done + 1 : -1
|
|
||||||
return labels.map((label, i) => ({
|
|
||||||
label,
|
|
||||||
state: i <= done ? 'done' : i === active ? 'active' : 'pending',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dot click → open the live guide board (the board hosts restart/reset per column).
|
|
||||||
function guideStepClick(format) {
|
|
||||||
emit('openGuideBoard', format)
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMsg(format) {
|
function errorMsg(format) {
|
||||||
const latest = props.latestByFormat[format]
|
const latest = props.latestByFormat[format]
|
||||||
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
|
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
|
||||||
@@ -141,23 +112,11 @@ function aborted(format) {
|
|||||||
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled')
|
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name click: finished guide → preview, otherwise toggle action panel.
|
// Name click: finished guide → preview, otherwise the generation view (start/cancel live there).
|
||||||
function handleFormatClick(format) {
|
function handleFormatClick(format) {
|
||||||
const guide = props.doneByFormat[format]
|
const guide = props.doneByFormat[format]
|
||||||
if (guide) emit('preview', guide)
|
if (guide) emit('preview', guide)
|
||||||
else togglePanel('fmt-' + format)
|
else emit('openGuideBoard', format)
|
||||||
}
|
|
||||||
|
|
||||||
// Lock reasons come from the backend (GET /guides/locks) — the rules only
|
|
||||||
// exist there now. While locks are not yet loaded: button enabled, the
|
|
||||||
// backend rejects invalid starts anyway (visible via uiError).
|
|
||||||
function playLock(format) {
|
|
||||||
return props.locks?.[format] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePlay(format) {
|
|
||||||
if (playLock(format)) return
|
|
||||||
emit('formatClick', { format, instructions: '', abStep: null }) // Restart-ab-Stage lebt auf dem Board
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash-message behavior: × only hides, nothing is deleted
|
// Flash-message behavior: × only hides, nothing is deleted
|
||||||
@@ -166,25 +125,6 @@ function dismissError(format) {
|
|||||||
if (latest?.status === 'error') emit('dismissError', latest.id)
|
if (latest?.status === 'error') emit('dismissError', latest.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(format) {
|
|
||||||
if (!props.latestByFormat[format]) return
|
|
||||||
armOrRun('fmt-' + format, () => {
|
|
||||||
// Cancel all running generations of the format (also covers duplicates)
|
|
||||||
const running = props.allGuides.filter(
|
|
||||||
(g) => g.topic === props.selectedTopic && g.format === format
|
|
||||||
&& (g.status === 'generating' || g.status === 'queued'),
|
|
||||||
)
|
|
||||||
if (running.length) {
|
|
||||||
for (const g of running) emit('cancelGuide', g.id)
|
|
||||||
} else if (aborted(format)) {
|
|
||||||
// Paused run: delete partial progress incl. step files (reset)
|
|
||||||
emit('deleteGuide', props.latestByFormat[format].id, true)
|
|
||||||
} else {
|
|
||||||
emit('deleteGuide', props.latestByFormat[format].id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create area: inline expandable (name + more info + source type).
|
// Create area: inline expandable (name + more info + source type).
|
||||||
const dlg = ref(false)
|
const dlg = ref(false)
|
||||||
const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
|
const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
|
||||||
@@ -347,7 +287,7 @@ function saveSource() {
|
|||||||
<div class="ord-blocks">
|
<div class="ord-blocks">
|
||||||
<div
|
<div
|
||||||
class="format-row blocks-row"
|
class="format-row blocks-row"
|
||||||
:class="{ 'is-active': blocksState === 'generating' || blocks.partial, 'row-open': isOpen('blocks') }"
|
:class="{ 'is-active': blocksState === 'generating' || blocks.partial }"
|
||||||
>
|
>
|
||||||
<button class="format-name blocks-name" @click="onBlocksName">
|
<button class="format-name blocks-name" @click="onBlocksName">
|
||||||
<span class="format-label">Blocks</span>
|
<span class="format-label">Blocks</span>
|
||||||
@@ -357,20 +297,6 @@ function saveSource() {
|
|||||||
title="Aborted — can be resumed"
|
title="Aborted — can be resumed"
|
||||||
>Paused</span>
|
>Paused</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="blocksState === 'generating' || blocks.ready || blocks.partial" class="panel-toggle" :class="{ open: isOpen('blocks') }" title="Actions" @click.stop="togglePanel('blocks')">▾</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="isOpen('blocks')" class="action-panel">
|
|
||||||
<template v-if="blocksState === 'generating'">
|
|
||||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'blocks' }" @click="confirmCancelBlocks">{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Cancel' }}</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<button
|
|
||||||
v-if="blocks.ready || blocks.partial"
|
|
||||||
class="panel-btn danger"
|
|
||||||
:class="{ armed: pendingConfirm === 'blocks' }"
|
|
||||||
@click="confirmResetBlocks"
|
|
||||||
>{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Remove' }}</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="blocksState === 'generating'" class="format-progress">
|
<div v-if="blocksState === 'generating'" class="format-progress">
|
||||||
{{ blocks.progress || 'Waiting…' }}
|
{{ blocks.progress || 'Waiting…' }}
|
||||||
@@ -378,10 +304,16 @@ function saveSource() {
|
|||||||
<div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
|
<div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
|
||||||
<span class="format-error-text">{{ blocks.error }}</span>
|
<span class="format-error-text">{{ blocks.error }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="format-row">
|
||||||
|
<button class="format-name" @click="emit('openGeneration')">
|
||||||
|
<span class="format-label gen-label">Generierung</span>
|
||||||
|
<span v-if="blocksState === 'generating'" class="gen-live-dot" title="Läuft"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Formats come after the blocks row via CSS order (order 2) -->
|
<!-- Formats come after the blocks row via CSS order (order 2) -->
|
||||||
<div v-for="f in formats" :key="f.key" :style="{ order: 3 }">
|
<div v-for="f in formats" :key="f.key" :style="{ order: 3 }">
|
||||||
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': aborted(f.key), 'row-open': isOpen('fmt-' + f.key) }]">
|
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': aborted(f.key) }]">
|
||||||
<button class="format-name" @click="handleFormatClick(f.key)">
|
<button class="format-name" @click="handleFormatClick(f.key)">
|
||||||
<span class="format-label">{{ f.label }}</span>
|
<span class="format-label">{{ f.label }}</span>
|
||||||
<span
|
<span
|
||||||
@@ -389,37 +321,7 @@ function saveSource() {
|
|||||||
class="resume-badge"
|
class="resume-badge"
|
||||||
title="Aborted — can be resumed"
|
title="Aborted — can be resumed"
|
||||||
>Paused</span>
|
>Paused</span>
|
||||||
<span class="step-dots" v-if="guideSteps(f.key).length">
|
|
||||||
<span
|
|
||||||
v-for="(s, i) in guideSteps(f.key)"
|
|
||||||
:key="s.label"
|
|
||||||
class="step-pill klickbar"
|
|
||||||
:class="[s.state]"
|
|
||||||
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + ' — Klick: Live-Board öffnen'"
|
|
||||||
@click.stop="guideStepClick(f.key)"
|
|
||||||
>{{ i + 1 }}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="panel-toggle" :class="{ open: isOpen('fmt-' + f.key) }" title="Actions" @click.stop="togglePanel('fmt-' + f.key)">▾</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="isOpen('fmt-' + f.key)" class="action-panel">
|
|
||||||
<template v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'">
|
|
||||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'fmt-' + f.key }" @click="handleDelete(f.key)">{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : 'Cancel' }}</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<button
|
|
||||||
class="panel-btn play"
|
|
||||||
:title="playLock(f.key) || (aborted(f.key) ? 'Resume' : 'Generate')"
|
|
||||||
:disabled="!!playLock(f.key)"
|
|
||||||
@click="handlePlay(f.key)"
|
|
||||||
>{{ aborted(f.key) ? 'Resume' : guideStatus(f.key) === 'done' ? 'Regenerate' : 'Generate' }}</button>
|
|
||||||
<button
|
|
||||||
v-if="guideStatus(f.key) !== 'none' || aborted(f.key)"
|
|
||||||
class="panel-btn danger"
|
|
||||||
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
|
|
||||||
@click="handleDelete(f.key)"
|
|
||||||
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : aborted(f.key) ? 'Delete progress' : 'Remove' }}</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
|
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
|
||||||
@@ -435,11 +337,6 @@ function saveSource() {
|
|||||||
<span class="format-label">General Exam</span>
|
<span class="format-label">General Exam</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="format-row ord-elemente">
|
|
||||||
<button class="format-name elements-btn" @click="emit('openElements')">
|
|
||||||
<span class="format-label">Elements</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="topic-list">
|
<ul class="topic-list">
|
||||||
@@ -723,46 +620,12 @@ function saveSource() {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-dots {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 5px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Coarse phases as numbered pills (1–5) — display + clickable for re-run from here. */
|
/* Coarse phases as numbered pills (1–5) — display + clickable for re-run from here. */
|
||||||
.step-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 17px;
|
|
||||||
height: 17px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--border-strong);
|
|
||||||
color: var(--bg);
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1.5px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-pill.done {
|
|
||||||
background: var(--success-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-pill.active {
|
|
||||||
background: var(--warning-border);
|
|
||||||
animation: dot-pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-pill.klickbar {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-pill.sel {
|
|
||||||
border-color: var(--text);
|
|
||||||
box-shadow: 0 0 0 1px var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dot-pulse {
|
@keyframes dot-pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -784,6 +647,16 @@ function saveSource() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gen-label { color: var(--text-muted); font-size: 0.86em; }
|
||||||
|
.gen-live-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: gen-side-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes gen-side-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||||
|
|
||||||
.ord-blocks {
|
.ord-blocks {
|
||||||
order: 2;
|
order: 2;
|
||||||
}
|
}
|
||||||
@@ -792,10 +665,6 @@ function saveSource() {
|
|||||||
order: 4;
|
order: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ord-elemente {
|
|
||||||
order: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.elements-btn {
|
.elements-btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -889,34 +758,7 @@ function saveSource() {
|
|||||||
.format-row.row-open,
|
.format-row.row-open,
|
||||||
.topic-list li.li-open .topic-row { background: var(--panel-soft); }
|
.topic-list li.li-open .topic-row { background: var(--panel-soft); }
|
||||||
|
|
||||||
.action-panel {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.15rem 0.75rem 0.55rem calc(0.75rem + 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.45rem 0.6rem;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.panel-btn:hover { border-color: var(--accent); }
|
|
||||||
.panel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
.panel-btn:disabled:hover { border-color: var(--border-strong); }
|
|
||||||
.panel-btn.play {
|
|
||||||
color: var(--success);
|
|
||||||
background: var(--success-soft);
|
|
||||||
border-color: var(--success-border);
|
|
||||||
}
|
|
||||||
.panel-btn.play:hover { background: var(--success-soft-hover); }
|
|
||||||
.panel-btn.danger { color: var(--danger); }
|
|
||||||
.panel-btn.danger:hover { border-color: var(--danger); }
|
.panel-btn.danger:hover { border-color: var(--danger); }
|
||||||
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
|
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { watch } from 'vue'
|
|
||||||
import { chatElement } from '../../api.js'
|
|
||||||
import { useChat } from '../../composables/useChat.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
element: { type: Object, required: true },
|
|
||||||
provider: { type: String, default: 'claude' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['changes'])
|
|
||||||
|
|
||||||
const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider))
|
|
||||||
const { messages, input, loading, messagesEl, inputEl, onScroll } = chat
|
|
||||||
|
|
||||||
// Different element selected → discard history
|
|
||||||
watch(() => props.element.id, () => chat.reset())
|
|
||||||
|
|
||||||
async function send() {
|
|
||||||
const res = await chat.send()
|
|
||||||
if (res?.changes?.length) emit('changes', res.changes)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="el-chat">
|
|
||||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
|
||||||
<p v-if="!messages.length" class="chat-hint">Write what should be changed on the element.</p>
|
|
||||||
<template v-for="(m, i) in messages" :key="i">
|
|
||||||
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="loading" class="chat-msg assistant chat-typing">Adjusting…</div>
|
|
||||||
</div>
|
|
||||||
<div class="chat-input">
|
|
||||||
<textarea
|
|
||||||
ref="inputEl"
|
|
||||||
v-model="input"
|
|
||||||
placeholder="Adjust element…"
|
|
||||||
@keydown.enter.exact.prevent="send"
|
|
||||||
></textarea>
|
|
||||||
<button
|
|
||||||
:disabled="!input.trim() && !loading"
|
|
||||||
:class="{ cancel: loading }"
|
|
||||||
:title="loading ? 'Cancel' : 'Send'"
|
|
||||||
@click="send"
|
|
||||||
>{{ loading ? '✕' : '➤' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.el-chat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-hint {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-msg {
|
|
||||||
max-width: 85%;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
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-typing {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.6rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input textarea {
|
|
||||||
flex: 1;
|
|
||||||
resize: none;
|
|
||||||
height: 72px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input textarea:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input button {
|
|
||||||
width: 38px;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
|
|
||||||
import { renderMarkdown, plainText } from '../../markdown.js'
|
|
||||||
import ElementSuggestion from './ElementSuggestion.vue'
|
|
||||||
import ElementChatTab from './ElementChatTab.vue'
|
|
||||||
import ElementEditTab from './ElementEditTab.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
element: { type: Object, required: true },
|
|
||||||
provider: { type: String, default: 'claude' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['back', 'close', 'updated', 'changed'])
|
|
||||||
|
|
||||||
const tab = ref('overview') // 'overview' | 'chat' | 'edit'
|
|
||||||
const savingEdit = ref(false)
|
|
||||||
|
|
||||||
// Strip Markdown characters from the header title
|
|
||||||
|
|
||||||
// Different element selected → reset exam state and tab
|
|
||||||
watch(() => props.element.id, () => {
|
|
||||||
tab.value = 'overview'
|
|
||||||
resetCheck()
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- AI exam for missing info (results land as inline suggestions) ---
|
|
||||||
const checking = ref(false)
|
|
||||||
const statusMsg = ref(null)
|
|
||||||
|
|
||||||
function resetCheck() {
|
|
||||||
checking.value = false
|
|
||||||
statusMsg.value = null
|
|
||||||
resetStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
let checkRun = 0 // identify the running exam; cancellation ignores its result
|
|
||||||
|
|
||||||
async function runCheck() {
|
|
||||||
if (checking.value) { // second click = cancel
|
|
||||||
checkRun++
|
|
||||||
checking.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const run = ++checkRun
|
|
||||||
checking.value = true
|
|
||||||
statusMsg.value = null
|
|
||||||
try {
|
|
||||||
const res = await checkElement(props.element.id, props.provider)
|
|
||||||
if (run !== checkRun) return // cancelled or a new exam started
|
|
||||||
const mapped = res.suggestions.map((s) => ({
|
|
||||||
text: s.text, action: 'add', target: s.target, index: null, content: s.content,
|
|
||||||
}))
|
|
||||||
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
|
|
||||||
else statusMsg.value = 'No important gaps found.'
|
|
||||||
} catch (e) {
|
|
||||||
if (run !== checkRun) return
|
|
||||||
console.error('Exam failed:', e)
|
|
||||||
statusMsg.value = 'Exam failed — please try again.'
|
|
||||||
} finally {
|
|
||||||
if (run === checkRun) checking.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Style exam: AI proposes changes, user confirms ---
|
|
||||||
const styleChanges = ref(null) // null = not yet examined
|
|
||||||
const styling = ref(false)
|
|
||||||
const applyingStyle = ref(false)
|
|
||||||
const refiningIdx = ref(null)
|
|
||||||
let styleRun = 0
|
|
||||||
|
|
||||||
function resetStyle() {
|
|
||||||
styleChanges.value = null
|
|
||||||
styling.value = false
|
|
||||||
applyingStyle.value = false
|
|
||||||
refiningIdx.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function suggBusy(i) {
|
|
||||||
return applyingStyle.value || refiningIdx.value === i
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refine a single suggestion via instruction (pencil icon)
|
|
||||||
async function refineChange(i, instruction) {
|
|
||||||
if (refiningIdx.value !== null || applyingStyle.value) return
|
|
||||||
refiningIdx.value = i
|
|
||||||
try {
|
|
||||||
const res = await refineSuggestion(props.element.id, styleChanges.value[i], instruction, props.provider)
|
|
||||||
const next = [...styleChanges.value]
|
|
||||||
next[i] = res.change
|
|
||||||
styleChanges.value = next
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Refinement failed:', e)
|
|
||||||
statusMsg.value = 'Refinement failed — please try again.'
|
|
||||||
} finally {
|
|
||||||
refiningIdx.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runStyle() {
|
|
||||||
if (styling.value) { // second click = cancel
|
|
||||||
styleRun++
|
|
||||||
styling.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const run = ++styleRun
|
|
||||||
styling.value = true
|
|
||||||
statusMsg.value = null
|
|
||||||
try {
|
|
||||||
const res = await styleElement(props.element.id, props.provider)
|
|
||||||
if (run !== styleRun) return
|
|
||||||
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
|
|
||||||
else statusMsg.value = 'Style already fits.'
|
|
||||||
} catch (e) {
|
|
||||||
if (run !== styleRun) return
|
|
||||||
console.error('Style exam failed:', e)
|
|
||||||
statusMsg.value = 'Style exam failed — please try again.'
|
|
||||||
} finally {
|
|
||||||
if (run === styleRun) styling.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat suggestions also land as inline suggestions in the overview
|
|
||||||
function onChatChanges(changes) {
|
|
||||||
styleChanges.value = [...(styleChanges.value || []), ...changes]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show suggestions at the target location: adjust/remove at the affected entry …
|
|
||||||
function styleAt(target, index = null) {
|
|
||||||
if (!styleChanges.value) return []
|
|
||||||
return styleChanges.value
|
|
||||||
.map((c, i) => [i, c])
|
|
||||||
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'add')
|
|
||||||
}
|
|
||||||
|
|
||||||
// … additions at the end of the respective section
|
|
||||||
function styleAdds(target) {
|
|
||||||
if (!styleChanges.value) return []
|
|
||||||
return styleChanges.value
|
|
||||||
.map((c, i) => [i, c])
|
|
||||||
.filter(([, c]) => c.target === target && c.action === 'add')
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissStyleChange(i) {
|
|
||||||
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyStyleChange(i) {
|
|
||||||
if (applyingStyle.value) return
|
|
||||||
const c = styleChanges.value[i]
|
|
||||||
applyingStyle.value = true
|
|
||||||
try {
|
|
||||||
const STRING_TARGETS = ['title', 'description']
|
|
||||||
const fields = {
|
|
||||||
title: props.element.title,
|
|
||||||
description: props.element.description,
|
|
||||||
examples: [...props.element.examples],
|
|
||||||
hints: [...props.element.hints],
|
|
||||||
}
|
|
||||||
if (c.action === 'remove') fields[c.target].splice(c.index, 1)
|
|
||||||
else if (c.action === 'add') {
|
|
||||||
if (c.target === 'title') fields.title = c.content
|
|
||||||
else if (c.target === 'description')
|
|
||||||
fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content
|
|
||||||
else fields[c.target].push(c.content)
|
|
||||||
} else if (STRING_TARGETS.includes(c.target)) fields[c.target] = c.content
|
|
||||||
else fields[c.target][c.index] = c.content
|
|
||||||
|
|
||||||
const updated = await updateElement(props.element.id, fields)
|
|
||||||
emit('updated', updated)
|
|
||||||
|
|
||||||
// Keep remaining suggestions; indices after a removal shift up
|
|
||||||
const rest = styleChanges.value.filter((_, j) => j !== i)
|
|
||||||
if (c.action === 'remove') {
|
|
||||||
for (const r of rest) {
|
|
||||||
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
styleChanges.value = rest
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Apply failed:', e)
|
|
||||||
} finally {
|
|
||||||
applyingStyle.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Edit tab: save fields directly ---
|
|
||||||
async function saveEdit(fields) {
|
|
||||||
if (savingEdit.value) return
|
|
||||||
savingEdit.value = true
|
|
||||||
try {
|
|
||||||
const updated = await updateElement(props.element.id, fields)
|
|
||||||
emit('updated', updated)
|
|
||||||
tab.value = 'overview'
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Save failed:', e)
|
|
||||||
} finally {
|
|
||||||
savingEdit.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<header class="el-header">
|
|
||||||
<button class="el-back" title="Back to list" @click="emit('back')">←</button>
|
|
||||||
<span class="el-title">{{ plainText(element.title) }}</span>
|
|
||||||
<button
|
|
||||||
class="el-tool" :class="{ busy: checking }"
|
|
||||||
:title="checking ? 'Cancel exam' : 'Check for missing info'" @click="runCheck"
|
|
||||||
>🔍</button>
|
|
||||||
<button
|
|
||||||
class="el-tool" :class="{ busy: styling }"
|
|
||||||
:title="styling ? 'Cancel exam' : 'Check & adjust style'" @click="runStyle"
|
|
||||||
>✨</button>
|
|
||||||
<button class="el-close" title="Close" @click="emit('close')">×</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<nav class="el-tabs">
|
|
||||||
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Overview</button>
|
|
||||||
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
|
|
||||||
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Edit</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Overview: inseparably intertwined with styleChanges/apply → stays here -->
|
|
||||||
<div v-show="tab === 'overview'" class="el-detail">
|
|
||||||
<div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
|
|
||||||
<ElementSuggestion
|
|
||||||
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
|
|
||||||
:key="'sgd' + ci" :change="c" :busy="suggBusy(ci)"
|
|
||||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
|
||||||
/>
|
|
||||||
<template v-for="(ex, i) in element.examples" :key="i">
|
|
||||||
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
|
|
||||||
<ElementSuggestion
|
|
||||||
v-for="[ci, c] in styleAt('examples', i)"
|
|
||||||
:key="'sge' + ci" :change="c" :busy="suggBusy(ci)"
|
|
||||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<ElementSuggestion
|
|
||||||
v-for="[ci, c] in styleAdds('examples')"
|
|
||||||
:key="'sgea' + ci" :change="c" :busy="suggBusy(ci)"
|
|
||||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
|
||||||
/>
|
|
||||||
<div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block">
|
|
||||||
<h4>Hints</h4>
|
|
||||||
<ul class="el-hints">
|
|
||||||
<li v-for="(h, i) in element.hints" :key="i">
|
|
||||||
<span class="markdown" v-html="renderMarkdown(h)"></span>
|
|
||||||
<ElementSuggestion
|
|
||||||
v-for="[ci, c] in styleAt('hints', i)"
|
|
||||||
:key="'sgh' + ci" :change="c" :busy="suggBusy(ci)"
|
|
||||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ElementSuggestion
|
|
||||||
v-for="[ci, c] in styleAdds('hints')"
|
|
||||||
:key="'sgha' + ci" :change="c" :busy="suggBusy(ci)"
|
|
||||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="checking || styling || statusMsg" class="el-check">
|
|
||||||
<p v-if="checking" class="check-empty busy-text">Checking for missing info…</p>
|
|
||||||
<p v-if="styling" class="check-empty busy-text">Checking the style…</p>
|
|
||||||
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- v-show preserves the chat history when switching tabs -->
|
|
||||||
<ElementChatTab
|
|
||||||
v-show="tab === 'chat'"
|
|
||||||
:element="element"
|
|
||||||
:provider="provider"
|
|
||||||
@changes="onChatChanges"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- v-if loads the edit fields fresh on every open -->
|
|
||||||
<ElementEditTab
|
|
||||||
v-if="tab === 'edit'"
|
|
||||||
:element="element"
|
|
||||||
:saving="savingEdit"
|
|
||||||
@save="saveEdit"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.el-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-title {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-back,
|
|
||||||
.el-close {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-back:hover,
|
|
||||||
.el-close:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tool {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 3px;
|
|
||||||
border-radius: 6px;
|
|
||||||
filter: grayscale(0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tool:hover {
|
|
||||||
background: var(--panel-soft);
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tool.busy {
|
|
||||||
filter: none;
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.busy-text {
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
50% { opacity: 0.35; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tabs {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tabs button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.5rem 0.25rem;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tabs button.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-tabs button:hover:not(.active) {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-detail {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-desc {
|
|
||||||
margin: 0 0 0.9rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints-block {
|
|
||||||
margin-top: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints-block h4 {
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-entry {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-entry:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints li {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep hint text inline next to the bullet (p is block otherwise) */
|
|
||||||
.el-hints li > .markdown {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-hints li > .markdown :deep(p) {
|
|
||||||
display: inline;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown: base is global (assets/markdown.css); narrow sidebar → more compact code blocks */
|
|
||||||
.markdown :deep(pre) {
|
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- AI exam --- */
|
|
||||||
.el-check {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding-top: 0.8rem;
|
|
||||||
border-top: 1px dashed var(--border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-empty {
|
|
||||||
margin: 0.6rem 0 0;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--text-faint);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
element: { type: Object, required: true },
|
|
||||||
saving: { type: Boolean, default: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['save'])
|
|
||||||
|
|
||||||
const edit = ref({ title: '', description: '', examples: [], hints: [] })
|
|
||||||
|
|
||||||
watch(() => props.element, load, { immediate: true })
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
edit.value = {
|
|
||||||
title: props.element.title,
|
|
||||||
description: props.element.description,
|
|
||||||
examples: [...props.element.examples],
|
|
||||||
hints: [...props.element.hints],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
if (props.saving) return
|
|
||||||
emit('save', {
|
|
||||||
title: edit.value.title,
|
|
||||||
description: edit.value.description,
|
|
||||||
examples: edit.value.examples.filter((s) => s.trim()),
|
|
||||||
hints: edit.value.hints.filter((s) => s.trim()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="el-edit">
|
|
||||||
<button class="edit-save" :disabled="saving" @click="save">
|
|
||||||
{{ saving ? 'Saving…' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<label>Title</label>
|
|
||||||
<input v-model="edit.title" placeholder="Title" />
|
|
||||||
|
|
||||||
<label>Description</label>
|
|
||||||
<textarea v-model="edit.description" placeholder="Description"></textarea>
|
|
||||||
|
|
||||||
<label>Examples</label>
|
|
||||||
<div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row">
|
|
||||||
<textarea v-model="edit.examples[i]" placeholder="Example"></textarea>
|
|
||||||
<button class="edit-del" title="Remove" @click="edit.examples.splice(i, 1)">×</button>
|
|
||||||
</div>
|
|
||||||
<button class="edit-add" @click="edit.examples.push('')">+ Example</button>
|
|
||||||
|
|
||||||
<label>Hints</label>
|
|
||||||
<div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row">
|
|
||||||
<textarea v-model="edit.hints[i]" placeholder="Hint"></textarea>
|
|
||||||
<button class="edit-del" title="Remove" @click="edit.hints.splice(i, 1)">×</button>
|
|
||||||
</div>
|
|
||||||
<button class="edit-add" @click="edit.hints.push('')">+ Hint</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.el-edit {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.9rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-edit label {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--text-faint);
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-edit input,
|
|
||||||
.el-edit textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-edit textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 120px;
|
|
||||||
overflow: auto;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-edit input:focus,
|
|
||||||
.el-edit textarea:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-row textarea {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-del {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 30px;
|
|
||||||
align-self: stretch;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: none;
|
|
||||||
color: var(--danger);
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-add {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border: 1px dashed var(--border-strong);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-add:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-save {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
margin-bottom: 0.3rem;
|
|
||||||
padding: 9px 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-save:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useConfirm } from '../../composables/useConfirm.js'
|
|
||||||
import { plainText } from '../../markdown.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
elements: { type: Array, required: true },
|
|
||||||
creating: { type: Boolean, default: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'create', 'remove'])
|
|
||||||
|
|
||||||
const query = ref('')
|
|
||||||
const { isArmed, armOrRun } = useConfirm()
|
|
||||||
|
|
||||||
// Strip Markdown characters for title and list preview
|
|
||||||
|
|
||||||
const filtered = computed(() => {
|
|
||||||
const q = query.value.trim().toLowerCase()
|
|
||||||
if (!q) return props.elements
|
|
||||||
return props.elements.filter(
|
|
||||||
(el) => el.title.toLowerCase().includes(q) || el.description.toLowerCase().includes(q),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function add() {
|
|
||||||
if (props.creating) return
|
|
||||||
emit('create', query.value.trim())
|
|
||||||
query.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline confirmation: first click "Sure?", second deletes
|
|
||||||
function confirmDelete(el) {
|
|
||||||
armOrRun('el-' + el.id, () => emit('remove', el))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="el-new">
|
|
||||||
<input
|
|
||||||
v-model="query"
|
|
||||||
placeholder="Search or keyword…"
|
|
||||||
:disabled="creating"
|
|
||||||
@keyup.enter="add"
|
|
||||||
/>
|
|
||||||
<button :disabled="creating" title="Create element via AI" @click="add">+</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="creating" class="el-creating">AI is creating element…</div>
|
|
||||||
<ul class="el-list">
|
|
||||||
<li v-for="el in filtered" :key="el.id" @click="emit('select', el)">
|
|
||||||
<div class="el-item-main">
|
|
||||||
<span class="el-item-title">{{ plainText(el.title) }}</span>
|
|
||||||
<span class="el-item-desc">{{ plainText(el.description) }}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="el-delete"
|
|
||||||
:class="{ armed: isArmed('el-' + el.id) }"
|
|
||||||
title="Delete element"
|
|
||||||
@click.stop="confirmDelete(el)"
|
|
||||||
>{{ isArmed('el-' + el.id) ? 'Sure?' : '×' }}</button>
|
|
||||||
</li>
|
|
||||||
<li v-if="!filtered.length && !creating" class="el-empty">
|
|
||||||
{{ elements.length ? 'No matches.' : 'No elements yet. Enter a keyword and click +.' }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.el-new {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-new input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-new input:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-new button {
|
|
||||||
width: 38px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-new button:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-creating {
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--warning);
|
|
||||||
background: var(--warning-soft);
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
50% { opacity: 0.35; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-list li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-list li:hover {
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-item-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-item-title {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-item-desc {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-faint);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-delete {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--danger);
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 2px;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-list li:hover .el-delete {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-delete.armed {
|
|
||||||
visibility: visible;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
background: var(--danger);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-empty {
|
|
||||||
cursor: default !important;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-empty:hover {
|
|
||||||
background: none !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, nextTick } from 'vue'
|
|
||||||
import { renderMarkdown } from '../../markdown.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
change: { type: Object, required: true },
|
|
||||||
busy: { type: Boolean, default: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['apply', 'dismiss', 'refine'])
|
|
||||||
|
|
||||||
const ACTION_LABELS = { remove: 'Remove:', adjust: 'Adjust:', add: 'Add:' }
|
|
||||||
|
|
||||||
const editing = ref(false)
|
|
||||||
const instruction = ref('')
|
|
||||||
const inputEl = ref(null)
|
|
||||||
|
|
||||||
function toggleEdit() {
|
|
||||||
editing.value = !editing.value
|
|
||||||
if (editing.value) nextTick(() => inputEl.value?.focus())
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
const text = instruction.value.trim()
|
|
||||||
if (!text || props.busy) return
|
|
||||||
emit('refine', text)
|
|
||||||
instruction.value = ''
|
|
||||||
editing.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="style-sugg" :class="{ busy }">
|
|
||||||
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[change.action] }}</strong> {{ change.text }}</div>
|
|
||||||
<div v-if="change.content" class="style-sugg-preview markdown" v-html="renderMarkdown(change.content)"></div>
|
|
||||||
<div class="style-sugg-actions">
|
|
||||||
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Confirm</button>
|
|
||||||
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Reject</button>
|
|
||||||
<button class="sugg-edit" :disabled="busy" title="Adjust suggestion via instruction" @click="toggleEdit">✏️</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="editing" class="sugg-edit-row">
|
|
||||||
<input
|
|
||||||
ref="inputEl"
|
|
||||||
v-model="instruction"
|
|
||||||
placeholder="Instruction for the suggestion…"
|
|
||||||
@keyup.enter="submit"
|
|
||||||
/>
|
|
||||||
<button :disabled="!instruction.trim() || busy" @click="submit">➤</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.style-sugg {
|
|
||||||
margin: 0.3rem 0 0.6rem;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
border: 1px dashed var(--accent);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.style-sugg.busy {
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
50% { opacity: 0.45; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.style-sugg-text {
|
|
||||||
font-size: 0.76rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.style-sugg-text strong {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.style-sugg-preview {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.style-sugg-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-ok,
|
|
||||||
.sugg-no {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-ok {
|
|
||||||
border: none;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-no {
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-no:hover {
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-edit {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 6px;
|
|
||||||
filter: grayscale(0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-edit:hover {
|
|
||||||
background: var(--border);
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-ok:disabled,
|
|
||||||
.sugg-no:disabled,
|
|
||||||
.sugg-edit:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-edit-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-edit-row input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 5px 8px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-edit-row input:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-edit-row button {
|
|
||||||
width: 30px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sugg-edit-row button:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown: base is global (assets/markdown.css); compact preview code blocks */
|
|
||||||
.markdown :deep(pre) {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 0.3em 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { fetchElements, createElement, deleteElement } from '../../api.js'
|
|
||||||
import ElementList from './ElementList.vue'
|
|
||||||
import ElementDetail from './ElementDetail.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
topic: { type: String, required: true },
|
|
||||||
provider: { type: String, default: 'claude' },
|
|
||||||
openId: { type: String, default: null }, // Element ID that should be opened
|
|
||||||
openTick: { type: Number, default: 0 }, // increment = (re)open openId
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'changed'])
|
|
||||||
|
|
||||||
const elements = ref([])
|
|
||||||
const creating = ref(false)
|
|
||||||
const selected = ref(null)
|
|
||||||
|
|
||||||
watch(() => props.topic, load, { immediate: true })
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
selected.value = null
|
|
||||||
try {
|
|
||||||
elements.value = await fetchElements(props.topic)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load elements:', e)
|
|
||||||
}
|
|
||||||
openFromProp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the element clicked in the overview of the main area
|
|
||||||
watch(() => props.openTick, openFromProp)
|
|
||||||
|
|
||||||
function openFromProp() {
|
|
||||||
if (!props.openId) return
|
|
||||||
const el = elements.value.find((e) => e.id === props.openId)
|
|
||||||
if (el) selected.value = el
|
|
||||||
}
|
|
||||||
|
|
||||||
async function create(hint) {
|
|
||||||
if (creating.value) return
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const el = await createElement(props.topic, hint, props.provider)
|
|
||||||
elements.value.unshift(el)
|
|
||||||
emit('changed')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to create element:', e)
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(el) {
|
|
||||||
await deleteElement(el.id)
|
|
||||||
elements.value = elements.value.filter((e) => e.id !== el.id)
|
|
||||||
if (selected.value?.id === el.id) selected.value = null
|
|
||||||
emit('changed')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the edited element in sync within the list and selection
|
|
||||||
function onUpdated(el) {
|
|
||||||
selected.value = el
|
|
||||||
const idx = elements.value.findIndex((e) => e.id === el.id)
|
|
||||||
if (idx !== -1) elements.value.splice(idx, 1, el)
|
|
||||||
emit('changed')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<aside class="elements-sidebar">
|
|
||||||
<ElementDetail
|
|
||||||
v-if="selected"
|
|
||||||
:element="selected"
|
|
||||||
:provider="provider"
|
|
||||||
@back="selected = null"
|
|
||||||
@close="emit('close')"
|
|
||||||
@updated="onUpdated"
|
|
||||||
@changed="emit('changed')"
|
|
||||||
/>
|
|
||||||
<template v-else>
|
|
||||||
<header class="el-header">
|
|
||||||
<span class="el-title">Elements</span>
|
|
||||||
<button class="el-close" title="Close" @click="emit('close')">×</button>
|
|
||||||
</header>
|
|
||||||
<ElementList
|
|
||||||
:elements="elements"
|
|
||||||
:creating="creating"
|
|
||||||
@select="(el) => (selected = el)"
|
|
||||||
@create="create"
|
|
||||||
@remove="remove"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.elements-sidebar {
|
|
||||||
width: 320px;
|
|
||||||
min-width: 320px;
|
|
||||||
height: 100dvh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--panel);
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
/* Above the guide chat (FAB/panel: z-index 20) */
|
|
||||||
position: relative;
|
|
||||||
z-index: 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile/narrow: lay it as an overlay over the main content instead of
|
|
||||||
squeezing it into the flex flow. */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.elements-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: min(100vw, 380px);
|
|
||||||
min-width: 0;
|
|
||||||
box-shadow: -4px 0 16px var(--shadow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-title {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-close {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-close:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Topic "{topic}". A first pass kept the blocks below, but each was FLAGGED as a likely **fragment** — a property, proof step, remark, bound, or notation that belongs to another block, not its own learning unit. Re-judge each one carefully. This is a focused second opinion; a good learning list has roughly 75–82 top-level blocks, so real fragments here SHOULD be demoted or dropped — but never sacrifice a genuine concept to hit a number.
|
Topic "{topic}". You are the SECOND OPINION of a filter pass. Each entry below was either FLAGGED as a likely **fragment** (a property, sub-form, detail, or notation that belongs to another block) or PROPOSED for demotion by a first judge. Re-judge each one independently and carefully — real fragments SHOULD be demoted or dropped, but never sacrifice a genuine standalone concept.
|
||||||
|
|
||||||
RE-JUDGE THESE (by their number):
|
RE-JUDGE THESE (by their number):
|
||||||
{survivors}
|
{survivors}
|
||||||
@@ -6,17 +6,18 @@ RE-JUDGE THESE (by their number):
|
|||||||
FULL BLOCK LIST (context — to find a parent number):
|
FULL BLOCK LIST (context — to find a parent number):
|
||||||
{list}
|
{list}
|
||||||
|
|
||||||
## Decide each survivor → one of three
|
## Decide each entry → one of three
|
||||||
- **demote (→ parent number):** it presupposes another block as its subject — a property/status („X ist NP-vollständig", „X ∈ NP"), a **lower bound** of X, a **bare theorem/remark** about X („Bemerkung: HK auch für gerichtete Graphen" → Hamiltonkreis; „Satz: F erfüllbar ⇔ … 3-dim Matching" → 3-dim Matching), an **approximation-güte facet** („Schärfe der 3/2 Rate" → Christofides), a **proof-example/gadget** („MST in Gegenbeispiel" → the approximation proof), a proof-variable. Put `{{"<nr>": <parent-nr>}}` in `fragments`.
|
- **demote (→ parent number):** it presupposes another block as its subject — a property/status („X ist NP-vollständig", „X ist optional"), a **sub-form/variant** of a base entry („ATX-Überschrift" → Überschriften), a **bound/güte/runtime facet**, a **bare theorem/remark** about X, a **proof-example/gadget**, a proof-variable. Put `{{"<nr>": <parent-nr>}}` in `fragments`.
|
||||||
- **drop:** pure exercise/reference scaffolding with NO real content and NO parent — a bare label („Remark 7.28", „Satz D*"), a one-off notation assignment („r = n + m"). Put its number in `drop`.
|
- **drop:** pure exercise/reference scaffolding with NO real content and NO parent — a bare label („Remark 7.28", „Satz D*"), a one-off notation assignment („r = n + m"). Put its number in `drop`.
|
||||||
- **keep:** it IS a self-contained concept. Do NOT touch it. (Just omit it.)
|
- **keep:** it IS a self-contained concept. Do NOT touch it. (Just omit it.)
|
||||||
|
|
||||||
## KEEP-guards — these are real blocks, never demote/drop them
|
## KEEP-guards — these are real blocks, never demote/drop them
|
||||||
- A **named theorem WITH its own statement or an author**: „Satz 6.24 Cook/Levin — SAT ist NP-vollständig", „Satz von Immerman–Szelepcsényi". KEEP.
|
- **Similarity is NOT containment:** an element with its own syntax/definition and its own purpose is a SIBLING of its neighbours, not their part — even with similar syntax, the same category, or shared context (a blockquote is not part of a code block; a footnote is not part of a task list). KEEP.
|
||||||
- A **complexity-class (in)equality / open question**: „P = NP?", „NL = coNL". KEEP.
|
- A **named theorem WITH its own statement or an author**: „Satz 6.24 Cook/Levin — SAT ist NP-vollständig". KEEP.
|
||||||
- Anything headed „**Definition**", a **problem**, an **algorithm**, a **reduction** („3-SAT ≤ Clique"). KEEP.
|
- A **fundamental (in)equality / open question** of the field: „P = NP?", „NL = coNL". KEEP.
|
||||||
|
- Anything headed „**Definition**", a **problem**, an **algorithm/method**, a **relation between two named things** („3-SAT ≤ Clique"). KEEP.
|
||||||
|
|
||||||
Judge by the CONTENT (after „—"), not the label. When unsure whether something is a fragment or a concept: if it has an obvious parent in the list → demote; otherwise → keep (never drop on doubt).
|
Judge by the CONTENT (after „—"), not the label. When unsure whether something is a fragment or a concept: demote only if the entry clearly makes a statement ABOUT its parent or is a form OF it; otherwise → keep (never drop on doubt).
|
||||||
|
|
||||||
Write ONLY the JSON file to: {out_path}
|
Write ONLY the JSON file to: {out_path}
|
||||||
|
|
||||||
|
|||||||
@@ -8,28 +8,26 @@ JUDGE the numbers **{from_n} to {to_n}** — go through them **ONE BY ONE**, one
|
|||||||
## Procedure per entry (mandatory for EACH one)
|
## Procedure per entry (mandatory for EACH one)
|
||||||
For each entry {from_n}–{to_n}:
|
For each entry {from_n}–{to_n}:
|
||||||
1. What is the **subject**? (What is being talked about?)
|
1. What is the **subject**? (What is being talked about?)
|
||||||
2. Is this subject itself another entry in the list — and does the entry only state a PROPERTY, a PROOF PART, a NOTATION, or a RUNTIME DETAIL about it?
|
2. Is this subject itself another entry in the list — and does the entry only state a PROPERTY, a PART, a SUB-FORM, or a DETAIL of it?
|
||||||
- **Yes → fragment**, parent = the number of that subject.
|
- **Yes → fragment**, parent = the number of that subject.
|
||||||
- No, it stands on its own → block (keep).
|
- No, it stands on its own → block (keep).
|
||||||
Lines marked with **⚠** are suspected cases (property/runtime/notation) — check them especially carefully. Decide by the content, not by the marking.
|
Lines marked with **⚠** are suspected cases (property/detail/notation) — check them especially carefully. Decide by the content, not by the marking.
|
||||||
|
|
||||||
## What is a BLOCK (standalone learning unit — keep)?
|
## What is a BLOCK (standalone learning unit — keep)?
|
||||||
A block is self-contained: you can explain it WITHOUT presupposing another block as its subject.
|
A block is self-contained: you can explain it WITHOUT presupposing another block as its subject. A distinct element, concept, method, problem, or named theorem with its own statement stands on its own.
|
||||||
- A **problem**: „3-SAT", „Clique", „Knapsack", „Dominating Set".
|
- **CRITICAL — similarity is NOT containment.** Two entries with similar syntax, related purpose, or the same category are SIBLINGS, not parent and part. A blockquote is not part of a code block just because both mark lines with a prefix; a footnote is not part of a task list just because both are extensions of the same standard. Demote ONLY when the entry makes a statement ABOUT the parent or is a form OF the parent — never because the two are alike or usually taught together.
|
||||||
- A **method/algorithm**: „LPT Scheduling", „Christofides", „FPTAS".
|
- An element with its own syntax/definition and its own purpose is its own block, even if a bigger neighbour exists.
|
||||||
- A **definition/concept**: „NP", „Reduktion", „Verifizierer", „KNF".
|
- Examples across domains: a **problem** („3-SAT", „Knapsack"), a **method/algorithm** („Christofides", „Quicksort"), a **definition/concept** („NP", „Reduktion", „Blockquote", „Directive"), a **named theorem WITH its own statement** („Cook-Levin: SAT ist NP-vollständig").
|
||||||
- A **named theorem WITH its own statement**: „Cook-Levin: SAT ist NP-vollständig".
|
|
||||||
|
|
||||||
## What is a FRAGMENT (belongs to another block → demote)?
|
## What is a FRAGMENT (belongs to another block → demote)?
|
||||||
Self-containment test: does the entry presuppose ANOTHER concept in the list as its subject? Then it is that concept's property/part, not its own block.
|
Self-containment test: does the entry presuppose ANOTHER concept in the list as its subject? Then it is that concept's property/part, not its own block.
|
||||||
- **Property/status** of a problem X (that is itself in the list): „X ist NP-vollständig", „X ∈ NP", „NP-Schwere von X", „Approximationsgüte von X". → parent = X.
|
- **Property/status of X** (X itself in the list): „X ist NP-vollständig", „X ∈ NP", „X ist optional", „Standard-Verhalten von X". → parent = X.
|
||||||
- **Lower bound / ETH bound** of a problem X: „Lower Bound bzgl. Knoten für VERTEX COVER", „ETH untere Schranke HITTING SET (|U|)", „2^Ω(√|E|) …". → parent = X (the problem the bound is about).
|
- **Parent named in the entry's OWN title:** if the title itself contains another block's name as its subject („Lower Bound für **VERTEX COVER**", „**List Scheduling** Güte", „Anker für **Überschriften**"), that named block IS the parent — demote to it. Do not keep such an entry just because you would scan the whole list; the parent is right there in the title.
|
||||||
- **Bare theorem / proof reference**: „Satz 6.12: P ⊆ NP", „Beweis Satz 6.16 (⇒)", „Beweis ⊃ von Satz 6.21", „Satz 7.20 (Sahni)", „Pm||Cmax NP-vollständig (Satz 7.23)" — a restated inclusion/membership or a bare „Satz N"/„Beweis …" is a proof detail. → parent = the problem/algorithm/class it is about (P/NP, Sahni's algorithm, Pm||Cmax …).
|
- **Sub-form/variant of a base entry** that is itself in the list: „ATX-Überschrift" and „Setext-Überschrift" are forms of „Überschriften"; „Even-Knapsack" is an exercise-tweaked variant of „Rucksackproblem". → parent = the base entry. (A genuinely different concept with its own rules stays its own block — see the sibling rule above.)
|
||||||
- **Proof/reduction gadget or variable**: „αEnde", „A-Komponente", „Dummy Items", „αu-Variablen", „Variablenungleichungen im ILP", „Austausch-Argument". → parent = the theorem/reduction in whose proof it appears.
|
- The following patterns are typical for THEORY-SCRIPT topics (use them when they fit, ignore them otherwise):
|
||||||
- **Approximation-guarantee facet**: „Güte 2", „Güte 2 − 1/m", „Approximative Güte 2", „List Scheduling Güte (2 − 1/m)". → parent = the algorithm it bounds (List Scheduling, LPT, …).
|
- **Bare theorem / proof reference**: „Satz 6.12: P ⊆ NP", „Beweis Satz 6.16 (⇒)", „Satz 7.20 (Sahni)" — a restated inclusion/membership or a bare „Satz N"/„Beweis …" is a proof detail. → parent = the object it is about.
|
||||||
- **Runtime/size detail**: „O(|V|⁴) Verifizierer-Laufzeit", „|V'| = |V| bei Reduktion", „Reduktion in O(|E|)". → parent = the algorithm/reduction.
|
- **Proof/reduction gadget or variable**: „αEnde", „A-Komponente", „Dummy Items", „Austausch-Argument". → parent = the theorem/reduction in whose proof it appears.
|
||||||
- **Parent named in the entry's OWN title:** if the title itself contains another block's name („Lower Bound … für **VERTEX COVER**", „**List Scheduling** Güte …", „**Pm||Cmax** NP-vollständig"), that named block IS the parent — demote to it. Do not keep such an entry just because you would scan the whole list; the parent is right there in the title.
|
- **Bound/guarantee/runtime facet**: „ETH untere Schranke HITTING SET", „Güte 2 − 1/m", „O(|V|⁴) Verifizierer-Laufzeit", „Reduktion in O(|E|)". → parent = the problem/algorithm it bounds.
|
||||||
- **Over-specific variant** of a base problem that is itself in the list: „Even-Knapsack", „Subset Sum Cardinality", „Partition (3·Summe)", „SAT3" are exercise-tweaked variants of „Rucksackproblem"/„Subset Sum"/„Partition"/„SAT". → parent = the base problem. (A genuinely different problem with its own theory stays its own block.)
|
|
||||||
|
|
||||||
## What is an EXERCISE ARTEFACT (no concept at all → hard-drop)?
|
## What is an EXERCISE ARTEFACT (no concept at all → hard-drop)?
|
||||||
Rare, and applied cautiously. ONLY clear exercise-sheet / cross-reference scaffolding that is neither a learnable concept nor a fragment of one AND has no parent in the list. These forms all count, no matter where the marker sits:
|
Rare, and applied cautiously. ONLY clear exercise-sheet / cross-reference scaffolding that is neither a learnable concept nor a fragment of one AND has no parent in the list. These forms all count, no matter where the marker sits:
|
||||||
@@ -37,13 +35,13 @@ Rare, and applied cautiously. ONLY clear exercise-sheet / cross-reference scaffo
|
|||||||
- a bare sheet/task reference: „Blatt 10", „Aufgabe 3", „Übung 7.31";
|
- a bare sheet/task reference: „Blatt 10", „Aufgabe 3", „Übung 7.31";
|
||||||
- a worked-example / table / figure reference: „Scheduling Beispiel Tab. 7.1", „Beispiel 3.2", „Abbildung 4.5";
|
- a worked-example / table / figure reference: „Scheduling Beispiel Tab. 7.1", „Beispiel 3.2", „Abbildung 4.5";
|
||||||
- a one-off framing with no standalone content.
|
- a one-off framing with no standalone content.
|
||||||
Put its number in `drop`. NEVER drop anything that names a real problem/method/definition/theorem/reduction — if there is any doubt, keep it (or demote it as a fragment with a parent). A **named theorem WITH its own statement** („Satz 6.24 Cook/Levin — SAT ist NP-vollständig") is a real block, never an artefact. If it has a parent in the list, prefer demoting (fragment) over dropping.
|
Put its number in `drop`. NEVER drop anything that names a real concept/element/method/definition/theorem — if there is any doubt, keep it (or demote it as a fragment with a parent). A **named theorem WITH its own statement** is a real block, never an artefact. If it has a parent in the list, prefer demoting (fragment) over dropping.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
- A fragment is demoted ONLY if its **parent block is in the list** (give its number). If you find no parent → keep it (don't list it).
|
- A fragment is demoted ONLY if its **parent block is in the list** (give its number). If you find no parent → keep it (don't list it).
|
||||||
- The doubt concerns STANDALONE-NESS: if it's unclear whether an entry stands on its own → keep it. But a clear property/notation/proof part WITH a parent in the list IS a fragment — don't keep it out of caution.
|
- The doubt concerns STANDALONE-NESS: if it's unclear whether an entry stands on its own → keep it. But a clear property/sub-form/detail WITH a parent in the list IS a fragment — don't keep it out of caution.
|
||||||
- A standalone **reduction between two problems** is a block, NOT a fragment („3-SAT ≤ Clique").
|
- A standalone **relation between two named things** is a block, NOT a fragment („3-SAT ≤ Clique").
|
||||||
- A **named theorem WITH its own relational statement** — a biconditional/implication/reduction between two named objects („Satz 6.37: 3-SAT ≤ 3-Färbung … ⇔ …") — is a block; keep it even if it references other blocks. Only a BARE label with no statement („Satz 7.18", „Remark 7.28"), a unary status („X ist NP-vollständig", „X ∈ NP"), or a güte/bound/proof-size facet is a fragment.
|
- A **named theorem WITH its own relational statement** — a biconditional/implication/reduction between two named objects — is a block; keep it even if it references other blocks. Only a BARE label with no statement, a unary status („X ist NP-vollständig"), or a bound/proof facet is a fragment.
|
||||||
- Judge by the CONTENT (after the „—"), not the title.
|
- Judge by the CONTENT (after the „—"), not the title.
|
||||||
|
|
||||||
Write ONLY the JSON file to: {out_path}
|
Write ONLY the JSON file to: {out_path}
|
||||||
|
|||||||
16
templates/Prompt/Guide-Outline-Review.md
Normal file
16
templates/Prompt/Guide-Outline-Review.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
Topic "{topic}". Below is the final chapter outline of a learning guide. Your ONLY job: find blocks that sit in the WRONG chapter and name the chapter where they belong. This is a placement check, not a redesign.
|
||||||
|
|
||||||
|
OUTLINE (chapters are numbered, blocks carry their block number):
|
||||||
|
{chapters}
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Go through every block: does its subject match the chapter's theme better than any other chapter's?
|
||||||
|
- Report ONLY clear misplacements. A defensible placement is NOT a misplacement — leave it.
|
||||||
|
- Do NOT rename chapters, do NOT create chapters, do NOT reorder within a chapter.
|
||||||
|
- If everything fits, report no moves.
|
||||||
|
{extra}
|
||||||
|
|
||||||
|
Write ONLY the JSON file to: {out_path}
|
||||||
|
|
||||||
|
Format — `moves` maps a misplaced BLOCK number to the target CHAPTER number (may be empty):
|
||||||
|
{{"moves": {{"7": 2, "15": 4}}}}
|
||||||
Reference in New Issue
Block a user