- New ml/agents/clustering.py: embed task content via nomic-embed-text (Ollama), greedy cosine clustering (threshold 0.72, max 6 clusters), graceful fallback to project-id grouping when Ollama is unreachable - focus_area v2.0.0: compute() uses semantic clusters as focus areas; adds preferred_areas InferredParam inferred from top-2 projects by task_completion count - 135 tests, all passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -240,11 +240,13 @@ class TestFocusAreaAgent:
|
||||
|
||||
def test_default_project_fallback(self):
|
||||
out = self.agent.compute(_inp(tasks=[_task("No project task")]))
|
||||
assert "default project" in out.prompt_text
|
||||
# Tasks without project_id fall back to a "Tasks" bucket
|
||||
assert "Tasks" in out.prompt_text
|
||||
|
||||
def test_snapshot_keys(self):
|
||||
out = self.agent.compute(_inp(tasks=[_task("T1", project_id="A")]))
|
||||
assert {"top_project", "top_task_count", "top_overdue_count", "project_count", "preferred_areas"} == set(out.signals_snapshot)
|
||||
assert {"top_cluster_label", "top_task_count", "top_overdue_count", "cluster_count",
|
||||
"strategy", "preferred_areas"} == set(out.signals_snapshot)
|
||||
|
||||
|
||||
# ── Registry ─────────────────────────────────────────────────────────────────
|
||||
|
||||
135
ml/agents/tests/test_clustering.py
Normal file
135
ml/agents/tests/test_clustering.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Unit tests for ml.agents.clustering (issue #97).
|
||||
|
||||
Embedding calls are mocked so tests run without Ollama.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from ml.agents.clustering import cluster_tasks, Cluster, _greedy_cluster, _cosine
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _task(content: str, project_id: str | None = None, is_overdue: bool = False) -> dict:
|
||||
t: dict = {"content": content, "is_overdue": is_overdue}
|
||||
if project_id:
|
||||
t["project_id"] = project_id
|
||||
return t
|
||||
|
||||
|
||||
def _embed_seq(*vecs):
|
||||
"""Return a side_effect list so successive _embed calls return these vectors."""
|
||||
return list(vecs)
|
||||
|
||||
|
||||
# ── Cluster dataclass ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestCluster:
|
||||
def test_task_count(self):
|
||||
c = Cluster(label="X", tasks=[_task("a"), _task("b")])
|
||||
assert c.task_count == 2
|
||||
|
||||
def test_overdue_count(self):
|
||||
c = Cluster(label="X", tasks=[_task("a", is_overdue=True), _task("b")])
|
||||
assert c.overdue_count == 1
|
||||
|
||||
|
||||
# ── cosine similarity ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestCosine:
|
||||
def test_identical_vectors(self):
|
||||
v = [1.0, 0.0, 0.0]
|
||||
assert _cosine(v, v) == 1.0
|
||||
|
||||
def test_orthogonal_vectors(self):
|
||||
assert _cosine([1.0, 0.0], [0.0, 1.0]) == 0.0
|
||||
|
||||
def test_zero_vector(self):
|
||||
assert _cosine([0.0, 0.0], [1.0, 0.0]) == 0.0
|
||||
|
||||
|
||||
# ── greedy clustering ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestGreedyClustering:
|
||||
def _similar_vec(self, base: list[float], noise: float = 0.01) -> list[float]:
|
||||
return [x + noise for x in base]
|
||||
|
||||
def test_similar_tasks_grouped(self):
|
||||
v = [1.0, 0.0, 0.0]
|
||||
v2 = [0.999, 0.001, 0.0]
|
||||
items = [
|
||||
(_task("A"), v),
|
||||
(_task("B"), v2),
|
||||
]
|
||||
clusters = _greedy_cluster(items)
|
||||
assert len(clusters) == 1
|
||||
assert clusters[0].task_count == 2
|
||||
|
||||
def test_dissimilar_tasks_separate(self):
|
||||
v1 = [1.0, 0.0, 0.0]
|
||||
v2 = [0.0, 1.0, 0.0]
|
||||
items = [(_task("A"), v1), (_task("B"), v2)]
|
||||
clusters = _greedy_cluster(items)
|
||||
assert len(clusters) == 2
|
||||
|
||||
def test_label_from_first_task(self):
|
||||
v = [1.0, 0.0]
|
||||
clusters = _greedy_cluster([(_task("Write report"), v)])
|
||||
assert clusters[0].label == "Write report"
|
||||
|
||||
|
||||
# ── cluster_tasks integration ─────────────────────────────────────────────────
|
||||
|
||||
class TestClusterTasks:
|
||||
def test_empty_tasks(self):
|
||||
result = cluster_tasks([])
|
||||
assert result == []
|
||||
|
||||
def test_fallback_when_ollama_unavailable(self):
|
||||
with patch("ml.agents.clustering._embed", return_value=None):
|
||||
tasks = [_task("A", "p1"), _task("B", "p2"), _task("C", "p1")]
|
||||
clusters = cluster_tasks(tasks)
|
||||
assert len(clusters) == 2
|
||||
labels = {c.label for c in clusters}
|
||||
assert "p1" in labels and "p2" in labels
|
||||
|
||||
def test_fallback_groups_by_project(self):
|
||||
with patch("ml.agents.clustering._embed", return_value=None):
|
||||
tasks = [_task("A", "work")] * 3 + [_task("B", "home")] * 2
|
||||
clusters = cluster_tasks(tasks)
|
||||
by_label = {c.label: c.task_count for c in clusters}
|
||||
assert by_label["work"] == 3
|
||||
assert by_label["home"] == 2
|
||||
|
||||
def test_tasks_without_content_go_to_other(self):
|
||||
v = [1.0, 0.0]
|
||||
with patch("ml.agents.clustering._embed", return_value=v):
|
||||
tasks = [_task("Has content"), {"is_overdue": False}]
|
||||
clusters = cluster_tasks(tasks)
|
||||
labels = {c.label for c in clusters}
|
||||
assert "Other tasks" in labels
|
||||
|
||||
def test_semantic_clustering_groups_similar(self):
|
||||
v_work = [1.0, 0.0, 0.0]
|
||||
v_home = [0.0, 1.0, 0.0]
|
||||
side_effects = [v_work, v_work, v_home, v_home]
|
||||
with patch("ml.agents.clustering._embed", side_effect=side_effects):
|
||||
tasks = [
|
||||
_task("Write report"),
|
||||
_task("Review PR"),
|
||||
_task("Buy groceries"),
|
||||
_task("Cook dinner"),
|
||||
]
|
||||
clusters = cluster_tasks(tasks)
|
||||
assert len(clusters) == 2
|
||||
assert all(c.task_count == 2 for c in clusters)
|
||||
|
||||
def test_all_tasks_no_content_fallback_by_project(self):
|
||||
tasks = [{"project_id": "p1", "is_overdue": False},
|
||||
{"project_id": "p2", "is_overdue": False}]
|
||||
clusters = cluster_tasks(tasks)
|
||||
assert len(clusters) == 2
|
||||
@@ -662,4 +662,51 @@ class TestFocusAreaPreferredAreas:
|
||||
|
||||
def test_version_bumped(self):
|
||||
from ml.agents.focus_area import MANIFEST as FA_MANIFEST
|
||||
assert FA_MANIFEST.version == "1.1.0"
|
||||
assert FA_MANIFEST.version == "2.0.0"
|
||||
|
||||
def test_snapshot_uses_cluster_keys(self):
|
||||
tasks = [self._task("T", "work")]
|
||||
out = self.agent.compute(_inp(tasks=tasks))
|
||||
assert "top_cluster_label" in out.signals_snapshot
|
||||
assert "cluster_count" in out.signals_snapshot
|
||||
assert "strategy" in out.signals_snapshot
|
||||
|
||||
|
||||
# ── 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
|
||||
result = run_inference(FA_MANIFEST, history)
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user