jang0294 commited on
Commit
dfdfc61
Β·
verified Β·
1 Parent(s): 7dc5a4a

Upload folder using huggingface_hub

Browse files
agents/backend.py CHANGED
@@ -277,6 +277,102 @@ class HuggingFaceBackend(Backend):
277
  return text
278
 
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  # ── Resolver ─────────────────────────────────────────────────────────────────
281
 
282
  def get_backend(spec_str: str) -> Backend:
@@ -289,7 +385,11 @@ def get_backend(spec_str: str) -> Backend:
289
  "hf:google/gemma-2-9b-it" β†’ HuggingFaceBackend (local, needs CUDA)
290
  "hf:base+adapter" β†’ HuggingFaceBackend with LoRA adapter (CUDA)
291
  "hf-endpoint:https://..." β†’ HFEndpointBackend (managed, no local GPU)
 
 
292
  """
 
 
293
  # Global local-LLM override: when LLM_BACKEND=local (or LOCAL_LLM_BASE is
294
  # set), route claude/* specialists to the local Gemma server too. The
295
  # multi-agent debate calls get_backend(spec.base_model).complete() (NOT
 
277
  return text
278
 
279
 
280
+ # ── Model families + deterministic dry-run backend (Phase 3 exams) ──────────
281
+
282
+ _FAMILIES = ("claude", "gemma", "llama", "qwen", "deepseek", "mistral",
283
+ "gpt", "stub")
284
+
285
+
286
+ def family_of(spec: str) -> str:
287
+ """Model family of a backend spec string.
288
+
289
+ Used by atp/exams.py to enforce the ATP standard's judge/candidate
290
+ separation (docs/ATP.md Β§2 principle 2): the grading model family must
291
+ differ from the candidate's. Substring match against the known families;
292
+ unknown specs map to 'unknown' (which still collides with itself, so two
293
+ unknown specs are treated as the same family β€” conservative by design).
294
+
295
+ The dry-run specs are special-cased to two DISTINCT synthetic families
296
+ ('dryrun:candidate' β†’ 'stub-a', 'dryrun:judge' β†’ 'stub-b') so CI can
297
+ exercise the whole exam loop, separation check included, with no real
298
+ models. Any other 'dryrun:*' spec is plain 'stub'.
299
+ """
300
+ s = (spec or "").lower()
301
+ if s.startswith("dryrun:candidate"):
302
+ return "stub-a"
303
+ if s.startswith("dryrun:judge"):
304
+ return "stub-b"
305
+ if s.startswith("dryrun"):
306
+ return "stub"
307
+ for fam in _FAMILIES:
308
+ if fam in s:
309
+ return fam
310
+ return "unknown"
311
+
312
+
313
+ class DryRunBackend(Backend):
314
+ """Deterministic stub backend for --dry-run exams and CI.
315
+
316
+ complete() is a pure function of (spec, system, user): a SHA-256 over the
317
+ prompts drives everything β€” no RNG state, no sampling, no network β€” so the
318
+ same inputs always yield byte-identical output (which keeps the Phase 3
319
+ evidence signatures reproducible). Two behaviours:
320
+
321
+ * judge-style prompts (they ask for a JSON object with a "score") get
322
+ valid strict JSON {"score": <0.55..0.95>, "rationale": "..."} with the
323
+ score derived from the prompt hash;
324
+ * anything else gets canned-but-plausible worked-answer text.
325
+
326
+ Distinct specs ('dryrun:candidate' vs 'dryrun:judge') hash differently,
327
+ so candidate and judge never parrot each other.
328
+ """
329
+
330
+ def __init__(self, seed_from_spec: str):
331
+ self.name = seed_from_spec
332
+ self.seed = seed_from_spec
333
+
334
+ def _digest(self, system: str, user: str) -> str:
335
+ import hashlib
336
+ raw = f"{self.seed}\x1f{system}\x1f{user}".encode("utf-8")
337
+ return hashlib.sha256(raw).hexdigest()
338
+
339
+ _RATIONALES = (
340
+ "Matches the reference result and the key steps are shown.",
341
+ "Correct final answer; one intermediate step is under-justified.",
342
+ "Mostly right, but the edge case in the reference is not addressed.",
343
+ "Sound method; minor imprecision versus the reference criteria.",
344
+ "Agrees with the reference on substance with small stylistic gaps.",
345
+ )
346
+ _OPENERS = (
347
+ "Working step by step:",
348
+ "Setting up the problem first:",
349
+ "Applying the standard method:",
350
+ "Checking the definition before computing:",
351
+ )
352
+ _CLOSERS = (
353
+ "which matches the expected form.",
354
+ "so the result follows directly.",
355
+ "verified by substituting back.",
356
+ "and the boundary case checks out.",
357
+ )
358
+
359
+ def complete(self, system: str, user: str, max_tokens: int = 1500) -> str:
360
+ import json
361
+ h = self._digest(system, user)
362
+ blob = (system + "\n" + user).lower()
363
+ if '"score"' in blob or ("json" in blob and "score" in blob):
364
+ # Judge-style prompt β†’ strict JSON verdict, score in [0.55, 0.95).
365
+ score = 0.55 + (int(h[:8], 16) % 4000) / 10000.0
366
+ rationale = self._RATIONALES[int(h[8:12], 16) % len(self._RATIONALES)]
367
+ return json.dumps({"score": round(score, 4), "rationale": rationale})
368
+ opener = self._OPENERS[int(h[:4], 16) % len(self._OPENERS)]
369
+ closer = self._CLOSERS[int(h[4:8], 16) % len(self._CLOSERS)]
370
+ return (
371
+ f"{opener} restate the givens, apply the relevant rule, and "
372
+ f"simplify. Final answer: see derivation ref {h[:12]}, {closer}"
373
+ )
374
+
375
+
376
  # ── Resolver ─────────────────────────────────────────────────────────────────
377
 
378
  def get_backend(spec_str: str) -> Backend:
 
385
  "hf:google/gemma-2-9b-it" β†’ HuggingFaceBackend (local, needs CUDA)
386
  "hf:base+adapter" β†’ HuggingFaceBackend with LoRA adapter (CUDA)
387
  "hf-endpoint:https://..." β†’ HFEndpointBackend (managed, no local GPU)
388
+ "dryrun:candidate" / "dryrun:judge" β†’ DryRunBackend (deterministic stub,
389
+ Phase 3 --dry-run exams / CI; no network)
390
  """
391
+ if spec_str.startswith("dryrun"):
392
+ return DryRunBackend(spec_str)
393
  # Global local-LLM override: when LLM_BACKEND=local (or LOCAL_LLM_BASE is
394
  # set), route claude/* specialists to the local Gemma server too. The
395
  # multi-agent debate calls get_backend(spec.base_model).complete() (NOT
agents/confusion.py CHANGED
@@ -44,53 +44,39 @@ def paragraph_hash(text: str) -> str:
44
  return hashlib.sha1((text or "").strip().encode()).hexdigest()[:12]
45
 
46
 
47
- # ── Persistence (lightweight table; lazy-creates) ─────────────────────────
48
-
49
- def _ensure_table(db):
50
- db.execute("""
51
- CREATE TABLE IF NOT EXISTS confusion_signals (
52
- user_id TEXT NOT NULL,
53
- paragraph_hash TEXT NOT NULL,
54
- signal_kind TEXT NOT NULL,
55
- value REAL NOT NULL,
56
- ts REAL NOT NULL
57
- )
58
- """)
59
- db.execute("""
60
- CREATE INDEX IF NOT EXISTS idx_confusion_user_para
61
- ON confusion_signals(user_id, paragraph_hash)
62
- """)
63
-
64
 
65
  def log_signal(user_id: str, paragraph_hash_str: str,
66
  signal_kind: str, value: float = 1.0) -> None:
