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.
- devcoach/SKILL.md +288 -0
- devcoach/__init__.py +3 -0
- devcoach/cli/__init__.py +0 -0
- devcoach/cli/commands.py +793 -0
- devcoach/core/__init__.py +0 -0
- devcoach/core/coach.py +141 -0
- devcoach/core/db.py +768 -0
- devcoach/core/detect.py +132 -0
- devcoach/core/git.py +97 -0
- devcoach/core/models.py +104 -0
- devcoach/core/prompts.py +52 -0
- devcoach/mcp/__init__.py +0 -0
- devcoach/mcp/server.py +545 -0
- devcoach/web/__init__.py +0 -0
- devcoach/web/app.py +319 -0
- devcoach/web/static/favicon.svg +3 -0
- devcoach/web/static/relative-time.js +24 -0
- devcoach/web/static/style.css +163 -0
- devcoach/web/static/vendor/alpinejs.min.js +5 -0
- devcoach/web/static/vendor/flatpickr-dark.min.css +795 -0
- devcoach/web/static/vendor/flatpickr.min.css +13 -0
- devcoach/web/static/vendor/flatpickr.min.js +2 -0
- devcoach/web/static/vendor/highlight.min.js +1232 -0
- devcoach/web/static/vendor/hljs-dark.min.css +1 -0
- devcoach/web/static/vendor/hljs-light.min.css +1 -0
- devcoach/web/static/vendor/htmx.min.js +1 -0
- devcoach/web/static/vendor/icons/bitbucket.svg +1 -0
- devcoach/web/static/vendor/icons/github.svg +1 -0
- devcoach/web/static/vendor/icons/gitlab.svg +1 -0
- devcoach/web/static/vendor/icons/vscode.svg +41 -0
- devcoach/web/static/vendor/marked.min.js +6 -0
- devcoach/web/static/vendor/tailwind.js +83 -0
- devcoach/web/templates/base.html +80 -0
- devcoach/web/templates/lesson_detail.html +215 -0
- devcoach/web/templates/lessons.html +546 -0
- devcoach/web/templates/profile.html +240 -0
- devcoach/web/templates/settings.html +144 -0
- devcoach-0.1.0.dist-info/METADATA +443 -0
- devcoach-0.1.0.dist-info/RECORD +43 -0
- devcoach-0.1.0.dist-info/WHEEL +4 -0
- devcoach-0.1.0.dist-info/entry_points.txt +2 -0
- devcoach-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|