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

@@ -1,5 +1,6 @@
from __future__ import annotations
import statistics
from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput
@@ -7,36 +8,64 @@ from .inference.history import UserHistory
from .manifest import AgentManifest, InferredParam
def _infer_lateness_tolerance(history: UserHistory) -> int:
"""Estimate how many days past due a task needs to be before the user acts.
def _infer_lateness_tolerance(history: UserHistory) -> float:
"""p50 lateness (days) across completed tasks that had a due date, clipped at 0.
High snooze rate → user doesn't act immediately → raise tolerance so the
agent doesn't nag them about tasks they'll handle in their own time.
Negative lateness (finished early) pulls the percentile down; we clip at 0
so punctual users always get tolerance=0, never a negative offset.
"""
total = len(history.events)
if total == 0:
return 0
snooze_rate = sum(1 for e in history.events if e.action == "snooze") / total
if snooze_rate > 0.40:
return 2
if snooze_rate > 0.20:
return 1
return 0
lateness = [c.lateness_days for c in history.task_completions]
if not lateness:
return 0.0
return max(0.0, statistics.median(lateness))
def _infer_project_realness(history: UserHistory) -> dict[str, float]:
"""Per-project realness: 1 (median project lateness / global median lateness).
Projects whose tasks are consistently completed on time get realness ≈ 1.
Aspirational projects (chronic lateness) get realness closer to 0.
"""
completions = [c for c in history.task_completions if c.project_id]
if not completions:
return {}
global_median = statistics.median(c.lateness_days for c in completions)
if global_median <= 0:
# Everyone finishes early — no project is less real than another.
return {pid: 1.0 for pid in {c.project_id for c in completions}} # type: ignore[misc]
by_project: dict[str, list[float]] = {}
for c in completions:
by_project.setdefault(c.project_id, []).append(c.lateness_days) # type: ignore[index]
result: dict[str, float] = {}
for pid, days in by_project.items():
project_median = statistics.median(days)
realness = 1.0 - (project_median / global_median)
result[pid] = round(max(0.0, min(1.0, realness)), 3)
return result
MANIFEST = AgentManifest(
id="overdue-task",
version="1.1.0", # bumped: lateness_tolerance_days InferredParam added (#115)
version="1.2.0", # #115: p50-lateness tolerance + per-project realness
description="Reports the user's overdue tasks by count and age.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"lateness_tolerance_days": {
"type": "integer",
"type": "number",
"minimum": 0,
"default": 0,
"description": "Days past due before a task is considered overdue. 0 = the moment it's late.",
"description": "Days past due before a task is flagged. p50 of historical lateness.",
},
"project_realness": {
"type": "object",
"additionalProperties": {"type": "number", "minimum": 0, "maximum": 1},
"default": {},
"description": "Per-project realness score [0,1]. Low = aspirational due dates.",
},
},
},
@@ -48,15 +77,40 @@ MANIFEST = AgentManifest(
inferred_params=[
InferredParam(
key="lateness_tolerance_days",
ttl_sec=86_400, # recompute daily — snooze pattern shifts slowly
cold_start_default=0,
ttl_sec=7 * 86_400, # recompute weekly — lateness habits shift slowly
cold_start_default=0.0,
min_history=10,
infer=_infer_lateness_tolerance,
),
InferredParam(
key="project_realness",
ttl_sec=7 * 86_400,
cold_start_default={},
min_history=10,
infer=_infer_project_realness,
),
],
)
def _realness(project_id: str | None, project_realness: dict[str, float]) -> float:
"""Return realness for a project, defaulting to 1.0 (treat as real)."""
if not project_id or not project_realness:
return 1.0
return project_realness.get(project_id, 1.0)
def _format_task(task: dict, project_realness: dict[str, float]) -> str:
content = task["content"]
age = round(task.get("task_age_days", 0))
pid = task.get("project_id")
r = _realness(pid, project_realness)
unit = "day" if age == 1 else "days"
if r < 0.4:
return f'"{content}" ({age} {unit} past target date)'
return f'"{content}" ({age} {unit} overdue)'
class OverdueTaskAgent(BaseAgent):
"""Reports the user's overdue tasks by count and age."""
agent_id: ClassVar[str] = MANIFEST.id
@@ -64,7 +118,9 @@ class OverdueTaskAgent(BaseAgent):
version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput:
tolerance = max(0, int(inp.agent_prefs.get("lateness_tolerance_days", 0)))
tolerance = max(0.0, float(inp.agent_prefs.get("lateness_tolerance_days", 0)))
project_realness: dict[str, float] = inp.agent_prefs.get("project_realness", {})
overdue = [
t for t in inp.tasks
if t.get("is_overdue") and t.get("task_age_days", 0) >= tolerance
@@ -75,18 +131,21 @@ class OverdueTaskAgent(BaseAgent):
prompt = "The user has no overdue tasks at this time."
elif len(overdue) == 1:
t = top[0]
age = round(t.get("task_age_days", 0))
prompt = (
f'The user has 1 overdue task: "{t["content"]}" '
f"({age} day{'s' if age != 1 else ''} overdue)."
)
r = _realness(t.get("project_id"), project_realness)
item = _format_task(t, project_realness)
if r < 0.4:
prompt = f"The user has 1 task past its target date: {item}."
else:
prompt = f"The user has 1 overdue task: {item}."
else:
items = ", ".join(
f'"{t["content"]}" ({round(t.get("task_age_days", 0))}d)'
for t in top
items = ", ".join(_format_task(t, project_realness) for t in top)
avg_realness = (
sum(_realness(t.get("project_id"), project_realness) for t in overdue)
/ len(overdue)
)
label = "tasks past their target dates" if avg_realness < 0.4 else "overdue tasks"
prompt = (
f"The user has {len(overdue)} overdue tasks. "
f"The user has {len(overdue)} {label}. "
f"Top {len(top)}: {items}."
)
@@ -94,7 +153,12 @@ class OverdueTaskAgent(BaseAgent):
"overdue_count": len(overdue),
"lateness_tolerance_days": tolerance,
"top_overdue": [
{"content": t["content"], "task_age_days": t.get("task_age_days", 0)}
{
"content": t["content"],
"task_age_days": t.get("task_age_days", 0),
"project_id": t.get("project_id"),
"realness": _realness(t.get("project_id"), project_realness),
}
for t in top
],
}