67
  if signal_kind not in SIGNAL_WEIGHTS:
68
  return
69
  import time
70
- from .persistence import open_db
71
- db = open_db()
72
- _ensure_table(db)
73
  db.execute(
74
  """INSERT INTO confusion_signals
75
  (user_id, paragraph_hash, signal_kind, value, ts)
76
- VALUES (?, ?, ?, ?, ?)""",
77
- (user_id, paragraph_hash_str, signal_kind, float(value), time.time()),
 
 
78
  )
79
- db.commit()
80
 
81
 
82
  def score(user_id: str, paragraph_hash_str: str) -> float:
83
  """Aggregate signals for one paragraph β†’ score in [0, 1]."""
84
- from .persistence import open_db
85
- db = open_db()
86
- _ensure_table(db)
87
- rows = db.execute(
88
  """SELECT signal_kind, value FROM confusion_signals
89
- WHERE user_id = ? AND paragraph_hash = ?""",
90
- (user_id, paragraph_hash_str),
91
- ).fetchall()
92
  agg = 0.0
93
- for kind, value in rows:
 
94
  weight = SIGNAL_WEIGHTS.get(kind, 0.0)
95
  if kind == "dwell_s":
96
  extra = max(0.0, value - 30.0)
@@ -102,18 +88,19 @@ def score(user_id: str, paragraph_hash_str: str) -> float:
102
 
103
  def hotspots(user_id: str, top_k: int = 5) -> list[dict]:
104
  """List the user's most-confusing paragraphs across the corpus."""
105
- from .persistence import open_db
106
- db = open_db()
107
- _ensure_table(db)
108
- rows = db.execute(
109
  """SELECT paragraph_hash
110
  FROM confusion_signals
111
- WHERE user_id = ?
112
  GROUP BY paragraph_hash""",
113
- (user_id,),
114
- ).fetchall()
115
  out = []
116
- for (ph,) in rows:
 
117
  out.append({"paragraph_hash": ph, "score": score(user_id, ph)})
118
  out.sort(key=lambda d: -d["score"])
119
  return out[:top_k]
 
44
  return hashlib.sha1((text or "").strip().encode()).hexdigest()[:12]
45
 
46
 
47
+ # ── Persistence (confusion_signals β€” provisioned by migrations/003) ───────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  def log_signal(user_id: str, paragraph_hash_str: str,
50
  signal_kind: str, value: float = 1.0) -> None:
51
  if signal_kind not in SIGNAL_WEIGHTS:
52
  return
53
  import time
54
+ from atp import db
55
+ from .persistence import ensure_schema
56
+ ensure_schema()
57
  db.execute(
58
  """INSERT INTO confusion_signals
59
  (user_id, paragraph_hash, signal_kind, value, ts)
60
+ VALUES (:user_id, :paragraph_hash, :signal_kind, :value, :ts)""",
61
+ {"user_id": user_id, "paragraph_hash": paragraph_hash_str,
62
+ "signal_kind": signal_kind, "value": float(value),
63
+ "ts": time.time()},
64
  )
 
65
 
66
 
67
  def score(user_id: str, paragraph_hash_str: str) -> float:
68
  """Aggregate signals for one paragraph β†’ score in [0, 1]."""
69
+ from atp import db
70
+ from .persistence import ensure_schema
71
+ ensure_schema()
72
+ rows = db.query(
73
  """SELECT signal_kind, value FROM confusion_signals
74
+ WHERE user_id = :user_id AND paragraph_hash = :paragraph_hash""",
75
+ {"user_id": user_id, "paragraph_hash": paragraph_hash_str},
76
+ )
77
  agg = 0.0
78
+ for row in rows:
79
+ kind, value = row["signal_kind"], row["value"]
80
  weight = SIGNAL_WEIGHTS.get(kind, 0.0)
81
  if kind == "dwell_s":
82
  extra = max(0.0, value - 30.0)
 
88
 
89
  def hotspots(user_id: str, top_k: int = 5) -> list[dict]:
90
  """List the user's most-confusing paragraphs across the corpus."""
91
+ from atp import db
92
+ from .persistence import ensure_schema
93
+ ensure_schema()
94
+ rows = db.query(
95
  """SELECT paragraph_hash
96
  FROM confusion_signals
97
+ WHERE user_id = :user_id
98
  GROUP BY paragraph_hash""",
99
+ {"user_id": user_id},
100
+ )
101
  out = []
102
+ for row in rows:
103
+ ph = row["paragraph_hash"]
104
  out.append({"paragraph_hash": ph, "score": score(user_id, ph)})
105
  out.sort(key=lambda d: -d["score"])
106
  return out[:top_k]
agents/heatmap.py CHANGED
@@ -23,10 +23,8 @@ from typing import Iterable
23
 
24
  def mastery_per_cluster(user_id: str, clusters: Iterable) -> dict:
25
  """Compute per-cluster mastery for a user."""
26
- from .persistence import open_db
27
- db = open_db()
28
- visited = _visited_nodes(db, user_id)
29
- sr_state = _sr_state(db, user_id)
30
 
31
  out = {}
32
  for cluster in clusters:
@@ -70,21 +68,24 @@ def weakest(user_id: str, clusters: Iterable, n: int = 5) -> list[int]:
70
  return [cid for cid, _ in ordered[:n]]
71
 
72
 
73
- def _visited_nodes(db, user_id: str) -> dict:
74
- """Map node β†’ {best_quality, n_visits}. Reads sessions table."""
75
- rows = db.execute(
76
- """SELECT node, MAX(quiz_quality), COUNT(*)
 
 
 
 
77
  FROM walk_events
78
- WHERE user_id = ? AND node IS NOT NULL
79
  GROUP BY node""",
80
- (user_id,),
81
- ).fetchall()
82
- return {r[0]: {"best_quality": r[1], "n_visits": r[2]} for r in rows}
 
83
 
84
 
85
- def _sr_state(db, user_id: str) -> dict:
86
- rows = db.execute(
87
- "SELECT node, repetitions FROM sr_cards WHERE user_id = ?",
88
- (user_id,),
89
- ).fetchall()
90
- return {r[0]: {"repetitions": r[1]} for r in rows}
 
23
 
24
  def mastery_per_cluster(user_id: str, clusters: Iterable) -> dict:
25
  """Compute per-cluster mastery for a user."""
26
+ visited = _visited_nodes(user_id)
27
+ sr_state = _sr_state(user_id)
 
 
28
 
29
  out = {}
30
  for cluster in clusters:
 
68
  return [cid for cid, _ in ordered[:n]]
69
 
70
 
71
+ def _visited_nodes(user_id: str) -> dict:
72
+ """Map node β†’ {best_quality, n_visits}. Reads walk_events."""
73
+ from atp import db
74
+ from .persistence import ensure_schema
75
+ ensure_schema()
76
+ rows = db.query(
77
+ """SELECT node, MAX(quiz_quality) AS best_quality,
78
+ COUNT(*) AS n_visits
79
  FROM walk_events
80
+ WHERE user_id = :user_id AND node IS NOT NULL
81
  GROUP BY node""",
82
+ {"user_id": user_id},
83
+ )
84
+ return {r["node"]: {"best_quality": r["best_quality"],
85
+ "n_visits": r["n_visits"]} for r in rows}
86
 
87
 
88
+ def _sr_state(user_id: str) -> dict:
89
+ from . import persistence
90
+ return {card["node"]: {"repetitions": card["repetitions"]}
91
+ for card in persistence.all_sr_cards(user_id)}
 
 
agents/jobs.py CHANGED
@@ -2,108 +2,136 @@
2
  Lightweight background job queue.
3
 
4
  Heavy work (podcast/video render, local TTS) should not block the HTTP
