This commit is contained in:
team3
2026-07-03 11:45:27 +02:00
parent 285317927d
commit abcadd145d
44 changed files with 1909 additions and 292 deletions

View File

@@ -186,6 +186,20 @@ CREATE TABLE IF NOT EXISTS sub_artefakte (
)
"""
# Leitner learning state per flashcard — IDENTITY-keyed (no content): survives the
# sub_artefakte wipe on regeneration; orphaned rows simply never match in the deck join.
CREATE_PRACTICE_PROGRESS = """
CREATE TABLE IF NOT EXISTS practice_progress (
topic TEXT NOT NULL,
block_norm TEXT NOT NULL,
sub_norm TEXT NOT NULL,
box INTEGER NOT NULL DEFAULT 1,
due_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, block_norm, sub_norm)
)
"""
# Kanban dataflow (boards 'inventory' + 'artefacts'): ONE generic card table for all card kinds
# (title/cluster/block). `stage` is the queue key — a worker pulls WHERE stage = <its input stage>.
# `payload` is a JSON blob (title, description, sources, readers, mentions, parent_norm, journal …);
@@ -307,6 +321,7 @@ async def init_db():
await db.execute(CREATE_SOURCE)
await db.execute(CREATE_GUIDE_OUTLINE)
await db.execute(CREATE_SUB_ARTEFAKTE)
await db.execute(CREATE_PRACTICE_PROGRESS)
await db.execute(CREATE_KANBAN_CARDS)
await db.execute(CREATE_EVENTS)
await db.execute(CREATE_EVENTS_INDEX)
@@ -592,8 +607,10 @@ async def subs_per_level(topic: str, block: str) -> dict[int, int]:
from textkit import _norm_title
db = await get_db()
cursor = await db.execute(
f"SELECT {_LEVEL_CASE} AS level, COUNT(*) FROM subblocks "
"WHERE topic = ? AND block_norm = ? AND status = 'consensus' GROUP BY level",
# alias must NOT be named `level`: SQLite resolves an ambiguous GROUP BY name to
# the source COLUMN, which silently miscounts peripheral subs
f"SELECT {_LEVEL_CASE} AS lv, COUNT(*) FROM subblocks "
"WHERE topic = ? AND block_norm = ? AND status = 'consensus' GROUP BY lv",
(topic, _norm_title(block)),
)
out = _empty_levels()
@@ -606,8 +623,8 @@ async def subs_per_level_raw(topic: str) -> dict[str, dict[int, int]]:
"""Subblocks per level, grouped by RAW block title (= guide section title)."""
db = await get_db()
cursor = await db.execute(
f"SELECT block, {_LEVEL_CASE} AS level, COUNT(*) FROM subblocks "
"WHERE topic = ? AND status = 'consensus' GROUP BY block, level",
f"SELECT block, {_LEVEL_CASE} AS lv, COUNT(*) FROM subblocks "
"WHERE topic = ? AND status = 'consensus' GROUP BY block, lv",
(topic,),
)
out: dict[str, dict[int, int]] = {}
@@ -620,8 +637,8 @@ async def subs_per_level_all() -> dict[tuple[str, str], dict[int, int]]:
"""Subblocks per level per (topic, block_norm) — for the topic-wide levels derivation."""
db = await get_db()
cursor = await db.execute(
f"SELECT topic, block_norm, {_LEVEL_CASE} AS level, COUNT(*) FROM subblocks "
"WHERE status = 'consensus' GROUP BY topic, block_norm, level"
f"SELECT topic, block_norm, {_LEVEL_CASE} AS lv, COUNT(*) FROM subblocks "
"WHERE status = 'consensus' GROUP BY topic, block_norm, lv"
)
out: dict[tuple[str, str], dict[int, int]] = {}
for t, bn, level, n in await cursor.fetchall():
@@ -741,11 +758,13 @@ def _card(row, cursor) -> dict:
async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]:
"""`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."""
block starts earliest and stops dominating the makespan tail. `n_size` (board 1,
reader count) is the coarser fallback estimate. Others stay FIFO."""
db = await get_db()
cursor = await db.execute(
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ?
ORDER BY COALESCE(json_extract(payload, '$.subs_n'), 0) DESC, updated_at LIMIT ?""",
ORDER BY COALESCE(json_extract(payload, '$.subs_n'),
json_extract(payload, '$.n_size'), 0) DESC, updated_at LIMIT ?""",
(topic, board, stage, _now(), limit))
return [_card(row, cursor) for row in await cursor.fetchall()]
@@ -1211,6 +1230,30 @@ async def list_question_pattern(topic: str, block_norm: str | None = None) -> li
return [_row_to_dict(row, cursor) for row in rows]
async def count_question_pattern_blocks(topic: str) -> int:
"""Blocks that have at least one exam question pattern."""
db = await get_db()
cur = await db.execute("SELECT COUNT(DISTINCT block_norm) FROM question_pattern WHERE topic = ?", (topic,))
return (await cur.fetchone())[0]
async def count_sub_artefakte(topic: str) -> int:
db = await get_db()
cur = await db.execute("SELECT COUNT(*) FROM sub_artefakte WHERE topic = ?", (topic,))
return (await cur.fetchone())[0]
async def event_span(topic: str) -> int:
"""Minutes between first and last pipeline event of the topic (0 if none)."""
db = await get_db()
cur = await db.execute("SELECT MIN(ts), MAX(ts) FROM events WHERE topic = ?", (topic,))
lo, hi = await cur.fetchone()
if not lo or not hi:
return 0
from datetime import datetime
return int((datetime.fromisoformat(hi) - datetime.fromisoformat(lo)).total_seconds() // 60)
async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None:
db = await get_db()
if block_norm is None:
@@ -1378,18 +1421,64 @@ async def put_sub_artifact(topic: str, block_norm: str, sub_norm: str, type: str
await db.commit()
async def get_sub_artefakte(topic: str, type: str | None = None) -> list[dict]:
async def get_sub_artefakte(topic: str, type: str | None = None,
block_norm: str | None = None) -> list[dict]:
db = await get_db()
if type is None:
cursor = await db.execute("SELECT * FROM sub_artefakte WHERE topic = ? ORDER BY rowid", (topic,))
else:
cursor = await db.execute(
"SELECT * FROM sub_artefakte WHERE topic = ? AND type = ? ORDER BY rowid", (topic, type)
)
sql = "SELECT * FROM sub_artefakte WHERE topic = ?"
args: list = [topic]
if type is not None:
sql += " AND type = ?"
args.append(type)
if block_norm is not None:
sql += " AND block_norm = ?"
args.append(block_norm)
cursor = await db.execute(sql + " ORDER BY rowid", args)
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def get_practice_progress(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT block_norm, sub_norm, box, due_at FROM practice_progress WHERE topic = ?", (topic,))
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def upsert_practice_progress(topic: str, block_norm: str, sub_norm: str,
box: int, due_at: str) -> None:
db = await get_db()
await db.execute(
"""INSERT INTO practice_progress (topic, block_norm, sub_norm, box, due_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, block_norm, sub_norm)
DO UPDATE SET box = excluded.box, due_at = excluded.due_at,
updated_at = excluded.updated_at""",
(topic, block_norm, sub_norm, box, due_at, _now()))
await db.commit()
async def sub_levels_norm(topic: str) -> dict[tuple[str, str], int]:
"""(block_norm, sub_norm) → level 1-4 for the consensus subs — the practice deck's gate."""
db = await get_db()
cursor = await db.execute(
f"SELECT block_norm, sub_norm, {_LEVEL_CASE} AS level FROM subblocks "
"WHERE topic = ? AND status = 'consensus'", (topic,))
return {(bn, sn): lv for bn, sn, lv in await cursor.fetchall()}
async def subs_per_level_norm(topic: str) -> dict[str, dict[int, int]]:
"""Subblocks per level, grouped by block_norm (sub_artefakte is norm-keyed)."""
db = await get_db()
cursor = await db.execute(
f"SELECT block_norm, {_LEVEL_CASE} AS lv, COUNT(*) FROM subblocks "
"WHERE topic = ? AND status = 'consensus' GROUP BY block_norm, lv", (topic,))
out: dict[str, dict[int, int]] = {}
for bn, level, n in await cursor.fetchall():
out.setdefault(bn, _empty_levels())[level] = n
return out
async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None:
db = await get_db()
if block_norm is None:
@@ -1426,6 +1515,6 @@ async def delete_topic_pipeline(topic: str) -> None:
NOT the topic config `source` — that is managed separately (delete_source)."""
db = await get_db()
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage",
"pipeline_state", "guide_outline", "sub_artefakte", "events"):
"pipeline_state", "guide_outline", "sub_artefakte", "practice_progress", "events"):
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
await db.commit()