Each ml/features/*.py now declares freshness, source, and fallback per feature. ProfileFeature gains ttl_sec (mirrored from registry.ts), freshness="batched", source, and fallback. context.py adds ContextFeatureSpec + CONTEXT_FEATURES for the three JIT features (hour_of_day, day_of_week, tasks). CI test parses ttlSec from registry.ts to catch drift. ml/README updated with split JIT/batched feature contract. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
108 lines
2.9 KiB
Python
108 lines
2.9 KiB
Python
"""
|
||
Context assembler — converts raw user signals into a PromptContext for LLM tip generation.
|
||
|
||
Usage:
|
||
from ml.features.context import build_context, CONTEXT_FEATURES
|
||
ctx = build_context(tasks, hour_of_day=9, day_of_week=2)
|
||
|
||
Feature-spec (issue #61):
|
||
All context features are JIT — they are assembled at request time from live
|
||
sources (system clock, caller-supplied task list) rather than read from a
|
||
cached profile store. They carry no TTL because they are never persisted.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
from dataclasses import dataclass, field
|
||
from typing import Literal
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ContextFeatureSpec:
|
||
name: str
|
||
dtype: Literal["numeric", "categorical", "list"]
|
||
freshness: Literal["jit", "batched"]
|
||
source: str
|
||
fallback: str
|
||
description: str
|
||
|
||
|
||
CONTEXT_FEATURES: tuple[ContextFeatureSpec, ...] = (
|
||
ContextFeatureSpec(
|
||
name="hour_of_day",
|
||
dtype="numeric",
|
||
freshness="jit",
|
||
source="request",
|
||
fallback="12",
|
||
description="Current hour (0–23), supplied by the caller at score time.",
|
||
),
|
||
ContextFeatureSpec(
|
||
name="day_of_week",
|
||
dtype="numeric",
|
||
freshness="jit",
|
||
source="request",
|
||
fallback="0",
|
||
description="ISO weekday (0=Monday … 6=Sunday), supplied by the caller at score time.",
|
||
),
|
||
ContextFeatureSpec(
|
||
name="tasks",
|
||
dtype="list",
|
||
freshness="jit",
|
||
source="todoist-integration",
|
||
fallback="[]",
|
||
description="User's open tasks fetched live from the Todoist integration at request time.",
|
||
),
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class TaskSignal:
|
||
id: str
|
||
content: str
|
||
priority: int = 1 # 1–4 (Todoist scale)
|
||
is_overdue: bool = False
|
||
task_age_days: float = 0.0
|
||
due_date: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class PromptContext:
|
||
tasks: list[dict] = field(default_factory=list)
|
||
hour_of_day: int = 12
|
||
day_of_week: int = 0
|
||
extra: dict = field(default_factory=dict)
|
||
|
||
|
||
def build_context(
|
||
tasks: list[TaskSignal],
|
||
hour_of_day: int = 12,
|
||
day_of_week: int = 0,
|
||
extra: dict | None = None,
|
||
) -> PromptContext:
|
||
"""
|
||
Assemble user signals into a PromptContext.
|
||
|
||
Signals are sorted so overdue + high-priority tasks appear first,
|
||
giving the LLM the most actionable context at the top of the prompt.
|
||
"""
|
||
sorted_tasks = sorted(
|
||
tasks,
|
||
key=lambda t: (not t.is_overdue, -t.priority, -t.task_age_days),
|
||
)
|
||
task_dicts = [
|
||
{
|
||
"id": t.id,
|
||
"content": t.content,
|
||
"priority": t.priority,
|
||
"is_overdue": t.is_overdue,
|
||
"task_age_days": round(t.task_age_days, 1),
|
||
"due_date": t.due_date,
|
||
}
|
||
for t in sorted_tasks
|
||
]
|
||
return PromptContext(
|
||
tasks=task_dicts,
|
||
hour_of_day=hour_of_day,
|
||
day_of_week=day_of_week,
|
||
extra=extra or {},
|
||
)
|