5
- request β€” it can take 30-120s. This runs such work on a bounded thread pool
6
- and tracks status in SQLite so any request can poll it.
 
7
 
8
- In-process + single-instance by design (matches a single Render web service).
9
- For multi-instance horizontal scaling, swap the store for Redis/RQ β€” the
10
- public API (submit / get) stays the same.
 
 
 
11
 
12
  Public:
13
- submit(kind, fn, *args, **kwargs) -> job_id # runs fn in background
14
- get(job_id) -> dict | None # status + result/error
15
 
16
  Job dict:
17
  {job_id, kind, status: queued|running|done|error,
18
  result: <json>|None, error: str|None, created_at, updated_at}
19
 
20
  The fn's return value MUST be JSON-serializable (dicts/lists/str/num).
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  """
22
 
23
  from __future__ import annotations
24
 
25
  import json
 
26
  import time
27
  import traceback
28
  import uuid
29
  from concurrent.futures import ThreadPoolExecutor
30
 
31
- from .persistence import open_db
32
-
33
- # Render is RAM/CPU heavy β€” keep concurrency low so the box doesn't OOM.
34
- _EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="bu-job")
35
-
36
-
37
- def _ensure_table(db) -> None:
38
- db.execute("""
39
- CREATE TABLE IF NOT EXISTS jobs (
40
- job_id TEXT PRIMARY KEY,
41
- kind TEXT NOT NULL,
42
- status TEXT NOT NULL,
43
- result TEXT,
44
- error TEXT,
45
- created_at REAL NOT NULL,
46
- updated_at REAL NOT NULL
47
- )
48
- """)
49
-
50
-
51
- def _set(job_id: str, *, status: str, result=None, error: str | None = None) -> None:
52
- db = open_db()
53
- _ensure_table(db)
54
- db.execute(
55
- """UPDATE jobs SET status=?, result=?, error=?, updated_at=?
56
- WHERE job_id=?""",
57
- (status,
58
- json.dumps(result) if result is not None else None,
59
- error, time.time(), job_id),
 
 
60
  )
61
- db.commit()
 
62
 
 
 
63
 
64
- def submit(kind: str, fn, *args, **kwargs) -> str:
65
- """Enqueue fn(*args, **kwargs) to run in the background. Returns job_id."""
 
 
66
  job_id = "job_" + uuid.uuid4().hex[:12]
67
  now = time.time()
68
- db = open_db()
69
- _ensure_table(db)
70
- db.execute(
71
- """INSERT INTO jobs (job_id, kind, status, created_at, updated_at)
72
- VALUES (?, ?, 'queued', ?, ?)""",
73
- (job_id, kind, now, now),
74
- )
75
- db.commit()
76
 
77
  def _run():
78
- _set(job_id, status="running")
 
79
  try:
80
  result = fn(*args, **kwargs)
81
- _set(job_id, status="done", result=result)
82
  except Exception as e: # noqa: BLE001 β€” capture everything for the poller
83
- _set(job_id, status="error",
84
- error=f"{type(e).__name__}: {e}\n{traceback.format_exc()[-1500:]}")
 
 
85
 
86
  _EXECUTOR.submit(_run)
87
  return job_id
88
 
89
 
90
- def get(job_id: str) -> dict | None:
91
- """Return the job's current status dict, or None if unknown."""
92
- db = open_db()
93
- _ensure_table(db)
94
- row = db.execute(
 
 
 
95
  """SELECT job_id, kind, status, result, error, created_at, updated_at
96
- FROM jobs WHERE job_id=?""",
97
- (job_id,),
98
- ).fetchone()
99
- if not row:
100
  return None
 
101
  return {
102
- "job_id": row[0],
103
- "kind": row[1],
104
- "status": row[2],
105
- "result": json.loads(row[3]) if row[3] else None,
106
- "error": row[4],
107
- "created_at": row[5],
108
- "updated_at": row[6],
109
  }
 
2
  Lightweight background job queue.
3
 
4
  Heavy work (podcast/video render, local TTS) should not block the HTTP
5
+ request β€” it can take 30-120s. This runs such work on a bounded in-process
6
+ thread pool and tracks status in the shared database so any request β€” on any
7
+ instance β€” can poll it.
8
 
9
+ The fn itself still executes in the submitting process (the closure can't
10
+ cross instances), but every status transition is a guarded UPDATE
11
+ (`WHERE job_id = :id AND status = :expected`), so with multiple instances
12
+ sharing one database a job can never be claimed or double-transitioned
13
+ twice. For fully distributed execution, swap the executor for Redis/RQ β€”
14
+ the public API (submit / get) stays the same.
15
 
16
  Public:
17
+ submit(kind, fn, *args, org_id="org-demo", **kwargs) -> job_id
18
+ get(job_id, org_id="org-demo") -> dict | None # status + result/error
19
 
20
  Job dict:
21
  {job_id, kind, status: queued|running|done|error,
22
  result: <json>|None, error: str|None, created_at, updated_at}
23
 
24
  The fn's return value MUST be JSON-serializable (dicts/lists/str/num).
