studyctl 2.0.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.
- studyctl/__init__.py +3 -0
- studyctl/calendar.py +140 -0
- studyctl/cli/__init__.py +56 -0
- studyctl/cli/_config.py +128 -0
- studyctl/cli/_content.py +462 -0
- studyctl/cli/_lazy.py +35 -0
- studyctl/cli/_review.py +491 -0
- studyctl/cli/_schedule.py +125 -0
- studyctl/cli/_setup.py +164 -0
- studyctl/cli/_shared.py +83 -0
- studyctl/cli/_state.py +69 -0
- studyctl/cli/_sync.py +156 -0
- studyctl/cli/_web.py +228 -0
- studyctl/content/__init__.py +5 -0
- studyctl/content/markdown_converter.py +271 -0
- studyctl/content/models.py +31 -0
- studyctl/content/notebooklm_client.py +434 -0
- studyctl/content/splitter.py +159 -0
- studyctl/content/storage.py +105 -0
- studyctl/content/syllabus.py +416 -0
- studyctl/history.py +982 -0
- studyctl/maintenance.py +69 -0
- studyctl/mcp/__init__.py +1 -0
- studyctl/mcp/server.py +58 -0
- studyctl/mcp/tools.py +234 -0
- studyctl/pdf.py +89 -0
- studyctl/review_db.py +277 -0
- studyctl/review_loader.py +375 -0
- studyctl/scheduler.py +242 -0
- studyctl/services/__init__.py +6 -0
- studyctl/services/content.py +39 -0
- studyctl/services/review.py +127 -0
- studyctl/settings.py +367 -0
- studyctl/shared.py +425 -0
- studyctl/state.py +120 -0
- studyctl/sync.py +229 -0
- studyctl/tui/__main__.py +33 -0
- studyctl/tui/app.py +395 -0
- studyctl/tui/study_cards.py +396 -0
- studyctl/web/__init__.py +1 -0
- studyctl/web/app.py +68 -0
- studyctl/web/routes/__init__.py +1 -0
- studyctl/web/routes/artefacts.py +57 -0
- studyctl/web/routes/cards.py +86 -0
- studyctl/web/routes/courses.py +91 -0
- studyctl/web/routes/history.py +69 -0
- studyctl/web/server.py +260 -0
- studyctl/web/static/app.js +853 -0
- studyctl/web/static/icon-192.svg +4 -0
- studyctl/web/static/icon-512.svg +4 -0
- studyctl/web/static/index.html +50 -0
- studyctl/web/static/manifest.json +21 -0
- studyctl/web/static/style.css +657 -0
- studyctl/web/static/sw.js +14 -0
- studyctl-2.0.0.dist-info/METADATA +49 -0
- studyctl-2.0.0.dist-info/RECORD +58 -0
- studyctl-2.0.0.dist-info/WHEEL +4 -0
- studyctl-2.0.0.dist-info/entry_points.txt +3 -0
studyctl/history.py
ADDED
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
"""Query session history for study mentor intelligence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
10
|
+
|
|
11
|
+
from .settings import load_settings
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_study_terms() -> list[str]:
|
|
18
|
+
"""Build study terms from configured topics, falling back to defaults."""
|
|
19
|
+
try:
|
|
20
|
+
from .settings import get_topics
|
|
21
|
+
|
|
22
|
+
topics = get_topics()
|
|
23
|
+
if topics:
|
|
24
|
+
terms: set[str] = set()
|
|
25
|
+
for t in topics:
|
|
26
|
+
terms.add(t.name.lower())
|
|
27
|
+
terms.update(tag.lower() for tag in t.tags)
|
|
28
|
+
return sorted(terms)
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
# Fallback defaults
|
|
32
|
+
return [
|
|
33
|
+
"spark",
|
|
34
|
+
"glue",
|
|
35
|
+
"athena",
|
|
36
|
+
"redshift",
|
|
37
|
+
"sql",
|
|
38
|
+
"python",
|
|
39
|
+
"pattern",
|
|
40
|
+
"strategy",
|
|
41
|
+
"bridge",
|
|
42
|
+
"template",
|
|
43
|
+
"factory",
|
|
44
|
+
"pipeline",
|
|
45
|
+
"etl",
|
|
46
|
+
"partition",
|
|
47
|
+
"dag",
|
|
48
|
+
"airflow",
|
|
49
|
+
"dbt",
|
|
50
|
+
"dataclass",
|
|
51
|
+
"protocol",
|
|
52
|
+
"abc",
|
|
53
|
+
"decorator",
|
|
54
|
+
"generator",
|
|
55
|
+
"async",
|
|
56
|
+
"type hint",
|
|
57
|
+
"testing",
|
|
58
|
+
"pytest",
|
|
59
|
+
"sagemaker",
|
|
60
|
+
"lake formation",
|
|
61
|
+
"iceberg",
|
|
62
|
+
"delta",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _find_db() -> Path | None:
|
|
67
|
+
db = load_settings().session_db
|
|
68
|
+
return db if db.exists() else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _connect() -> sqlite3.Connection | None:
|
|
72
|
+
db = _find_db()
|
|
73
|
+
if not db:
|
|
74
|
+
return None
|
|
75
|
+
conn = sqlite3.connect(db, timeout=5)
|
|
76
|
+
conn.row_factory = sqlite3.Row
|
|
77
|
+
return conn
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def topic_frequency(topic_keywords: list[str], days: int = 30) -> list[dict]:
|
|
81
|
+
"""How often a topic appears in recent sessions.
|
|
82
|
+
|
|
83
|
+
Returns list of {date, session_id, snippet} for sessions mentioning the topic.
|
|
84
|
+
"""
|
|
85
|
+
conn = _connect()
|
|
86
|
+
if not conn:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
|
|
90
|
+
placeholders = " OR ".join("content MATCH ?" for _ in topic_keywords)
|
|
91
|
+
query = f"""
|
|
92
|
+
SELECT m.session_id, m.timestamp,
|
|
93
|
+
snippet(messages_fts, 0, '>>>', '<<<', '...', 30) as snippet
|
|
94
|
+
FROM messages_fts
|
|
95
|
+
JOIN messages m ON messages_fts.rowid = m.rowid
|
|
96
|
+
WHERE ({placeholders}) AND m.timestamp > ?
|
|
97
|
+
ORDER BY m.timestamp DESC
|
|
98
|
+
LIMIT 50
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
rows = conn.execute(query, [*topic_keywords, cutoff]).fetchall()
|
|
102
|
+
return [dict(r) for r in rows]
|
|
103
|
+
except sqlite3.OperationalError:
|
|
104
|
+
return []
|
|
105
|
+
finally:
|
|
106
|
+
conn.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def last_studied(topic_keywords: list[str]) -> str | None:
|
|
110
|
+
"""When was a topic last discussed? Returns ISO timestamp or None."""
|
|
111
|
+
results = topic_frequency(topic_keywords, days=365)
|
|
112
|
+
return results[0]["timestamp"] if results else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def struggle_topics(days: int = 30, min_sessions: int = 3) -> list[dict]:
|
|
116
|
+
"""Find topics that keep coming up — potential struggle areas.
|
|
117
|
+
|
|
118
|
+
Returns topics mentioned in 3+ sessions (user asking, not assistant explaining).
|
|
119
|
+
"""
|
|
120
|
+
conn = _connect()
|
|
121
|
+
if not conn:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
|
|
125
|
+
# Look for user questions (role='user') with question marks
|
|
126
|
+
try:
|
|
127
|
+
rows = conn.execute(
|
|
128
|
+
"""
|
|
129
|
+
SELECT m.content, m.session_id, m.timestamp
|
|
130
|
+
FROM messages m
|
|
131
|
+
WHERE m.role = 'user' AND m.content LIKE '%?%' AND m.timestamp > ?
|
|
132
|
+
ORDER BY m.timestamp DESC
|
|
133
|
+
LIMIT 200
|
|
134
|
+
""",
|
|
135
|
+
[cutoff],
|
|
136
|
+
).fetchall()
|
|
137
|
+
except sqlite3.OperationalError:
|
|
138
|
+
return []
|
|
139
|
+
finally:
|
|
140
|
+
conn.close()
|
|
141
|
+
|
|
142
|
+
# Simple keyword extraction from questions
|
|
143
|
+
from collections import Counter
|
|
144
|
+
|
|
145
|
+
keywords = Counter()
|
|
146
|
+
study_terms = _get_study_terms()
|
|
147
|
+
for row in rows:
|
|
148
|
+
content = row["content"].lower()
|
|
149
|
+
for term in study_terms:
|
|
150
|
+
if term in content:
|
|
151
|
+
keywords[term] += 1
|
|
152
|
+
|
|
153
|
+
return [{"topic": k, "mentions": v} for k, v in keywords.most_common(10) if v >= min_sessions]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def spaced_repetition_due(topic_keywords_map: dict[str, list[str]]) -> list[dict]:
|
|
157
|
+
"""Check which topics are due for spaced review.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
topic_keywords_map: {"python": ["python", "pattern", "dataclass"], ...}
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of {topic, last_studied, days_ago, review_type}
|
|
164
|
+
"""
|
|
165
|
+
due = []
|
|
166
|
+
now = datetime.now(UTC)
|
|
167
|
+
intervals = [
|
|
168
|
+
(1, "5-min recall quiz"),
|
|
169
|
+
(3, "10-min Socratic review"),
|
|
170
|
+
(7, "15-min deep review"),
|
|
171
|
+
(14, "Apply to new problem"),
|
|
172
|
+
(30, "Teach-back session"),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
for topic, keywords in topic_keywords_map.items():
|
|
176
|
+
last = last_studied(keywords)
|
|
177
|
+
if not last:
|
|
178
|
+
due.append(
|
|
179
|
+
{
|
|
180
|
+
"topic": topic,
|
|
181
|
+
"last_studied": None,
|
|
182
|
+
"days_ago": None,
|
|
183
|
+
"review_type": "New topic — start fresh",
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
last_dt = datetime.fromisoformat(last.replace("Z", "+00:00"))
|
|
190
|
+
except (ValueError, AttributeError):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
days_ago = (now - last_dt).days
|
|
194
|
+
review_type = None
|
|
195
|
+
for interval, rtype in intervals:
|
|
196
|
+
if days_ago >= interval:
|
|
197
|
+
review_type = rtype
|
|
198
|
+
|
|
199
|
+
if review_type:
|
|
200
|
+
due.append(
|
|
201
|
+
{
|
|
202
|
+
"topic": topic,
|
|
203
|
+
"last_studied": last[:10],
|
|
204
|
+
"days_ago": days_ago,
|
|
205
|
+
"review_type": review_type,
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return sorted(due, key=lambda x: x.get("days_ago") or 999, reverse=True)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_last_session_summary() -> dict | None:
|
|
213
|
+
"""Get a summary of the most recent study session for auto-resume.
|
|
214
|
+
|
|
215
|
+
Returns {session_id, source, project_path, started, topics_covered,
|
|
216
|
+
last_message_preview, concepts_in_progress} or None.
|
|
217
|
+
"""
|
|
218
|
+
conn = _connect()
|
|
219
|
+
if not conn:
|
|
220
|
+
return None
|
|
221
|
+
try:
|
|
222
|
+
# Find the most recent session
|
|
223
|
+
session = conn.execute(
|
|
224
|
+
"""
|
|
225
|
+
SELECT s.id, s.source, s.project_path, s.created_at, s.updated_at
|
|
226
|
+
FROM sessions s
|
|
227
|
+
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
228
|
+
LIMIT 1
|
|
229
|
+
"""
|
|
230
|
+
).fetchone()
|
|
231
|
+
if not session:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
session_id = session["id"]
|
|
235
|
+
|
|
236
|
+
# Get last few messages for context
|
|
237
|
+
messages = conn.execute(
|
|
238
|
+
"""
|
|
239
|
+
SELECT role, content FROM messages
|
|
240
|
+
WHERE session_id = ? AND role IN ('user', 'assistant')
|
|
241
|
+
ORDER BY COALESCE(seq, rowid) DESC
|
|
242
|
+
LIMIT 6
|
|
243
|
+
""",
|
|
244
|
+
(session_id,),
|
|
245
|
+
).fetchall()
|
|
246
|
+
|
|
247
|
+
# Get concepts currently in progress
|
|
248
|
+
in_progress = conn.execute(
|
|
249
|
+
"""
|
|
250
|
+
SELECT concept, topic, confidence FROM study_progress
|
|
251
|
+
WHERE confidence IN ('struggling', 'learning')
|
|
252
|
+
ORDER BY last_seen DESC
|
|
253
|
+
LIMIT 5
|
|
254
|
+
"""
|
|
255
|
+
).fetchall()
|
|
256
|
+
|
|
257
|
+
# Extract topic keywords from recent messages
|
|
258
|
+
study_terms = _get_study_terms()
|
|
259
|
+
topics_mentioned: set[str] = set()
|
|
260
|
+
for msg in messages:
|
|
261
|
+
content = (msg["content"] or "").lower()
|
|
262
|
+
for term in study_terms:
|
|
263
|
+
if term in content:
|
|
264
|
+
topics_mentioned.add(term)
|
|
265
|
+
|
|
266
|
+
# Build preview from last assistant message
|
|
267
|
+
preview = ""
|
|
268
|
+
for msg in messages:
|
|
269
|
+
if msg["role"] == "assistant" and msg["content"]:
|
|
270
|
+
preview = msg["content"][:200].strip()
|
|
271
|
+
break
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"session_id": session_id,
|
|
275
|
+
"source": session["source"],
|
|
276
|
+
"project_path": session["project_path"],
|
|
277
|
+
"started": session["created_at"],
|
|
278
|
+
"updated": session["updated_at"],
|
|
279
|
+
"topics_covered": sorted(topics_mentioned)[:5],
|
|
280
|
+
"last_message_preview": preview,
|
|
281
|
+
"concepts_in_progress": [
|
|
282
|
+
{"concept": r["concept"], "topic": r["topic"], "confidence": r["confidence"]}
|
|
283
|
+
for r in in_progress
|
|
284
|
+
],
|
|
285
|
+
}
|
|
286
|
+
except sqlite3.OperationalError:
|
|
287
|
+
return None
|
|
288
|
+
finally:
|
|
289
|
+
conn.close()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_study_streaks() -> dict:
|
|
293
|
+
"""Calculate study streak data from session history.
|
|
294
|
+
|
|
295
|
+
Returns {current_streak, longest_streak, total_days, sessions_this_week,
|
|
296
|
+
last_session_date, day_counts}.
|
|
297
|
+
"""
|
|
298
|
+
conn = _connect()
|
|
299
|
+
if not conn:
|
|
300
|
+
return {
|
|
301
|
+
"current_streak": 0,
|
|
302
|
+
"longest_streak": 0,
|
|
303
|
+
"total_days": 0,
|
|
304
|
+
"sessions_this_week": 0,
|
|
305
|
+
"last_session_date": None,
|
|
306
|
+
}
|
|
307
|
+
try:
|
|
308
|
+
# Get distinct study days (dates only) from last 90 days
|
|
309
|
+
rows = conn.execute(
|
|
310
|
+
"""
|
|
311
|
+
SELECT DISTINCT DATE(COALESCE(s.updated_at, s.created_at)) as study_date
|
|
312
|
+
FROM sessions s
|
|
313
|
+
WHERE s.created_at > datetime('now', '-90 days')
|
|
314
|
+
ORDER BY study_date DESC
|
|
315
|
+
"""
|
|
316
|
+
).fetchall()
|
|
317
|
+
|
|
318
|
+
if not rows:
|
|
319
|
+
return {
|
|
320
|
+
"current_streak": 0,
|
|
321
|
+
"longest_streak": 0,
|
|
322
|
+
"total_days": 0,
|
|
323
|
+
"sessions_this_week": 0,
|
|
324
|
+
"last_session_date": None,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
study_dates = [datetime.fromisoformat(r["study_date"]).date() for r in rows]
|
|
328
|
+
today = datetime.now(UTC).date()
|
|
329
|
+
|
|
330
|
+
# Calculate current streak (consecutive days ending today or yesterday)
|
|
331
|
+
current_streak = 0
|
|
332
|
+
check_date = today
|
|
333
|
+
for d in study_dates:
|
|
334
|
+
if d == check_date or d == check_date - timedelta(days=1):
|
|
335
|
+
current_streak += 1
|
|
336
|
+
check_date = d - timedelta(days=1)
|
|
337
|
+
else:
|
|
338
|
+
break
|
|
339
|
+
|
|
340
|
+
# Calculate longest streak
|
|
341
|
+
longest_streak = 1
|
|
342
|
+
current_run = 1
|
|
343
|
+
sorted_dates = sorted(study_dates)
|
|
344
|
+
for i in range(1, len(sorted_dates)):
|
|
345
|
+
if (sorted_dates[i] - sorted_dates[i - 1]).days == 1:
|
|
346
|
+
current_run += 1
|
|
347
|
+
longest_streak = max(longest_streak, current_run)
|
|
348
|
+
else:
|
|
349
|
+
current_run = 1
|
|
350
|
+
|
|
351
|
+
# Sessions this week
|
|
352
|
+
week_start = today - timedelta(days=today.weekday())
|
|
353
|
+
sessions_this_week = conn.execute(
|
|
354
|
+
"""
|
|
355
|
+
SELECT COUNT(*) as cnt FROM sessions
|
|
356
|
+
WHERE DATE(COALESCE(updated_at, created_at)) >= ?
|
|
357
|
+
""",
|
|
358
|
+
(week_start.isoformat(),),
|
|
359
|
+
).fetchone()["cnt"]
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
"current_streak": current_streak,
|
|
363
|
+
"longest_streak": longest_streak,
|
|
364
|
+
"total_days": len(study_dates),
|
|
365
|
+
"sessions_this_week": sessions_this_week,
|
|
366
|
+
"last_session_date": study_dates[0].isoformat() if study_dates else None,
|
|
367
|
+
}
|
|
368
|
+
except sqlite3.OperationalError:
|
|
369
|
+
return {
|
|
370
|
+
"current_streak": 0,
|
|
371
|
+
"longest_streak": 0,
|
|
372
|
+
"total_days": 0,
|
|
373
|
+
"sessions_this_week": 0,
|
|
374
|
+
"last_session_date": None,
|
|
375
|
+
}
|
|
376
|
+
finally:
|
|
377
|
+
conn.close()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def check_medication_window(medication_config: dict) -> dict | None:
|
|
381
|
+
"""Check current time against medication schedule.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
medication_config: {dose_time: "08:00", onset_minutes: 30,
|
|
385
|
+
peak_hours: 4, duration_hours: 8}
|
|
386
|
+
|
|
387
|
+
Returns {phase, recommendation, minutes_remaining} or None if not configured.
|
|
388
|
+
"""
|
|
389
|
+
if not medication_config or "dose_time" not in medication_config:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
now = datetime.now()
|
|
393
|
+
dose_h, dose_m = medication_config["dose_time"].split(":")
|
|
394
|
+
dose_time = now.replace(hour=int(dose_h), minute=int(dose_m), second=0, microsecond=0)
|
|
395
|
+
|
|
396
|
+
# If dose time is in the future, assume yesterday's dose
|
|
397
|
+
if dose_time > now:
|
|
398
|
+
dose_time -= timedelta(days=1)
|
|
399
|
+
|
|
400
|
+
minutes_since_dose = (now - dose_time).total_seconds() / 60
|
|
401
|
+
onset = medication_config.get("onset_minutes", 30)
|
|
402
|
+
peak_hours = medication_config.get("peak_hours", 4)
|
|
403
|
+
duration_hours = medication_config.get("duration_hours", 8)
|
|
404
|
+
|
|
405
|
+
if minutes_since_dose < onset:
|
|
406
|
+
return {
|
|
407
|
+
"phase": "onset",
|
|
408
|
+
"recommendation": "Meds ramping up. Light review or body doubling is a good fit.",
|
|
409
|
+
"minutes_remaining": int(onset - minutes_since_dose),
|
|
410
|
+
}
|
|
411
|
+
elif minutes_since_dose < (peak_hours * 60):
|
|
412
|
+
return {
|
|
413
|
+
"phase": "peak",
|
|
414
|
+
"recommendation": "Peak window. Best time for new material or hard problems.",
|
|
415
|
+
"minutes_remaining": int(peak_hours * 60 - minutes_since_dose),
|
|
416
|
+
}
|
|
417
|
+
elif minutes_since_dose < (duration_hours * 60):
|
|
418
|
+
return {
|
|
419
|
+
"phase": "tapering",
|
|
420
|
+
"recommendation": "Meds tapering. Switch to review or lighter material.",
|
|
421
|
+
"minutes_remaining": int(duration_hours * 60 - minutes_since_dose),
|
|
422
|
+
}
|
|
423
|
+
else:
|
|
424
|
+
return {
|
|
425
|
+
"phase": "worn_off",
|
|
426
|
+
"recommendation": "Meds have worn off. Review-only or body doubling recommended.",
|
|
427
|
+
"minutes_remaining": 0,
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def get_progress_for_map() -> list[dict]:
|
|
432
|
+
"""Get all study progress entries for rendering a progress map.
|
|
433
|
+
|
|
434
|
+
Returns list of {topic, concept, confidence, session_count, first_seen, last_seen}.
|
|
435
|
+
"""
|
|
436
|
+
conn = _connect()
|
|
437
|
+
if not conn:
|
|
438
|
+
return []
|
|
439
|
+
try:
|
|
440
|
+
rows = conn.execute(
|
|
441
|
+
"""
|
|
442
|
+
SELECT topic, concept, confidence, session_count, first_seen, last_seen
|
|
443
|
+
FROM study_progress
|
|
444
|
+
ORDER BY topic, confidence DESC, concept
|
|
445
|
+
"""
|
|
446
|
+
).fetchall()
|
|
447
|
+
return [dict(r) for r in rows]
|
|
448
|
+
except sqlite3.OperationalError:
|
|
449
|
+
return []
|
|
450
|
+
finally:
|
|
451
|
+
conn.close()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def get_wins(days: int = 30) -> list[dict]:
|
|
455
|
+
"""Find concepts that improved in confidence over the given period."""
|
|
456
|
+
conn = _connect()
|
|
457
|
+
if not conn:
|
|
458
|
+
return []
|
|
459
|
+
try:
|
|
460
|
+
rows = conn.execute(
|
|
461
|
+
"""
|
|
462
|
+
SELECT topic, concept, confidence, first_seen, last_seen, session_count
|
|
463
|
+
FROM study_progress
|
|
464
|
+
WHERE confidence IN ('confident', 'mastered')
|
|
465
|
+
AND last_seen > datetime('now', ?)
|
|
466
|
+
ORDER BY last_seen DESC
|
|
467
|
+
""",
|
|
468
|
+
(f"-{days} days",),
|
|
469
|
+
).fetchall()
|
|
470
|
+
return [dict(r) for r in rows]
|
|
471
|
+
except sqlite3.OperationalError:
|
|
472
|
+
return []
|
|
473
|
+
finally:
|
|
474
|
+
conn.close()
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def get_progress_summary() -> dict:
|
|
478
|
+
"""Get overall progress summary across all concepts."""
|
|
479
|
+
conn = _connect()
|
|
480
|
+
if not conn:
|
|
481
|
+
return {}
|
|
482
|
+
try:
|
|
483
|
+
rows = conn.execute(
|
|
484
|
+
"SELECT confidence, COUNT(*) as count FROM study_progress GROUP BY confidence"
|
|
485
|
+
).fetchall()
|
|
486
|
+
summary = {r["confidence"]: r["count"] for r in rows}
|
|
487
|
+
summary["total"] = sum(summary.values())
|
|
488
|
+
return summary
|
|
489
|
+
except sqlite3.OperationalError:
|
|
490
|
+
return {}
|
|
491
|
+
finally:
|
|
492
|
+
conn.close()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def record_progress(
|
|
496
|
+
topic: str,
|
|
497
|
+
concept: str,
|
|
498
|
+
confidence: str,
|
|
499
|
+
notes: str | None = None,
|
|
500
|
+
) -> bool:
|
|
501
|
+
"""Record or update progress on a concept."""
|
|
502
|
+
conn = _connect()
|
|
503
|
+
if not conn:
|
|
504
|
+
return False
|
|
505
|
+
try:
|
|
506
|
+
# Normalise to avoid case-sensitive duplicates (e.g. "Python" vs "python")
|
|
507
|
+
topic = topic.lower().strip()
|
|
508
|
+
concept = concept.lower().strip()
|
|
509
|
+
now = datetime.now(UTC).isoformat()
|
|
510
|
+
progress_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{topic}:{concept}"))
|
|
511
|
+
conn.execute(
|
|
512
|
+
"""
|
|
513
|
+
INSERT INTO study_progress
|
|
514
|
+
(id, topic, concept, confidence, first_seen, last_seen, session_count, notes)
|
|
515
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
|
516
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
517
|
+
confidence = excluded.confidence,
|
|
518
|
+
last_seen = excluded.last_seen,
|
|
519
|
+
session_count = session_count + 1,
|
|
520
|
+
notes = COALESCE(excluded.notes, notes),
|
|
521
|
+
updated_at = datetime('now')
|
|
522
|
+
""",
|
|
523
|
+
(progress_id, topic, concept, confidence, now, now, notes),
|
|
524
|
+
)
|
|
525
|
+
conn.commit()
|
|
526
|
+
return True
|
|
527
|
+
except sqlite3.OperationalError:
|
|
528
|
+
return False
|
|
529
|
+
finally:
|
|
530
|
+
conn.close()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ── teach-back scoring ────────────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def record_teachback(
|
|
537
|
+
concept: str,
|
|
538
|
+
topic: str,
|
|
539
|
+
scores: tuple[int, int, int, int, int],
|
|
540
|
+
review_type: str,
|
|
541
|
+
angle: str | None = None,
|
|
542
|
+
notes: str | None = None,
|
|
543
|
+
session_id: str | None = None,
|
|
544
|
+
) -> bool:
|
|
545
|
+
"""Record a teach-back score for a concept.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
concept: The concept being assessed.
|
|
549
|
+
topic: Study topic (python, sql, etc.).
|
|
550
|
+
scores: Tuple of (accuracy, own_words, structure, depth, transfer) each 1-4.
|
|
551
|
+
review_type: One of micro, structured, transfer, full.
|
|
552
|
+
angle: Question angle used (e.g. "bloom_apply", "network_analogy").
|
|
553
|
+
notes: Optional notes about the assessment.
|
|
554
|
+
session_id: Optional session ID to link to.
|
|
555
|
+
"""
|
|
556
|
+
conn = _connect()
|
|
557
|
+
if not conn:
|
|
558
|
+
return False
|
|
559
|
+
try:
|
|
560
|
+
accuracy, own_words, structure, depth, transfer = scores
|
|
561
|
+
conn.execute(
|
|
562
|
+
"""
|
|
563
|
+
INSERT INTO teach_back_scores
|
|
564
|
+
(concept, topic, session_id, score_accuracy, score_own_words,
|
|
565
|
+
score_structure, score_depth, score_transfer,
|
|
566
|
+
review_type, question_angle, notes)
|
|
567
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
568
|
+
""",
|
|
569
|
+
(
|
|
570
|
+
concept,
|
|
571
|
+
topic,
|
|
572
|
+
session_id,
|
|
573
|
+
accuracy,
|
|
574
|
+
own_words,
|
|
575
|
+
structure,
|
|
576
|
+
depth,
|
|
577
|
+
transfer,
|
|
578
|
+
review_type,
|
|
579
|
+
angle,
|
|
580
|
+
notes,
|
|
581
|
+
),
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# Update study_progress with latest teach-back score and angle
|
|
585
|
+
total = sum(scores)
|
|
586
|
+
progress_id = str(
|
|
587
|
+
uuid.uuid5(uuid.NAMESPACE_DNS, f"{topic.lower().strip()}:{concept.lower().strip()}")
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# Get existing angles_used and append
|
|
591
|
+
existing = conn.execute(
|
|
592
|
+
"SELECT angles_used FROM study_progress WHERE id = ?",
|
|
593
|
+
(progress_id,),
|
|
594
|
+
).fetchone()
|
|
595
|
+
angles: list[str] = []
|
|
596
|
+
if existing and existing["angles_used"]:
|
|
597
|
+
angles = json.loads(existing["angles_used"])
|
|
598
|
+
if angle and angle not in angles:
|
|
599
|
+
angles.append(angle)
|
|
600
|
+
|
|
601
|
+
conn.execute(
|
|
602
|
+
"""
|
|
603
|
+
UPDATE study_progress
|
|
604
|
+
SET last_teachback_score = ?,
|
|
605
|
+
angles_used = ?,
|
|
606
|
+
updated_at = datetime('now')
|
|
607
|
+
WHERE id = ?
|
|
608
|
+
""",
|
|
609
|
+
(total, json.dumps(angles), progress_id),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
conn.commit()
|
|
613
|
+
return True
|
|
614
|
+
except sqlite3.OperationalError:
|
|
615
|
+
return False
|
|
616
|
+
finally:
|
|
617
|
+
conn.close()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def get_teachback_history(concept: str, topic: str | None = None) -> list[dict]:
|
|
621
|
+
"""Get teach-back score history for a concept."""
|
|
622
|
+
conn = _connect()
|
|
623
|
+
if not conn:
|
|
624
|
+
return []
|
|
625
|
+
try:
|
|
626
|
+
if topic:
|
|
627
|
+
rows = conn.execute(
|
|
628
|
+
"""
|
|
629
|
+
SELECT concept, topic, score_accuracy, score_own_words,
|
|
630
|
+
score_structure, score_depth, score_transfer,
|
|
631
|
+
total_score, review_type, question_angle, notes, created_at
|
|
632
|
+
FROM teach_back_scores
|
|
633
|
+
WHERE concept = ? AND topic = ?
|
|
634
|
+
ORDER BY created_at DESC
|
|
635
|
+
LIMIT 20
|
|
636
|
+
""",
|
|
637
|
+
(concept, topic),
|
|
638
|
+
).fetchall()
|
|
639
|
+
else:
|
|
640
|
+
rows = conn.execute(
|
|
641
|
+
"""
|
|
642
|
+
SELECT concept, topic, score_accuracy, score_own_words,
|
|
643
|
+
score_structure, score_depth, score_transfer,
|
|
644
|
+
total_score, review_type, question_angle, notes, created_at
|
|
645
|
+
FROM teach_back_scores
|
|
646
|
+
WHERE concept = ?
|
|
647
|
+
ORDER BY created_at DESC
|
|
648
|
+
LIMIT 20
|
|
649
|
+
""",
|
|
650
|
+
(concept,),
|
|
651
|
+
).fetchall()
|
|
652
|
+
return [dict(r) for r in rows]
|
|
653
|
+
except sqlite3.OperationalError:
|
|
654
|
+
return []
|
|
655
|
+
finally:
|
|
656
|
+
conn.close()
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# ── knowledge bridges ─────────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def record_bridge(
|
|
663
|
+
source_concept: str,
|
|
664
|
+
source_domain: str,
|
|
665
|
+
target_concept: str,
|
|
666
|
+
target_domain: str,
|
|
667
|
+
structural_mapping: str | None = None,
|
|
668
|
+
quality: str = "proposed",
|
|
669
|
+
created_by: str = "agent",
|
|
670
|
+
) -> bool:
|
|
671
|
+
"""Record a knowledge bridge between two concepts."""
|
|
672
|
+
conn = _connect()
|
|
673
|
+
if not conn:
|
|
674
|
+
return False
|
|
675
|
+
try:
|
|
676
|
+
conn.execute(
|
|
677
|
+
"""
|
|
678
|
+
INSERT INTO knowledge_bridges
|
|
679
|
+
(source_concept, source_domain, target_concept, target_domain,
|
|
680
|
+
structural_mapping, quality, created_by)
|
|
681
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
682
|
+
""",
|
|
683
|
+
(
|
|
684
|
+
source_concept,
|
|
685
|
+
source_domain,
|
|
686
|
+
target_concept,
|
|
687
|
+
target_domain,
|
|
688
|
+
structural_mapping,
|
|
689
|
+
quality,
|
|
690
|
+
created_by,
|
|
691
|
+
),
|
|
692
|
+
)
|
|
693
|
+
conn.commit()
|
|
694
|
+
return True
|
|
695
|
+
except sqlite3.OperationalError:
|
|
696
|
+
return False
|
|
697
|
+
finally:
|
|
698
|
+
conn.close()
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def get_bridges(
|
|
702
|
+
target_domain: str | None = None,
|
|
703
|
+
source_domain: str | None = None,
|
|
704
|
+
quality: str | None = None,
|
|
705
|
+
) -> list[dict]:
|
|
706
|
+
"""Get knowledge bridges, optionally filtered."""
|
|
707
|
+
conn = _connect()
|
|
708
|
+
if not conn:
|
|
709
|
+
return []
|
|
710
|
+
try:
|
|
711
|
+
conditions = []
|
|
712
|
+
params: list[str] = []
|
|
713
|
+
if target_domain:
|
|
714
|
+
conditions.append("target_domain = ?")
|
|
715
|
+
params.append(target_domain)
|
|
716
|
+
if source_domain:
|
|
717
|
+
conditions.append("source_domain = ?")
|
|
718
|
+
params.append(source_domain)
|
|
719
|
+
if quality:
|
|
720
|
+
conditions.append("quality = ?")
|
|
721
|
+
params.append(quality)
|
|
722
|
+
|
|
723
|
+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
724
|
+
rows = conn.execute(
|
|
725
|
+
f"""
|
|
726
|
+
SELECT id, source_concept, source_domain, target_concept, target_domain,
|
|
727
|
+
structural_mapping, quality, times_used, times_helpful,
|
|
728
|
+
created_by, created_at
|
|
729
|
+
FROM knowledge_bridges
|
|
730
|
+
{where}
|
|
731
|
+
ORDER BY times_helpful DESC, created_at DESC
|
|
732
|
+
""",
|
|
733
|
+
params,
|
|
734
|
+
).fetchall()
|
|
735
|
+
return [dict(r) for r in rows]
|
|
736
|
+
except sqlite3.OperationalError:
|
|
737
|
+
return []
|
|
738
|
+
finally:
|
|
739
|
+
conn.close()
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def update_bridge_usage(bridge_id: int, helpful: bool) -> bool:
|
|
743
|
+
"""Record that a bridge was used, and whether it was helpful."""
|
|
744
|
+
conn = _connect()
|
|
745
|
+
if not conn:
|
|
746
|
+
return False
|
|
747
|
+
try:
|
|
748
|
+
helpful_increment = 1 if helpful else 0
|
|
749
|
+
conn.execute(
|
|
750
|
+
"""
|
|
751
|
+
UPDATE knowledge_bridges
|
|
752
|
+
SET times_used = times_used + 1,
|
|
753
|
+
times_helpful = times_helpful + ?,
|
|
754
|
+
updated_at = datetime('now')
|
|
755
|
+
WHERE id = ?
|
|
756
|
+
""",
|
|
757
|
+
(helpful_increment, bridge_id),
|
|
758
|
+
)
|
|
759
|
+
conn.commit()
|
|
760
|
+
return True
|
|
761
|
+
except sqlite3.OperationalError:
|
|
762
|
+
return False
|
|
763
|
+
finally:
|
|
764
|
+
conn.close()
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# ── study sessions ────────────────────────────────────────────────────────────
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def start_study_session(
|
|
771
|
+
topic: str,
|
|
772
|
+
energy_level: str,
|
|
773
|
+
session_id: str | None = None,
|
|
774
|
+
) -> str | None:
|
|
775
|
+
"""Start a tracked study session. Returns the study session ID."""
|
|
776
|
+
conn = _connect()
|
|
777
|
+
if not conn:
|
|
778
|
+
return None
|
|
779
|
+
try:
|
|
780
|
+
study_id = str(uuid.uuid4())
|
|
781
|
+
now = datetime.now(UTC).isoformat()
|
|
782
|
+
conn.execute(
|
|
783
|
+
"""
|
|
784
|
+
INSERT INTO study_sessions (id, session_id, topic, energy_level, started_at)
|
|
785
|
+
VALUES (?, ?, ?, ?, ?)
|
|
786
|
+
""",
|
|
787
|
+
(study_id, session_id, topic.lower().strip(), energy_level, now),
|
|
788
|
+
)
|
|
789
|
+
conn.commit()
|
|
790
|
+
return study_id
|
|
791
|
+
except sqlite3.OperationalError:
|
|
792
|
+
return None
|
|
793
|
+
finally:
|
|
794
|
+
conn.close()
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def end_study_session(study_id: str, notes: str | None = None) -> bool:
|
|
798
|
+
"""End a tracked study session, recording duration."""
|
|
799
|
+
conn = _connect()
|
|
800
|
+
if not conn:
|
|
801
|
+
return False
|
|
802
|
+
try:
|
|
803
|
+
now = datetime.now(UTC).isoformat()
|
|
804
|
+
conn.execute(
|
|
805
|
+
"""
|
|
806
|
+
UPDATE study_sessions
|
|
807
|
+
SET ended_at = ?,
|
|
808
|
+
duration_minutes = CAST(
|
|
809
|
+
(julianday(?) - julianday(started_at)) * 1440 AS INTEGER
|
|
810
|
+
),
|
|
811
|
+
notes = COALESCE(?, notes)
|
|
812
|
+
WHERE id = ?
|
|
813
|
+
""",
|
|
814
|
+
(now, now, notes, study_id),
|
|
815
|
+
)
|
|
816
|
+
conn.commit()
|
|
817
|
+
return True
|
|
818
|
+
except sqlite3.OperationalError:
|
|
819
|
+
return False
|
|
820
|
+
finally:
|
|
821
|
+
conn.close()
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def get_study_session_stats(days: int = 30) -> list[dict]:
|
|
825
|
+
"""Get study session stats grouped by topic for the given period."""
|
|
826
|
+
conn = _connect()
|
|
827
|
+
if not conn:
|
|
828
|
+
return []
|
|
829
|
+
try:
|
|
830
|
+
rows = conn.execute(
|
|
831
|
+
"""
|
|
832
|
+
SELECT topic,
|
|
833
|
+
COUNT(*) as sessions,
|
|
834
|
+
SUM(duration_minutes) as total_minutes,
|
|
835
|
+
AVG(duration_minutes) as avg_minutes,
|
|
836
|
+
energy_level as most_common_energy
|
|
837
|
+
FROM study_sessions
|
|
838
|
+
WHERE started_at > datetime('now', ?)
|
|
839
|
+
AND duration_minutes IS NOT NULL
|
|
840
|
+
GROUP BY topic
|
|
841
|
+
ORDER BY total_minutes DESC
|
|
842
|
+
""",
|
|
843
|
+
(f"-{days} days",),
|
|
844
|
+
).fetchall()
|
|
845
|
+
return [dict(r) for r in rows]
|
|
846
|
+
except sqlite3.OperationalError:
|
|
847
|
+
return []
|
|
848
|
+
finally:
|
|
849
|
+
conn.close()
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def migrate_bridges_to_graph() -> int:
|
|
853
|
+
"""One-time migration of knowledge_bridges → concept graph.
|
|
854
|
+
|
|
855
|
+
Creates concept rows and analogy_to relations from existing bridges.
|
|
856
|
+
Returns the number of bridges migrated.
|
|
857
|
+
"""
|
|
858
|
+
conn = _connect()
|
|
859
|
+
if not conn:
|
|
860
|
+
return 0
|
|
861
|
+
|
|
862
|
+
try:
|
|
863
|
+
# Check if both tables exist
|
|
864
|
+
tables = {
|
|
865
|
+
r[0]
|
|
866
|
+
for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
|
867
|
+
}
|
|
868
|
+
if "knowledge_bridges" not in tables or "concepts" not in tables:
|
|
869
|
+
return 0
|
|
870
|
+
|
|
871
|
+
bridges = conn.execute(
|
|
872
|
+
"""
|
|
873
|
+
SELECT source_concept, source_domain, target_concept, target_domain,
|
|
874
|
+
structural_mapping, quality, created_by
|
|
875
|
+
FROM knowledge_bridges
|
|
876
|
+
"""
|
|
877
|
+
).fetchall()
|
|
878
|
+
|
|
879
|
+
count = 0
|
|
880
|
+
for b in bridges:
|
|
881
|
+
src_name = b["source_concept"].lower()
|
|
882
|
+
tgt_name = b["target_concept"].lower()
|
|
883
|
+
src_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{b['source_domain']}:{src_name}"))
|
|
884
|
+
tgt_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{b['target_domain']}:{tgt_name}"))
|
|
885
|
+
|
|
886
|
+
conn.execute(
|
|
887
|
+
"INSERT OR IGNORE INTO concepts (id, name, domain) VALUES (?, ?, ?)",
|
|
888
|
+
(src_id, src_name, b["source_domain"]),
|
|
889
|
+
)
|
|
890
|
+
conn.execute(
|
|
891
|
+
"INSERT OR IGNORE INTO concepts (id, name, domain) VALUES (?, ?, ?)",
|
|
892
|
+
(tgt_id, tgt_name, b["target_domain"]),
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
quality = b["quality"]
|
|
896
|
+
confidence = 1.0 if quality == "effective" else 0.7 if quality == "validated" else 0.3
|
|
897
|
+
|
|
898
|
+
conn.execute(
|
|
899
|
+
"""
|
|
900
|
+
INSERT OR IGNORE INTO concept_relations
|
|
901
|
+
(source_concept_id, target_concept_id, relation_type,
|
|
902
|
+
confidence, created_by)
|
|
903
|
+
VALUES (?, ?, 'analogy_to', ?, ?)
|
|
904
|
+
""",
|
|
905
|
+
(src_id, tgt_id, confidence, b["created_by"]),
|
|
906
|
+
)
|
|
907
|
+
count += 1
|
|
908
|
+
|
|
909
|
+
conn.commit()
|
|
910
|
+
return count
|
|
911
|
+
except sqlite3.OperationalError:
|
|
912
|
+
return 0
|
|
913
|
+
finally:
|
|
914
|
+
conn.close()
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def seed_concepts_from_config() -> int:
|
|
918
|
+
"""Create concept rows from configured topics + tags.
|
|
919
|
+
|
|
920
|
+
Returns the number of concepts seeded.
|
|
921
|
+
"""
|
|
922
|
+
conn = _connect()
|
|
923
|
+
if not conn:
|
|
924
|
+
return 0
|
|
925
|
+
|
|
926
|
+
try:
|
|
927
|
+
tables = {
|
|
928
|
+
r[0]
|
|
929
|
+
for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
|
930
|
+
}
|
|
931
|
+
if "concepts" not in tables:
|
|
932
|
+
return 0
|
|
933
|
+
|
|
934
|
+
from .settings import get_topics
|
|
935
|
+
|
|
936
|
+
count = 0
|
|
937
|
+
for topic in get_topics():
|
|
938
|
+
domain = topic.name.lower()
|
|
939
|
+
for tag in topic.tags:
|
|
940
|
+
name = tag.lower().strip()
|
|
941
|
+
concept_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{domain}:{name}"))
|
|
942
|
+
conn.execute(
|
|
943
|
+
"INSERT OR IGNORE INTO concepts (id, name, domain) VALUES (?, ?, ?)",
|
|
944
|
+
(concept_id, name, domain),
|
|
945
|
+
)
|
|
946
|
+
count += 1
|
|
947
|
+
|
|
948
|
+
conn.commit()
|
|
949
|
+
return count
|
|
950
|
+
except (sqlite3.OperationalError, Exception):
|
|
951
|
+
return 0
|
|
952
|
+
finally:
|
|
953
|
+
conn.close()
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
class ConceptSummary(NamedTuple):
|
|
957
|
+
id: str
|
|
958
|
+
name: str
|
|
959
|
+
domain: str
|
|
960
|
+
description: str | None
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def list_concepts(domain: str | None = None) -> list[ConceptSummary]:
|
|
964
|
+
"""List all concepts, optionally filtered by domain."""
|
|
965
|
+
conn = _connect()
|
|
966
|
+
if not conn:
|
|
967
|
+
return []
|
|
968
|
+
try:
|
|
969
|
+
if domain:
|
|
970
|
+
rows = conn.execute(
|
|
971
|
+
"SELECT id, name, domain, description FROM concepts WHERE domain = ? ORDER BY name",
|
|
972
|
+
(domain,),
|
|
973
|
+
).fetchall()
|
|
974
|
+
else:
|
|
975
|
+
rows = conn.execute(
|
|
976
|
+
"SELECT id, name, domain, description FROM concepts ORDER BY domain, name"
|
|
977
|
+
).fetchall()
|
|
978
|
+
return [ConceptSummary(id=r[0], name=r[1], domain=r[2], description=r[3]) for r in rows]
|
|
979
|
+
except sqlite3.OperationalError:
|
|
980
|
+
return []
|
|
981
|
+
finally:
|
|
982
|
+
conn.close()
|