feat(agents): p50-lateness tolerance + per-project realness for overdue-task (#115)

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>
This commit is contained in:
2026-05-06 05:14:04 +00:00
parent 35257b7756
commit 04212ff318
5 changed files with 210 additions and 60 deletions

View File

@@ -4,6 +4,6 @@ Each agent's manifest declares InferredParams; this package owns the
scheduling contract, history data model, and write path to user_preferences.
"""
from .framework import run_inference
from .history import FeedbackEvent, UserHistory
from .history import FeedbackEvent, TaskCompletion, UserHistory
__all__ = ["run_inference", "FeedbackEvent", "UserHistory"]
__all__ = ["run_inference", "FeedbackEvent", "TaskCompletion", "UserHistory"]

View File

@@ -23,7 +23,27 @@ class FeedbackEvent:
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)