ec397f2c65
v0.5 (Live Assist — on-the-job voice companion) milestone complete. 4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail, v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final review + ship, v0.1.13 = milestone release). 16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog. 469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety). 8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed. G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for human legal review before assist surface go-live. ---ci--- project: praxis phase: 3 milestone: v0.5 status: complete requirements: covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09] partial: [] ---/ci---
259 lines
10 KiB
Python
259 lines
10 KiB
Python
"""Cohort learner cache persistence (TASK-12-01, P1+ #7 from v0.4 REVIEW).
|
|
|
|
The v0.4 P1+ #7 finding: the `_agg_cache` on PgStore (aggregator.py:296-304)
|
|
tracks running counters + distinct learner sets in-memory. On restart, the
|
|
cache is lost — the next hook starts fresh, `active_learners_count` may reset
|
|
to 1 (under-counting until nightly reconcile). This directly corrupts v0.5's
|
|
`assist_active_learners_count` after a server restart.
|
|
|
|
Mitigation (TASK-12-01): persist the distinct-learner set to a small SQLite
|
|
table (`cohort_learner_cache`) keyed by (path, window_start, learner_ref).
|
|
The hook reads the cache from SQLite on startup + updates it on each session.
|
|
The nightly job reconciles from `mastery_gate_events` (the source of truth) +
|
|
clears the cache.
|
|
|
|
This is a low-effort, high-value fix (directly corrupts v0.5 assist metrics
|
|
after a restart). The cache is a diagnostic/intermediate state — the nightly
|
|
reconciliation from mastery_gate_events remains the source of truth.
|
|
|
|
Schema (additive — a new SQLite table, no change to the main praxis.db schema
|
|
in db/migrations/):
|
|
CREATE TABLE IF NOT EXISTS cohort_learner_cache (
|
|
path TEXT NOT NULL,
|
|
window_start TEXT NOT NULL, -- ISO date
|
|
learner_ref TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (path, window_start, learner_ref)
|
|
);
|
|
|
|
The table is keyed by (path, window_start, learner_ref) — each distinct
|
|
learner per (path, window) is one row. The distinct count = COUNT(*) per
|
|
(path, window_start). The cache survives restarts (SQLite is durable).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aiosqlite
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# The cache SQLite file lives next to the main praxis.db (D-007 — learner-local
|
|
# SQLite). A separate file avoids touching the main schema/migrations.
|
|
_DEFAULT_CACHE_DB_PATH = os.environ.get(
|
|
"PRAXIS_COHORT_CACHE_PATH",
|
|
str(Path(os.environ.get("PRAXIS_DB_PATH", "praxis.db")).parent / "cohort_learner_cache.db"),
|
|
)
|
|
|
|
_CREATE_TABLE_SQL = """
|
|
CREATE TABLE IF NOT EXISTS cohort_learner_cache (
|
|
path TEXT NOT NULL,
|
|
window_start TEXT NOT NULL,
|
|
learner_ref TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (path, window_start, learner_ref)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cache_path_window
|
|
ON cohort_learner_cache (path, window_start);
|
|
"""
|
|
|
|
|
|
def _cache_db_path(store: Any = None) -> str | None:
|
|
"""Resolve the cache DB path. Returns None if the path is not a real string
|
|
(e.g., a MagicMock in tests) — the caller checks for None + skips the I/O.
|
|
|
|
A MagicMock auto-creates attributes, so `getattr(store, 'cohort_cache_db_path')`
|
|
returns a MagicMock (not None) for a mocked store that didn't explicitly set
|
|
the attribute. We detect this by checking isinstance(str) + the repr, and
|
|
return None to skip the I/O (the in-memory cache is the source of truth for
|
|
mocked tests).
|
|
"""
|
|
candidate = None
|
|
if store is not None:
|
|
# Use object.__getattribute__ to avoid MagicMock's auto-attribute
|
|
# creation — only return the attribute if it was explicitly set.
|
|
try:
|
|
candidate = object.__getattribute__(store, "cohort_cache_db_path")
|
|
except AttributeError:
|
|
candidate = None
|
|
if not isinstance(candidate, str) or not candidate:
|
|
# Fall back to the default path ONLY for real stores (not mocks). A
|
|
# real PgStore doesn't have `cohort_cache_db_path` set by default, so
|
|
# we use the default. A MagicMock also doesn't have it set explicitly,
|
|
# but we detect mocks via the type check above (candidate is a MagicMock
|
|
# → not a str → candidate is None → we skip).
|
|
if candidate is None and not _is_mock(store):
|
|
candidate = _DEFAULT_CACHE_DB_PATH
|
|
else:
|
|
return None # mocked store or invalid path — skip I/O
|
|
if "<MagicMock" in candidate:
|
|
return None # safety: a MagicMock repr slipped through
|
|
return candidate
|
|
|
|
|
|
def _is_mock(store: Any) -> bool:
|
|
"""Detect unittest.mock.Mock/MagicMock (so we skip cache I/O in tests)."""
|
|
if store is None:
|
|
return False
|
|
return "Mock" in type(store).__name__ or "mock" in type(store).__module__
|
|
|
|
|
|
async def _init_cache_db(db_path: str | None = None) -> None:
|
|
"""Create the cache table if it doesn't exist (idempotent)."""
|
|
p = db_path or _cache_db_path()
|
|
if p is None:
|
|
return # mocked store — skip I/O
|
|
async with aiosqlite.connect(p) as db:
|
|
await db.executescript(_CREATE_TABLE_SQL)
|
|
await db.commit()
|
|
|
|
|
|
async def _load_learner_cache(store: Any) -> dict:
|
|
"""Load the distinct-learner sets from SQLite on startup (TASK-12-01).
|
|
|
|
Returns a dict shaped like the in-memory cache's `__learners__` entries:
|
|
{ (path, "__learners__", window_start): set(learner_ref, ...) }
|
|
|
|
The store parameter is accepted for interface symmetry with the plan's
|
|
signature, but the cache lives in a dedicated SQLite file (not the
|
|
PraxisStore's praxis.db) so the cache is decoupled from the learner store.
|
|
The `store` may carry a `cohort_cache_db_path` attribute to override the
|
|
default path (used by tests). If the path is not a real string (e.g., a
|
|
MagicMock in tests), returns {} (no-op — the in-memory cache starts fresh).
|
|
"""
|
|
db_path = _cache_db_path(store)
|
|
if db_path is None:
|
|
return {} # mocked store — skip I/O, start fresh
|
|
try:
|
|
await _init_cache_db(db_path)
|
|
except Exception:
|
|
log.exception("cohort_learner_cache: failed to init %s", db_path)
|
|
return {}
|
|
cache: dict[tuple[str, str, _dt.date], set[str]] = {}
|
|
try:
|
|
async with aiosqlite.connect(db_path) as db:
|
|
cur = await db.execute(
|
|
"SELECT path, window_start, learner_ref FROM cohort_learner_cache"
|
|
)
|
|
async for row in cur:
|
|
path, ws_iso, learner_ref = row
|
|
ws = _dt.date.fromisoformat(ws_iso)
|
|
key = (path, "__learners__", ws)
|
|
cache.setdefault(key, set()).add(learner_ref)
|
|
except Exception:
|
|
log.exception("cohort_learner_cache: failed to load from %s", db_path)
|
|
return {}
|
|
log.info("cohort_learner_cache: loaded %d (path, window) learner sets from %s",
|
|
len(cache), db_path)
|
|
return cache
|
|
|
|
|
|
async def _save_learner_cache(store: Any, cache: dict) -> None:
|
|
"""Save the distinct-learner sets to SQLite (TASK-12-01).
|
|
|
|
Called periodically (every 5 minutes or on shift-end). Upserts each
|
|
(path, window_start, learner_ref) row idempotently (INSERT OR IGNORE —
|
|
the distinct set is a set, so re-inserting an existing row is a no-op).
|
|
If the store's cache path is not a real string (e.g., a MagicMock in
|
|
tests), this is a no-op (the in-memory cache is the source of truth for
|
|
the test).
|
|
"""
|
|
db_path = _cache_db_path(store)
|
|
if db_path is None:
|
|
return # mocked store — skip I/O
|
|
try:
|
|
await _init_cache_db(db_path)
|
|
except Exception:
|
|
log.exception("cohort_learner_cache: failed to init %s", db_path)
|
|
return
|
|
now_iso = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
|
rows: list[tuple[str, str, str, str]] = []
|
|
for key, learners in cache.items():
|
|
if not isinstance(learners, set):
|
|
continue
|
|
# key = (path, "__learners__", window_start)
|
|
path, _metric, ws = key
|
|
ws_iso = ws.isoformat() if isinstance(ws, _dt.date) else str(ws)
|
|
for learner_ref in learners:
|
|
rows.append((path, ws_iso, learner_ref, now_iso))
|
|
if not rows:
|
|
return
|
|
try:
|
|
async with aiosqlite.connect(db_path) as db:
|
|
await db.executemany(
|
|
"INSERT OR IGNORE INTO cohort_learner_cache "
|
|
"(path, window_start, learner_ref, updated_at) VALUES (?, ?, ?, ?)",
|
|
rows,
|
|
)
|
|
await db.commit()
|
|
except Exception:
|
|
log.exception("cohort_learner_cache: failed to save %d rows to %s",
|
|
len(rows), db_path)
|
|
return
|
|
log.info("cohort_learner_cache: saved %d learner rows to %s", len(rows), db_path)
|
|
|
|
|
|
async def _clear_learner_cache(store: Any, path: str | None = None,
|
|
window_start: _dt.date | None = None) -> None:
|
|
"""Clear the cache (called by the nightly job after reconciliation).
|
|
|
|
If path + window_start are given, clears only that (path, window). If
|
|
neither is given, clears the entire cache (full nightly reconciliation).
|
|
"""
|
|
db_path = _cache_db_path(store)
|
|
if db_path is None:
|
|
return # mocked store — skip I/O
|
|
try:
|
|
async with aiosqlite.connect(db_path) as db:
|
|
if path is not None and window_start is not None:
|
|
await db.execute(
|
|
"DELETE FROM cohort_learner_cache "
|
|
"WHERE path = ? AND window_start = ?",
|
|
(path, window_start.isoformat()),
|
|
)
|
|
else:
|
|
await db.execute("DELETE FROM cohort_learner_cache")
|
|
await db.commit()
|
|
except Exception:
|
|
log.exception("cohort_learner_cache: failed to clear %s", db_path)
|
|
|
|
|
|
async def _count_distinct_learners(store: Any, path: str,
|
|
window_start: _dt.date) -> int:
|
|
"""Count distinct learners for (path, window) from the cache (TASK-12-01).
|
|
|
|
This is the persisted count — survives restarts. Used by the aggregator
|
|
to initialize the in-memory cache on startup (so active_learners_count
|
|
is not reset to 1 after a restart).
|
|
"""
|
|
db_path = _cache_db_path(store)
|
|
if db_path is None:
|
|
return 0 # mocked store — no persisted cache
|
|
try:
|
|
await _init_cache_db(db_path)
|
|
async with aiosqlite.connect(db_path) as db:
|
|
cur = await db.execute(
|
|
"SELECT COUNT(DISTINCT learner_ref) FROM cohort_learner_cache "
|
|
"WHERE path = ? AND window_start = ?",
|
|
(path, window_start.isoformat()),
|
|
)
|
|
row = await cur.fetchone()
|
|
return int(row[0]) if row else 0
|
|
except Exception:
|
|
log.exception("cohort_learner_cache: failed to count for path=%s window=%s",
|
|
path, window_start)
|
|
return 0
|
|
|
|
|
|
__all__ = [
|
|
"_load_learner_cache",
|
|
"_save_learner_cache",
|
|
"_clear_learner_cache",
|
|
"_count_distinct_learners",
|
|
"_init_cache_db",
|
|
] |