mentar 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mentar/__init__.py +6 -0
- mentar/cli/__init__.py +1 -0
- mentar/cli/__main__.py +62 -0
- mentar/db/__init__.py +4 -0
- mentar/db/store.py +416 -0
- mentar/dialogue/__init__.py +4 -0
- mentar/engine/__init__.py +4 -0
- mentar/engine/bkt.py +99 -0
- mentar/engine/fringe.py +104 -0
- mentar/engine/probe_classify.py +79 -0
- mentar/eval/__init__.py +4 -0
- mentar/eval/verify_numeric.py +619 -0
- mentar/grounding/__init__.py +65 -0
- mentar/grounding/cache.py +127 -0
- mentar/grounding/reader.py +271 -0
- mentar/grounding/resolve.py +125 -0
- mentar/grounding/source_map.py +120 -0
- mentar/grounding/sources.py +267 -0
- mentar/grounding/wrapper.py +50 -0
- mentar/inference/__init__.py +7 -0
- mentar/safety/__init__.py +4 -0
- mentar/safety/escalation.py +316 -0
- mentar/tools/__init__.py +4 -0
- mentar/tools/validate_template.py +322 -0
- mentar-0.1.0.dev0.dist-info/METADATA +178 -0
- mentar-0.1.0.dev0.dist-info/RECORD +29 -0
- mentar-0.1.0.dev0.dist-info/WHEEL +5 -0
- mentar-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- mentar-0.1.0.dev0.dist-info/top_level.txt +1 -0
mentar/__init__.py
ADDED
mentar/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Mentar CLI entry: `mentar serve`, `mentar eval`, `mentar validate-template`."""
|
mentar/cli/__main__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""CLI entry point. Wired in pyproject.toml [project.scripts].
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
serve — Start a pilot tutoring session (stub).
|
|
5
|
+
eval — Run the eval harness (stub).
|
|
6
|
+
validate-template — Validate a curriculum template against the W3.1 schema.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(prog="mentar")
|
|
17
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
18
|
+
sub.add_parser("serve", help="Start a pilot tutoring session (stub).")
|
|
19
|
+
sub.add_parser("eval", help="Run the eval harness (stub).")
|
|
20
|
+
vt = sub.add_parser(
|
|
21
|
+
"validate-template",
|
|
22
|
+
help="Validate a curriculum template against the W3.1 schema.",
|
|
23
|
+
)
|
|
24
|
+
vt.add_argument("path", help="Path to curriculum template Markdown file.")
|
|
25
|
+
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
|
|
28
|
+
if args.cmd == "validate-template":
|
|
29
|
+
from mentar.tools.validate_template import validate
|
|
30
|
+
|
|
31
|
+
result = validate(args.path)
|
|
32
|
+
|
|
33
|
+
for w in result.warnings:
|
|
34
|
+
print(f"WARNING: {w}", file=sys.stderr)
|
|
35
|
+
|
|
36
|
+
for e in result.errors:
|
|
37
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
38
|
+
|
|
39
|
+
if result.ok:
|
|
40
|
+
n = len(result.concept_ids)
|
|
41
|
+
print(
|
|
42
|
+
f"OK: {args.path} — {n} concept(s); "
|
|
43
|
+
f"roots={result.roots}; leaves={result.leaves}",
|
|
44
|
+
file=sys.stdout,
|
|
45
|
+
)
|
|
46
|
+
if result.warnings:
|
|
47
|
+
print(f" {len(result.warnings)} warning(s) — see stderr.", file=sys.stdout)
|
|
48
|
+
else:
|
|
49
|
+
print(
|
|
50
|
+
f"FAIL: {args.path} — {len(result.errors)} error(s).",
|
|
51
|
+
file=sys.stdout,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return 0 if result.ok else 1
|
|
55
|
+
|
|
56
|
+
# stubs
|
|
57
|
+
print(f"mentar: '{args.cmd}' not implemented yet (stub).", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
raise SystemExit(main())
|
mentar/db/__init__.py
ADDED
mentar/db/store.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""LearnerStore — minimal SQLite wrapper for Mentar learner data.
|
|
2
|
+
|
|
3
|
+
Spec: docs/PHASE0.md W3.6
|
|
4
|
+
Safety: docs/SAFETY.md Layer 4 (data/privacy), Layer 5 (parental oversight)
|
|
5
|
+
Tests: tests/db/test_datamodel.py (T3.6)
|
|
6
|
+
|
|
7
|
+
Design notes:
|
|
8
|
+
- Stdlib sqlite3 only; no ORM.
|
|
9
|
+
- Row factory = sqlite3.Row (dict-like access by column name).
|
|
10
|
+
- All queries are parameterised; no string interpolation.
|
|
11
|
+
- Schema applied from schema.sql on first open (user_version == 0).
|
|
12
|
+
- user_version == 1 after schema applied; future migrations bump this.
|
|
13
|
+
- Transcript immutability is enforced by DB triggers; this layer does not
|
|
14
|
+
add a second guard — the trigger is the authority.
|
|
15
|
+
- Multi-learner namespacing: every write method accepts learner_id and
|
|
16
|
+
every read method filters by learner_id. Never query without it.
|
|
17
|
+
- export/backup = file copy at OS level; call close() first so WAL is
|
|
18
|
+
checkpointed (see export note below).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sqlite3
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Union
|
|
26
|
+
|
|
27
|
+
# Path to the schema DDL file alongside this module.
|
|
28
|
+
_SCHEMA_PATH = Path(__file__).parent / "schema.sql"
|
|
29
|
+
|
|
30
|
+
_EXPECTED_VERSION = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LearnerStore:
|
|
34
|
+
"""Local SQLite store for one Mentar installation (one .db file per device).
|
|
35
|
+
|
|
36
|
+
Multi-learner support is achieved via the learner_id column present on
|
|
37
|
+
every table — each method scopes queries to a single learner.
|
|
38
|
+
|
|
39
|
+
Thread safety: sqlite3 connections are NOT thread-safe by default.
|
|
40
|
+
For the pilot (single-process, single-thread) this is fine. A future
|
|
41
|
+
multi-threaded web tier would need one connection per thread or a pool.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, db_path: Union[str, Path]) -> None:
|
|
45
|
+
"""Open (or create) the SQLite database at *db_path*.
|
|
46
|
+
|
|
47
|
+
If the database is new (user_version == 0), the schema DDL in
|
|
48
|
+
schema.sql is applied and user_version is set to 1.
|
|
49
|
+
"""
|
|
50
|
+
self._path = Path(db_path)
|
|
51
|
+
self._conn = sqlite3.connect(str(self._path))
|
|
52
|
+
self._conn.row_factory = sqlite3.Row
|
|
53
|
+
# Enable FK enforcement for this connection (must be per-connection).
|
|
54
|
+
self._conn.execute("PRAGMA foreign_keys = ON;")
|
|
55
|
+
self._apply_schema_if_needed()
|
|
56
|
+
|
|
57
|
+
# ── Schema management ────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def _apply_schema_if_needed(self) -> None:
|
|
60
|
+
"""Apply schema.sql if the DB is uninitialised (user_version == 0)."""
|
|
61
|
+
version = self._user_version()
|
|
62
|
+
if version == 0:
|
|
63
|
+
ddl = _SCHEMA_PATH.read_text(encoding="utf-8")
|
|
64
|
+
self._conn.executescript(ddl)
|
|
65
|
+
self._conn.commit()
|
|
66
|
+
elif version < _EXPECTED_VERSION:
|
|
67
|
+
# Future migration hook: run incremental ALTER TABLE statements here,
|
|
68
|
+
# then bump user_version to _EXPECTED_VERSION.
|
|
69
|
+
# For now (schema v1 is the only version) this branch is unreachable.
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"Database schema version {version} is older than expected "
|
|
72
|
+
f"{_EXPECTED_VERSION}. Run the migration script."
|
|
73
|
+
)
|
|
74
|
+
# version == _EXPECTED_VERSION: nothing to do.
|
|
75
|
+
|
|
76
|
+
def _user_version(self) -> int:
|
|
77
|
+
row = self._conn.execute("PRAGMA user_version;").fetchone()
|
|
78
|
+
return int(row[0])
|
|
79
|
+
|
|
80
|
+
def schema_version(self) -> int:
|
|
81
|
+
"""Return the current PRAGMA user_version of the database."""
|
|
82
|
+
return self._user_version()
|
|
83
|
+
|
|
84
|
+
# ── Learner profile ──────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def create_learner(
|
|
87
|
+
self,
|
|
88
|
+
name: str,
|
|
89
|
+
year_level: str,
|
|
90
|
+
country: str,
|
|
91
|
+
age_mode: str,
|
|
92
|
+
) -> int:
|
|
93
|
+
"""Insert a learner profile row and return the new learner_id (int)."""
|
|
94
|
+
cur = self._conn.execute(
|
|
95
|
+
"""
|
|
96
|
+
INSERT INTO learner_profile (name, year_level, country, age_mode)
|
|
97
|
+
VALUES (?, ?, ?, ?)
|
|
98
|
+
""",
|
|
99
|
+
(name, year_level, country, age_mode),
|
|
100
|
+
)
|
|
101
|
+
self._conn.commit()
|
|
102
|
+
return cur.lastrowid # type: ignore[return-value]
|
|
103
|
+
|
|
104
|
+
def get_learner(self, learner_id: int) -> sqlite3.Row | None:
|
|
105
|
+
"""Return the learner_profile row for *learner_id*, or None."""
|
|
106
|
+
return self._conn.execute(
|
|
107
|
+
"SELECT * FROM learner_profile WHERE id = ?;",
|
|
108
|
+
(learner_id,),
|
|
109
|
+
).fetchone()
|
|
110
|
+
|
|
111
|
+
# ── Session ──────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def create_session(self, learner_id: int, session_id: str) -> None:
|
|
114
|
+
"""Insert a session row. session_id is caller-supplied (e.g. UUID)."""
|
|
115
|
+
self._conn.execute(
|
|
116
|
+
"INSERT INTO session (id, learner_id) VALUES (?, ?);",
|
|
117
|
+
(session_id, learner_id),
|
|
118
|
+
)
|
|
119
|
+
self._conn.commit()
|
|
120
|
+
|
|
121
|
+
def end_session(self, learner_id: int, session_id: str, ended_reason: str) -> None:
|
|
122
|
+
"""Mark a session as ended."""
|
|
123
|
+
self._conn.execute(
|
|
124
|
+
"""
|
|
125
|
+
UPDATE session
|
|
126
|
+
SET ended_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
|
|
127
|
+
ended_reason = ?
|
|
128
|
+
WHERE id = ? AND learner_id = ?;
|
|
129
|
+
""",
|
|
130
|
+
(ended_reason, session_id, learner_id),
|
|
131
|
+
)
|
|
132
|
+
self._conn.commit()
|
|
133
|
+
|
|
134
|
+
def get_session(self, learner_id: int, session_id: str) -> sqlite3.Row | None:
|
|
135
|
+
"""Return a session row, scoped to the given learner."""
|
|
136
|
+
return self._conn.execute(
|
|
137
|
+
"SELECT * FROM session WHERE id = ? AND learner_id = ?;",
|
|
138
|
+
(session_id, learner_id),
|
|
139
|
+
).fetchone()
|
|
140
|
+
|
|
141
|
+
# ── Skill state ──────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def update_skill_state(
|
|
144
|
+
self,
|
|
145
|
+
learner_id: int,
|
|
146
|
+
skill_id: str,
|
|
147
|
+
p_mastery: float,
|
|
148
|
+
priors_used: bool,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Upsert the BKT mastery estimate for one skill.
|
|
151
|
+
|
|
152
|
+
Only p_mastery and prior_mode are updated here because the BKT
|
|
153
|
+
parameters (p_guess, p_slip, p_learns, p_forgets) are set once at
|
|
154
|
+
cold-start from the priors table and not changed until the fitted
|
|
155
|
+
model supersedes them (W3.3: N >= 100 scored responses per skill).
|
|
156
|
+
"""
|
|
157
|
+
self._conn.execute(
|
|
158
|
+
"""
|
|
159
|
+
INSERT INTO skill_state (learner_id, skill_id, p_mastery, prior_mode,
|
|
160
|
+
updated_at)
|
|
161
|
+
VALUES (?, ?, ?, ?,
|
|
162
|
+
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
163
|
+
ON CONFLICT (learner_id, skill_id) DO UPDATE
|
|
164
|
+
SET p_mastery = excluded.p_mastery,
|
|
165
|
+
prior_mode = excluded.prior_mode,
|
|
166
|
+
updated_at = excluded.updated_at;
|
|
167
|
+
""",
|
|
168
|
+
(learner_id, skill_id, p_mastery, int(priors_used)),
|
|
169
|
+
)
|
|
170
|
+
self._conn.commit()
|
|
171
|
+
|
|
172
|
+
def get_skill_state(self, learner_id: int, skill_id: str) -> sqlite3.Row | None:
|
|
173
|
+
"""Return the skill_state row for one (learner, skill) pair."""
|
|
174
|
+
return self._conn.execute(
|
|
175
|
+
"SELECT * FROM skill_state WHERE learner_id = ? AND skill_id = ?;",
|
|
176
|
+
(learner_id, skill_id),
|
|
177
|
+
).fetchone()
|
|
178
|
+
|
|
179
|
+
def all_skill_states(self, learner_id: int) -> list[sqlite3.Row]:
|
|
180
|
+
"""Return all skill_state rows for a learner."""
|
|
181
|
+
return self._conn.execute(
|
|
182
|
+
"SELECT * FROM skill_state WHERE learner_id = ? ORDER BY skill_id;",
|
|
183
|
+
(learner_id,),
|
|
184
|
+
).fetchall()
|
|
185
|
+
|
|
186
|
+
# ── Response log ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
def write_response(
|
|
189
|
+
self,
|
|
190
|
+
learner_id: int,
|
|
191
|
+
session_id: str,
|
|
192
|
+
skill_id: str,
|
|
193
|
+
prompt_ref: str,
|
|
194
|
+
answer: str,
|
|
195
|
+
scored: int,
|
|
196
|
+
hinted: int,
|
|
197
|
+
check_result: str | None = None,
|
|
198
|
+
) -> int:
|
|
199
|
+
"""Insert a response_log row and return the new response id."""
|
|
200
|
+
cur = self._conn.execute(
|
|
201
|
+
"""
|
|
202
|
+
INSERT INTO response_log
|
|
203
|
+
(learner_id, session_id, skill_id, prompt_ref, answer,
|
|
204
|
+
scored, hinted, check_result)
|
|
205
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
|
206
|
+
""",
|
|
207
|
+
(learner_id, session_id, skill_id, prompt_ref, answer,
|
|
208
|
+
scored, hinted, check_result),
|
|
209
|
+
)
|
|
210
|
+
self._conn.commit()
|
|
211
|
+
return cur.lastrowid # type: ignore[return-value]
|
|
212
|
+
|
|
213
|
+
def session_responses(self, learner_id: int, session_id: str) -> list[dict]:
|
|
214
|
+
"""Return all response_log rows for one (learner, session) pair as dicts."""
|
|
215
|
+
rows = self._conn.execute(
|
|
216
|
+
"""
|
|
217
|
+
SELECT * FROM response_log
|
|
218
|
+
WHERE learner_id = ? AND session_id = ?
|
|
219
|
+
ORDER BY id;
|
|
220
|
+
""",
|
|
221
|
+
(learner_id, session_id),
|
|
222
|
+
).fetchall()
|
|
223
|
+
return [dict(r) for r in rows]
|
|
224
|
+
|
|
225
|
+
# ── Help events ──────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
def write_help_event(
|
|
228
|
+
self,
|
|
229
|
+
learner_id: int,
|
|
230
|
+
session_id: str,
|
|
231
|
+
skill_id: str,
|
|
232
|
+
modality: str,
|
|
233
|
+
response_log_id: int,
|
|
234
|
+
) -> int:
|
|
235
|
+
"""Insert a help_event row and return the new id."""
|
|
236
|
+
cur = self._conn.execute(
|
|
237
|
+
"""
|
|
238
|
+
INSERT INTO help_event
|
|
239
|
+
(learner_id, session_id, skill_id, modality, response_log_id)
|
|
240
|
+
VALUES (?, ?, ?, ?, ?);
|
|
241
|
+
""",
|
|
242
|
+
(learner_id, session_id, skill_id, modality, response_log_id),
|
|
243
|
+
)
|
|
244
|
+
self._conn.commit()
|
|
245
|
+
return cur.lastrowid # type: ignore[return-value]
|
|
246
|
+
|
|
247
|
+
def session_help_events(self, learner_id: int, session_id: str) -> list[dict]:
|
|
248
|
+
"""Return all help_event rows for one (learner, session) pair as dicts."""
|
|
249
|
+
rows = self._conn.execute(
|
|
250
|
+
"""
|
|
251
|
+
SELECT * FROM help_event
|
|
252
|
+
WHERE learner_id = ? AND session_id = ?
|
|
253
|
+
ORDER BY id;
|
|
254
|
+
""",
|
|
255
|
+
(learner_id, session_id),
|
|
256
|
+
).fetchall()
|
|
257
|
+
return [dict(r) for r in rows]
|
|
258
|
+
|
|
259
|
+
# ── Probe events ─────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
def write_probe_event(
|
|
262
|
+
self,
|
|
263
|
+
learner_id: int,
|
|
264
|
+
session_id: str,
|
|
265
|
+
skill_id: str,
|
|
266
|
+
response_log_id: int,
|
|
267
|
+
retry_response_log_id: int | None,
|
|
268
|
+
class_: str,
|
|
269
|
+
) -> int:
|
|
270
|
+
"""Insert a probe_event row and return the new id."""
|
|
271
|
+
cur = self._conn.execute(
|
|
272
|
+
"""
|
|
273
|
+
INSERT INTO probe_event
|
|
274
|
+
(learner_id, session_id, skill_id, response_log_id,
|
|
275
|
+
retry_response_log_id, class)
|
|
276
|
+
VALUES (?, ?, ?, ?, ?, ?);
|
|
277
|
+
""",
|
|
278
|
+
(learner_id, session_id, skill_id, response_log_id,
|
|
279
|
+
retry_response_log_id, class_),
|
|
280
|
+
)
|
|
281
|
+
self._conn.commit()
|
|
282
|
+
return cur.lastrowid # type: ignore[return-value]
|
|
283
|
+
|
|
284
|
+
def session_probe_events(self, learner_id: int, session_id: str) -> list[dict]:
|
|
285
|
+
"""Return all probe_event rows for one (learner, session) pair as dicts."""
|
|
286
|
+
rows = self._conn.execute(
|
|
287
|
+
"""
|
|
288
|
+
SELECT * FROM probe_event
|
|
289
|
+
WHERE learner_id = ? AND session_id = ?
|
|
290
|
+
ORDER BY id;
|
|
291
|
+
""",
|
|
292
|
+
(learner_id, session_id),
|
|
293
|
+
).fetchall()
|
|
294
|
+
return [dict(r) for r in rows]
|
|
295
|
+
|
|
296
|
+
# ── Escalation log ───────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
def write_escalation(
|
|
299
|
+
self,
|
|
300
|
+
learner_id: int,
|
|
301
|
+
trigger_class: str,
|
|
302
|
+
trigger_text_verbatim: str,
|
|
303
|
+
) -> int:
|
|
304
|
+
"""Insert an escalation_log row and return the new id.
|
|
305
|
+
|
|
306
|
+
trigger_text_verbatim is stored exactly as received — never truncated
|
|
307
|
+
(SAFETY.md §3.3 Step 2: "never silently dropped").
|
|
308
|
+
"""
|
|
309
|
+
cur = self._conn.execute(
|
|
310
|
+
"""
|
|
311
|
+
INSERT INTO escalation_log
|
|
312
|
+
(learner_id, trigger_class, trigger_text_verbatim)
|
|
313
|
+
VALUES (?, ?, ?);
|
|
314
|
+
""",
|
|
315
|
+
(learner_id, trigger_class, trigger_text_verbatim),
|
|
316
|
+
)
|
|
317
|
+
self._conn.commit()
|
|
318
|
+
return cur.lastrowid # type: ignore[return-value]
|
|
319
|
+
|
|
320
|
+
def parent_ack_escalation(self, esc_id: int) -> None:
|
|
321
|
+
"""Record the parent's acknowledgment of an escalation event."""
|
|
322
|
+
self._conn.execute(
|
|
323
|
+
"""
|
|
324
|
+
UPDATE escalation_log
|
|
325
|
+
SET parent_ack_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
|
|
326
|
+
session_outcome = 'acknowledged'
|
|
327
|
+
WHERE id = ?;
|
|
328
|
+
""",
|
|
329
|
+
(esc_id,),
|
|
330
|
+
)
|
|
331
|
+
self._conn.commit()
|
|
332
|
+
|
|
333
|
+
def get_escalation(self, learner_id: int, esc_id: int) -> sqlite3.Row | None:
|
|
334
|
+
"""Return one escalation_log row, scoped to learner."""
|
|
335
|
+
return self._conn.execute(
|
|
336
|
+
"SELECT * FROM escalation_log WHERE id = ? AND learner_id = ?;",
|
|
337
|
+
(esc_id, learner_id),
|
|
338
|
+
).fetchone()
|
|
339
|
+
|
|
340
|
+
def learner_escalations(self, learner_id: int) -> list[dict]:
|
|
341
|
+
"""Return all escalation_log rows for a learner as dicts."""
|
|
342
|
+
rows = self._conn.execute(
|
|
343
|
+
"SELECT * FROM escalation_log WHERE learner_id = ? ORDER BY id;",
|
|
344
|
+
(learner_id,),
|
|
345
|
+
).fetchall()
|
|
346
|
+
return [dict(r) for r in rows]
|
|
347
|
+
|
|
348
|
+
# ── Transcript ───────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
def write_transcript(
|
|
351
|
+
self,
|
|
352
|
+
learner_id: int,
|
|
353
|
+
session_id: str,
|
|
354
|
+
turn_index: int,
|
|
355
|
+
role: str,
|
|
356
|
+
text: str,
|
|
357
|
+
) -> int:
|
|
358
|
+
"""Append one turn to the immutable transcript and return the new id.
|
|
359
|
+
|
|
360
|
+
Immutability is enforced by DB triggers (trg_transcript_no_update and
|
|
361
|
+
trg_transcript_no_delete in schema.sql) — attempts to UPDATE or DELETE
|
|
362
|
+
a transcript row raise sqlite3.OperationalError.
|
|
363
|
+
"""
|
|
364
|
+
cur = self._conn.execute(
|
|
365
|
+
"""
|
|
366
|
+
INSERT INTO transcript
|
|
367
|
+
(learner_id, session_id, turn_index, role, text)
|
|
368
|
+
VALUES (?, ?, ?, ?, ?);
|
|
369
|
+
""",
|
|
370
|
+
(learner_id, session_id, turn_index, role, text),
|
|
371
|
+
)
|
|
372
|
+
self._conn.commit()
|
|
373
|
+
return cur.lastrowid # type: ignore[return-value]
|
|
374
|
+
|
|
375
|
+
def transcript_for_session(
|
|
376
|
+
self, learner_id: int, session_id: str
|
|
377
|
+
) -> list[dict]:
|
|
378
|
+
"""Return all transcript rows for one (learner, session) pair as dicts.
|
|
379
|
+
|
|
380
|
+
Ordered by turn_index ascending — safe for deterministic replay.
|
|
381
|
+
"""
|
|
382
|
+
rows = self._conn.execute(
|
|
383
|
+
"""
|
|
384
|
+
SELECT * FROM transcript
|
|
385
|
+
WHERE learner_id = ? AND session_id = ?
|
|
386
|
+
ORDER BY turn_index ASC;
|
|
387
|
+
""",
|
|
388
|
+
(learner_id, session_id),
|
|
389
|
+
).fetchall()
|
|
390
|
+
return [dict(r) for r in rows]
|
|
391
|
+
|
|
392
|
+
# ── Connection lifecycle ─────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
def checkpoint(self) -> None:
|
|
395
|
+
"""Checkpoint the WAL so an OS file-copy produces a consistent snapshot.
|
|
396
|
+
|
|
397
|
+
Call this before shutil.copy2() / any file-level export.
|
|
398
|
+
PRAGMA wal_checkpoint(TRUNCATE) flushes and truncates the WAL file.
|
|
399
|
+
"""
|
|
400
|
+
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE);")
|
|
401
|
+
|
|
402
|
+
def close(self) -> None:
|
|
403
|
+
"""Checkpoint and close the connection.
|
|
404
|
+
|
|
405
|
+
After close() the .db file is safe to copy (export = file copy per W3.6).
|
|
406
|
+
"""
|
|
407
|
+
self.checkpoint()
|
|
408
|
+
self._conn.close()
|
|
409
|
+
|
|
410
|
+
# ── Context manager support ──────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def __enter__(self) -> "LearnerStore":
|
|
413
|
+
return self
|
|
414
|
+
|
|
415
|
+
def __exit__(self, *_: object) -> None:
|
|
416
|
+
self.close()
|
mentar/engine/bkt.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""BKT per-turn mastery update — cold-start priors + hinted-win discount.
|
|
2
|
+
|
|
3
|
+
Spec: docs/SPEC.md §11 (Mastery / BKT), §13.2 (hinted-win); docs/design/W3.3_bkt.md.
|
|
4
|
+
Tests: docs/TESTS.md T3.3.
|
|
5
|
+
|
|
6
|
+
This is Mentar's own deterministic BKT recurrence (Corbett & Anderson 1995),
|
|
7
|
+
used for the per-turn update in the session FSM `bkt_update` state. pyBKT is NOT
|
|
8
|
+
called here: it cannot fit parameters from one learner's cold-start (W3.3), so it
|
|
9
|
+
is reserved for OFFLINE parameter fitting post-pilot (N >= 100 scored responses
|
|
10
|
+
per skill, flipping skill_state.prior_mode -> 0). See design doc §1.
|
|
11
|
+
|
|
12
|
+
stdlib-only, pure, side-effect-free: the caller persists the result via
|
|
13
|
+
store.update_skill_state(). No DB, no I/O, no RNG.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
# Initial mastery prior for a freshly-fringed concept (design §2). NOT 0.0:
|
|
21
|
+
# at exactly 0, no correct answer can ever move mastery (0*(1-slip)/... == 0).
|
|
22
|
+
P_L0 = 0.10
|
|
23
|
+
|
|
24
|
+
# How much a hinted-correct answer is discounted, as a fraction of the gap to a
|
|
25
|
+
# pure guess. 0.5 => guess_hinted is a coin-flip's worth of evidence (design §3.1).
|
|
26
|
+
# Deliberately strong: over-crediting a hinted win is the dangerous direction.
|
|
27
|
+
HINT_DISCOUNT = 0.5
|
|
28
|
+
|
|
29
|
+
# Node-class defaults keyed by verifier.answer_type (design §2). Template
|
|
30
|
+
# `bkt_priors:` overrides these per node.
|
|
31
|
+
_CLASS_DEFAULTS = {
|
|
32
|
+
"mc4": {"guess": 0.20, "slip": 0.10, "learns": 0.20, "forgets": 0.0},
|
|
33
|
+
"numeric": {"guess": 0.05, "slip": 0.10, "learns": 0.20, "forgets": 0.0},
|
|
34
|
+
}
|
|
35
|
+
# answer_type -> node class
|
|
36
|
+
_NUMERIC_TYPES = frozenset({"int", "decimal", "fraction"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class BktParams:
|
|
41
|
+
"""Per-skill BKT parameters. forgets is stored for forward-compat; unused in v0."""
|
|
42
|
+
|
|
43
|
+
guess: float
|
|
44
|
+
slip: float
|
|
45
|
+
learns: float
|
|
46
|
+
forgets: float = 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def params_for(answer_type: str, overrides: dict | None = None) -> BktParams:
|
|
50
|
+
"""Resolve params for a node: template `bkt_priors:` override wins, else the
|
|
51
|
+
class default by answer_type (design §2). `mc4` -> MC default; int/decimal/
|
|
52
|
+
fraction -> numeric default."""
|
|
53
|
+
node_class = "mc4" if answer_type == "mc4" else (
|
|
54
|
+
"numeric" if answer_type in _NUMERIC_TYPES else None
|
|
55
|
+
)
|
|
56
|
+
if node_class is None:
|
|
57
|
+
raise ValueError(f"no BKT prior class for answer_type {answer_type!r}")
|
|
58
|
+
base = dict(_CLASS_DEFAULTS[node_class])
|
|
59
|
+
if overrides:
|
|
60
|
+
base.update({k: v for k, v in overrides.items() if k in base})
|
|
61
|
+
return BktParams(**base)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _posterior_given_obs(p: float, correct: bool, guess: float, slip: float) -> float:
|
|
65
|
+
"""Bayesian conditioning of mastery on one observation (design §3 step a)."""
|
|
66
|
+
if correct:
|
|
67
|
+
num = p * (1.0 - slip)
|
|
68
|
+
den = num + (1.0 - p) * guess
|
|
69
|
+
else:
|
|
70
|
+
num = p * slip
|
|
71
|
+
den = num + (1.0 - p) * (1.0 - guess)
|
|
72
|
+
return num / den if den > 0.0 else p
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def bkt_update(
|
|
76
|
+
p_prior: float | None,
|
|
77
|
+
correct: bool,
|
|
78
|
+
hinted: bool,
|
|
79
|
+
params: BktParams,
|
|
80
|
+
) -> float:
|
|
81
|
+
"""Return the updated p_mastery after one scored observation.
|
|
82
|
+
|
|
83
|
+
p_prior is the current skill_state.p_mastery; pass None (or 0.0) for an
|
|
84
|
+
uninitialised skill -> seeded to P_L0 (design §2; regression guard for the
|
|
85
|
+
degenerate-zero bug).
|
|
86
|
+
|
|
87
|
+
Hinted-win discount (design §3.1): a *correct* answer after Help uses an
|
|
88
|
+
elevated guess, so it raises mastery strictly less than a cold correct. A
|
|
89
|
+
hinted *incorrect* uses the normal guess (we do not soften a wrong answer).
|
|
90
|
+
"""
|
|
91
|
+
p = P_L0 if (p_prior is None or p_prior <= 0.0) else p_prior
|
|
92
|
+
|
|
93
|
+
guess_eff = params.guess
|
|
94
|
+
if correct and hinted:
|
|
95
|
+
guess_eff = params.guess + (1.0 - params.guess) * HINT_DISCOUNT
|
|
96
|
+
|
|
97
|
+
p_cond = _posterior_given_obs(p, correct, guess_eff, params.slip)
|
|
98
|
+
# learning transition (within-session; forgets unused in v0)
|
|
99
|
+
return p_cond + (1.0 - p_cond) * params.learns
|