devcoach 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 (43) hide show
  1. devcoach/SKILL.md +288 -0
  2. devcoach/__init__.py +3 -0
  3. devcoach/cli/__init__.py +0 -0
  4. devcoach/cli/commands.py +793 -0
  5. devcoach/core/__init__.py +0 -0
  6. devcoach/core/coach.py +141 -0
  7. devcoach/core/db.py +768 -0
  8. devcoach/core/detect.py +132 -0
  9. devcoach/core/git.py +97 -0
  10. devcoach/core/models.py +104 -0
  11. devcoach/core/prompts.py +52 -0
  12. devcoach/mcp/__init__.py +0 -0
  13. devcoach/mcp/server.py +545 -0
  14. devcoach/web/__init__.py +0 -0
  15. devcoach/web/app.py +319 -0
  16. devcoach/web/static/favicon.svg +3 -0
  17. devcoach/web/static/relative-time.js +24 -0
  18. devcoach/web/static/style.css +163 -0
  19. devcoach/web/static/vendor/alpinejs.min.js +5 -0
  20. devcoach/web/static/vendor/flatpickr-dark.min.css +795 -0
  21. devcoach/web/static/vendor/flatpickr.min.css +13 -0
  22. devcoach/web/static/vendor/flatpickr.min.js +2 -0
  23. devcoach/web/static/vendor/highlight.min.js +1232 -0
  24. devcoach/web/static/vendor/hljs-dark.min.css +1 -0
  25. devcoach/web/static/vendor/hljs-light.min.css +1 -0
  26. devcoach/web/static/vendor/htmx.min.js +1 -0
  27. devcoach/web/static/vendor/icons/bitbucket.svg +1 -0
  28. devcoach/web/static/vendor/icons/github.svg +1 -0
  29. devcoach/web/static/vendor/icons/gitlab.svg +1 -0
  30. devcoach/web/static/vendor/icons/vscode.svg +41 -0
  31. devcoach/web/static/vendor/marked.min.js +6 -0
  32. devcoach/web/static/vendor/tailwind.js +83 -0
  33. devcoach/web/templates/base.html +80 -0
  34. devcoach/web/templates/lesson_detail.html +215 -0
  35. devcoach/web/templates/lessons.html +546 -0
  36. devcoach/web/templates/profile.html +240 -0
  37. devcoach/web/templates/settings.html +144 -0
  38. devcoach-0.1.0.dist-info/METADATA +443 -0
  39. devcoach-0.1.0.dist-info/RECORD +43 -0
  40. devcoach-0.1.0.dist-info/WHEEL +4 -0
  41. devcoach-0.1.0.dist-info/entry_points.txt +2 -0
  42. devcoach-0.1.0.dist-info/licenses/LICENSE +201 -0
  43. devcoach-0.1.0.dist-info/licenses/NOTICE +20 -0