25
+
26
+ Tenancy (Phase 2, docs/TENANCY.md leak surface #2): `jobs` is a tenant table
27
+ (migration 005 β€” org_id column, Postgres RLS deny-by-default), so ALL access
28
+ goes through the atp/tenant_db.py scoped helpers, never raw atp.db
29
+ query()/execute(). Every row carries the submitting org's id: `org_id` is
30
+ consumed by submit() itself (NOT forwarded to fn) and MUST come from the
31
+ verified request state. get() only sees rows belonging to the org it is
32
+ called with β€” the /jobs route in api/server.py turns a cross-tenant miss
33
+ into the same 404 as an unknown id.
34
+
35
+ Env:
36
+ BU_JOB_WORKERS β€” thread-pool size (default 2; Render is RAM/CPU heavy,
37
+ keep concurrency low so the box doesn't OOM).
38
  """
39
 
40
  from __future__ import annotations
41
 
42
  import json
43
+ import os
44
  import time
45
  import traceback
46
  import uuid
47
  from concurrent.futures import ThreadPoolExecutor
48
 
49
+ from atp import tenant_db
50
+ from atp.tenant_db import DEFAULT_ORG
51
+
52
+ from .persistence import ensure_schema
53
+
54
+ ensure_schema() # jobs table lives in migrations/001_core (+ org_id in 005)
55
+
56
+ _EXECUTOR = ThreadPoolExecutor(
57
+ max_workers=int(os.environ.get("BU_JOB_WORKERS", "2")),
58
+ thread_name_prefix="bu-job",
59
+ )
60
+
61
+
62
+ def _transition(job_id: str, org_id: str, *, expect: str, to: str,
63
+ result=None, error: str | None = None) -> bool:
64
+ """Move a job expect→to atomically. True iff THIS call made the change.
65
+
66
+ The `status = :expect` guard means a second worker/instance racing on
67
+ the same row loses (rowcount 0) instead of overwriting the winner.
68
+ """
69
+ res = tenant_db.scoped_execute(
70
+ org_id,
71
+ """UPDATE jobs
72
+ SET status = :to, result = :result, error = :error,
73
+ updated_at = :now
74
+ WHERE job_id = :job_id AND status = :expect
75
+ AND org_id = :org""",
76
+ {"to": to,
77
+ "result": json.dumps(result) if result is not None else None,
78
+ "error": error, "now": time.time(),
79
+ "job_id": job_id, "expect": expect, "org": org_id},
80
  )
81
+ return res.rowcount > 0
82
+
83
 
84
+ def submit(kind: str, fn, *args, org_id: str = DEFAULT_ORG, **kwargs) -> str:
85
+ """Enqueue fn(*args, **kwargs) to run in the background. Returns job_id.
86
 
87
+ org_id tags the row with the submitting tenant (docs/TENANCY.md); it is
88
+ consumed here and never forwarded to fn.
89
+ """
90
+ org_id = org_id or DEFAULT_ORG
91
  job_id = "job_" + uuid.uuid4().hex[:12]
92
  now = time.time()
93
+ tenant_db.insert_scoped("jobs", {
94
+ "job_id": job_id, "kind": kind, "status": "queued",
95
+ "created_at": now, "updated_at": now,
96
+ }, org_id)
 
 
 
 
97
 
98
  def _run():
99
+ if not _transition(job_id, org_id, expect="queued", to="running"):
100
+ return # already claimed elsewhere β€” never run the same job twice
101
  try:
102
  result = fn(*args, **kwargs)
103
+ _transition(job_id, org_id, expect="running", to="done", result=result)
104
  except Exception as e: # noqa: BLE001 β€” capture everything for the poller
105
+ _transition(
106
+ job_id, org_id, expect="running", to="error",
107
+ error=f"{type(e).__name__}: {e}\n{traceback.format_exc()[-1500:]}",
108
+ )
109
 
110
  _EXECUTOR.submit(_run)
111
  return job_id
112
 
113
 
114
+ def get(job_id: str, org_id: str = DEFAULT_ORG) -> dict | None:
115
+ """Return the job's current status dict, or None if unknown.
116
+
117
+ Org-scoped: a job belonging to another org is indistinguishable from a
118
+ nonexistent one (None) β€” no cross-tenant existence oracle (TENANCY.md #6).
119
+ """
120
+ rows = tenant_db.scoped_query(
121
+ org_id or DEFAULT_ORG,
122
  """SELECT job_id, kind, status, result, error, created_at, updated_at
123
+ FROM jobs WHERE job_id = :job_id AND org_id = :org""",
124
+ {"job_id": job_id, "org": org_id or DEFAULT_ORG},
125
+ )
126
+ if not rows:
127
  return None
128
+ row = rows[0]
129
  return {
130
+ "job_id": row["job_id"],
131
+ "kind": row["kind"],
132
+ "status": row["status"],
133
+ "result": json.loads(row["result"]) if row["result"] else None,
134
+ "error": row["error"],
135
+ "created_at": row["created_at"],
136
+ "updated_at": row["updated_at"],
137
  }
agents/paper_drop.py CHANGED
@@ -200,9 +200,9 @@ def cascade(url_or_id: str, emit: Callable[[str, dict], None] | None = None,
200
  step("warn", {"step": "semsch", "error": "no_data"})
201
 
202
  try:
203
- from graph.corpus_graph import build_corpus_graph
204
  from graph.clusters import cluster_graph
205
- G = build_corpus_graph()
206
  clusters = cluster_graph(G)
207
  except Exception as e:
208
  clusters = []
 
200
  step("warn", {"step": "semsch", "error": "no_data"})
201
 
202
  try:
203
+ from graph.corpus_graph import build_mixed_graph
204
  from graph.clusters import cluster_graph
205
+ G = build_mixed_graph()
206
  clusters = cluster_graph(G)
207
  except Exception as e:
208
  clusters = []
agents/persistence.py CHANGED
@@ -1,140 +1,148 @@
1
  """
2
- SQLite persistence layer.
3
 
4
  Stores:
5
- users β€” anonymous user_id + display_name + created_at
6
- walks β€” recorded tours (start_node + planned stops + completed)
7
- walk_events β€” per-stop event log (node, lesson_mode, quiz_quality, ts)
8
- sr_cards β€” spaced-repetition state per (user, node)
9
- podcast_state β€” per-user per-episode play position + interjections
10
- feedback β€” thumbs-up/down on lessons (feeds RL ranker)
11
-
12
- Default path: data/brain_university.db (override with BRAIN_DB env var).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  """
14
 
15
  from __future__ import annotations
16
 
17
- import os
 
 
18
  import sqlite3
19
  import time
 
20
  from pathlib import Path
21
- from typing import Iterable
 
22
 
23
  PROJECT_ROOT = Path(__file__).parent.parent
24
  DEFAULT_DB = PROJECT_ROOT / "data" / "brain_university.db"
25
 
 
26
 
27
- _SCHEMA = """
28
- CREATE TABLE IF NOT EXISTS users (
29
- user_id TEXT PRIMARY KEY,
30
- display_name TEXT,
31
- created_at REAL NOT NULL
32
- );
33
-
34
- CREATE TABLE IF NOT EXISTS walks (
35
- walk_id INTEGER PRIMARY KEY AUTOINCREMENT,
36
- user_id TEXT NOT NULL,
37
- start_node TEXT NOT NULL,
38
- planned_json TEXT NOT NULL,
39
- completed INTEGER NOT NULL DEFAULT 0,
40
- started_at REAL NOT NULL,
41
- completed_at REAL
42
- );
43
-
44
- CREATE TABLE IF NOT EXISTS walk_events (
45
- event_id INTEGER PRIMARY KEY AUTOINCREMENT,
46
- walk_id INTEGER NOT NULL,
47
- user_id TEXT NOT NULL,
48
- node TEXT,
49
- step_idx INTEGER,
50
- lesson_mode TEXT,
51
- quiz_quality INTEGER,
52
- duration_s REAL,
53
- ts REAL NOT NULL,
54
- FOREIGN KEY (walk_id) REFERENCES walks(walk_id)
55
- );
56
- CREATE INDEX IF NOT EXISTS idx_walk_events_user_node
57
- ON walk_events(user_id, node);
58
-
59
- CREATE TABLE IF NOT EXISTS sr_cards (
60
- user_id TEXT NOT NULL,
61
- node TEXT NOT NULL,
62
- repetitions INTEGER NOT NULL DEFAULT 0,
63
- ef REAL NOT NULL DEFAULT 2.5,
64
- interval_days REAL NOT NULL DEFAULT 0,
65
- due_ts REAL NOT NULL DEFAULT 0,
66
- last_quality INTEGER NOT NULL DEFAULT -1,
67
- last_review_ts REAL NOT NULL DEFAULT 0,
68
- PRIMARY KEY (user_id, node)
69
- );
70
- CREATE INDEX IF NOT EXISTS idx_sr_due ON sr_cards(user_id, due_ts);
71
-
72
- CREATE TABLE IF NOT EXISTS podcast_state (
73
- user_id TEXT NOT NULL,
74
- episode_key TEXT NOT NULL,
75
- current_turn INTEGER NOT NULL DEFAULT 0,
76
- interjections TEXT,
77
- last_played REAL NOT NULL,
78
- PRIMARY KEY (user_id, episode_key)
79
- );
80
-
81
- CREATE TABLE IF NOT EXISTS feedback (
82
- feedback_id INTEGER PRIMARY KEY AUTOINCREMENT,
83
- user_id TEXT NOT NULL,
84
- target_kind TEXT NOT NULL,
85
- target_id TEXT NOT NULL,
86
- thumb INTEGER NOT NULL,
87
- note TEXT,
88
- ts REAL NOT NULL
89
- );
90
- CREATE INDEX IF NOT EXISTS idx_feedback_target
91
- ON feedback(target_kind, target_id);
92
-
93
- CREATE TABLE IF NOT EXISTS socratic_sessions (
94
- session_id TEXT PRIMARY KEY,
95
- user_id TEXT NOT NULL,
96
- node TEXT NOT NULL,
97
- state_json TEXT NOT NULL,
98
- updated_at REAL NOT NULL
99
- );
100
- """
101
 
102
 
 
 
103
  def open_db(path: str | Path | None = None) -> sqlite3.Connection:
104
- """Open (and lazily create) the SQLite DB. Returns a Connection."""
105
- target = Path(path or os.environ.get("BRAIN_DB", DEFAULT_DB))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  target.parent.mkdir(parents=True, exist_ok=True)
107
  conn = sqlite3.connect(str(target))
108
- conn.executescript(_SCHEMA)
 
109
  return conn
110
 
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  # ── User helpers ────────────────────────────────────────────────────────────
113
 
114
  def ensure_user(user_id: str, display_name: str | None = None) -> None:
115
- db = open_db()
116
  db.execute(
117
  """INSERT INTO users (user_id, display_name, created_at)
118
- VALUES (?, ?, ?)
119
  ON CONFLICT(user_id) DO UPDATE SET
120
  display_name = COALESCE(excluded.display_name, users.display_name)""",
121
- (user_id, display_name, time.time()),
 
122
  )
123
- db.commit()
124
 
125
 
126
  # ── Walks ──────────────────────────────────────────────────────────────────
127
 
128
  def start_walk(user_id: str, start_node: str, planned_stops: list[str]) -> int:
129
- import json
130
- db = open_db()
131
- cur = db.execute(
132
  """INSERT INTO walks (user_id, start_node, planned_json, started_at)
133
- VALUES (?, ?, ?, ?)""",
134
- (user_id, start_node, json.dumps(planned_stops), time.time()),
 
 
135
  )
136
- db.commit()
137
- return cur.lastrowid
138
 
139
 
140
  def log_walk_event(
@@ -143,82 +151,139 @@ def log_walk_event(
143
  lesson_mode: str | None = None, quiz_quality: int | None = None,
144
  duration_s: float | None = None,
145
  ) -> None:
146
- db = open_db()
147
  db.execute(
148
  """INSERT INTO walk_events
149
  (walk_id, user_id, node, step_idx, lesson_mode,
150
  quiz_quality, duration_s, ts)
151
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
152
- (walk_id, user_id, node, step_idx, lesson_mode,
153
- quiz_quality, duration_s, time.time()),
 
 
 
154
  )
155
- db.commit()
156
 
157
 
158
  def complete_walk(walk_id: int) -> None:
159
- db = open_db()
160
  db.execute(
161
- "UPDATE walks SET completed = 1, completed_at = ? WHERE walk_id = ?",
162
- (time.time(), walk_id),
 
163
  )
164
- db.commit()
165
 
166
 
167
  def user_walks(user_id: str, limit: int = 50) -> list[dict]:
168
- db = open_db()
169
- rows = db.execute(
170
  """SELECT walk_id, start_node, planned_json, completed,
171
  started_at, completed_at
172
- FROM walks WHERE user_id = ?
173
- ORDER BY started_at DESC LIMIT ?""",
174
- (user_id, limit),
175
- ).fetchall()
176
- import json
177
  return [
178
  {
179
- "walk_id": r[0], "start_node": r[1],
180
- "planned": json.loads(r[2]),
181
- "completed": bool(r[3]),
182
- "started_at": r[4], "completed_at": r[5],
183
  }
184
  for r in rows
185
  ]
186
 
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  # ── Podcast state ──────────────────────────────────────────────────────────
189
 
190
  def save_podcast_state(user_id: str, episode_key: str, current_turn: int,
191
  interjections: list[dict] | None = None) -> None:
192
- import json
193
- db = open_db()
194
  db.execute(
195
  """INSERT INTO podcast_state
196
  (user_id, episode_key, current_turn, interjections, last_played)
197
- VALUES (?, ?, ?, ?, ?)
 
198
  ON CONFLICT(user_id, episode_key) DO UPDATE SET
199
  current_turn = excluded.current_turn,
200
  interjections = excluded.interjections,
201
  last_played = excluded.last_played""",
202
- (user_id, episode_key, current_turn,
203
- json.dumps(interjections or []), time.time()),
 
 
204
  )
205
- db.commit()
206
 
207
 
208
  def load_podcast_state(user_id: str, episode_key: str) -> dict | None:
209
- db = open_db()
210
- row = db.execute(
211
  """SELECT current_turn, interjections, last_played
212
- FROM podcast_state WHERE user_id = ? AND episode_key = ?""",
213
- (user_id, episode_key),
214
- ).fetchone()
215
- if not row:
 
216
  return None
217
- import json
218
  return {
219
- "current_turn": row[0],
220
- "interjections": json.loads(row[1] or "[]"),
221
- "last_played": row[2],
222
  }
223
 
224
 
@@ -226,22 +291,65 @@ def load_podcast_state(user_id: str, episode_key: str) -> dict | None:
226
 
227
  def add_feedback(user_id: str, target_kind: str, target_id: str,
228
  thumb: int, note: str | None = None) -> None:
229
- db = open_db()
230
  db.execute(
231
  """INSERT INTO feedback
232
  (user_id, target_kind, target_id, thumb, note, ts)
233
- VALUES (?, ?, ?, ?, ?, ?)""",
234
- (user_id, target_kind, target_id, thumb, note, time.time()),
 
 
235
  )
236
- db.commit()
237
 
238
 
239
  def feedback_for_target(target_kind: str, target_id: str) -> list[dict]:
240
- db = open_db()
241
- rows = db.execute(
242
  """SELECT user_id, thumb, note, ts FROM feedback
243
- WHERE target_kind = ? AND target_id = ?
244
  ORDER BY ts DESC""",
245
- (target_kind, target_id),
246
- ).fetchall()
247
- return [{"user_id": r[0], "thumb": r[1], "note": r[2], "ts": r[3]} for r in rows]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Persistence layer β€” app state via the shared DAL (atp.db).
3
 
4
  Stores:
5
+ users β€” anonymous user_id + display_name + created_at
6
+ walks β€” recorded tours (start_node + planned stops + completed)
7
+ walk_events β€” per-stop event log (node, lesson_mode, quiz_quality, ts)
8
+ sr_cards β€” spaced-repetition state per (user, node)
9
+ podcast_state β€” per-user per-episode play position + interjections
10
+ feedback β€” thumbs-up/down on lessons (feeds RL ranker)
11
+ socratic_sessions β€” Socratic dialogue state per session_id
12
+
13
+ Schema lives in migrations/ (001_core ports the old inline `_SCHEMA`,
14
+ 003_confusion the table agents/confusion.py used to lazy-create) and is
15
+ applied once per process at import via ensure_schema() (idempotent).
16
+
17
+ Database location β€” resolved by atp.db, in precedence order:
18
+ 1. DATABASE_URL β€” full SQLAlchemy URL (Postgres in prod:
19
+ postgresql+psycopg2://…; any sqlite:/// URL also works)
20
+ 2. BRAIN_DB β€” legacy env var: filesystem path to a SQLite file
21
+ 3. default β€” data/brain_university.db (SQLite; dev/demo)
22
+
23
+ open_db() survives as a DEPRECATED, SQLite-only shim with ZERO in-repo
24
+ callers β€” kept only for external scripts holding hand-written sqlite SQL.
25
+ Every call emits a DeprecationWarning naming the caller, and it raises under
26
+ Postgres β€” use atp.db.query/execute or the helpers below instead.
27
  """
28
 
29
  from __future__ import annotations
30
 
31
+ import inspect
32
+ import json
33
+ import re
34
  import sqlite3
35
  import time
36
+ import warnings
37
  from pathlib import Path
38
+
39
+ from atp import db
40
 
41
  PROJECT_ROOT = Path(__file__).parent.parent
42
  DEFAULT_DB = PROJECT_ROOT / "data" / "brain_university.db"
43
 
44
+ _SCHEMA_READY = False
45
 
46
+
47
+ def ensure_schema() -> None:
48
+ """Apply pending migrations once per process (idempotent, guarded)."""
49
+ global _SCHEMA_READY
50
+ if not _SCHEMA_READY:
51
+ db.run_migrations()
52
+ _SCHEMA_READY = True
53
+
54
+
55
+ ensure_schema()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
 
58
+ # ── Deprecated raw-connection shim ──────────────────────────────────────────
59
+
60
  def open_db(path: str | Path | None = None) -> sqlite3.Connection:
61
+ """DEPRECATED β€” raw sqlite3.Connection onto the app database.
62
+
63
+ No in-repo callers remain (all ported to atp.db / the helpers in this
64
+ module); kept only for external scripts holding hand-written sqlite SQL.
65
+ Works ONLY when the app runs on SQLite; under Postgres (DATABASE_URL) it
66
+ raises β€” use atp.db.query/execute or the helpers in this module.
67
+
68
+ With an explicit `path`, opens that SQLite file and applies the sqlite
69
+ migration DDL so ad-hoc scratch DBs keep working as before.
70
+ """
71
+ caller = inspect.stack()[1]
72
+ warnings.warn(
73
+ f"agents.persistence.open_db() is deprecated and SQLite-only "
74
+ f"(called from {caller.filename}:{caller.lineno} in "
75
+ f"{caller.function}) β€” use atp.db.query/execute or the "
76
+ f"agents.persistence helpers instead.",
77
+ DeprecationWarning,
78
+ stacklevel=2,
79
+ )
80
+ if path is None:
81
+ if db.is_postgres():
82
+ raise RuntimeError(
83
+ "agents.persistence.open_db() is a SQLite-only legacy shim, "
84
+ "but this deployment runs on Postgres (DATABASE_URL). Use "
85
+ "atp.db.query/execute or the agents.persistence helpers."
86
+ )
87
+ ensure_schema() # engine + migrations create the file/schema
88
+ target = Path(db.get_engine().url.database or DEFAULT_DB)
89
+ return sqlite3.connect(str(target))
90
+
91
+ target = Path(path)
92
  target.parent.mkdir(parents=True, exist_ok=True)
93
  conn = sqlite3.connect(str(target))
94
+ for script in _sqlite_migration_scripts():
95
+ conn.executescript(script)
96
  return conn
97
 
98
 
99
+ def _sqlite_migration_scripts() -> list[str]:
100
+ """SQLite DDL of every migration, dialect-preferred (.sqlite.sql > .sql).
101
+
102
+ Only for the explicit-path open_db() compat case β€” the real database is
103
+ migrated by atp.db.run_migrations(). Assumes idempotent DDL (IF NOT
104
+ EXISTS), which the shipped migrations use. `--;;` separator lines are
105
+ plain SQL comments, so executescript() handles the files as-is.
106
+ """
107
+ if not db.MIGRATIONS_DIR.is_dir():
108
+ return []
109
+ by_base: dict[str, dict[str | None, Path]] = {}
110
+ for p in db.MIGRATIONS_DIR.iterdir():
111
+ m = re.match(r"^(\d+_\w+?)(?:\.(pg|sqlite))?\.sql$", p.name)
112
+ if m:
113
+ by_base.setdefault(m.group(1), {})[m.group(2)] = p
114
+ scripts: list[str] = []
115
+ for base in sorted(by_base):
116
+ best = by_base[base].get("sqlite") or by_base[base].get(None)
117
+ if best is not None: # pg-only bases don't apply to a sqlite file
118
+ scripts.append(best.read_text())
119
+ return scripts
120
+
121
+
122
  # ── User helpers ────────────────────────────────────────────────────────────
123
 
124
  def ensure_user(user_id: str, display_name: str | None = None) -> None:
 
125
  db.execute(
126
  """INSERT INTO users (user_id, display_name, created_at)
127
+ VALUES (:user_id, :display_name, :created_at)
128
  ON CONFLICT(user_id) DO UPDATE SET
129
  display_name = COALESCE(excluded.display_name, users.display_name)""",
130
+ {"user_id": user_id, "display_name": display_name,
131
+ "created_at": time.time()},
132
  )
 
133
 
134
 
135
  # ── Walks ──────────────────────────────────────────────────────────────────
136
 
137
  def start_walk(user_id: str, start_node: str, planned_stops: list[str]) -> int:
138
+ res = db.execute(
 
 
139
  """INSERT INTO walks (user_id, start_node, planned_json, started_at)
140
+ VALUES (:user_id, :start_node, :planned_json, :started_at)
141
+ RETURNING walk_id""",
142
+ {"user_id": user_id, "start_node": start_node,
143
+ "planned_json": json.dumps(planned_stops), "started_at": time.time()},
144
  )
145
+ return int(res.lastrowid)
 
146
 
147
 
148
  def log_walk_event(
 
151
  lesson_mode: str | None = None, quiz_quality: int | None = None,
152
  duration_s: float | None = None,
153
  ) -> None:
 
154
  db.execute(
155
  """INSERT INTO walk_events
156
  (walk_id, user_id, node, step_idx, lesson_mode,
157
  quiz_quality, duration_s, ts)
158
+ VALUES (:walk_id, :user_id, :node, :step_idx, :lesson_mode,
159
+ :quiz_quality, :duration_s, :ts)""",
160
+ {"walk_id": walk_id, "user_id": user_id, "node": node,
161
+ "step_idx": step_idx, "lesson_mode": lesson_mode,
162
+ "quiz_quality": quiz_quality, "duration_s": duration_s,
163
+ "ts": time.time()},
164
  )
 
165
 
166
 
167
  def complete_walk(walk_id: int) -> None:
 
168
  db.execute(
169
+ """UPDATE walks SET completed = 1, completed_at = :completed_at
170
+ WHERE walk_id = :walk_id""",
171
+ {"completed_at": time.time(), "walk_id": walk_id},
172
  )
 
173
 
174
 
175
  def user_walks(user_id: str, limit: int = 50) -> list[dict]:
176
+ rows = db.query(
 
177
  """SELECT walk_id, start_node, planned_json, completed,
178
  started_at, completed_at
179
+ FROM walks WHERE user_id = :user_id
180
+ ORDER BY started_at DESC LIMIT :limit""",
181
+ {"user_id": user_id, "limit": limit},
182
+ )
 
183
  return [
184
  {
185
+ "walk_id": r["walk_id"], "start_node": r["start_node"],
186
+ "planned": json.loads(r["planned_json"]),
187
+ "completed": bool(r["completed"]),
188
+ "started_at": r["started_at"], "completed_at": r["completed_at"],
189
  }
190
  for r in rows
191
  ]
192
 
193
 
194
+ # ── Spaced-repetition cards ────────────────────────────────────────────────
195
+
196
+ def upsert_sr_card(
197
+ user_id: str, node: str, *,
198
+ repetitions: int = 0, ef: float = 2.5, interval_days: float = 0.0,
199
+ due_ts: float = 0.0, last_quality: int = -1, last_review_ts: float = 0.0,
200
+ ) -> None:
201
+ """Insert-or-update the full SM-2 state for one (user, node) card."""
202
+ db.execute(
203
+ """INSERT INTO sr_cards
204
+ (user_id, node, repetitions, ef, interval_days, due_ts,
205
+ last_quality, last_review_ts)
206
+ VALUES (:user_id, :node, :repetitions, :ef, :interval_days,
207
+ :due_ts, :last_quality, :last_review_ts)
208
+ ON CONFLICT(user_id, node) DO UPDATE SET
209
+ repetitions = excluded.repetitions,
210
+ ef = excluded.ef,
211
+ interval_days = excluded.interval_days,
212
+ due_ts = excluded.due_ts,
213
+ last_quality = excluded.last_quality,
214
+ last_review_ts = excluded.last_review_ts""",
215
+ {"user_id": user_id, "node": node, "repetitions": repetitions,
216
+ "ef": ef, "interval_days": interval_days, "due_ts": due_ts,
217
+ "last_quality": last_quality, "last_review_ts": last_review_ts},
218
+ )
219
+
220
+
221
+ def get_sr_card(user_id: str, node: str) -> dict | None:
222
+ rows = db.query(
223
+ """SELECT user_id, node, repetitions, ef, interval_days, due_ts,
224
+ last_quality, last_review_ts
225
+ FROM sr_cards WHERE user_id = :user_id AND node = :node""",
226
+ {"user_id": user_id, "node": node},
227
+ )
228
+ return rows[0] if rows else None
229
+
230
+
231
+ def due_sr_cards(user_id: str, now: float | None = None,
232
+ limit: int = 10) -> list[dict]:
233
+ """Cards whose due_ts <= now, oldest-due first."""
234
+ return db.query(
235
+ """SELECT user_id, node, repetitions, ef, interval_days, due_ts,
236
+ last_quality, last_review_ts
237
+ FROM sr_cards
238
+ WHERE user_id = :user_id AND due_ts <= :now
239
+ ORDER BY due_ts ASC LIMIT :limit""",
240
+ {"user_id": user_id, "now": now or time.time(), "limit": limit},
241
+ )
242
+
243
+
244
+ def all_sr_cards(user_id: str) -> list[dict]:
245
+ return db.query(
246
+ """SELECT user_id, node, repetitions, ef, interval_days, due_ts,
247
+ last_quality, last_review_ts
248
+ FROM sr_cards WHERE user_id = :user_id ORDER BY due_ts""",
249
+ {"user_id": user_id},
250
+ )
251
+
252
+
253
  # ── Podcast state ──────────────────────────────────────────────────────────
254
 
255
  def save_podcast_state(user_id: str, episode_key: str, current_turn: int,
256
  interjections: list[dict] | None = None) -> None:
 
 
257
  db.execute(
258
  """INSERT INTO podcast_state
259
  (user_id, episode_key, current_turn, interjections, last_played)
260
+ VALUES (:user_id, :episode_key, :current_turn, :interjections,
261
+ :last_played)
262
  ON CONFLICT(user_id, episode_key) DO UPDATE SET
263
  current_turn = excluded.current_turn,
264
  interjections = excluded.interjections,
265
  last_played = excluded.last_played""",
266
+ {"user_id": user_id, "episode_key": episode_key,
267
+ "current_turn": current_turn,
268
+ "interjections": json.dumps(interjections or []),
269
+ "last_played": time.time()},
270
  )
 
271
 
272
 
273
  def load_podcast_state(user_id: str, episode_key: str) -> dict | None:
274
+ rows = db.query(
 
275
  """SELECT current_turn, interjections, last_played
276
+ FROM podcast_state
277
+ WHERE user_id = :user_id AND episode_key = :episode_key""",
278
+ {"user_id": user_id, "episode_key": episode_key},
279
+ )
280
+ if not rows:
281
  return None
282
+ row = rows[0]
283
  return {
284
+ "current_turn": row["current_turn"],
285
+ "interjections": json.loads(row["interjections"] or "[]"),
286
+ "last_played": row["last_played"],
287
  }
288
 
289
 
 
291
 
292
  def add_feedback(user_id: str, target_kind: str, target_id: str,
293
  thumb: int, note: str | None = None) -> None:
 
294
  db.execute(
295
  """INSERT INTO feedback
296
  (user_id, target_kind, target_id, thumb, note, ts)
297
+ VALUES (:user_id, :target_kind, :target_id, :thumb, :note, :ts)""",
298
+ {"user_id": user_id, "target_kind": target_kind,
299
+ "target_id": target_id, "thumb": thumb, "note": note,
300
+ "ts": time.time()},
301
  )
 
302
 
303
 
304
  def feedback_for_target(target_kind: str, target_id: str) -> list[dict]:
305
+ return db.query(
 
306
  """SELECT user_id, thumb, note, ts FROM feedback
307
+ WHERE target_kind = :target_kind AND target_id = :target_id
308
  ORDER BY ts DESC""",
309
+ {"target_kind": target_kind, "target_id": target_id},
310
+ )
311
+
312
+
313
+ # ── Socratic sessions ──────────────────────────────────────────────────────
314
+
315
+ def save_socratic_session(session_id: str, user_id: str, node: str,
316
+ state: dict) -> None:
317
+ """Insert-or-replace one Socratic session (state: JSON-serializable)."""
318
+ db.execute(
319
+ """INSERT INTO socratic_sessions
320
+ (session_id, user_id, node, state_json, updated_at)
321
+ VALUES (:session_id, :user_id, :node, :state_json, :updated_at)
322
+ ON CONFLICT(session_id) DO UPDATE SET
323
+ state_json = excluded.state_json,
324
+ updated_at = excluded.updated_at""",
325
+ {"session_id": session_id, "user_id": user_id, "node": node,
326
+ "state_json": json.dumps(state), "updated_at": time.time()},
327
+ )
328
+
329
+
330
+ def load_socratic_session(session_id: str) -> dict | None:
331
+ rows = db.query(
332
+ """SELECT session_id, user_id, node, state_json, updated_at
333
+ FROM socratic_sessions WHERE session_id = :session_id""",
334
+ {"session_id": session_id},
335
+ )
336
+ if not rows:
337
+ return None
338
+ row = rows[0]
339
+ return {
340
+ "session_id": row["session_id"], "user_id": row["user_id"],
341
+ "node": row["node"], "state": json.loads(row["state_json"]),
342
+ "updated_at": row["updated_at"],
343
+ }
344
+
345
+
346
+ def update_socratic_session(session_id: str, state: dict) -> bool:
347
+ """Update an existing session's state. Returns False if it doesn't exist."""
348
+ res = db.execute(
349
+ """UPDATE socratic_sessions
350
+ SET state_json = :state_json, updated_at = :updated_at
351
+ WHERE session_id = :session_id""",
352
+ {"state_json": json.dumps(state), "updated_at": time.time(),
353
+ "session_id": session_id},
354
+ )
355
+ return res.rowcount > 0
agents/spaced_repetition.py CHANGED
@@ -2,8 +2,9 @@
2
  SM-2 spaced repetition scheduler.
3
 
4
  Each quiz attempt for a (user, node) pair feeds a quality grade 0-5 into
5
- SuperMemo-2. The scheduler stores per-card state in SQLite (lib/persistence)
6
- and surfaces "due now" cards back through the trailhead / dashboard.
 
7
 
8
  Quality grading (from quiz):
9
  5 correct on first try, < 5s
@@ -22,8 +23,6 @@ from __future__ import annotations
22
 
23
  import time
24
  from dataclasses import dataclass, asdict
25
- from datetime import datetime, timedelta
26
- from pathlib import Path
27
 
28
  DAY_SECONDS = 86400
29
 
@@ -64,88 +63,34 @@ def update_sm2(state: CardState, quality: int, now: float | None = None) -> Card
64
  return state
65
 
66
 
67
- # ── Persistence ────────────────────────────────────────────────────────────
68
 
69
  def schedule_review(user_id: str, node: str, quality: int,
70
  now: float | None = None) -> float:
71
  """Record a quiz attempt and return the next due timestamp."""
72
- from .persistence import open_db
73
- db = open_db()
74
- state = _load(db, user_id, node) or CardState(user_id=user_id, node=node)
75
  update_sm2(state, quality, now=now)
76
- _save(db, state)
77
  return state.due_ts
78
 
79
 
80
  def due_cards(user_id: str, now: float | None = None,
81
  limit: int = 10) -> list[dict]:
82
  """Return cards whose due_ts <= now, oldest-due first."""
83
- from .persistence import open_db
84
- now = now or time.time()
85
- db = open_db()
86
- rows = db.execute(
87
- """SELECT user_id, node, repetitions, ef, interval_days, due_ts,
88
- last_quality, last_review_ts
89
- FROM sr_cards
90
- WHERE user_id = ? AND due_ts <= ?
91
- ORDER BY due_ts ASC LIMIT ?""",
92
- (user_id, now, limit),
93
- ).fetchall()
94
- return [_row_to_dict(r) for r in rows]
95
 
96
 
97
  def all_cards(user_id: str) -> list[dict]:
98
- from .persistence import open_db
99
- db = open_db()
100
- rows = db.execute(
101
- """SELECT user_id, node, repetitions, ef, interval_days, due_ts,
102
- last_quality, last_review_ts
103
- FROM sr_cards WHERE user_id = ? ORDER BY due_ts""",
104
- (user_id,),
105
- ).fetchall()
106
- return [_row_to_dict(r) for r in rows]
107
-
108
-
109
- def _load(db, user_id: str, node: str) -> CardState | None:
110
- row = db.execute(
111
- """SELECT user_id, node, repetitions, ef, interval_days, due_ts,
112
- last_quality, last_review_ts
113
- FROM sr_cards WHERE user_id = ? AND node = ?""",
114
- (user_id, node),
115
- ).fetchone()
116
- if not row:
117
- return None
118
- return CardState(
119
- user_id=row[0], node=row[1], repetitions=row[2], ef=row[3],
120
- interval_days=row[4], due_ts=row[5], last_quality=row[6],
121
- last_review_ts=row[7],
122
- )
123
-
124
-
125
- def _save(db, state: CardState) -> None:
126
- db.execute(
127
- """INSERT INTO sr_cards
128
- (user_id, node, repetitions, ef, interval_days, due_ts,
129
- last_quality, last_review_ts)
130
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
131
- ON CONFLICT(user_id, node) DO UPDATE SET
132
- repetitions = excluded.repetitions,
133
- ef = excluded.ef,
134
- interval_days = excluded.interval_days,
135
- due_ts = excluded.due_ts,
136
- last_quality = excluded.last_quality,
137
- last_review_ts = excluded.last_review_ts""",
138
- (state.user_id, state.node, state.repetitions, state.ef,
139
- state.interval_days, state.due_ts, state.last_quality,
140
- state.last_review_ts),
141
- )
142
- db.commit()
143
-
144
-
145
- def _row_to_dict(row) -> dict:
146
- return {
147
- "user_id": row[0], "node": row[1], "repetitions": row[2],
148
- "ef": row[3], "interval_days": row[4], "due_ts": row[5],
149
- "last_quality": row[6], "last_review_ts": row[7],
150
- "due_in_days": (row[5] - time.time()) / DAY_SECONDS,
151
- }
 
2
  SM-2 spaced repetition scheduler.
3
 
4
  Each quiz attempt for a (user, node) pair feeds a quality grade 0-5 into
5
+ SuperMemo-2. The scheduler stores per-card state through agents.persistence
6
+ (SQLite or Postgres via the atp.db DAL) and surfaces "due now" cards back
7
+ through the trailhead / dashboard.
8
 
9
  Quality grading (from quiz):
10
  5 correct on first try, < 5s
 
23
 
24
  import time
25
  from dataclasses import dataclass, asdict
 
 
26
 
27
  DAY_SECONDS = 86400
28
 
 
63
  return state
64
 
65
 
66
+ # ── Persistence (agents.persistence helpers β€” SQLite + Postgres) ──────────
67
 
68
  def schedule_review(user_id: str, node: str, quality: int,
69
  now: float | None = None) -> float:
70
  """Record a quiz attempt and return the next due timestamp."""
71
+ from . import persistence
72
+ row = persistence.get_sr_card(user_id, node)
73
+ state = CardState(**row) if row else CardState(user_id=user_id, node=node)
74
  update_sm2(state, quality, now=now)
75
+ persistence.upsert_sr_card(**asdict(state))
76
  return state.due_ts
77
 
78
 
79
  def due_cards(user_id: str, now: float | None = None,
80
  limit: int = 10) -> list[dict]:
81
  """Return cards whose due_ts <= now, oldest-due first."""
82
+ from . import persistence
83
+ rows = persistence.due_sr_cards(user_id, now=now, limit=limit)
84
+ return [_with_due_in_days(r) for r in rows]
 
 
 
 
 
 
 
 
 
85
 
86
 
87
  def all_cards(user_id: str) -> list[dict]:
88
+ from . import persistence
89
+ rows = persistence.all_sr_cards(user_id)
90
+ return [_with_due_in_days(r) for r in rows]
91
+
92
+
93
+ def _with_due_in_days(row: dict) -> dict:
94
+ out = dict(row)
95
+ out["due_in_days"] = (out["due_ts"] - time.time()) / DAY_SECONDS
96
+ return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
agents/time_travel.py CHANGED
@@ -20,38 +20,38 @@ from __future__ import annotations
20
  import time as _time
21
  from typing import Iterable
22
 
23
- from .persistence import open_db
 
24
 
25
 
26
  def timeline(user_id: str) -> list[dict]:
27
  """Distinct timestamps where the user's state changed. Used as
28
  slider tick positions on the frontend."""
29
- db = open_db()
30
- rows = db.execute(
31
  """SELECT ts, node, quiz_quality FROM walk_events
32
- WHERE user_id = ? AND ts IS NOT NULL
33
  ORDER BY ts ASC""",
34
- (user_id,),
35
- ).fetchall()
36
- out = []
37
- for r in rows:
38
- out.append({"ts": r[0], "node": r[1], "quiz_quality": r[2]})
39
- return out
40
 
41
 
42
  def snapshot_at(user_id: str, ts: float | None = None) -> dict:
43
  """Map node β†’ mastery score in [0, 1] as of `ts` (defaults to now)."""
44
  if ts is None:
45
  ts = _time.time()
46
- db = open_db()
47
- rows = db.execute(
48
- """SELECT node, MAX(quiz_quality) FROM walk_events
49
- WHERE user_id = ? AND node IS NOT NULL AND ts <= ?
50
  GROUP BY node""",
51
- (user_id, ts),
52
- ).fetchall()
53
  out: dict[str, float] = {}
54
- for node, max_q in rows:
 
55
  if node is None:
56
  continue
57
  if max_q is None:
 
20
  import time as _time
21
  from typing import Iterable
22
 
23
+ from atp import db
24
+ from .persistence import ensure_schema # import also applies migrations
25
 
26
 
27
  def timeline(user_id: str) -> list[dict]:
28
  """Distinct timestamps where the user's state changed. Used as
29
  slider tick positions on the frontend."""
30
+ ensure_schema()
31
+ rows = db.query(
32
  """SELECT ts, node, quiz_quality FROM walk_events
33
+ WHERE user_id = :user_id AND ts IS NOT NULL
34
  ORDER BY ts ASC""",
35
+ {"user_id": user_id},
36
+ )
37
+ return [{"ts": r["ts"], "node": r["node"],
38
+ "quiz_quality": r["quiz_quality"]} for r in rows]
 
 
39
 
40
 
41
  def snapshot_at(user_id: str, ts: float | None = None) -> dict:
42
  """Map node β†’ mastery score in [0, 1] as of `ts` (defaults to now)."""
43
  if ts is None:
44
  ts = _time.time()
45
+ ensure_schema()
46
+ rows = db.query(
47
+ """SELECT node, MAX(quiz_quality) AS max_q FROM walk_events
48
+ WHERE user_id = :user_id AND node IS NOT NULL AND ts <= :ts
49
  GROUP BY node""",
50
+ {"user_id": user_id, "ts": ts},
51
+ )
52
  out: dict[str, float] = {}
53
+ for row in rows:
54
+ node, max_q = row["node"], row["max_q"]
55
  if node is None:
56
  continue
57
  if max_q is None: