Replaces snooze-rate heuristic with p50 of actual task lateness (completedAt − dueAt).
Adds project_realness inference: projects with chronic lateness get realness < 1 and
the agent softens its snippet language from "overdue" to "past target date".
- TaskCompletion added to UserHistory with lateness_days computed property
- _infer_lateness_tolerance: p50 of task_completions, clipped at 0, float
- _infer_project_realness: per-project median lateness normalised by global median
- Both InferredParams use 7d TTL; cold_start = 0.0 / {}
- AgentInferRequest accepts task_completions; endpoint wires them through
- 12 new tests covering punctual/chronic/mixed users and language softening
- Agent bumped to v1.2.0
Closes #115
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
50 lines
1.6 KiB
Python
50 lines
1.6 KiB
Python
"""UserHistory — normalised view of a user's feedback events for inference."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
@dataclass
|
|
class FeedbackEvent:
|
|
action: str # 'done' | 'dismiss' | 'snooze' | 'helpful' | 'not_helpful'
|
|
dwell_ms: int | None
|
|
created_at: str # ISO 8601
|
|
|
|
@property
|
|
def hour(self) -> int:
|
|
"""Hour of day (0-23) when the feedback was recorded."""
|
|
try:
|
|
dt = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return 12
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt.hour
|
|
|
|
|
|
@dataclass
|
|
class TaskCompletion:
|
|
"""A completed task that had a due date — used for lateness inference."""
|
|
project_id: str | None
|
|
completed_at: str # ISO 8601
|
|
due_at: str # ISO 8601
|
|
|
|
@property
|
|
def lateness_days(self) -> float:
|
|
"""Days between due_at and completed_at. Negative = completed early."""
|
|
try:
|
|
def _parse(s: str) -> datetime:
|
|
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
return (_parse(self.completed_at) - _parse(self.due_at)).total_seconds() / 86_400
|
|
except ValueError:
|
|
return 0.0
|
|
|
|
|
|
@dataclass
|
|
class UserHistory:
|
|
user_id: str
|
|
events: list[FeedbackEvent] = field(default_factory=list)
|
|
task_completions: list[TaskCompletion] = field(default_factory=list)
|