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.
Files changed (58) hide show
  1. studyctl/__init__.py +3 -0
  2. studyctl/calendar.py +140 -0
  3. studyctl/cli/__init__.py +56 -0
  4. studyctl/cli/_config.py +128 -0
  5. studyctl/cli/_content.py +462 -0
  6. studyctl/cli/_lazy.py +35 -0
  7. studyctl/cli/_review.py +491 -0
  8. studyctl/cli/_schedule.py +125 -0
  9. studyctl/cli/_setup.py +164 -0
  10. studyctl/cli/_shared.py +83 -0
  11. studyctl/cli/_state.py +69 -0
  12. studyctl/cli/_sync.py +156 -0
  13. studyctl/cli/_web.py +228 -0
  14. studyctl/content/__init__.py +5 -0
  15. studyctl/content/markdown_converter.py +271 -0
  16. studyctl/content/models.py +31 -0
  17. studyctl/content/notebooklm_client.py +434 -0
  18. studyctl/content/splitter.py +159 -0
  19. studyctl/content/storage.py +105 -0
  20. studyctl/content/syllabus.py +416 -0
  21. studyctl/history.py +982 -0
  22. studyctl/maintenance.py +69 -0
  23. studyctl/mcp/__init__.py +1 -0
  24. studyctl/mcp/server.py +58 -0
  25. studyctl/mcp/tools.py +234 -0
  26. studyctl/pdf.py +89 -0
  27. studyctl/review_db.py +277 -0
  28. studyctl/review_loader.py +375 -0
  29. studyctl/scheduler.py +242 -0
  30. studyctl/services/__init__.py +6 -0
  31. studyctl/services/content.py +39 -0
  32. studyctl/services/review.py +127 -0
  33. studyctl/settings.py +367 -0
  34. studyctl/shared.py +425 -0
  35. studyctl/state.py +120 -0
  36. studyctl/sync.py +229 -0
  37. studyctl/tui/__main__.py +33 -0
  38. studyctl/tui/app.py +395 -0
  39. studyctl/tui/study_cards.py +396 -0
  40. studyctl/web/__init__.py +1 -0
  41. studyctl/web/app.py +68 -0
  42. studyctl/web/routes/__init__.py +1 -0
  43. studyctl/web/routes/artefacts.py +57 -0
  44. studyctl/web/routes/cards.py +86 -0
  45. studyctl/web/routes/courses.py +91 -0
  46. studyctl/web/routes/history.py +69 -0
  47. studyctl/web/server.py +260 -0
  48. studyctl/web/static/app.js +853 -0
  49. studyctl/web/static/icon-192.svg +4 -0
  50. studyctl/web/static/icon-512.svg +4 -0
  51. studyctl/web/static/index.html +50 -0
  52. studyctl/web/static/manifest.json +21 -0
  53. studyctl/web/static/style.css +657 -0
  54. studyctl/web/static/sw.js +14 -0
  55. studyctl-2.0.0.dist-info/METADATA +49 -0
  56. studyctl-2.0.0.dist-info/RECORD +58 -0
  57. studyctl-2.0.0.dist-info/WHEEL +4 -0
  58. 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()