codelith 0.1.0__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.
- backend/__init__.py +1 -0
- backend/agents/__init__.py +6 -0
- backend/agents/assessment_agent.py +273 -0
- backend/agents/coding_agent.py +779 -0
- backend/agents/concept_categories.py +131 -0
- backend/agents/concept_detector.py +1217 -0
- backend/agents/debug_agent.py +166 -0
- backend/agents/teacher_agent.py +179 -0
- backend/cli/__init__.py +1 -0
- backend/cli/config_cmd.py +135 -0
- backend/cli/main.py +606 -0
- backend/daemon/__init__.py +1 -0
- backend/daemon/launcher.py +243 -0
- backend/daemon/server.py +453 -0
- backend/daemon/state.py +110 -0
- backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
- backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
- backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
- backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
- backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
- backend/daemon/static/assets/array-BifhSqXX.js +1 -0
- backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
- backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
- backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
- backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
- backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
- backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
- backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
- backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
- backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
- backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
- backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
- backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
- backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
- backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
- backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
- backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
- backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
- backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
- backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
- backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
- backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
- backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
- backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
- backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
- backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
- backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
- backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
- backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
- backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
- backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
- backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
- backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
- backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
- backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
- backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
- backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
- backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
- backend/daemon/static/assets/index-D3vj8REa.js +63 -0
- backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
- backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
- backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
- backend/daemon/static/assets/init-C-OQMol4.js +1 -0
- backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
- backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
- backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
- backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
- backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
- backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
- backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
- backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
- backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
- backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
- backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
- backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
- backend/daemon/static/assets/path-fybaL0A-.js +1 -0
- backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
- backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
- backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
- backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
- backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
- backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
- backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
- backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
- backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
- backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
- backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
- backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
- backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
- backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
- backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
- backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
- backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
- backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
- backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
- backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
- backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
- backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
- backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
- backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
- backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
- backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
- backend/daemon/static/index.html +49 -0
- backend/database/__init__.py +1 -0
- backend/database/concept_slug.py +39 -0
- backend/database/concepts.py +804 -0
- backend/llm/__init__.py +5 -0
- backend/llm/client.py +333 -0
- backend/llm/config.py +254 -0
- backend/llm/key_setup.py +237 -0
- backend/main.py +13 -0
- backend/orchestrator/__init__.py +1 -0
- backend/orchestrator/events.py +52 -0
- backend/orchestrator/graph.py +316 -0
- backend/orchestrator/modes.py +125 -0
- codelith-0.1.0.dist-info/METADATA +301 -0
- codelith-0.1.0.dist-info/RECORD +129 -0
- codelith-0.1.0.dist-info/WHEEL +5 -0
- codelith-0.1.0.dist-info/entry_points.txt +2 -0
- codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
- codelith-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
"""Concept storage — persists learned concepts per session.
|
|
2
|
+
|
|
3
|
+
Storage is a single SQLite database (``~/.codelith/codelith.db``) with three
|
|
4
|
+
tables — ``concepts``, ``teachings``, ``assessments`` — mirroring the
|
|
5
|
+
previous three JSON stores. WAL journal mode is enabled so the daemon
|
|
6
|
+
can write while the dashboard reads concurrently without
|
|
7
|
+
``database is locked`` errors.
|
|
8
|
+
|
|
9
|
+
Concepts are keyed by *identity*: a stable slug derived from the concept
|
|
10
|
+
name, not by which file it happened to appear in. A concept carries a
|
|
11
|
+
``content_hash`` of the underlying code it was detected from, so the
|
|
12
|
+
detect node can reuse a previously stored explanation/diagram without
|
|
13
|
+
recomputing when the concept resurfaces — in the same file or a
|
|
14
|
+
different one — and only recompute when the underlying code actually
|
|
15
|
+
changed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import functools
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sqlite3
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Callable, Iterator
|
|
32
|
+
|
|
33
|
+
from backend.database.concept_slug import concept_slug, normalize_concept_name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_db_path() -> Path:
|
|
37
|
+
"""Resolve the store location — fail closed under test runners.
|
|
38
|
+
|
|
39
|
+
When the process looks like a test run (unittest/pytest in argv,
|
|
40
|
+
or pytest's env marker), the store resolves into a per-process temp
|
|
41
|
+
directory instead of the user's real ``~/.codelith``. This is the
|
|
42
|
+
hard structural guarantee that a test can never pollute real data:
|
|
43
|
+
it does not depend on any fixture remembering to patch DB_PATH —
|
|
44
|
+
an unisolated test simply lands in temp space, where a wrong
|
|
45
|
+
assertion is the worst outcome.
|
|
46
|
+
|
|
47
|
+
argv[0] is included in the check because ``python -m unittest``
|
|
48
|
+
puts the runner's path there, not in the arguments.
|
|
49
|
+
"""
|
|
50
|
+
argv = " ".join(sys.argv).lower()
|
|
51
|
+
argv0 = os.path.basename(sys.argv[0] or "").lower()
|
|
52
|
+
under_test_runner = (
|
|
53
|
+
"unittest" in argv
|
|
54
|
+
or "pytest" in argv
|
|
55
|
+
or "test" in argv0 # direct execution: python tests/test_x.py
|
|
56
|
+
or "PYTEST_CURRENT_TEST" in os.environ
|
|
57
|
+
)
|
|
58
|
+
if under_test_runner:
|
|
59
|
+
return (
|
|
60
|
+
Path(tempfile.gettempdir()) / "codelith-tests"
|
|
61
|
+
/ f"codelith-{os.getpid()}.db"
|
|
62
|
+
)
|
|
63
|
+
return Path.home() / ".codelith" / "codelith.db"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
DB_PATH = _resolve_db_path()
|
|
67
|
+
|
|
68
|
+
_SCHEMA = """
|
|
69
|
+
CREATE TABLE IF NOT EXISTS concepts (
|
|
70
|
+
slug TEXT NOT NULL,
|
|
71
|
+
session TEXT NOT NULL,
|
|
72
|
+
name TEXT NOT NULL,
|
|
73
|
+
category TEXT NOT NULL DEFAULT '',
|
|
74
|
+
subcategory TEXT NOT NULL DEFAULT '',
|
|
75
|
+
description TEXT NOT NULL DEFAULT '',
|
|
76
|
+
decision TEXT NOT NULL DEFAULT '',
|
|
77
|
+
diagram TEXT NOT NULL DEFAULT '',
|
|
78
|
+
source_file TEXT NOT NULL DEFAULT '',
|
|
79
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
80
|
+
line_start INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
line_end INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
PRIMARY KEY (slug, session)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS teachings (
|
|
86
|
+
slug TEXT NOT NULL,
|
|
87
|
+
session TEXT NOT NULL,
|
|
88
|
+
concept_name TEXT NOT NULL,
|
|
89
|
+
category TEXT NOT NULL DEFAULT '',
|
|
90
|
+
explanation TEXT NOT NULL DEFAULT '',
|
|
91
|
+
decision TEXT NOT NULL DEFAULT '',
|
|
92
|
+
diagram TEXT NOT NULL DEFAULT '',
|
|
93
|
+
source_file TEXT NOT NULL DEFAULT '',
|
|
94
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
95
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
96
|
+
PRIMARY KEY (slug, session)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS assessments (
|
|
100
|
+
id TEXT NOT NULL,
|
|
101
|
+
session TEXT NOT NULL,
|
|
102
|
+
payload TEXT NOT NULL,
|
|
103
|
+
answered INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
correct INTEGER NOT NULL DEFAULT 0,
|
|
105
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
106
|
+
PRIMARY KEY (id, session)
|
|
107
|
+
);
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_schema_lock = threading.Lock()
|
|
113
|
+
_schema_ready = False
|
|
114
|
+
|
|
115
|
+
# Serializes writers *within this process*. The daemon's thread pool
|
|
116
|
+
# would otherwise stampede SQLite's lock-retry logic from many threads
|
|
117
|
+
# at once; an in-memory queue is cheaper and deterministic. Writers
|
|
118
|
+
# from OTHER processes queue via busy_timeout + BEGIN IMMEDIATE below.
|
|
119
|
+
_write_lock = threading.Lock()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _retry_on_busy(fn: Callable) -> Callable:
|
|
123
|
+
"""Retry a store operation on transient SQLITE_BUSY/LOCKED errors.
|
|
124
|
+
|
|
125
|
+
Third layer of the concurrency story (after the in-process write
|
|
126
|
+
lock and BEGIN IMMEDIATE + busy_timeout): WAL checkpoints and
|
|
127
|
+
cross-process lockers can still briefly block an operation past
|
|
128
|
+
what the busy handler absorbs. "database is locked" must never
|
|
129
|
+
reach a user of this store; a bounded exponential backoff turns
|
|
130
|
+
the residual window into a wait. All store operations are safe
|
|
131
|
+
to re-run — the transaction rolls back atomically on failure.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
@functools.wraps(fn)
|
|
135
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
136
|
+
delay = 0.05
|
|
137
|
+
for attempt in range(4):
|
|
138
|
+
try:
|
|
139
|
+
return fn(*args, **kwargs)
|
|
140
|
+
except sqlite3.OperationalError as exc:
|
|
141
|
+
msg = str(exc).lower()
|
|
142
|
+
transient = "locked" in msg or "busy" in msg
|
|
143
|
+
if not transient or attempt == 3:
|
|
144
|
+
raise
|
|
145
|
+
time.sleep(delay)
|
|
146
|
+
delay *= 2
|
|
147
|
+
return None # unreachable; keeps type-checkers content
|
|
148
|
+
|
|
149
|
+
return wrapper
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def ensure_schema() -> None:
|
|
153
|
+
"""Create the tables if they do not exist yet (idempotent, thread-safe)."""
|
|
154
|
+
global _schema_ready
|
|
155
|
+
if _schema_ready:
|
|
156
|
+
return
|
|
157
|
+
with _schema_lock:
|
|
158
|
+
if _schema_ready:
|
|
159
|
+
return
|
|
160
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
162
|
+
# busy_timeout BEFORE any DDL: table creation takes the write
|
|
163
|
+
# lock, and the default 0 would make a concurrent first-touch
|
|
164
|
+
# fail instantly instead of waiting.
|
|
165
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
166
|
+
try:
|
|
167
|
+
conn.executescript(_SCHEMA)
|
|
168
|
+
# Columns added after first release: a database created by an
|
|
169
|
+
# older build lacks them, and CREATE TABLE IF NOT EXISTS does
|
|
170
|
+
# not repair an existing table. Idempotent: ALTER fails with
|
|
171
|
+
# "duplicate column" when the column is already there.
|
|
172
|
+
for table in ("concepts", "teachings"):
|
|
173
|
+
try:
|
|
174
|
+
conn.execute(
|
|
175
|
+
f"ALTER TABLE {table} ADD COLUMN decision "
|
|
176
|
+
"TEXT NOT NULL DEFAULT ''"
|
|
177
|
+
)
|
|
178
|
+
except sqlite3.OperationalError as exc:
|
|
179
|
+
if "duplicate column" not in str(exc).lower():
|
|
180
|
+
raise
|
|
181
|
+
conn.commit()
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
_schema_ready = True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@contextmanager
|
|
188
|
+
def _connect(write: bool = True) -> Iterator[sqlite3.Connection]:
|
|
189
|
+
"""Yield a per-operation connection, always closed afterwards.
|
|
190
|
+
|
|
191
|
+
sqlite3 connections commit on context exit but do NOT close — on
|
|
192
|
+
Windows an open handle keeps the database file locked, so every
|
|
193
|
+
operation must close explicitly. One connection per operation
|
|
194
|
+
(opened and closed on the same thread) avoids SQLite's
|
|
195
|
+
``check_same_thread`` restriction under the daemon's thread pool.
|
|
196
|
+
|
|
197
|
+
``PRAGMA synchronous=NORMAL`` is the recommended pairing with WAL —
|
|
198
|
+
durable across app crashes, no per-commit fsync stall.
|
|
199
|
+
|
|
200
|
+
Two concurrency mechanisms, one per contention domain:
|
|
201
|
+
|
|
202
|
+
- ``write=False`` opens an autocommit READER. In WAL mode readers
|
|
203
|
+
never block writers and vice versa, so a read must not queue on
|
|
204
|
+
the write lock (or it would just add write pressure).
|
|
205
|
+
- ``write=True`` (default) acquires the in-process ``_write_lock`",
|
|
206
|
+
then takes the database write lock via ``BEGIN IMMEDIATE`` — not
|
|
207
|
+
the default DEFERRED, which fails with a non-retryable
|
|
208
|
+
``database is locked`` (SQLITE_BUSY_SNAPSHOT) when another
|
|
209
|
+
process commits between this transaction's read and its write;
|
|
210
|
+
busy_timeout cannot help a deferred snapshot. IMMEDIATE makes
|
|
211
|
+
cross-process writers queue via busy_timeout instead.
|
|
212
|
+
"""
|
|
213
|
+
ensure_schema()
|
|
214
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
# isolation_level=None: Python stops managing implicit DEFERRED
|
|
216
|
+
# transactions, so the explicit BEGIN IMMEDIATE below governs.
|
|
217
|
+
conn = sqlite3.connect(str(DB_PATH), isolation_level=None)
|
|
218
|
+
conn.row_factory = sqlite3.Row
|
|
219
|
+
# busy_timeout FIRST: it must be in effect before anything that can
|
|
220
|
+
# touch a lock — including the journal_mode pragma below, whose
|
|
221
|
+
# WAL-index lock acquisition fails instantly (default timeout 0)
|
|
222
|
+
# when another connection is mid-commit.
|
|
223
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
224
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
225
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
226
|
+
if write:
|
|
227
|
+
_write_lock.acquire()
|
|
228
|
+
try:
|
|
229
|
+
if write:
|
|
230
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
231
|
+
yield conn
|
|
232
|
+
if write:
|
|
233
|
+
conn.commit()
|
|
234
|
+
except BaseException:
|
|
235
|
+
if write:
|
|
236
|
+
conn.rollback()
|
|
237
|
+
raise
|
|
238
|
+
finally:
|
|
239
|
+
conn.close()
|
|
240
|
+
if write:
|
|
241
|
+
_write_lock.release()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def content_hash(code: str) -> str:
|
|
245
|
+
"""Stable short hash of the code a concept was detected in."""
|
|
246
|
+
return hashlib.sha256(code.encode("utf-8", "replace")).hexdigest()[:16]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _row_to_concept(row: sqlite3.Row) -> dict[str, Any]:
|
|
250
|
+
return {
|
|
251
|
+
"slug": row["slug"],
|
|
252
|
+
"name": row["name"],
|
|
253
|
+
"category": row["category"],
|
|
254
|
+
"subcategory": row["subcategory"],
|
|
255
|
+
"description": row["description"],
|
|
256
|
+
"decision": row["decision"],
|
|
257
|
+
"diagram": row["diagram"],
|
|
258
|
+
"source_file": row["source_file"],
|
|
259
|
+
"content_hash": row["content_hash"],
|
|
260
|
+
"line_range": [row["line_start"], row["line_end"]],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Concepts
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
@_retry_on_busy
|
|
269
|
+
def load_concepts(session: str = "default") -> list[dict[str, Any]]:
|
|
270
|
+
"""Load all stored concepts for *session* in insertion order."""
|
|
271
|
+
with _connect(write=False) as conn:
|
|
272
|
+
rows = conn.execute(
|
|
273
|
+
"SELECT * FROM concepts WHERE session = ? ORDER BY rowid",
|
|
274
|
+
(session,),
|
|
275
|
+
).fetchall()
|
|
276
|
+
return [_row_to_concept(r) for r in rows]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@_retry_on_busy
|
|
280
|
+
def save_concept(
|
|
281
|
+
session: str,
|
|
282
|
+
name: str,
|
|
283
|
+
category: str,
|
|
284
|
+
description: str,
|
|
285
|
+
source_file: str = "",
|
|
286
|
+
diagram: str = "",
|
|
287
|
+
subcategory: str = "",
|
|
288
|
+
code_hash: str = "",
|
|
289
|
+
decision: str = "",
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
"""Insert or refresh a concept, keyed by slug identity.
|
|
292
|
+
|
|
293
|
+
Returns the saved concept dict. A re-detection of the same concept
|
|
294
|
+
name in a different file *moves* the concept to the new source
|
|
295
|
+
(identity is the name, not the file) and refreshes its metadata;
|
|
296
|
+
the teaching entry keeps its original explanation (see
|
|
297
|
+
:func:`save_teaching`), so the dashboard stays consistent.
|
|
298
|
+
"""
|
|
299
|
+
slug = concept_slug(name)
|
|
300
|
+
with _connect() as conn:
|
|
301
|
+
conn.execute(
|
|
302
|
+
"""
|
|
303
|
+
INSERT INTO concepts
|
|
304
|
+
(slug, session, name, category, subcategory, description,
|
|
305
|
+
decision, diagram, source_file, content_hash)
|
|
306
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
307
|
+
ON CONFLICT(slug, session) DO UPDATE SET
|
|
308
|
+
name = excluded.name,
|
|
309
|
+
category = excluded.category,
|
|
310
|
+
subcategory = excluded.subcategory,
|
|
311
|
+
description = excluded.description,
|
|
312
|
+
decision = CASE
|
|
313
|
+
WHEN excluded.decision != '' THEN excluded.decision
|
|
314
|
+
ELSE concepts.decision END,
|
|
315
|
+
diagram = CASE
|
|
316
|
+
WHEN excluded.diagram != '' THEN excluded.diagram
|
|
317
|
+
ELSE concepts.diagram END,
|
|
318
|
+
source_file = excluded.source_file,
|
|
319
|
+
content_hash = excluded.content_hash
|
|
320
|
+
""",
|
|
321
|
+
(slug, session, name, category, subcategory, description,
|
|
322
|
+
decision, diagram, source_file, code_hash),
|
|
323
|
+
)
|
|
324
|
+
return {
|
|
325
|
+
"slug": slug,
|
|
326
|
+
"name": name,
|
|
327
|
+
"category": category,
|
|
328
|
+
"subcategory": subcategory,
|
|
329
|
+
"description": description,
|
|
330
|
+
"decision": decision,
|
|
331
|
+
"diagram": diagram,
|
|
332
|
+
"source_file": source_file,
|
|
333
|
+
"content_hash": code_hash,
|
|
334
|
+
"line_range": [0, 0],
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@_retry_on_busy
|
|
339
|
+
def save_concepts_bulk(
|
|
340
|
+
session: str,
|
|
341
|
+
concepts: list[dict[str, Any]],
|
|
342
|
+
) -> list[dict[str, Any]]:
|
|
343
|
+
"""Merge concept dicts into the session store, keyed by identity.
|
|
344
|
+
|
|
345
|
+
Accepts both rich dicts (with ``subcategory``/``diagram``/``code_hash``)
|
|
346
|
+
and the plain dicts stored by the legacy JSON format. Returns the
|
|
347
|
+
full list of stored concepts after the merge.
|
|
348
|
+
"""
|
|
349
|
+
existing_by_slug = {c["slug"]: c for c in load_concepts(session)}
|
|
350
|
+
|
|
351
|
+
for c in concepts:
|
|
352
|
+
name = c.get("name", "")
|
|
353
|
+
if not name:
|
|
354
|
+
continue
|
|
355
|
+
existing = existing_by_slug.get(concept_slug(name))
|
|
356
|
+
code_hash = c.get("code_hash") or c.get("content_hash") or ""
|
|
357
|
+
if existing is not None:
|
|
358
|
+
# Same identity, new sighting: refresh provenance, keep the
|
|
359
|
+
# richer stored content unless the new one carries more.
|
|
360
|
+
existing["source_file"] = c.get("source_file", "") or existing["source_file"]
|
|
361
|
+
existing["line_range"] = c.get("line_range") or existing.get("line_range", [0, 0])
|
|
362
|
+
if code_hash:
|
|
363
|
+
existing["content_hash"] = code_hash
|
|
364
|
+
if c.get("subcategory"):
|
|
365
|
+
existing["subcategory"] = c["subcategory"]
|
|
366
|
+
if c.get("description"):
|
|
367
|
+
existing["description"] = c["description"]
|
|
368
|
+
if c.get("category"):
|
|
369
|
+
existing["category"] = c["category"]
|
|
370
|
+
if c.get("decision"):
|
|
371
|
+
existing["decision"] = c["decision"]
|
|
372
|
+
if c.get("diagram"):
|
|
373
|
+
existing["diagram"] = c["diagram"]
|
|
374
|
+
else:
|
|
375
|
+
concept = {
|
|
376
|
+
"slug": concept_slug(name),
|
|
377
|
+
"name": name,
|
|
378
|
+
"category": c.get("category", ""),
|
|
379
|
+
"subcategory": c.get("subcategory", ""),
|
|
380
|
+
"description": c.get("description", ""),
|
|
381
|
+
"decision": c.get("decision", ""),
|
|
382
|
+
"diagram": c.get("diagram", ""),
|
|
383
|
+
"source_file": c.get("source_file", ""),
|
|
384
|
+
"content_hash": code_hash,
|
|
385
|
+
"line_range": c.get("line_range", [0, 0]),
|
|
386
|
+
}
|
|
387
|
+
existing_by_slug[concept["slug"]] = concept
|
|
388
|
+
|
|
389
|
+
concepts_list = list(existing_by_slug.values())
|
|
390
|
+
with _connect() as conn:
|
|
391
|
+
for c in concepts_list:
|
|
392
|
+
conn.execute(
|
|
393
|
+
"""
|
|
394
|
+
INSERT INTO concepts
|
|
395
|
+
(slug, session, name, category, subcategory, description,
|
|
396
|
+
decision, diagram, source_file, content_hash,
|
|
397
|
+
line_start, line_end)
|
|
398
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
399
|
+
ON CONFLICT(slug, session) DO UPDATE SET
|
|
400
|
+
name = excluded.name,
|
|
401
|
+
category = excluded.category,
|
|
402
|
+
subcategory = excluded.subcategory,
|
|
403
|
+
description = excluded.description,
|
|
404
|
+
decision = CASE
|
|
405
|
+
WHEN excluded.decision != '' THEN excluded.decision
|
|
406
|
+
ELSE concepts.decision END,
|
|
407
|
+
diagram = CASE
|
|
408
|
+
WHEN excluded.diagram != '' THEN excluded.diagram
|
|
409
|
+
ELSE concepts.diagram END,
|
|
410
|
+
source_file = excluded.source_file,
|
|
411
|
+
content_hash = excluded.content_hash,
|
|
412
|
+
line_start = excluded.line_start,
|
|
413
|
+
line_end = excluded.line_end
|
|
414
|
+
""",
|
|
415
|
+
(c["slug"], session, c["name"], c["category"],
|
|
416
|
+
c.get("subcategory", ""), c.get("description", ""),
|
|
417
|
+
c.get("decision", ""), c.get("diagram", ""),
|
|
418
|
+
c.get("source_file", ""),
|
|
419
|
+
c.get("content_hash", ""),
|
|
420
|
+
(c.get("line_range") or [0, 0])[0],
|
|
421
|
+
(c.get("line_range") or [0, 0])[1]),
|
|
422
|
+
)
|
|
423
|
+
return concepts_list
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@_retry_on_busy
|
|
427
|
+
def get_cached_teaching(
|
|
428
|
+
session: str, slug: str, code_hash: str
|
|
429
|
+
) -> dict[str, Any] | None:
|
|
430
|
+
"""Return the cached teaching for a concept whose code is unchanged.
|
|
431
|
+
|
|
432
|
+
Cache hit requires BOTH the same concept identity (slug) and the
|
|
433
|
+
same ``content_hash`` — the underlying code for that concept must
|
|
434
|
+
be unchanged, otherwise the explanation may be stale.
|
|
435
|
+
"""
|
|
436
|
+
with _connect(write=False) as conn:
|
|
437
|
+
row = conn.execute(
|
|
438
|
+
"""
|
|
439
|
+
SELECT t.* FROM teachings t
|
|
440
|
+
JOIN concepts c ON c.slug = t.slug AND c.session = t.session
|
|
441
|
+
WHERE t.slug = ? AND t.session = ? AND t.content_hash != ''
|
|
442
|
+
AND t.content_hash = ? AND c.content_hash = ?
|
|
443
|
+
""",
|
|
444
|
+
(slug, session, code_hash, code_hash),
|
|
445
|
+
).fetchone()
|
|
446
|
+
if row is None:
|
|
447
|
+
return None
|
|
448
|
+
return {
|
|
449
|
+
"slug": row["slug"],
|
|
450
|
+
"concept_name": row["concept_name"],
|
|
451
|
+
"concept_category": row["category"],
|
|
452
|
+
"explanation": row["explanation"],
|
|
453
|
+
"decision": row["decision"],
|
|
454
|
+
"diagram": row["diagram"],
|
|
455
|
+
"source_file": row["source_file"],
|
|
456
|
+
"content_hash": row["content_hash"],
|
|
457
|
+
"cached": True,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@_retry_on_busy
|
|
462
|
+
def get_cached_diagram(
|
|
463
|
+
session: str, slug: str, code_hash: str
|
|
464
|
+
) -> str:
|
|
465
|
+
"""Return the stored diagram for a concept whose code is unchanged."""
|
|
466
|
+
with _connect(write=False) as conn:
|
|
467
|
+
row = conn.execute(
|
|
468
|
+
"""
|
|
469
|
+
SELECT c.diagram FROM concepts c
|
|
470
|
+
WHERE c.slug = ? AND c.session = ? AND c.content_hash = ?
|
|
471
|
+
AND c.diagram != ''
|
|
472
|
+
""",
|
|
473
|
+
(slug, session, code_hash),
|
|
474
|
+
).fetchone()
|
|
475
|
+
return row["diagram"] if row else ""
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@_retry_on_busy
|
|
479
|
+
def file_scan_cached(
|
|
480
|
+
session: str, source_file: str, code_hash: str
|
|
481
|
+
) -> bool:
|
|
482
|
+
"""True when this exact file content was already LLM-scanned.
|
|
483
|
+
|
|
484
|
+
The generation-time identity cache: keys on (session, path,
|
|
485
|
+
content_hash). True means a stored concept was detected from
|
|
486
|
+
*this same content* of *this same file* — its explanation lives in
|
|
487
|
+
the teachings table and its detection cost is already sunk — so
|
|
488
|
+
the detect node skips the whole LLM generation call for the file.
|
|
489
|
+
|
|
490
|
+
Requires a non-empty code_hash: hash-less callers never consult
|
|
491
|
+
(nor can they ever pollute) the store.
|
|
492
|
+
"""
|
|
493
|
+
if not code_hash:
|
|
494
|
+
return False
|
|
495
|
+
with _connect(write=False) as conn:
|
|
496
|
+
row = conn.execute(
|
|
497
|
+
"""
|
|
498
|
+
SELECT 1 FROM concepts c
|
|
499
|
+
JOIN teachings t ON t.slug = c.slug AND t.session = c.session
|
|
500
|
+
WHERE c.session = ? AND c.source_file = ?
|
|
501
|
+
AND c.content_hash = ?
|
|
502
|
+
LIMIT 1
|
|
503
|
+
""",
|
|
504
|
+
(session, source_file, code_hash),
|
|
505
|
+
).fetchone()
|
|
506
|
+
return row is not None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
# Assessment storage
|
|
511
|
+
# ---------------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
@_retry_on_busy
|
|
514
|
+
def get_pending_assessments(session: str = "default") -> list[dict[str, Any]]:
|
|
515
|
+
"""Return the current (first unanswered) assessment for *session*.
|
|
516
|
+
|
|
517
|
+
Only one question is surfaced at a time — the rest stay queued in
|
|
518
|
+
storage and are promoted as earlier ones get answered.
|
|
519
|
+
"""
|
|
520
|
+
with _connect(write=False) as conn:
|
|
521
|
+
rows = conn.execute(
|
|
522
|
+
"SELECT payload FROM assessments WHERE session = ? "
|
|
523
|
+
"ORDER BY created_at, rowid",
|
|
524
|
+
(session,),
|
|
525
|
+
).fetchall()
|
|
526
|
+
for r in rows:
|
|
527
|
+
a = json.loads(r["payload"])
|
|
528
|
+
if not a.get("answered", False):
|
|
529
|
+
return [a]
|
|
530
|
+
return []
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@_retry_on_busy
|
|
534
|
+
def get_assessment_counts(session: str = "default") -> dict[str, int]:
|
|
535
|
+
"""Return counts for the assessment queue.
|
|
536
|
+
|
|
537
|
+
- ``total``: all assessments ever generated
|
|
538
|
+
- ``answered``: how many the user has answered
|
|
539
|
+
- ``pending``: unanswered assessments (the current question + queue)
|
|
540
|
+
- ``queued``: unanswered assessments waiting behind the current one
|
|
541
|
+
"""
|
|
542
|
+
with _connect(write=False) as conn:
|
|
543
|
+
rows = conn.execute(
|
|
544
|
+
"SELECT payload FROM assessments WHERE session = ?",
|
|
545
|
+
(session,),
|
|
546
|
+
).fetchall()
|
|
547
|
+
assessments = [json.loads(r["payload"]) for r in rows]
|
|
548
|
+
answered = [a for a in assessments if a.get("answered", False)]
|
|
549
|
+
pending = [a for a in assessments if not a.get("answered", False)]
|
|
550
|
+
return {
|
|
551
|
+
"total": len(assessments),
|
|
552
|
+
"answered": len(answered),
|
|
553
|
+
"pending": len(pending),
|
|
554
|
+
"queued": max(0, len(pending) - 1),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@_retry_on_busy
|
|
559
|
+
def get_all_assessments(session: str = "default") -> list[dict[str, Any]]:
|
|
560
|
+
"""Load all assessments (answered and unanswered) for *session*."""
|
|
561
|
+
with _connect(write=False) as conn:
|
|
562
|
+
rows = conn.execute(
|
|
563
|
+
"SELECT payload FROM assessments WHERE session = ? "
|
|
564
|
+
"ORDER BY created_at, rowid",
|
|
565
|
+
(session,),
|
|
566
|
+
).fetchall()
|
|
567
|
+
return [json.loads(r["payload"]) for r in rows]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@_retry_on_busy
|
|
571
|
+
def save_assessment(session: str, assessment: dict[str, Any]) -> None:
|
|
572
|
+
"""Append an assessment to the session's store. Deduplicates by id."""
|
|
573
|
+
with _connect() as conn:
|
|
574
|
+
conn.execute(
|
|
575
|
+
"""
|
|
576
|
+
INSERT INTO assessments (id, session, payload, answered, correct)
|
|
577
|
+
VALUES (?, ?, ?, ?, ?)
|
|
578
|
+
ON CONFLICT(id, session) DO NOTHING
|
|
579
|
+
""",
|
|
580
|
+
(
|
|
581
|
+
assessment["id"],
|
|
582
|
+
session,
|
|
583
|
+
json.dumps(assessment, ensure_ascii=False),
|
|
584
|
+
1 if assessment.get("answered", False) else 0,
|
|
585
|
+
1 if assessment.get("correct", False) else 0,
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@_retry_on_busy
|
|
591
|
+
def submit_assessment_answer(
|
|
592
|
+
session: str,
|
|
593
|
+
assessment_id: str,
|
|
594
|
+
answer: str,
|
|
595
|
+
correct: bool = False,
|
|
596
|
+
feedback: str = "",
|
|
597
|
+
) -> dict[str, Any] | None:
|
|
598
|
+
"""Record an attempt at an assessment. Returns the updated assessment or None.
|
|
599
|
+
|
|
600
|
+
A correct answer marks the assessment as answered; an incorrect one is
|
|
601
|
+
stored as ``feedback``/``last_answer`` on the still-open question so
|
|
602
|
+
the learner can retry. Only correct answers count toward progress.
|
|
603
|
+
"""
|
|
604
|
+
with _connect() as conn:
|
|
605
|
+
row = conn.execute(
|
|
606
|
+
"SELECT payload FROM assessments WHERE id = ? AND session = ?",
|
|
607
|
+
(assessment_id, session),
|
|
608
|
+
).fetchone()
|
|
609
|
+
if row is None:
|
|
610
|
+
return None
|
|
611
|
+
a = json.loads(row["payload"])
|
|
612
|
+
a["attempts"] = int(a.get("attempts", 0)) + 1
|
|
613
|
+
a["last_answer"] = answer
|
|
614
|
+
a["feedback"] = feedback
|
|
615
|
+
if correct:
|
|
616
|
+
a["answered"] = True
|
|
617
|
+
a["correct"] = True
|
|
618
|
+
a["answer"] = answer
|
|
619
|
+
conn.execute(
|
|
620
|
+
"UPDATE assessments SET payload = ?, answered = ?, correct = ? "
|
|
621
|
+
"WHERE id = ? AND session = ?",
|
|
622
|
+
(
|
|
623
|
+
json.dumps(a, ensure_ascii=False),
|
|
624
|
+
1 if a.get("answered", False) else 0,
|
|
625
|
+
1 if a.get("correct", False) else 0,
|
|
626
|
+
assessment_id,
|
|
627
|
+
session,
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
return a
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@_retry_on_busy
|
|
634
|
+
def get_assessment_progress(session: str = "default") -> dict[str, Any]:
|
|
635
|
+
"""Return a summary of assessment performance."""
|
|
636
|
+
assessments = get_all_assessments(session)
|
|
637
|
+
answered = [a for a in assessments if a.get("answered", False)]
|
|
638
|
+
correct = [a for a in answered if a.get("correct", False)]
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
"session": session,
|
|
642
|
+
"total": len(assessments),
|
|
643
|
+
"answered": len(answered),
|
|
644
|
+
"correct": len(correct),
|
|
645
|
+
"accuracy": round(len(correct) / len(answered) * 100, 1) if answered else 0,
|
|
646
|
+
"assessments": assessments,
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@_retry_on_busy
|
|
651
|
+
def clear_assessments(session: str = "default") -> None:
|
|
652
|
+
"""Delete all stored assessments for *session*."""
|
|
653
|
+
with _connect() as conn:
|
|
654
|
+
conn.execute("DELETE FROM assessments WHERE session = ?", (session,))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# ---------------------------------------------------------------------------
|
|
658
|
+
# Teaching storage (for dashboard)
|
|
659
|
+
# ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
@_retry_on_busy
|
|
662
|
+
def save_teaching(session: str, teaching: dict[str, Any]) -> None:
|
|
663
|
+
"""Append a teaching entry to the session's store.
|
|
664
|
+
|
|
665
|
+
Pure validation gate: the entry's ``diagram`` (Mermaid) is checked
|
|
666
|
+
before the write and stripped to "" when invalid, so the dashboard
|
|
667
|
+
renders the prose explanation instead of a syntax error.
|
|
668
|
+
|
|
669
|
+
No repair happens here — a safety net must be cheap and
|
|
670
|
+
deterministic, and this layer has none of the context (concept
|
|
671
|
+
name, description, source code) a good repair prompt needs. The
|
|
672
|
+
single LLM repair attempt lives in
|
|
673
|
+
:func:`backend.agents.concept_detector.detect_concepts_with_llm`,
|
|
674
|
+
whose backfill runs with that context; this gate is defense in
|
|
675
|
+
depth against any future caller that bypasses it.
|
|
676
|
+
|
|
677
|
+
Entries are keyed by concept identity (slug, session): saving a
|
|
678
|
+
teaching for an existing concept is a no-op unless the stored
|
|
679
|
+
``content_hash`` differs from the incoming one (i.e. the underlying
|
|
680
|
+
code changed) — keeping a concept's explanation stable across
|
|
681
|
+
re-detections.
|
|
682
|
+
"""
|
|
683
|
+
from backend.agents.concept_detector import is_valid_mermaid
|
|
684
|
+
|
|
685
|
+
diagram = teaching.get("diagram") or ""
|
|
686
|
+
if diagram and not is_valid_mermaid(diagram):
|
|
687
|
+
teaching = {**teaching, "diagram": ""}
|
|
688
|
+
|
|
689
|
+
slug = teaching.get("slug") or concept_slug(teaching.get("concept_name", ""))
|
|
690
|
+
code_hash = teaching.get("content_hash", "")
|
|
691
|
+
|
|
692
|
+
with _connect() as conn:
|
|
693
|
+
existing = conn.execute(
|
|
694
|
+
"SELECT content_hash FROM teachings WHERE slug = ? AND session = ?",
|
|
695
|
+
(slug, session),
|
|
696
|
+
).fetchone()
|
|
697
|
+
if existing is not None and existing["content_hash"] == code_hash:
|
|
698
|
+
return # cached: same concept, same code — keep stored entry
|
|
699
|
+
|
|
700
|
+
conn.execute(
|
|
701
|
+
"""
|
|
702
|
+
INSERT INTO teachings
|
|
703
|
+
(slug, session, concept_name, category, explanation,
|
|
704
|
+
decision, diagram, source_file, content_hash)
|
|
705
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
706
|
+
ON CONFLICT(slug, session) DO UPDATE SET
|
|
707
|
+
concept_name = excluded.concept_name,
|
|
708
|
+
category = excluded.category,
|
|
709
|
+
explanation = excluded.explanation,
|
|
710
|
+
decision = excluded.decision,
|
|
711
|
+
diagram = excluded.diagram,
|
|
712
|
+
source_file = excluded.source_file,
|
|
713
|
+
content_hash = excluded.content_hash,
|
|
714
|
+
created_at = datetime('now')
|
|
715
|
+
""",
|
|
716
|
+
(
|
|
717
|
+
slug,
|
|
718
|
+
session,
|
|
719
|
+
teaching.get("concept_name", ""),
|
|
720
|
+
teaching.get("concept_category", ""),
|
|
721
|
+
teaching.get("explanation", ""),
|
|
722
|
+
teaching.get("decision", ""),
|
|
723
|
+
teaching.get("diagram", ""),
|
|
724
|
+
teaching.get("source_file", ""),
|
|
725
|
+
code_hash,
|
|
726
|
+
),
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@_retry_on_busy
|
|
731
|
+
def get_teachings(session: str = "default") -> list[dict[str, Any]]:
|
|
732
|
+
"""Load all teaching entries for *session* in insertion order."""
|
|
733
|
+
with _connect(write=False) as conn:
|
|
734
|
+
rows = conn.execute(
|
|
735
|
+
"SELECT * FROM teachings WHERE session = ? ORDER BY rowid",
|
|
736
|
+
(session,),
|
|
737
|
+
).fetchall()
|
|
738
|
+
return [
|
|
739
|
+
{
|
|
740
|
+
"slug": r["slug"],
|
|
741
|
+
"concept_name": r["concept_name"],
|
|
742
|
+
"concept_category": r["category"],
|
|
743
|
+
"explanation": r["explanation"],
|
|
744
|
+
"decision": r["decision"],
|
|
745
|
+
"diagram": r["diagram"],
|
|
746
|
+
"source_file": r["source_file"],
|
|
747
|
+
"content_hash": r["content_hash"],
|
|
748
|
+
}
|
|
749
|
+
for r in rows
|
|
750
|
+
]
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
@_retry_on_busy
|
|
754
|
+
def clear_teachings(session: str = "default") -> None:
|
|
755
|
+
"""Delete all stored teachings for *session*."""
|
|
756
|
+
with _connect() as conn:
|
|
757
|
+
conn.execute("DELETE FROM teachings WHERE session = ?", (session,))
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@_retry_on_busy
|
|
761
|
+
def clear_concepts(session: str = "default") -> None:
|
|
762
|
+
"""Delete all stored concepts for *session*."""
|
|
763
|
+
with _connect() as conn:
|
|
764
|
+
conn.execute("DELETE FROM concepts WHERE session = ?", (session,))
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# ---------------------------------------------------------------------------
|
|
768
|
+
# Progress
|
|
769
|
+
# ---------------------------------------------------------------------------
|
|
770
|
+
|
|
771
|
+
@_retry_on_busy
|
|
772
|
+
def get_progress(session: str = "default") -> dict[str, Any]:
|
|
773
|
+
"""Return a summary of the user's learning progress.
|
|
774
|
+
|
|
775
|
+
Progress is mastery-based: a concept only counts once its assessment
|
|
776
|
+
question has been answered *correctly*. Concepts that were merely
|
|
777
|
+
detected still appear in ``concepts`` (and in ``detected``) but do not
|
|
778
|
+
count toward ``total_concepts`` or ``categories``.
|
|
779
|
+
"""
|
|
780
|
+
concepts = load_concepts(session)
|
|
781
|
+
assessments = get_all_assessments(session)
|
|
782
|
+
|
|
783
|
+
mastered_names = {
|
|
784
|
+
a.get("concept_name", "")
|
|
785
|
+
for a in assessments
|
|
786
|
+
if a.get("answered", False) and a.get("correct", False)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
categories: dict[str, int] = {}
|
|
790
|
+
mastered: list[dict[str, Any]] = []
|
|
791
|
+
for c in concepts:
|
|
792
|
+
if c.get("name") in mastered_names:
|
|
793
|
+
mastered.append(c)
|
|
794
|
+
cat = c.get("category", "General")
|
|
795
|
+
categories[cat] = categories.get(cat, 0) + 1
|
|
796
|
+
|
|
797
|
+
return {
|
|
798
|
+
"session": session,
|
|
799
|
+
"total_concepts": len(mastered),
|
|
800
|
+
"categories": categories,
|
|
801
|
+
"concepts": concepts,
|
|
802
|
+
"mastered": len(mastered),
|
|
803
|
+
"detected": len(concepts),
|
|
804
|
+
}
|