feat(ml): multi-agent context framework + v4 orchestrator prompt

Adds ml/agents/ — five specialised sub-agents (overdue_task, momentum,
time_of_day, recent_patterns, focus_area) each producing a prompt snippet
from user signals. A registry wires them up; the orchestrator prompt in
ml/serving/prompts.py synthesises their outputs into one tip via LiteLLM.

Also wires /api/agents route in the API and updates the Dockerfile to copy
the full ml/ tree with PYTHONPATH=/app so agent imports resolve correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 10:20:05 +00:00
parent f8d66aa01f
commit b3cf588f2f
14 changed files with 667 additions and 2 deletions

50
ml/agents/time_of_day.py Normal file
View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput
_DOW_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
class TimeOfDayAgent(BaseAgent):
"""Frames the current moment relative to the user's productive peak."""
agent_id: ClassVar[str] = "time-of-day"
ttl_seconds: ClassVar[int] = 900 # 15m — must stay current-hour accurate
version: ClassVar[str] = "1.0.0"
def compute(self, inp: AgentInput) -> AgentOutput:
hour = inp.now.hour
dow = inp.now.weekday() # 0=Monday … 6=Sunday
preferred = inp.profile.get("preferred_hour")
is_weekend = dow >= 5
parts = [f"It is {hour:02d}:00 on {_DOW_NAMES[dow]} ({self._label(hour)})."]
if is_weekend:
parts.append("Weekend context — prefer personal or reflective tips over work tasks.")
if preferred is not None:
ph = int(preferred)
delta = min(abs(hour - ph), 24 - abs(hour - ph)) # circular distance
if delta == 0:
parts.append(
f"This is the user's peak productivity hour ({ph:02d}:00) — "
f"a high-impact tip is appropriate."
)
elif delta <= 2:
parts.append(f"Approaching the user's peak productivity window ({ph:02d}:00).")
else:
parts.append("No preferred-hour data yet.")
prompt = " ".join(parts)
snapshot = {"hour": hour, "day_of_week": dow, "preferred_hour": preferred}
return self._make_output(inp, prompt, snapshot)
@staticmethod
def _label(hour: int) -> str:
if 5 <= hour < 12:
return "morning"
if 12 <= hour < 17:
return "afternoon"
if 17 <= hour < 21:
return "evening"
return "night"