""" 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 {}, )