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.
Files changed (129) hide show
  1. backend/__init__.py +1 -0
  2. backend/agents/__init__.py +6 -0
  3. backend/agents/assessment_agent.py +273 -0
  4. backend/agents/coding_agent.py +779 -0
  5. backend/agents/concept_categories.py +131 -0
  6. backend/agents/concept_detector.py +1217 -0
  7. backend/agents/debug_agent.py +166 -0
  8. backend/agents/teacher_agent.py +179 -0
  9. backend/cli/__init__.py +1 -0
  10. backend/cli/config_cmd.py +135 -0
  11. backend/cli/main.py +606 -0
  12. backend/daemon/__init__.py +1 -0
  13. backend/daemon/launcher.py +243 -0
  14. backend/daemon/server.py +453 -0
  15. backend/daemon/state.py +110 -0
  16. backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
  17. backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
  18. backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
  19. backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
  20. backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
  21. backend/daemon/static/assets/array-BifhSqXX.js +1 -0
  22. backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
  23. backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
  24. backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
  25. backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  26. backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
  27. backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  28. backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
  29. backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
  30. backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
  31. backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
  32. backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
  33. backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
  34. backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
  35. backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  36. backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
  37. backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
  38. backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
  39. backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
  40. backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
  41. backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
  42. backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
  43. backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  44. backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
  45. backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
  46. backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
  47. backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
  48. backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
  49. backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
  50. backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
  51. backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
  52. backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
  53. backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
  54. backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
  55. backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
  56. backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
  57. backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
  58. backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
  59. backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
  60. backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
  61. backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
  62. backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
  63. backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
  64. backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
  65. backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
  66. backend/daemon/static/assets/index-D3vj8REa.js +63 -0
  67. backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
  68. backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
  69. backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
  70. backend/daemon/static/assets/init-C-OQMol4.js +1 -0
  71. backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
  72. backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
  73. backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
  74. backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
  75. backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
  76. backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
  77. backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
  78. backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
  79. backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
  80. backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
  81. backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
  82. backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
  83. backend/daemon/static/assets/path-fybaL0A-.js +1 -0
  84. backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
  85. backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
  86. backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
  87. backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
  88. backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
  89. backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
  90. backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
  91. backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
  92. backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
  93. backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
  94. backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
  95. backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
  96. backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
  97. backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
  98. backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  99. backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
  100. backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
  101. backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
  102. backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
  103. backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
  104. backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
  105. backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
  106. backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
  107. backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
  108. backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
  109. backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
  110. backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
  111. backend/daemon/static/index.html +49 -0
  112. backend/database/__init__.py +1 -0
  113. backend/database/concept_slug.py +39 -0
  114. backend/database/concepts.py +804 -0
  115. backend/llm/__init__.py +5 -0
  116. backend/llm/client.py +333 -0
  117. backend/llm/config.py +254 -0
  118. backend/llm/key_setup.py +237 -0
  119. backend/main.py +13 -0
  120. backend/orchestrator/__init__.py +1 -0
  121. backend/orchestrator/events.py +52 -0
  122. backend/orchestrator/graph.py +316 -0
  123. backend/orchestrator/modes.py +125 -0
  124. codelith-0.1.0.dist-info/METADATA +301 -0
  125. codelith-0.1.0.dist-info/RECORD +129 -0
  126. codelith-0.1.0.dist-info/WHEEL +5 -0
  127. codelith-0.1.0.dist-info/entry_points.txt +2 -0
  128. codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
  129. 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
+ }