Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- agents/backend.py +100 -0
- agents/confusion.py +26 -39
- agents/heatmap.py +19 -18
- agents/jobs.py +95 -67
- agents/paper_drop.py +2 -2
- agents/persistence.py +253 -145
- agents/spaced_repetition.py +20 -75
- agents/time_travel.py +17 -17
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 (
|
| 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
|
| 71 |
-
|
| 72 |
-
|
| 73 |
db.execute(
|
| 74 |
"""INSERT INTO confusion_signals
|
| 75 |
(user_id, paragraph_hash, signal_kind, value, ts)
|
| 76 |
-
VALUES (
|
| 77 |
-
|
|
|
|
|
|
|
| 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
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
rows = db.
|
| 88 |
"""SELECT signal_kind, value FROM confusion_signals
|
| 89 |
-
WHERE user_id =
|
| 90 |
-
|
| 91 |
-
)
|
| 92 |
agg = 0.0
|
| 93 |
-
for
|
|
|
|
| 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
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
rows = db.
|
| 109 |
"""SELECT paragraph_hash
|
| 110 |
FROM confusion_signals
|
| 111 |
-
WHERE user_id =
|
| 112 |
GROUP BY paragraph_hash""",
|
| 113 |
-
|
| 114 |
-
)
|
| 115 |
out = []
|
| 116 |
-
for
|
|
|
|
| 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 |
-
|
| 27 |
-
|
| 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(
|
| 74 |
-
"""Map node β {best_quality, n_visits}. Reads
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
FROM walk_events
|
| 78 |
-
WHERE user_id =
|
| 79 |
GROUP BY node""",
|
| 80 |
-
|
| 81 |
-
)
|
| 82 |
-
return {r[
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
-
def _sr_state(
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 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
|
| 6 |
-
and tracks status in
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
Public:
|
| 13 |
-
submit(kind, fn, *args, **kwargs) -> job_id
|
| 14 |
-
get(job_id) -> dict | None
|
| 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
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
)
|
| 61 |
-
|
|
|
|
| 62 |
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
job_id = "job_" + uuid.uuid4().hex[:12]
|
| 67 |
now = time.time()
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
VALUES (?, ?, 'queued', ?, ?)""",
|
| 73 |
-
(job_id, kind, now, now),
|
| 74 |
-
)
|
| 75 |
-
db.commit()
|
| 76 |
|
| 77 |
def _run():
|
| 78 |
-
|
|
|
|
| 79 |
try:
|
| 80 |
result = fn(*args, **kwargs)
|
| 81 |
-
|
| 82 |
except Exception as e: # noqa: BLE001 β capture everything for the poller
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
| 95 |
"""SELECT job_id, kind, status, result, error, created_at, updated_at
|
| 96 |
-
FROM jobs WHERE job_id=
|
| 97 |
-
|
| 98 |
-
)
|
| 99 |
-
if not
|
| 100 |
return None
|
|
|
|
| 101 |
return {
|
| 102 |
-
"job_id": row[
|
| 103 |
-
"kind": row[
|
| 104 |
-
"status": row[
|
| 105 |
-
"result": json.loads(row[
|
| 106 |
-
"error": row[
|
| 107 |
-
"created_at": row[
|
| 108 |
-
"updated_at": row[
|
| 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
|
| 204 |
from graph.clusters import cluster_graph
|
| 205 |
-
G =
|
| 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 |
-
|
| 3 |
|
| 4 |
Stores:
|
| 5 |
-
users
|
| 6 |
-
walks
|
| 7 |
-
walk_events
|
| 8 |
-
sr_cards
|
| 9 |
-
podcast_state
|
| 10 |
-
feedback
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
-
import
|
|
|
|
|
|
|
| 18 |
import sqlite3
|
| 19 |
import time
|
|
|
|
| 20 |
from pathlib import Path
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
PROJECT_ROOT = Path(__file__).parent.parent
|
| 24 |
DEFAULT_DB = PROJECT_ROOT / "data" / "brain_university.db"
|
| 25 |
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 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 |
-
"""
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
target.parent.mkdir(parents=True, exist_ok=True)
|
| 107 |
conn = sqlite3.connect(str(target))
|
| 108 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 130 |
-
db = open_db()
|
| 131 |
-
cur = db.execute(
|
| 132 |
"""INSERT INTO walks (user_id, start_node, planned_json, started_at)
|
| 133 |
-
VALUES (
|
| 134 |
-
|
|
|
|
|
|
|
| 135 |
)
|
| 136 |
-
|
| 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 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 162 |
-
|
|
|
|
| 163 |
)
|
| 164 |
-
db.commit()
|
| 165 |
|
| 166 |
|
| 167 |
def user_walks(user_id: str, limit: int = 50) -> list[dict]:
|
| 168 |
-
|
| 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 |
-
|
| 175 |
-
)
|
| 176 |
-
import json
|
| 177 |
return [
|
| 178 |
{
|
| 179 |
-
"walk_id": r[
|
| 180 |
-
"planned": json.loads(r[
|
| 181 |
-
"completed": bool(r[
|
| 182 |
-
"started_at": r[
|
| 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 |
-
|
| 203 |
-
|
|
|
|
|
|
|
| 204 |
)
|
| 205 |
-
db.commit()
|
| 206 |
|
| 207 |
|
| 208 |
def load_podcast_state(user_id: str, episode_key: str) -> dict | None:
|
| 209 |
-
|
| 210 |
-
row = db.execute(
|
| 211 |
"""SELECT current_turn, interjections, last_played
|
| 212 |
-
FROM podcast_state
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
| 216 |
return None
|
| 217 |
-
|
| 218 |
return {
|
| 219 |
-
"current_turn": row[
|
| 220 |
-
"interjections": json.loads(row[
|
| 221 |
-
"last_played": row[
|
| 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 |
-
|
|
|
|
|
|
|
| 235 |
)
|
| 236 |
-
db.commit()
|
| 237 |
|
| 238 |
|
| 239 |
def feedback_for_target(target_kind: str, target_id: str) -> list[dict]:
|
| 240 |
-
|
| 241 |
-
rows = db.execute(
|
| 242 |
"""SELECT user_id, thumb, note, ts FROM feedback
|
| 243 |
-
WHERE target_kind =
|
| 244 |
ORDER BY ts DESC""",
|
| 245 |
-
|
| 246 |
-
)
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
-
and surfaces "due now" cards back
|
|
|
|
| 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 .
|
| 73 |
-
|
| 74 |
-
state =
|
| 75 |
update_sm2(state, quality, now=now)
|
| 76 |
-
|
| 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 .
|
| 84 |
-
|
| 85 |
-
|
| 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 .
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
return
|
| 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
|
|
|
|
| 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 |
-
|
| 30 |
-
rows = db.
|
| 31 |
"""SELECT ts, node, quiz_quality FROM walk_events
|
| 32 |
-
WHERE user_id =
|
| 33 |
ORDER BY ts ASC""",
|
| 34 |
-
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
|
| 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 |
-
|
| 47 |
-
rows = db.
|
| 48 |
-
"""SELECT node, MAX(quiz_quality) FROM walk_events
|
| 49 |
-
WHERE user_id =
|
| 50 |
GROUP BY node""",
|
| 51 |
-
|
| 52 |
-
)
|
| 53 |
out: dict[str, float] = {}
|
| 54 |
-
for
|
|
|
|
| 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:
|