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

53
ml/agents/base.py Normal file
View File

@@ -0,0 +1,53 @@
"""Base class and shared data structures for all recommendation sub-agents."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import ClassVar
@dataclass
class AgentInput:
"""Everything an agent may need to produce its prompt snippet."""
user_id: str
tasks: list[dict] # task signal dicts (content, priority, is_overdue, …)
profile: dict[str, float | None] # profile feature values keyed by feature name
feedback_history: list[dict] = field(default_factory=list) # [{action, dwell_ms, created_at}, …]
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class AgentOutput:
"""Result produced by an agent; persisted to agent_outputs table."""
user_id: str
agent_id: str
prompt_text: str # snippet passed to the orchestrator
signals_snapshot: dict # inputs consumed (for explainability / debugging)
computed_at: str # ISO 8601
expires_at: str # ISO 8601
agent_version: str
class BaseAgent(ABC):
agent_id: ClassVar[str]
ttl_seconds: ClassVar[int]
version: ClassVar[str]
@abstractmethod
def compute(self, inp: AgentInput) -> AgentOutput:
"""Analyse inp and return a prompt snippet describing what was found."""
...
def _make_output(self, inp: AgentInput, prompt_text: str, snapshot: dict) -> AgentOutput:
computed_at = inp.now.astimezone(timezone.utc).isoformat()
expires_at = (inp.now.astimezone(timezone.utc) + timedelta(seconds=self.ttl_seconds)).isoformat()
return AgentOutput(
user_id=inp.user_id,
agent_id=self.agent_id,
prompt_text=prompt_text,
signals_snapshot=snapshot,
computed_at=computed_at,
expires_at=expires_at,
agent_version=self.version,
)