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
File without changes
devcoach/core/coach.py ADDED
@@ -0,0 +1,141 @@
1
+ """Rate limit checking and lesson selection logic for devcoach."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ from devcoach.core.db import (
9
+ count_filtered_lessons,
10
+ count_lessons_since,
11
+ get_all_knowledge,
12
+ get_knowledge_entries,
13
+ get_knowledge_group_list,
14
+ get_last_lesson_timestamp,
15
+ get_settings,
16
+ get_taught_topic_ids,
17
+ set_feedback,
18
+ upsert_knowledge,
19
+ )
20
+ from devcoach.core.models import Profile, RateLimitResult
21
+
22
+
23
+ def check_rate_limit(conn: sqlite3.Connection) -> RateLimitResult:
24
+ """Determine whether a new lesson can be delivered.
25
+
26
+ Rules (evaluated in order):
27
+ 1. Count lessons in the last 24h. If >= max_per_day → denied.
28
+ 2. Find timestamp of last lesson. If < min_hours_between ago → denied.
29
+ 3. Otherwise: allowed.
30
+ """
31
+ try:
32
+ settings = get_settings(conn)
33
+ now = datetime.now(UTC)
34
+
35
+ since_24h = (now - timedelta(hours=24)).isoformat()
36
+ count = count_lessons_since(conn, since_24h)
37
+ if count >= settings.max_per_day:
38
+ return RateLimitResult(
39
+ allowed=False,
40
+ reason=f"Daily limit reached ({count}/{settings.max_per_day} lessons in the last 24h)",
41
+ )
42
+
43
+ last_ts = get_last_lesson_timestamp(conn)
44
+ if last_ts is not None:
45
+ last_dt = datetime.fromisoformat(last_ts)
46
+ if last_dt.tzinfo is None:
47
+ last_dt = last_dt.replace(tzinfo=UTC)
48
+ elapsed_minutes = (now - last_dt).total_seconds() / 60
49
+ if elapsed_minutes < settings.min_gap_minutes:
50
+ remaining = settings.min_gap_minutes - elapsed_minutes
51
+ gap_h, gap_m = divmod(settings.min_gap_minutes, 60)
52
+ rem_h, rem_m = divmod(int(remaining), 60)
53
+ return RateLimitResult(
54
+ allowed=False,
55
+ reason=(
56
+ f"Too soon: last lesson {elapsed_minutes:.0f}m ago, "
57
+ f"minimum interval is {gap_h}h {gap_m}m "
58
+ f"({rem_h}h {rem_m}m remaining)"
59
+ ),
60
+ )
61
+
62
+ return RateLimitResult(allowed=True)
63
+
64
+ except Exception as exc:
65
+ return RateLimitResult(allowed=True, reason=f"Rate limit check failed: {exc}")
66
+
67
+
68
+ def get_profile(conn: sqlite3.Connection) -> Profile:
69
+ """Load and return the current user profile."""
70
+ try:
71
+ return Profile(
72
+ knowledge=get_knowledge_entries(conn),
73
+ groups=get_knowledge_group_list(conn),
74
+ )
75
+ except Exception:
76
+ return Profile(knowledge=[], groups=[])
77
+
78
+
79
+ def apply_knowledge_delta(conn: sqlite3.Connection, topic: str, delta: int) -> Profile:
80
+ """Add delta to the current confidence for a topic (clamped 0-10).
81
+
82
+ If the topic does not exist it is created with a base confidence of 5.
83
+ """
84
+ try:
85
+ row = conn.execute("SELECT confidence FROM knowledge WHERE topic = ?", (topic,)).fetchone()
86
+ current = row[0] if row else 5
87
+ upsert_knowledge(conn, topic, current + delta)
88
+ return get_profile(conn)
89
+ except Exception:
90
+ return get_profile(conn)
91
+
92
+
93
+ def record_feedback(
94
+ conn: sqlite3.Connection, lesson_id: str, feedback_value: str | None
95
+ ) -> str | None:
96
+ """Record feedback for a lesson and auto-adjust knowledge confidence by ±1.
97
+
98
+ feedback_value: "know" | "dont_know" | None (to clear).
99
+ Returns the topic_id of the updated lesson, or None if lesson not found.
100
+ """
101
+ topic_id = set_feedback(conn, lesson_id, feedback_value)
102
+ if topic_id and feedback_value in ("know", "dont_know"):
103
+ delta = 1 if feedback_value == "know" else -1
104
+ apply_knowledge_delta(conn, topic_id, delta)
105
+ return topic_id
106
+
107
+
108
+ def get_stats(conn: sqlite3.Connection) -> dict:
109
+ """Return aggregate coaching statistics.
110
+
111
+ Returns total_lessons, lessons_today (last 24h), lessons_this_week (last 7d),
112
+ weakest_topics (up to 5), and strongest_topics (up to 5).
113
+ """
114
+ try:
115
+ now = datetime.now(UTC)
116
+ total = count_filtered_lessons(conn)
117
+ today_cutoff = (now - timedelta(hours=24)).isoformat()
118
+ week_cutoff = (now - timedelta(days=7)).isoformat()
119
+ lessons_today = count_lessons_since(conn, today_cutoff)
120
+ lessons_week = count_lessons_since(conn, week_cutoff)
121
+ knowledge = get_all_knowledge(conn)
122
+ sorted_k = sorted(knowledge.items(), key=lambda x: x[1])
123
+ weakest = [{"topic": t, "confidence": c} for t, c in sorted_k[:5]]
124
+ strongest = [{"topic": t, "confidence": c} for t, c in sorted_k[-5:][::-1]]
125
+ return {
126
+ "total_lessons": total,
127
+ "lessons_today": lessons_today,
128
+ "lessons_this_week": lessons_week,
129
+ "weakest_topics": weakest,
130
+ "strongest_topics": strongest,
131
+ }
132
+ except Exception as exc:
133
+ return {"error": str(exc)}
134
+
135
+
136
+ def list_taught_topics(conn: sqlite3.Connection) -> list[str]:
137
+ """Return all topic_ids already present in the lesson log."""
138
+ try:
139
+ return get_taught_topic_ids(conn)
140
+ except Exception:
141
+ return []