Skip to content

Commit 41cea8c

Browse files
committed
feat: prefer single-word topic names + clamp future lesson timestamps
Topic naming: - DEFAULT_PROFILE keys renamed to single most-characterising word (software_architecture → architecture, docker_compose merged into docker, performance_optimization → performance, linux_cli → linux, html_css → frontend, node_js → node, debugging_mindset → debugging, design_patterns → patterns, general_engineering → engineering) - SKILL.md, server.py add_topic docstring, and CLAUDE.md updated with the naming rule: use the single most characterising word; strip descriptive suffixes (ci_cd_pipeline_design → ci_cd); max 3 words Timestamp safety: - Lesson.parse_and_normalize_timestamp now clamps any future timestamp to datetime.now(UTC) — lessons can never be stored with a future date - coach.py rate-limit message improved: displays "in Xm (future timestamp)" instead of "-Xm ago" when the last lesson has a future timestamp - Test fixtures updated to use T00:01-03Z (always-past UTC times on today's date)
1 parent a39631b commit 41cea8c

8 files changed

Lines changed: 39 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ The `log_lesson` tool accepts this schema:
182182
{
183183
"id": "uuid-or-random-string",
184184
"timestamp": "2025-01-15T20:30:00Z",
185-
"topic_id": "python_generators",
185+
"topic_id": "python",
186186
"category": "python",
187187
"title": "Generator expressions vs list comprehensions",
188188
"level": "mid",

src/devcoach/SKILL.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ Call `log_lesson` right after delivering the lesson, without waiting for feedbac
338338
{
339339
"id": "unique-slug-or-uuid",
340340
"timestamp": "2026-04-27T14:30:00Z",
341-
"topic_id": "snake_case_identifier",
341+
"topic_id": "topic",
342342
"categories": ["the_topic_category", "architecture"],
343343
"title": "Lesson title",
344344
"level": "junior|mid|senior",
@@ -348,6 +348,11 @@ Call `log_lesson` right after delivering the lesson, without waiting for feedbac
348348
}
349349
```
350350

351+
**`topic_id` naming:** use the single most characterizing word for the domain
352+
(e.g. `sqlite`, `python`, `docker`). For compound concepts with no single-word
353+
equivalent, keep the essential part — `ci_cd_pipeline_design``ci_cd`. Never
354+
more than 3 words.
355+
351356
Git metadata (`project`, `repository`, `branch`, `commit_hash`, `folder`,
352357
`repository_platform`) is **auto-detected server-side**. Do not run git commands
353358
manually. Omitting these fields is correct and will not reduce lesson quality.

src/devcoach/core/coach.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,14 @@ def check_rate_limit(conn: sqlite3.Connection) -> RateLimitResult:
5050
remaining = settings.min_gap_minutes - elapsed_minutes
5151
gap_h, gap_m = divmod(settings.min_gap_minutes, 60)
5252
rem_h, rem_m = divmod(int(remaining), 60)
53+
if elapsed_minutes < 0:
54+
ago_text = f"in {-elapsed_minutes:.0f}m (future timestamp)"
55+
else:
56+
ago_text = f"{elapsed_minutes:.0f}m ago"
5357
return RateLimitResult(
5458
allowed=False,
5559
reason=(
56-
f"Too soon: last lesson {elapsed_minutes:.0f}m ago, "
60+
f"Too soon: last lesson {ago_text}, "
5761
f"minimum interval is {gap_h}h {gap_m}m "
5862
f"({rem_h}h {rem_m}m remaining)"
5963
),

src/devcoach/core/db.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,30 @@
1919
LEARNING_STATE_PATH = Path.home() / ".devcoach" / "learning-state.md"
2020

2121
DEFAULT_PROFILE: dict[str, int] = {
22-
"general_engineering": 8,
23-
"software_architecture": 8,
24-
"design_patterns": 7,
25-
"debugging_mindset": 8,
26-
"node_js": 7,
22+
"engineering": 8,
23+
"architecture": 8,
24+
"patterns": 7,
25+
"debugging": 8,
26+
"node": 7,
2727
"javascript": 7,
2828
"typescript": 6,
2929
"python": 4,
3030
"django": 3,
3131
"fastapi": 4,
3232
"docker": 8,
33-
"docker_compose": 8,
3433
"traefik": 7,
3534
"coolify": 7,
3635
"postgresql": 6,
3736
"redis": 6,
3837
"git": 7,
3938
"ci_cd": 6,
4039
"security": 5,
41-
"performance_optimization": 6,
40+
"performance": 6,
4241
"testing": 5,
43-
"linux_cli": 7,
42+
"linux": 7,
4443
"networking": 6,
4544
"react": 5,
46-
"html_css": 5,
45+
"frontend": 5,
4746
}
4847

4948
DEFAULT_SETTINGS: dict[str, str] = {

src/devcoach/core/models.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,18 @@ class Lesson(BaseModel):
4141
@field_validator("timestamp", mode="before")
4242
@classmethod
4343
def parse_and_normalize_timestamp(cls, v: str | datetime) -> datetime:
44-
"""Accept any ISO 8601 string or datetime; always return UTC-aware datetime."""
44+
"""Accept any ISO 8601 string or datetime; always return UTC-aware datetime clamped to now."""
4545
if isinstance(v, datetime):
46-
return v if v.tzinfo else v.replace(tzinfo=UTC)
47-
try:
48-
dt = datetime.fromisoformat(v)
49-
if dt.tzinfo is None:
50-
dt = dt.replace(tzinfo=UTC)
51-
return dt.astimezone(UTC)
52-
except ValueError:
53-
raise ValueError(f"Cannot parse timestamp {v!r} — expected ISO 8601")
46+
dt = v if v.tzinfo else v.replace(tzinfo=UTC)
47+
else:
48+
try:
49+
dt = datetime.fromisoformat(v)
50+
if dt.tzinfo is None:
51+
dt = dt.replace(tzinfo=UTC)
52+
dt = dt.astimezone(UTC)
53+
except ValueError:
54+
raise ValueError(f"Cannot parse timestamp {v!r} — expected ISO 8601")
55+
return min(dt, datetime.now(UTC))
5456

5557
@field_serializer("timestamp")
5658
def serialize_timestamp(self, v: datetime) -> str:

src/devcoach/mcp/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,8 @@ async def add_topic(
293293
) -> bool:
294294
"""Add a new topic to the knowledge map, or update confidence if it already exists.
295295
296-
topic: topic identifier, e.g. 'rust_lifetimes'
296+
topic: topic identifier — prefer a single word (e.g. 'rust', 'sqlite', 'docker');
297+
use multiple words only when strictly necessary (e.g. 'ci_cd'); max 3 words
297298
confidence: initial confidence score 0-10 (default 5)
298299
group: optional group name; topic appears under 'Other' if omitted
299300
Returns True on success, False on error.

tests/conftest.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from devcoach.core.models import Lesson
1919

2020
# Use today's date so period="today" filters always match in tests.
21+
# Times are chosen to be early UTC so they are always in the past regardless of
22+
# when the test suite runs (midnight-edge risk is acceptable for CI).
2123
_TODAY = date.today().isoformat()
2224

2325

@@ -26,7 +28,7 @@
2628
TEST_LESSONS: list[Lesson] = [
2729
Lesson(
2830
id="lesson-sqlite3-row-factory-001",
29-
timestamp=f"{_TODAY}T12:00:00Z",
31+
timestamp=f"{_TODAY}T00:01:00Z",
3032
topic_id="sqlite3_row_factory",
3133
categories=["python", "sqlite", "databases"],
3234
title="sqlite3.Row: accessing query results by column name",
@@ -36,7 +38,7 @@
3638
),
3739
Lesson(
3840
id="lesson-sqlite-upsert-patterns-001",
39-
timestamp=f"{_TODAY}T16:10:00Z",
41+
timestamp=f"{_TODAY}T00:02:00Z",
4042
topic_id="sqlite_upsert_patterns",
4143
categories=["python", "sqlite", "databases"],
4244
title="INSERT OR REPLACE vs ON CONFLICT DO UPDATE",
@@ -53,7 +55,7 @@
5355
),
5456
Lesson(
5557
id="lesson-sqlite-pragma-introspection-001",
56-
timestamp=f"{_TODAY}T17:30:00Z",
58+
timestamp=f"{_TODAY}T00:03:00Z",
5759
topic_id="sqlite_pragma_introspection",
5860
categories=["python", "sqlite", "databases"],
5961
title="PRAGMA table_info — zero-dependency schema migrations in SQLite",

tests/test_db_extra.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ def test_count_lessons_since_future(self, conn):
355355
def test_get_last_lesson_timestamp(self, conn):
356356
ts = db.get_last_lesson_timestamp(conn)
357357
assert ts is not None
358-
assert "17:30:00" in ts # latest lesson in conftest
358+
assert "00:03:00" in ts # latest lesson in conftest
359359

360360
def test_get_last_lesson_timestamp_empty(self, tmp_path):
361361
path = tmp_path / "empty.db"

0 commit comments

Comments
 (0)