update
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user