refactor(focus-area): output all clusters as context; remove scoring and preferred_areas

The agent no longer picks a winner — it summarises every cluster so the
orchestrator can decide what's relevant. Scoring by overdue count overlapped
with the overdue-task agent. preferred_areas (project-ID based, broken label
matching) removed entirely.

Output format: numbered list of areas with task titles included.
Snapshot: {cluster_count, clusters: [{label, task_count, tasks}]}.
Version bumped to 3.0.0; inferred_params cleared.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:57:04 +00:00
parent 12c956b588
commit f6b89fc849
3 changed files with 69 additions and 168 deletions

View File

@@ -1,69 +1,37 @@
from __future__ import annotations from __future__ import annotations
from collections import Counter
from typing import ClassVar from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput from .base import BaseAgent, AgentInput, AgentOutput
from .clustering import cluster_tasks from .clustering import cluster_tasks
from .inference.history import UserHistory from .manifest import AgentManifest
from .manifest import AgentManifest, InferredParam
def _infer_preferred_areas(history: UserHistory) -> list[str]:
"""Top-2 project IDs by completed task count (last 90 days worth of data)."""
counts: Counter[str] = Counter()
for tc in history.task_completions:
if tc.project_id:
counts[tc.project_id] += 1
return [pid for pid, _ in counts.most_common(2)]
MANIFEST = AgentManifest( MANIFEST = AgentManifest(
id="focus-area", id="focus-area",
version="2.1.0", # 1h TTL + task-change detection (#129) version="3.0.0", # output all clusters as context; no scoring (#129)
description="Identifies the most congested semantic focus area in the user's task list.", description="Clusters the user's task list and summarises all areas for the orchestrator.",
pref_schema={ pref_schema={"type": "object", "additionalProperties": False, "properties": {}},
"type": "object",
"additionalProperties": False,
"properties": {
"preferred_areas": {
"type": "array",
"items": {"type": "string"},
"default": [],
"description": "Project IDs or label names to prioritise when multiple areas tie.",
},
},
},
context_schema=["todoist.tasks"], context_schema=["todoist.tasks"],
required_consents=["data:core", "data:todoist"], required_consents=["data:core", "data:todoist"],
output_contract={"type": "snippet", "format": "free_text"}, output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=86_400, ttl_sec=86_400,
inferred_params=[ inferred_params=[],
InferredParam(
key="preferred_areas",
ttl_sec=86_400,
cold_start_default=[],
min_history=0, # use task_completions, not feedback events; handle empty inside
infer=_infer_preferred_areas,
),
],
) )
class FocusAreaAgent(BaseAgent): class FocusAreaAgent(BaseAgent):
"""Identifies the most congested semantic focus area in the user's task list.""" """Clusters tasks and outputs a full area summary for the orchestrator."""
agent_id: ClassVar[str] = MANIFEST.id agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = MANIFEST.version # 2.1.0 version: ClassVar[str] = MANIFEST.version # 3.0.0
def compute(self, inp: AgentInput) -> AgentOutput: def compute(self, inp: AgentInput) -> AgentOutput:
preferred: list[str] = inp.agent_prefs.get("preferred_areas", [])
if not inp.tasks: if not inp.tasks:
return self._make_output( return self._make_output(
inp, inp,
"No tasks available to identify a focus area.", "No tasks available to identify focus areas.",
{"cluster_count": 0, "strategy": "none"}, {"cluster_count": 0},
) )
clusters, new_enrichments = cluster_tasks(inp.tasks, enrichment_cache=inp.enrichment_cache) clusters, new_enrichments = cluster_tasks(inp.tasks, enrichment_cache=inp.enrichment_cache)
@@ -71,45 +39,27 @@ class FocusAreaAgent(BaseAgent):
if not clusters: if not clusters:
return self._make_output( return self._make_output(
inp, inp,
"No tasks available to identify a focus area.", "No tasks available to identify focus areas.",
{"cluster_count": 0, "strategy": "none"}, {"cluster_count": 0},
) )
strategy = "semantic" if len(clusters) > 1 or len(inp.tasks) > 1 else "fallback" lines = [f"The user's tasks are grouped into {len(clusters)} area(s):"]
for i, cluster in enumerate(clusters, 1):
titles = [t.get("content", "").strip() for t in cluster.tasks if t.get("content")]
titles_str = "; ".join(f'"{t}"' for t in titles[:8])
if len(titles) > 8:
titles_str += f" (and {len(titles) - 8} more)"
lines.append(f"{i}. {cluster.label}{cluster.task_count} task(s): {titles_str}")
def score(cluster) -> float: lines.append("(Task titles may be in any language — always write the tip in English.)")
base = sum(2.0 if t.get("is_overdue") else 1.0 for t in cluster.tasks)
boosted = any(p in cluster.label for p in preferred) if preferred else False
return base + (0.5 if boosted else 0.0)
top = max(clusters, key=score)
boosted = bool(preferred) and any(p in top.label for p in preferred)
parts = [
f'The user\'s most active focus area is "{top.label}" '
f"({top.task_count} task{'s' if top.task_count != 1 else ''}, "
f"{top.overdue_count} overdue). "
f"(Note: task titles may be in any language — always write the tip in English.)"
]
if boosted:
parts.append("This area matches the user's stated focus preferences.")
if top.overdue_count >= 3:
parts.append("Consider surfacing an action from this area.")
if len(clusters) > 1:
other_total = sum(c.task_count for c in clusters if c is not top)
parts.append(
f"{len(clusters) - 1} other area{'s' if len(clusters) > 2 else ''} "
f"contain {other_total} task{'s' if other_total != 1 else ''}."
)
snapshot = { snapshot = {
"top_cluster_label": top.label,
"top_task_count": top.task_count,
"top_overdue_count": top.overdue_count,
"cluster_count": len(clusters), "cluster_count": len(clusters),
"strategy": strategy, "clusters": [
"preferred_areas": preferred, {"label": c.label, "task_count": c.task_count,
# Consumed by compute_agent endpoint; stripped before storing the snapshot. "tasks": [t.get("content", "") for t in c.tasks]}
for c in clusters
],
"_new_enrichments": new_enrichments, "_new_enrichments": new_enrichments,
} }
return self._make_output(inp, " ".join(parts), snapshot) return self._make_output(inp, "\n".join(lines), snapshot)

View File

@@ -213,41 +213,41 @@ class TestFocusAreaAgent:
out = self.agent.compute(_inp()) out = self.agent.compute(_inp())
assert "no tasks" in out.prompt_text.lower() assert "no tasks" in out.prompt_text.lower()
def test_single_project(self): def test_lists_all_clusters(self):
tasks = [_task(f"T{i}", project_id="Work") for i in range(3)]
out = self.agent.compute(_inp(tasks=tasks))
assert '"Work"' in out.prompt_text
assert "3 tasks" in out.prompt_text
def test_most_congested_wins(self):
tasks = ( tasks = (
[_task(f"W{i}", project_id="Work") for i in range(5)] [_task(f"W{i}", project_id="Work") for i in range(3)]
+ [_task(f"H{i}", project_id="Home") for i in range(2)] + [_task(f"H{i}", project_id="Home") for i in range(2)]
) )
out = self.agent.compute(_inp(tasks=tasks)) out = self.agent.compute(_inp(tasks=tasks))
assert '"Work"' in out.prompt_text assert "Work" in out.prompt_text
assert "Home" in out.prompt_text
def test_overdue_weighting(self): def test_includes_task_titles(self):
# Home has 2 tasks (1 overdue), Work has 3 non-overdue tasks tasks = [_task("Buy milk", project_id="Personal"), _task("Write report", project_id="Personal")]
# Home score = 2+1 = 3; Work score = 3 — Home should win due to overdue weight
tasks = (
[_task("Home1", project_id="Home", is_overdue=True),
_task("Home2", project_id="Home")]
+ [_task(f"W{i}", project_id="Work") for i in range(3)]
)
out = self.agent.compute(_inp(tasks=tasks)) out = self.agent.compute(_inp(tasks=tasks))
assert '"Work"' not in out.prompt_text or '"Home"' in out.prompt_text assert '"Buy milk"' in out.prompt_text
assert '"Write report"' in out.prompt_text
def test_task_count_in_output(self):
tasks = [_task(f"T{i}", project_id="Work") for i in range(3)]
out = self.agent.compute(_inp(tasks=tasks))
assert "3 task" in out.prompt_text
def test_default_project_fallback(self): def test_default_project_fallback(self):
out = self.agent.compute(_inp(tasks=[_task("No project task")])) out = self.agent.compute(_inp(tasks=[_task("No project task")]))
# Tasks without project_id fall back to a "Tasks" bucket
assert "Tasks" in out.prompt_text assert "Tasks" in out.prompt_text
def test_snapshot_keys(self): def test_snapshot_keys(self):
out = self.agent.compute(_inp(tasks=[_task("T1", project_id="A")])) out = self.agent.compute(_inp(tasks=[_task("T1", project_id="A")]))
public_keys = {k for k in out.signals_snapshot if not k.startswith("_")} public_keys = {k for k in out.signals_snapshot if not k.startswith("_")}
assert {"top_cluster_label", "top_task_count", "top_overdue_count", "cluster_count", assert {"cluster_count", "clusters"} == public_keys
"strategy", "preferred_areas"} == public_keys
def test_snapshot_clusters_shape(self):
tasks = [_task("Buy milk", project_id="P1"), _task("Fix bug", project_id="P2")]
out = self.agent.compute(_inp(tasks=tasks))
clusters = out.signals_snapshot["clusters"]
assert isinstance(clusters, list)
assert all("label" in c and "task_count" in c and "tasks" in c for c in clusters)
# ── Registry ───────────────────────────────────────────────────────────────── # ── Registry ─────────────────────────────────────────────────────────────────

View File

@@ -627,86 +627,37 @@ class TestTimeOfDaySnippet:
assert {"quiet_start", "quiet_end", "peak_hours", "tz"}.issubset(keys) assert {"quiet_start", "quiet_end", "peak_hours", "tz"}.issubset(keys)
# ── focus-area: preferred_areas wiring ─────────────────────────────────────── # ── focus-area: cluster summary output ───────────────────────────────────────
class TestFocusAreaPreferredAreas: class TestFocusAreaOutput:
agent = FocusAreaAgent() agent = FocusAreaAgent()
def _task(self, content: str, project_id: str, is_overdue: bool = False) -> dict: def _task(self, content: str, project_id: str) -> dict:
return {"id": "t1", "content": content, "is_overdue": is_overdue, return {"id": "t1", "content": content, "is_overdue": False,
"task_age_days": 2.0, "priority": 1, "project_id": project_id} "task_age_days": 2.0, "priority": 1, "project_id": project_id}
def test_preferred_area_wins_tie(self): def test_version(self):
tasks = [
self._task("Work thing", "work"),
self._task("Home thing", "home"),
]
out = self.agent.compute(_inp(tasks=tasks, agent_prefs={"preferred_areas": ["work"]}))
assert "work" in out.prompt_text
assert "matches the user's stated focus preferences" in out.prompt_text
def test_no_preferred_areas_uses_congestion_score(self):
tasks = [
self._task("W1", "work"),
self._task("H1", "home"),
self._task("H2", "home"),
]
out = self.agent.compute(_inp(tasks=tasks))
# home has more tasks → wins without any preference
assert "home" in out.prompt_text
def test_snapshot_includes_preferred_areas(self):
tasks = [self._task("T", "work")]
out = self.agent.compute(_inp(tasks=tasks, agent_prefs={"preferred_areas": ["work"]}))
assert out.signals_snapshot["preferred_areas"] == ["work"]
def test_version_bumped(self):
from ml.agents.focus_area import MANIFEST as FA_MANIFEST from ml.agents.focus_area import MANIFEST as FA_MANIFEST
assert FA_MANIFEST.version == "2.1.0" assert FA_MANIFEST.version == "3.0.0"
def test_snapshot_uses_cluster_keys(self): def test_all_clusters_in_output(self):
tasks = [self._task("Work thing", "work"), self._task("Home thing", "home")]
out = self.agent.compute(_inp(tasks=tasks))
assert "work" in out.prompt_text.lower()
assert "home" in out.prompt_text.lower()
def test_task_titles_in_output(self):
tasks = [self._task("Buy milk", "personal")]
out = self.agent.compute(_inp(tasks=tasks))
assert '"Buy milk"' in out.prompt_text
def test_snapshot_shape(self):
tasks = [self._task("T", "work")] tasks = [self._task("T", "work")]
out = self.agent.compute(_inp(tasks=tasks)) out = self.agent.compute(_inp(tasks=tasks))
assert "top_cluster_label" in out.signals_snapshot public_keys = {k for k in out.signals_snapshot if not k.startswith("_")}
assert "cluster_count" in out.signals_snapshot assert public_keys == {"cluster_count", "clusters"}
assert "strategy" in out.signals_snapshot assert isinstance(out.signals_snapshot["clusters"], list)
def test_no_inferred_params(self):
# ── focus-area: preferred_areas inference from task_completions (#113) ────────
class TestFocusAreaPreferredAreasInference:
from ml.agents.focus_area import MANIFEST as _FA_MANIFEST
def _completion(self, project_id: str) -> TaskCompletion:
return _completion(project_id, lateness_days=0.0)
def test_cold_start_no_completions(self):
history = _history(completions=[])
from ml.agents.focus_area import MANIFEST as FA_MANIFEST from ml.agents.focus_area import MANIFEST as FA_MANIFEST
result = run_inference(FA_MANIFEST, history) assert FA_MANIFEST.inferred_params == []
assert result["preferred_areas"] == []
def test_top_two_projects_returned(self):
completions = (
[_completion("p1", 0)] * 8
+ [_completion("p2", 0)] * 5
+ [_completion("p3", 0)] * 2
)
history = _history(completions=completions)
from ml.agents.focus_area import MANIFEST as FA_MANIFEST
result = run_inference(FA_MANIFEST, history)
assert result["preferred_areas"] == ["p1", "p2"]
def test_single_project_returns_one(self):
completions = [_completion("work", 0)] * 6
history = _history(completions=completions)
from ml.agents.focus_area import MANIFEST as FA_MANIFEST
result = run_inference(FA_MANIFEST, history)
assert result["preferred_areas"] == ["work"]
def test_none_project_id_ignored(self):
completions = [_completion(None, 0)] * 5 + [_completion("real", 0)] * 3
history = _history(completions=completions)
from ml.agents.focus_area import MANIFEST as FA_MANIFEST
result = run_inference(FA_MANIFEST, history)
assert result["preferred_areas"] == ["real"]