devcoach/core/db.py ADDED
@@ -0,0 +1,768 @@
1
+ """SQLite schema, migrations, and pure query helpers for devcoach."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import sqlite3
8
+ import zipfile
9
+ from collections.abc import Generator
10
+ from contextlib import contextmanager
11
+ from datetime import UTC, datetime, timedelta
12
+ from pathlib import Path
13
+
14
+ from devcoach.core.models import KnowledgeEntry, KnowledgeGroup, Lesson, Settings
15
+
16
+ # ── Constants ──────────────────────────────────────────────────────────────
17
+
18
+ DB_PATH = Path.home() / ".devcoach" / "coaching.db"
19
+
20
+ DEFAULT_PROFILE: dict[str, int] = {
21
+ "general_engineering": 8,
22
+ "software_architecture": 8,
23
+ "design_patterns": 7,
24
+ "debugging_mindset": 8,
25
+ "node_js": 7,
26
+ "javascript": 7,
27
+ "typescript": 6,
28
+ "python": 4,
29
+ "django": 3,
30
+ "fastapi": 4,
31
+ "docker": 8,
32
+ "docker_compose": 8,
33
+ "traefik": 7,
34
+ "coolify": 7,
35
+ "postgresql": 6,
36
+ "redis": 6,
37
+ "git": 7,
38
+ "ci_cd": 6,
39
+ "security": 5,
40
+ "performance_optimization": 6,
41
+ "testing": 5,
42
+ "linux_cli": 7,
43
+ "networking": 6,
44
+ "react": 5,
45
+ "html_css": 5,
46
+ }
47
+
48
+ DEFAULT_SETTINGS: dict[str, str] = {
49
+ "max_per_day": "2",
50
+ "min_gap_minutes": "240", # replaces min_hours_between
51
+ "onboarding_completed": "0",
52
+ }
53
+
54
+ # Ordered category → topic list mapping for the knowledge map UI.
55
+ # Topics not listed here land in "Other".
56
+
57
+ # ── Connection ─────────────────────────────────────────────────────────────
58
+
59
+
60
+ def get_connection() -> sqlite3.Connection:
61
+ """Open a connection to the DB, creating the directory if needed."""
62
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
63
+ conn = sqlite3.connect(DB_PATH)
64
+ conn.row_factory = sqlite3.Row
65
+ return conn
66
+
67
+
68
+ def get_initialized_connection() -> sqlite3.Connection:
69
+ """Open a connection, run schema init, and return it ready to use."""
70
+ conn = get_connection()
71
+ init_schema(conn)
72
+ return conn
73
+
74
+
75
+ @contextmanager
76
+ def connection() -> Generator[sqlite3.Connection, None, None]:
77
+ """Context manager that opens an initialized connection and guarantees close."""
78
+ conn = get_initialized_connection()
79
+ try:
80
+ yield conn
81
+ finally:
82
+ conn.close()
83
+
84
+
85
+ # ── Schema init ────────────────────────────────────────────────────────────
86
+
87
+
88
+ def init_schema(conn: sqlite3.Connection) -> None:
89
+ """Create tables, indexes, and seed defaults if needed. Idempotent."""
90
+ conn.executescript("""
91
+ CREATE TABLE IF NOT EXISTS lessons (
92
+ id TEXT PRIMARY KEY,
93
+ timestamp TEXT NOT NULL,
94
+ topic_id TEXT NOT NULL,
95
+ categories TEXT NOT NULL,
96
+ title TEXT NOT NULL,
97
+ level TEXT NOT NULL,
98
+ summary TEXT NOT NULL,
99
+ task_context TEXT,
100
+ project TEXT,
101
+ repository TEXT,
102
+ branch TEXT,
103
+ commit_hash TEXT,
104
+ folder TEXT,
105
+ feedback TEXT,
106
+ repository_platform TEXT,
107
+ starred INTEGER NOT NULL DEFAULT 0
108
+ );
109
+
110
+ CREATE TABLE IF NOT EXISTS knowledge (
111
+ topic TEXT PRIMARY KEY,
112
+ confidence INTEGER NOT NULL DEFAULT 5,
113
+ updated_at TEXT NOT NULL
114
+ );
115
+
116
+ CREATE TABLE IF NOT EXISTS settings (
117
+ key TEXT PRIMARY KEY,
118
+ value TEXT NOT NULL
119
+ );
120
+
121
+ -- Named groups registry — exists independently of topic assignments.
122
+ CREATE TABLE IF NOT EXISTS knowledge_group_names (
123
+ group_name TEXT PRIMARY KEY
124
+ );
125
+
126
+ -- Maps topics to named groups for the knowledge map display.
127
+ -- Topics not present here appear under "Other".
128
+ CREATE TABLE IF NOT EXISTS knowledge_groups (
129
+ group_name TEXT NOT NULL,
130
+ topic TEXT NOT NULL,
131
+ PRIMARY KEY (group_name, topic)
132
+ );
133
+
134
+ -- Period/date-range filters, rate-limit count, get_last_lesson_timestamp,
135
+ -- and the default ORDER BY timestamp DESC all benefit from this index.
136
+ CREATE INDEX IF NOT EXISTS idx_lessons_timestamp
137
+ ON lessons (timestamp);
138
+
139
+ -- Starred filter combined with timestamp sort (common listing pattern).
140
+ CREATE INDEX IF NOT EXISTS idx_lessons_starred_ts
141
+ ON lessons (starred, timestamp);
142
+
143
+ -- Feedback equality filter (feedback = ? / IS NULL) and ORDER BY feedback.
144
+ CREATE INDEX IF NOT EXISTS idx_lessons_feedback
145
+ ON lessons (feedback);
146
+
147
+ -- get_taught_topics uses SELECT DISTINCT topic_id; also covers ORDER BY topic_id.
148
+ CREATE INDEX IF NOT EXISTS idx_lessons_topic_id
149
+ ON lessons (topic_id);
150
+ """)
151
+ conn.commit()
152
+ _seed_defaults(conn)
153
+
154
+
155
+ def _migrate(conn: sqlite3.Connection) -> None:
156
+ """Placeholder for future schema migrations. No-op while schema is current."""
157
+ pass
158
+
159
+
160
+ def _seed_defaults(conn: sqlite3.Connection) -> None:
161
+ """Seed knowledge and settings tables on the first run. Idempotent."""
162
+ row = conn.execute("SELECT COUNT(*) FROM knowledge").fetchone()
163
+ if row[0] == 0:
164
+ now = datetime.now(UTC).isoformat()
165
+ conn.executemany(
166
+ "INSERT INTO knowledge (topic, confidence, updated_at) VALUES (?, ?, ?)",
167
+ [(topic, confidence, now) for topic, confidence in DEFAULT_PROFILE.items()],
168
+ )
169
+
170
+ for key, value in DEFAULT_SETTINGS.items():
171
+ conn.execute(
172
+ "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
173
+ (key, value),
174
+ )
175
+
176
+ conn.commit()
177
+
178
+
179
+ # ── Lessons ────────────────────────────────────────────────────────────────
180
+
181
+
182
+ def insert_lesson(conn: sqlite3.Connection, lesson: Lesson) -> None:
183
+ """Insert or replace a lesson record."""
184
+ conn.execute(
185
+ """INSERT OR REPLACE INTO lessons
186
+ (id, timestamp, topic_id, categories, title, level, summary,
187
+ task_context, project, repository, branch, commit_hash, folder,
188
+ repository_platform, starred, feedback)
189
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
190
+ (
191
+ lesson.id,
192
+ lesson.timestamp_iso,
193
+ lesson.topic_id,
194
+ json.dumps(lesson.categories),
195
+ lesson.title,
196
+ lesson.level,
197
+ lesson.summary,
198
+ lesson.task_context,
199
+ lesson.project,
200
+ lesson.repository,
201
+ lesson.branch,
202
+ lesson.commit_hash,
203
+ lesson.folder,
204
+ lesson.repository_platform,
205
+ 1 if lesson.starred else 0,
206
+ lesson.feedback,
207
+ ),
208
+ )
209
+ conn.commit()
210
+
211
+
212
+ def _lesson_where(
213
+ period: str | None = None,
214
+ category: str | None = None,
215
+ level: str | None = None,
216
+ project: str | None = None,
217
+ repository: str | None = None,
218
+ branch: str | None = None,
219
+ commit: str | None = None,
220
+ starred: bool | None = None,
221
+ search: str | None = None,
222
+ feedback: str | None = None,
223
+ date_from: str | None = None,
224
+ date_to: str | None = None,
225
+ ) -> tuple[str, list[object]]:
226
+ """Build the WHERE clause and params list for lesson queries."""
227
+ conditions: list[str] = []
228
+ params: list[object] = []
229
+
230
+ if date_from is not None or date_to is not None:
231
+ if date_from is not None:
232
+ conditions.append("timestamp >= ?")
233
+ params.append(date_from)
234
+ if date_to is not None:
235
+ conditions.append("timestamp <= ?")
236
+ # If no time component supplied, treat date_to as end-of-day
237
+ params.append(date_to if ("T" in date_to or " " in date_to) else date_to + "T23:59:59")
238
+ else:
239
+ cutoff = _period_to_cutoff(period)
240
+ if cutoff is not None:
241
+ conditions.append("timestamp >= ?")
242
+ params.append(cutoff)
243
+
244
+ if category is not None:
245
+ conditions.append("categories LIKE ?")
246
+ params.append(f'%"{category}"%')
247
+ if level is not None:
248
+ conditions.append("level = ?")
249
+ params.append(level)
250
+ if project is not None:
251
+ conditions.append("project LIKE ?")
252
+ params.append(f"%{project}%")
253
+ if repository is not None:
254
+ conditions.append("repository LIKE ?")
255
+ params.append(f"%{repository}%")
256
+ if branch is not None:
257
+ conditions.append("branch LIKE ?")
258
+ params.append(f"%{branch}%")
259
+ if commit is not None:
260
+ conditions.append("commit_hash LIKE ?")
261
+ params.append(f"%{commit}%")
262
+ if starred is not None:
263
+ conditions.append("starred = ?")
264
+ params.append(1 if starred else 0)
265
+ if search is not None:
266
+ conditions.append("(title LIKE ? OR topic_id LIKE ? OR summary LIKE ?)")
267
+ like = f"%{search}%"
268
+ params.extend([like, like, like])
269
+ if feedback == "none":
270
+ conditions.append("feedback IS NULL")
271
+ elif feedback is not None:
272
+ conditions.append("feedback = ?")
273
+ params.append(feedback)
274
+
275
+ where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
276
+ return where, params
277
+
278
+
279
+ def count_filtered_lessons(
280
+ conn: sqlite3.Connection,
281
+ **kwargs: object,
282
+ ) -> int:
283
+ """Return the total number of lessons matching the given filters."""
284
+ where, params = _lesson_where(**kwargs) # type: ignore[arg-type]
285
+ row = conn.execute(f"SELECT COUNT(*) FROM lessons {where}", params).fetchone()
286
+ return int(row[0])
287
+
288
+
289
+ _SORT_COLUMNS = frozenset({"timestamp", "level", "topic_id", "title", "feedback"})
290
+
291
+
292
+ def get_lessons(
293
+ conn: sqlite3.Connection,
294
+ period: str | None = None,
295
+ category: str | None = None,
296
+ level: str | None = None,
297
+ project: str | None = None,
298
+ repository: str | None = None,
299
+ branch: str | None = None,
300
+ commit: str | None = None,
301
+ starred: bool | None = None,
302
+ search: str | None = None,
303
+ feedback: str | None = None,
304
+ date_from: str | None = None,
305
+ date_to: str | None = None,
306
+ sort: str = "timestamp",
307
+ order: str = "desc",
308
+ page: int | None = None,
309
+ per_page: int = 25,
310
+ ) -> list[Lesson]:
311
+ """Return lessons filtered by period, category, level, git metadata, starred flag, and/or search text.
312
+
313
+ period: today | week | month | year | all | None (same as all)
314
+ category: exact tag match inside the JSON categories array
315
+ level: junior | mid | senior
316
+ project, repository, branch: fuzzy match on metadata columns
317
+ commit: fuzzy match on commit_hash
318
+ starred: True for starred only, False for unstarred only, None for all
319
+ search: fuzzy match across title, topic_id, and summary
320
+ date_from / date_to: ISO date or datetime strings (YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]);
321
+ take precedence over period when set; date_to with date-only defaults to end-of-day
322
+ page / per_page: if page is given, apply LIMIT/OFFSET pagination
323
+ """
324
+ where, params = _lesson_where(
325
+ period=period,
326
+ category=category,
327
+ level=level,
328
+ project=project,
329
+ repository=repository,
330
+ branch=branch,
331
+ commit=commit,
332
+ starred=starred,
333
+ search=search,
334
+ feedback=feedback,
335
+ date_from=date_from,
336
+ date_to=date_to,
337
+ )
338
+ col = sort if sort in _SORT_COLUMNS else "timestamp"
339
+ direction = "ASC" if order.lower() == "asc" else "DESC"
340
+ query = f"SELECT * FROM lessons {where} ORDER BY {col} {direction}"
341
+ if page is not None:
342
+ query += " LIMIT ? OFFSET ?"
343
+ params = list(params) + [per_page, (page - 1) * per_page]
344
+ rows = conn.execute(query, params).fetchall()
345
+ return [_row_to_lesson(row) for row in rows]
346
+
347
+
348
+ def toggle_star(conn: sqlite3.Connection, lesson_id: str) -> bool:
349
+ """Flip the starred flag on a lesson. Returns the new starred state."""
350
+ conn.execute(
351
+ "UPDATE lessons SET starred = CASE WHEN starred=1 THEN 0 ELSE 1 END WHERE id = ?",
352
+ (lesson_id,),
353
+ )
354
+ conn.commit()
355
+ row = conn.execute("SELECT starred FROM lessons WHERE id = ?", (lesson_id,)).fetchone()
356
+ return bool(row["starred"]) if row else False
357
+
358
+
359
+ def set_feedback(conn: sqlite3.Connection, lesson_id: str, feedback: str | None) -> str | None:
360
+ """Set feedback ('know'/'dont_know'/None) on a lesson. Returns topic_id for knowledge update."""
361
+ conn.execute(
362
+ "UPDATE lessons SET feedback = ? WHERE id = ?",
363
+ (feedback or None, lesson_id),
364
+ )
365
+ conn.commit()
366
+ row = conn.execute("SELECT topic_id FROM lessons WHERE id = ?", (lesson_id,)).fetchone()
367
+ return row["topic_id"] if row else None
368
+
369
+
370
+ def export_lessons(conn: sqlite3.Connection) -> list[dict]:
371
+ """Return all lessons as a list of plain dicts, suitable for JSON serialisation."""
372
+ rows = conn.execute("SELECT * FROM lessons ORDER BY timestamp DESC").fetchall()
373
+ return [_row_to_lesson(row).model_dump(mode="json") for row in rows]
374
+
375
+
376
+ def import_lessons(conn: sqlite3.Connection, records: list[dict]) -> tuple[int, int, int]:
377
+ """Insert lessons from a list of dicts.
378
+
379
+ Validates each record through the Lesson model (normalizes timestamps, enums, etc.).
380
+ Returns (inserted, duplicated, invalid).
381
+ """
382
+ inserted = 0
383
+ duplicated = 0
384
+ invalid = 0
385
+ for r in records:
386
+ try:
387
+ lesson = Lesson(**r)
388
+ except Exception:
389
+ invalid += 1
390
+ continue
391
+ cur = conn.execute(
392
+ """INSERT OR IGNORE INTO lessons
393
+ (id, timestamp, topic_id, categories, title, level, summary,
394
+ task_context, project, repository, branch, commit_hash, folder,
395
+ repository_platform, starred, feedback)
396
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
397
+ (
398
+ lesson.id,
399
+ lesson.timestamp_iso,
400
+ lesson.topic_id,
401
+ json.dumps(lesson.categories)
402
+ if isinstance(lesson.categories, list)
403
+ else lesson.categories,
404
+ lesson.title,
405
+ lesson.level,
406
+ lesson.summary,
407
+ lesson.task_context,
408
+ lesson.project,
409
+ lesson.repository,
410
+ lesson.branch,
411
+ lesson.commit_hash,
412
+ lesson.folder,
413
+ lesson.repository_platform,
414
+ 1 if lesson.starred else 0,
415
+ lesson.feedback,
416
+ ),
417
+ )
418
+ if cur.rowcount:
419
+ inserted += 1
420
+ else:
421
+ duplicated += 1
422
+ conn.commit()
423
+ return inserted, duplicated, invalid
424
+
425
+
426
+ def create_backup_zip(conn: sqlite3.Connection) -> bytes:
427
+ """Return a zip archive (bytes) containing settings.json, lessons.json, knowledge.json."""
428
+ settings = get_settings(conn)
429
+ lessons = export_lessons(conn)
430
+
431
+ entries = get_knowledge_entries(conn)
432
+ groups = get_knowledge_group_list(conn)
433
+ topic_group: dict[str, str] = {t: g.name for g in groups for t in g.topics}
434
+ knowledge_data = {
435
+ "groups": [g.name for g in groups],
436
+ "topics": [
437
+ {
438
+ "topic": e.topic,
439
+ "confidence": e.confidence,
440
+ "group": topic_group.get(e.topic),
441
+ }
442
+ for e in entries
443
+ ],
444
+ }
445
+
446
+ buf = io.BytesIO()
447
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
448
+ zf.writestr("settings.json", json.dumps(settings.model_dump(), indent=2))
449
+ zf.writestr("lessons.json", json.dumps(lessons, indent=2, ensure_ascii=False))
450
+ zf.writestr("knowledge.json", json.dumps(knowledge_data, indent=2))
451
+ return buf.getvalue()
452
+
453
+
454
+ def restore_backup_zip(conn: sqlite3.Connection, data: bytes) -> dict[str, int]:
455
+ """Restore from a backup zip.
456
+
457
+ Returns a dict with counts: {"settings": 1, "topics": N, "lessons": N, "skipped": N, "invalid": N}.
458
+ Settings are overwritten; knowledge entries are upserted; duplicate lessons are skipped.
459
+ """
460
+ result: dict[str, int] = {
461
+ "settings": 0,
462
+ "topics": 0,
463
+ "groups": 0,
464
+ "lessons": 0,
465
+ "skipped": 0,
466
+ "invalid": 0,
467
+ }
468
+
469
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
470
+ names = zf.namelist()
471
+
472
+ if "settings.json" in names:
473
+ s = json.loads(zf.read("settings.json"))
474
+ if "max_per_day" in s:
475
+ set_setting(conn, "max_per_day", str(s["max_per_day"]))
476
+ if "min_gap_minutes" in s:
477
+ set_setting(conn, "min_gap_minutes", str(s["min_gap_minutes"]))
478
+ result["settings"] = 1
479
+
480
+ if "knowledge.json" in names:
481
+ knowledge = json.loads(zf.read("knowledge.json"))
482
+ if isinstance(knowledge, dict) and "topics" in knowledge:
483
+ # Current format: {"groups": [...], "topics": [{topic, confidence, group}, ...]}
484
+ groups_added = 0
485
+ for g in knowledge.get("groups", []):
486
+ cur = conn.execute(
487
+ "INSERT OR IGNORE INTO knowledge_group_names (group_name) VALUES (?)",
488
+ (g.strip(),),
489
+ )
490
+ groups_added += cur.rowcount
491
+ for item in knowledge.get("topics", []):
492
+ upsert_knowledge(conn, item["topic"], item["confidence"])
493
+ group = item.get("group")
494
+ if group and group != "Other":
495
+ cur = conn.execute(
496
+ "INSERT OR IGNORE INTO knowledge_group_names (group_name) VALUES (?)",
497
+ (group.strip(),),
498
+ )
499
+ groups_added += cur.rowcount
500
+ assign_topic_to_group(conn, item["topic"], group)
501
+ result["topics"] = len(knowledge.get("topics", []))
502
+ result["groups"] = groups_added
503
+ else:
504
+ # Legacy format: {"topic": confidence, ...}
505
+ for topic, confidence in knowledge.items():
506
+ upsert_knowledge(conn, topic, confidence)
507
+ result["topics"] = len(knowledge)
508
+
509
+ if "lessons.json" in names:
510
+ lessons_data = json.loads(zf.read("lessons.json"))
511
+ inserted, duplicated, invalid = import_lessons(conn, lessons_data)
512
+ result["lessons"] = inserted
513
+ result["skipped"] = duplicated
514
+ result["invalid"] = invalid
515
+
516
+ return result
517
+
518
+
519
+ def get_distinct_column(conn: sqlite3.Connection, column: str) -> list[str]:
520
+ """Return sorted distinct non-null values for a metadata column."""
521
+ rows = conn.execute(
522
+ f"SELECT DISTINCT {column} FROM lessons WHERE {column} IS NOT NULL ORDER BY {column}"
523
+ ).fetchall()
524
+ return [row[0] for row in rows]
525
+
526
+
527
+ def get_lesson_by_id(conn: sqlite3.Connection, lesson_id: str) -> Lesson | None:
528
+ """Return a single lesson by id, or None if not found."""
529
+ row = conn.execute("SELECT * FROM lessons WHERE id = ?", (lesson_id,)).fetchone()
530
+ return _row_to_lesson(row) if row else None
531
+
532
+
533
+ def get_all_categories(conn: sqlite3.Connection) -> list[str]:
534
+ """Return a distinct sorted list of all category tags across all lessons."""
535
+ rows = conn.execute("SELECT categories FROM lessons").fetchall()
536
+ seen: set[str] = set()
537
+ for row in rows:
538
+ try:
539
+ tags: list[str] = json.loads(row[0])
540
+ seen.update(tags)
541
+ except (json.JSONDecodeError, TypeError):
542
+ pass
543
+ return sorted(seen)
544
+
545
+
546
+ def get_taught_topic_ids(conn: sqlite3.Connection) -> list[str]:
547
+ """Return all distinct topic_ids already taught."""
548
+ rows = conn.execute("SELECT DISTINCT topic_id FROM lessons").fetchall()
549
+ return [row[0] for row in rows]
550
+
551
+
552
+ def count_lessons_since(conn: sqlite3.Connection, since: str) -> int:
553
+ """Count lessons delivered since a given ISO 8601 timestamp."""
554
+ row = conn.execute("SELECT COUNT(*) FROM lessons WHERE timestamp >= ?", (since,)).fetchone()
555
+ return row[0]
556
+
557
+
558
+ def get_last_lesson_timestamp(conn: sqlite3.Connection) -> str | None:
559
+ """Return the ISO 8601 timestamp of the most recent lesson, or None."""
560
+ row = conn.execute("SELECT timestamp FROM lessons ORDER BY timestamp DESC LIMIT 1").fetchone()
561
+ return row[0] if row else None
562
+
563
+
564
+ # ── Knowledge ──────────────────────────────────────────────────────────────
565
+
566
+
567
+ def get_all_knowledge(conn: sqlite3.Connection) -> dict[str, int]:
568
+ """Return the full knowledge map as {topic: confidence}."""
569
+ rows = conn.execute("SELECT topic, confidence FROM knowledge").fetchall()
570
+ return {row[0]: row[1] for row in rows}
571
+
572
+
573
+ def get_knowledge_entries(conn: sqlite3.Connection) -> list[KnowledgeEntry]:
574
+ """Return every knowledge topic as a typed list, ordered by topic name."""
575
+ rows = conn.execute("SELECT topic, confidence FROM knowledge ORDER BY topic").fetchall()
576
+ return [KnowledgeEntry(topic=row[0], confidence=row[1]) for row in rows]
577
+
578
+
579
+ def get_knowledge_group_list(conn: sqlite3.Connection) -> list[KnowledgeGroup]:
580
+ """Return all named groups (including empty ones) as a typed list."""
581
+ names = [
582
+ row[0]
583
+ for row in conn.execute(
584
+ "SELECT group_name FROM knowledge_group_names ORDER BY group_name"
585
+ ).fetchall()
586
+ ]
587
+ assignments: dict[str, list[str]] = {}
588
+ for row in conn.execute(
589
+ "SELECT group_name, topic FROM knowledge_groups ORDER BY group_name, topic"
590
+ ).fetchall():
591
+ assignments.setdefault(row[0], []).append(row[1])
592
+ return [KnowledgeGroup(name=n, topics=assignments.get(n, [])) for n in names]
593
+
594
+
595
+ def upsert_knowledge(conn: sqlite3.Connection, topic: str, confidence: int) -> None:
596
+ """Insert or update a knowledge entry, clamping confidence to 0-10."""
597
+ clamped = max(0, min(10, confidence))
598
+ now = datetime.now(UTC).isoformat()
599
+ conn.execute(
600
+ """INSERT INTO knowledge (topic, confidence, updated_at) VALUES (?, ?, ?)
601
+ ON CONFLICT(topic) DO UPDATE SET confidence = excluded.confidence,
602
+ updated_at = excluded.updated_at""",
603
+ (topic, clamped, now),
604
+ )
605
+ conn.commit()
606
+
607
+
608
+ def delete_knowledge(conn: sqlite3.Connection, topic: str) -> bool:
609
+ """Remove a topic from the knowledge map and all groups. Returns True if it existed."""
610
+ cur = conn.execute("DELETE FROM knowledge WHERE topic = ?", (topic,))
611
+ conn.execute("DELETE FROM knowledge_groups WHERE topic = ?", (topic,))
612
+ conn.commit()
613
+ return cur.rowcount > 0
614
+
615
+
616
+ # ── Knowledge groups ───────────────────────────────────────────────────────
617
+
618
+
619
+ def get_knowledge_groups(conn: sqlite3.Connection) -> dict[str, list[str]]:
620
+ """Return all named groups as {group_name: [topic, ...]} (empty groups included)."""
621
+ groups: dict[str, list[str]] = {
622
+ row[0]: []
623
+ for row in conn.execute(
624
+ "SELECT group_name FROM knowledge_group_names ORDER BY group_name"
625
+ ).fetchall()
626
+ }
627
+ for row in conn.execute(
628
+ "SELECT group_name, topic FROM knowledge_groups ORDER BY group_name, topic"
629
+ ).fetchall():
630
+ if row[0] in groups:
631
+ groups[row[0]].append(row[1])
632
+ return groups
633
+
634
+
635
+ def add_group(conn: sqlite3.Connection, group_name: str) -> None:
636
+ """Register a new named group (persists even when empty)."""
637
+ name = group_name.strip()
638
+ if not name:
639
+ raise ValueError("Group name must not be empty")
640
+ conn.execute("INSERT OR IGNORE INTO knowledge_group_names (group_name) VALUES (?)", (name,))
641
+ conn.commit()
642
+
643
+
644
+ def delete_group(conn: sqlite3.Connection, group_name: str) -> int:
645
+ """Remove a group and its topic assignments. Topics become ungrouped (Other).
646
+
647
+ Returns the number of topic assignments removed.
648
+ """
649
+ cur = conn.execute("DELETE FROM knowledge_groups WHERE group_name = ?", (group_name,))
650
+ conn.execute("DELETE FROM knowledge_group_names WHERE group_name = ?", (group_name,))
651
+ conn.commit()
652
+ return cur.rowcount
653
+
654
+
655
+ def assign_topic_to_group(conn: sqlite3.Connection, topic: str, group_name: str) -> None:
656
+ """Assign a topic to a group, replacing any previous group assignment."""
657
+ conn.execute(
658
+ "INSERT OR IGNORE INTO knowledge_group_names (group_name) VALUES (?)", (group_name,)
659
+ )
660
+ conn.execute("DELETE FROM knowledge_groups WHERE topic = ?", (topic,))
661
+ conn.execute(
662
+ "INSERT OR IGNORE INTO knowledge_groups (group_name, topic) VALUES (?, ?)",
663
+ (group_name, topic),
664
+ )
665
+ conn.commit()
666
+
667
+
668
+ def unassign_topic_from_group(conn: sqlite3.Connection, topic: str) -> None:
669
+ """Remove a topic from its group (it will appear under Other)."""
670
+ conn.execute("DELETE FROM knowledge_groups WHERE topic = ?", (topic,))
671
+ conn.commit()
672
+
673
+
674
+ # ── Settings ───────────────────────────────────────────────────────────────
675
+
676
+
677
+ def get_settings(conn: sqlite3.Connection) -> Settings:
678
+ """Load settings from DB, falling back to defaults. Migrates old min_hours_between."""
679
+ rows = conn.execute("SELECT key, value FROM settings").fetchall()
680
+ data: dict[str, str] = {row[0]: row[1] for row in rows}
681
+ if "min_gap_minutes" in data:
682
+ gap = int(data["min_gap_minutes"])
683
+ elif "min_hours_between" in data:
684
+ gap = int(data["min_hours_between"]) * 60 # migrate hours → minutes
685
+ else:
686
+ gap = int(DEFAULT_SETTINGS["min_gap_minutes"])
687
+ return Settings(
688
+ max_per_day=int(data.get("max_per_day", DEFAULT_SETTINGS["max_per_day"])),
689
+ min_gap_minutes=gap,
690
+ )
691
+
692
+
693
+ def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
694
+ """Insert or update a single setting."""
695
+ conn.execute(
696
+ "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
697
+ (key, value),
698
+ )
699
+ conn.commit()
700
+
701
+
702
+ def is_onboarding_complete(conn: sqlite3.Connection) -> bool:
703
+ """Return True if the user has completed the onboarding setup flow."""
704
+ row = conn.execute("SELECT value FROM settings WHERE key = 'onboarding_completed'").fetchone()
705
+ return row is not None and row[0] == "1"
706
+
707
+
708
+ def get_usage_defaults(conn: sqlite3.Connection) -> dict[str, str | None]:
709
+ """Return the most-used value for each git metadata column across all lessons.
710
+
711
+ Used as a fallback when a field is not supplied to log_lesson and cannot
712
+ be detected from the current workspace.
713
+ Returns {column: value_or_None} for project, repository, branch,
714
+ repository_platform.
715
+ """
716
+ result: dict[str, str | None] = {}
717
+ for col in ("project", "repository", "branch", "repository_platform"):
718
+ row = conn.execute(
719
+ f"SELECT {col}, COUNT(*) c FROM lessons "
720
+ f"WHERE {col} IS NOT NULL GROUP BY {col} ORDER BY c DESC LIMIT 1"
721
+ ).fetchone()
722
+ result[col] = row[0] if row else None
723
+ return result
724
+
725
+
726
+ # ── Private helpers ────────────────────────────────────────────────────────
727
+
728
+
729
+ def _period_to_cutoff(period: str | None) -> str | None:
730
+ """Convert a period string to an ISO 8601 cutoff timestamp, or None for all."""
731
+ now = datetime.now(UTC)
732
+ if period == "today":
733
+ cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0)
734
+ elif period == "week":
735
+ cutoff = now - timedelta(days=7)
736
+ elif period == "month":
737
+ cutoff = now - timedelta(days=30)
738
+ elif period == "year":
739
+ cutoff = now - timedelta(days=365)
740
+ else:
741
+ return None
742
+ return cutoff.isoformat()
743
+
744
+
745
+ def _row_to_lesson(row: sqlite3.Row) -> Lesson:
746
+ """Convert a sqlite3.Row to a Lesson model."""
747
+ try:
748
+ categories: list[str] = json.loads(row["categories"])
749
+ except (json.JSONDecodeError, TypeError):
750
+ categories = []
751
+ return Lesson(
752
+ id=row["id"],
753
+ timestamp=row["timestamp"],
754
+ topic_id=row["topic_id"],
755
+ categories=categories,
756
+ title=row["title"],
757
+ level=row["level"],
758
+ summary=row["summary"],
759
+ task_context=row["task_context"],
760
+ project=row["project"],
761
+ repository=row["repository"],
762
+ branch=row["branch"],
763
+ commit_hash=row["commit_hash"],
764
+ folder=row["folder"],
765
+ repository_platform=row["repository_platform"],
766
+ starred=bool(row["starred"]) if row["starred"] is not None else False,
767
+ feedback=row["feedback"],
768
+ )