Files
oO/ml/features/context.py
alvis 45416000f9 feat(features): per-feature freshness spec — JIT vs batched (#61)
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>
2026-04-25 17:02:55 +00:00

108 lines
2.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 (023), 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 # 14 (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 {},
)