chore: initialize EverOS 1.0.0
md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
0
tests/unit/test_memory/test_strategies/__init__.py
Normal file
0
tests/unit/test_memory/test_strategies/__init__.py
Normal file
@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import importlib
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import structlog.testing
|
||||
from everalgo.types import AgentCase, ChatMessage, MemCell
|
||||
|
||||
from everos.core.persistence import EntryId
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.memory.events import AgentCaseExtracted, AgentPipelineStarted
|
||||
from everos.memory.strategies.extract_agent_case import extract_agent_case
|
||||
|
||||
|
||||
def _fake_eid() -> EntryId:
|
||||
return EntryId(prefix="ac", date=_dt.date(2026, 5, 17), seq=1)
|
||||
|
||||
|
||||
mod = importlib.import_module("everos.memory.strategies.extract_agent_case")
|
||||
|
||||
|
||||
def _agent_memcell() -> MemCell:
|
||||
return MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="please summarise the doc",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m2",
|
||||
role="assistant",
|
||||
content="here's the summary ...",
|
||||
timestamp=1_700_000_001_000,
|
||||
sender_id="agent_42",
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_001_000,
|
||||
)
|
||||
|
||||
|
||||
def _event() -> AgentPipelineStarted:
|
||||
return AgentPipelineStarted(
|
||||
memcell_id="mc_a", session_id="s1", memcell=_agent_memcell()
|
||||
)
|
||||
|
||||
|
||||
def _algo_case(
|
||||
*,
|
||||
task_intent: str = "summarise doc",
|
||||
approach: str = "read + condense",
|
||||
quality_score: float = 0.8,
|
||||
key_insight: str = "",
|
||||
) -> AgentCase:
|
||||
return AgentCase(
|
||||
id=uuid.uuid4().hex,
|
||||
timestamp=1_700_000_001_000,
|
||||
task_intent=task_intent,
|
||||
approach=approach,
|
||||
quality_score=quality_score,
|
||||
key_insight=key_insight,
|
||||
)
|
||||
|
||||
|
||||
async def test_strategy_meta_is_attached() -> None:
|
||||
meta = extract_agent_case._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.name == "extract_agent_case"
|
||||
assert AgentPipelineStarted in meta.trigger.on
|
||||
assert meta.emits == frozenset({AgentCaseExtracted})
|
||||
assert meta.max_retries == 2
|
||||
|
||||
|
||||
async def test_writes_md_when_algo_returns_a_case(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
case = _algo_case(quality_score=0.9, key_insight="batch-then-summarise")
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
|
||||
) as mock_wcls,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[case])
|
||||
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
|
||||
ctx = FakeStrategyContext()
|
||||
|
||||
await extract_agent_case(_event(), ctx)
|
||||
|
||||
assert mock_cls.return_value.aextract.await_count == 1
|
||||
assert mock_wcls.return_value.append_entry.call_count == 1
|
||||
_, kwargs = mock_wcls.return_value.append_entry.call_args
|
||||
assert kwargs["inline"]["owner_id"] == "agent_42"
|
||||
assert kwargs["inline"]["session_id"] == "s1"
|
||||
assert kwargs["inline"]["parent_type"] == "memcell"
|
||||
assert kwargs["inline"]["parent_id"] == "mc_a"
|
||||
assert kwargs["inline"]["quality_score"] == 0.9
|
||||
assert kwargs["sections"] == {
|
||||
"TaskIntent": "summarise doc",
|
||||
"Approach": "read + condense",
|
||||
"KeyInsight": "batch-then-summarise",
|
||||
}
|
||||
# Chain emit: AgentCaseExtracted fires after the md write.
|
||||
emitted = [e for e in ctx.emitted if isinstance(e, AgentCaseExtracted)]
|
||||
assert len(emitted) == 1
|
||||
assert emitted[0].memcell_id == "mc_a"
|
||||
assert emitted[0].case_entry_id == _fake_eid().format()
|
||||
assert emitted[0].task_intent == "summarise doc"
|
||||
assert emitted[0].quality_score == 0.9
|
||||
assert emitted[0].case_timestamp_ms == 1_700_000_001_000
|
||||
assert emitted[0].agent_id == "agent_42"
|
||||
|
||||
matching = [e for e in captured if e.get("event") == "agent_case_extracted"]
|
||||
assert matching, "expected agent_case_extracted log line"
|
||||
assert matching[0]["owner_ids"] == ["agent_42"]
|
||||
assert matching[0]["fanout"] == 1
|
||||
assert matching[0]["quality_score"] == 0.9
|
||||
|
||||
|
||||
async def test_fans_out_per_assistant_sender(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""One LLM call, then md write + emit per distinct assistant sender.
|
||||
|
||||
Case text is third-person (``the agent did X``) so the same body
|
||||
is a valid reference experience for every assistant sender that
|
||||
participated in the trajectory. Verifies: aextract is called
|
||||
exactly once, md is written once per agent, and an
|
||||
``AgentCaseExtracted`` event fires per agent so the downstream
|
||||
skill clustering chain runs in each agent's own scope.
|
||||
"""
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
multi_agent_cell = MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="please dispatch",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m2",
|
||||
role="assistant",
|
||||
content="dispatching to specialist",
|
||||
timestamp=1_700_000_001_000,
|
||||
sender_id="agent_lead",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m3",
|
||||
role="assistant",
|
||||
content="here is the answer",
|
||||
timestamp=1_700_000_002_000,
|
||||
sender_id="agent_specialist",
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_002_000,
|
||||
)
|
||||
event = AgentPipelineStarted(
|
||||
memcell_id="mc_multi", session_id="s_multi", memcell=multi_agent_cell
|
||||
)
|
||||
case = _algo_case(quality_score=0.85)
|
||||
|
||||
# writer.append_entry returns a different entry_id per call so the
|
||||
# emitted events carry per-agent entry_ids (cascade keys off owner+entry).
|
||||
eids = [
|
||||
EntryId(prefix="ac", date=_dt.date(2026, 5, 17), seq=1),
|
||||
EntryId(prefix="ac", date=_dt.date(2026, 5, 17), seq=2),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
|
||||
) as mock_wcls,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[case])
|
||||
mock_wcls.return_value.append_entry = AsyncMock(side_effect=eids)
|
||||
ctx = FakeStrategyContext()
|
||||
|
||||
await extract_agent_case(event, ctx)
|
||||
|
||||
# Exactly one LLM call regardless of agent count.
|
||||
assert mock_cls.return_value.aextract.await_count == 1
|
||||
|
||||
# Two md writes (one per distinct assistant sender), in first-seen order.
|
||||
assert mock_wcls.return_value.append_entry.call_count == 2
|
||||
owners_written = [
|
||||
call.kwargs["inline"]["owner_id"]
|
||||
for call in mock_wcls.return_value.append_entry.call_args_list
|
||||
]
|
||||
assert owners_written == ["agent_lead", "agent_specialist"]
|
||||
|
||||
# Two emits, each tagged with its own agent_id + per-agent entry_id.
|
||||
emitted = [e for e in ctx.emitted if isinstance(e, AgentCaseExtracted)]
|
||||
assert len(emitted) == 2
|
||||
assert [e.agent_id for e in emitted] == ["agent_lead", "agent_specialist"]
|
||||
assert [e.case_entry_id for e in emitted] == [eids[0].format(), eids[1].format()]
|
||||
# Same task body / quality across the fan-out (broadcast semantics).
|
||||
assert {e.task_intent for e in emitted} == {"summarise doc"}
|
||||
assert {e.quality_score for e in emitted} == {0.85}
|
||||
|
||||
matching = [e for e in captured if e.get("event") == "agent_case_extracted"]
|
||||
assert matching, "expected agent_case_extracted log line"
|
||||
assert matching[0]["owner_ids"] == ["agent_lead", "agent_specialist"]
|
||||
assert matching[0]["fanout"] == 2
|
||||
|
||||
|
||||
async def test_omits_key_insight_section_when_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
case = _algo_case(key_insight="")
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
|
||||
) as mock_wcls,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[case])
|
||||
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
|
||||
await extract_agent_case(_event(), FakeStrategyContext())
|
||||
|
||||
_, kwargs = mock_wcls.return_value.append_entry.call_args
|
||||
assert "KeyInsight" not in kwargs["sections"]
|
||||
assert kwargs["sections"]["TaskIntent"] == "summarise doc"
|
||||
|
||||
|
||||
async def test_skips_when_algo_returns_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Algo pre-filter rejected the cell — no md written, log a noop line."""
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
|
||||
) as mock_wcls,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[])
|
||||
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
|
||||
await extract_agent_case(_event(), FakeStrategyContext())
|
||||
|
||||
mock_wcls.return_value.append_entry.assert_not_called()
|
||||
matching = [e for e in captured if e.get("event") == "agent_case_skipped_by_algo"]
|
||||
assert matching, "expected agent_case_skipped_by_algo log line"
|
||||
|
||||
|
||||
async def test_skips_when_no_assistant_sender(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No assistant in the cell → no agent_id can be inferred; algo not called."""
|
||||
user_only = MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="hi",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_000_000,
|
||||
)
|
||||
event = AgentPipelineStarted(memcell_id="mc_b", session_id="s1", memcell=user_only)
|
||||
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
|
||||
) as mock_wcls,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[])
|
||||
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
|
||||
await extract_agent_case(event, FakeStrategyContext())
|
||||
|
||||
# Algo extractor must not be invoked at all when there's no agent.
|
||||
mock_cls.return_value.aextract.assert_not_called()
|
||||
mock_wcls.return_value.append_entry.assert_not_called()
|
||||
matching = [
|
||||
e for e in captured if e.get("event") == "agent_case_skipped_no_assistant"
|
||||
]
|
||||
assert matching, "expected agent_case_skipped_no_assistant log line"
|
||||
@ -0,0 +1,584 @@
|
||||
"""Tests for :func:`extract_agent_skill`.
|
||||
|
||||
Mocked seams: ``cluster_repo`` (sqlite), ``agent_case_repo`` /
|
||||
``agent_skill_repo`` (LanceDB), ``get_embedder`` (component),
|
||||
``AgentSkillExtractor`` (algo), ``AgentSkillWriter`` (md). Each
|
||||
retry-class exception (cluster missing / case-not-indexed) bubbles up so
|
||||
OME's ``max_retries`` machinery catches the race instead of the strategy
|
||||
implementing its own backoff loop.
|
||||
|
||||
LanceDB repo behaviour itself (predicate isolation, cosine ranking,
|
||||
``_distance`` stripping) lives under
|
||||
``tests/unit/test_infra/test_lancedb/test_repos/``; strategy tests only
|
||||
verify routing decisions and orchestration glue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from everalgo.clustering import Cluster as AlgoCluster
|
||||
from everalgo.types import AgentSkill as AlgoAgentSkill
|
||||
|
||||
from everos.component.embedding import (
|
||||
EmbeddingError,
|
||||
EmbeddingNotConfiguredError,
|
||||
)
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.memory.events import SkillClusterUpdated
|
||||
from everos.memory.strategies._partition_locks import _reset_for_tests
|
||||
from everos.memory.strategies.extract_agent_skill import (
|
||||
MAX_SKILLS_IN_PROMPT,
|
||||
MAX_SUPPORTING_CASES,
|
||||
_CaseNotYetIndexedError,
|
||||
_ClusterMissingError,
|
||||
_collect_supporting_entry_ids,
|
||||
_resolve_query_vector,
|
||||
_select_existing_skills,
|
||||
_select_supporting_cases,
|
||||
extract_agent_skill,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_partition_locks() -> None:
|
||||
_reset_for_tests()
|
||||
|
||||
|
||||
def _event(
|
||||
*,
|
||||
cluster_id: str = "cl_xxxxxxxxxxx1",
|
||||
case_entry_id: str = "ac_20260517_0001",
|
||||
agent_id: str = "agent_42",
|
||||
) -> SkillClusterUpdated:
|
||||
return SkillClusterUpdated(
|
||||
case_entry_id=case_entry_id,
|
||||
cluster_id=cluster_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
|
||||
def _algo_cluster(
|
||||
*,
|
||||
cluster_id: str = "cl_xxxxxxxxxxx1",
|
||||
members: list[str] | None = None,
|
||||
) -> AlgoCluster:
|
||||
return AlgoCluster(
|
||||
id=cluster_id,
|
||||
centroid=np.zeros(1024, dtype=np.float32),
|
||||
count=len(members or ["ac_20260517_0001"]),
|
||||
last_ts=1_700_000_000_000,
|
||||
preview=[],
|
||||
members=members or ["ac_20260517_0001"],
|
||||
)
|
||||
|
||||
|
||||
def _lance_case(
|
||||
entry_id: str,
|
||||
*,
|
||||
quality_score: float = 0.8,
|
||||
timestamp: _dt.datetime | None = None,
|
||||
vector: list[float] | None = None,
|
||||
task_intent: str | None = None,
|
||||
) -> MagicMock:
|
||||
"""Stand-in for a LanceDB AgentCase row (only fields the strategy reads)."""
|
||||
case = MagicMock()
|
||||
case.entry_id = entry_id
|
||||
case.timestamp = timestamp or _dt.datetime(2026, 5, 17, tzinfo=_dt.UTC)
|
||||
case.task_intent = (
|
||||
task_intent if task_intent is not None else f"intent of {entry_id}"
|
||||
)
|
||||
case.approach = f"approach of {entry_id}"
|
||||
case.quality_score = quality_score
|
||||
case.key_insight = ""
|
||||
case.vector = vector or []
|
||||
return case
|
||||
|
||||
|
||||
def _lance_skill(
|
||||
*,
|
||||
name: str = "old_skill",
|
||||
cluster_id: str = "cl_xxxxxxxxxxx1",
|
||||
source_case_ids: list[str] | None = None,
|
||||
) -> MagicMock:
|
||||
skill = MagicMock()
|
||||
skill.id = f"agent_42_{name}"
|
||||
skill.cluster_id = cluster_id
|
||||
skill.name = name
|
||||
skill.description = f"desc {name}"
|
||||
skill.content = f"content {name}"
|
||||
skill.confidence = 0.5
|
||||
skill.maturity_score = 0.5
|
||||
skill.source_case_ids = source_case_ids or []
|
||||
return skill
|
||||
|
||||
|
||||
def _algo_skill(name: str = "summarise_doc") -> AlgoAgentSkill:
|
||||
return AlgoAgentSkill(
|
||||
id="dummyuuid",
|
||||
cluster_id="", # caller will post-stamp
|
||||
name=name,
|
||||
description=f"how to {name}",
|
||||
content="full body of the skill",
|
||||
confidence=0.7,
|
||||
maturity_score=0.5,
|
||||
source_case_ids=["ac_20260517_0001"],
|
||||
)
|
||||
|
||||
|
||||
# ── strategy meta + retry-class errors ───────────────────────────────────
|
||||
|
||||
|
||||
async def test_strategy_meta_is_attached() -> None:
|
||||
meta = extract_agent_skill._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.name == "extract_agent_skill"
|
||||
assert SkillClusterUpdated in meta.trigger.on
|
||||
assert meta.emits == frozenset()
|
||||
assert meta.max_retries == 3
|
||||
|
||||
|
||||
async def test_raises_when_cluster_missing_for_retry() -> None:
|
||||
"""No cluster row yet — OME will retry the run."""
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.cluster_repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_with_members = AsyncMock(return_value=None)
|
||||
with pytest.raises(_ClusterMissingError):
|
||||
await extract_agent_skill(_event(), FakeStrategyContext())
|
||||
|
||||
|
||||
async def test_raises_when_target_case_not_yet_in_lancedb() -> None:
|
||||
"""LanceDB has not yet indexed the freshly-written case — let OME retry."""
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.cluster_repo"
|
||||
) as mock_cluster_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
|
||||
) as mock_case_repo,
|
||||
):
|
||||
mock_cluster_repo.get_with_members = AsyncMock(return_value=_algo_cluster())
|
||||
mock_case_repo.find_by_owner_entry = AsyncMock(return_value=None)
|
||||
with pytest.raises(_CaseNotYetIndexedError):
|
||||
await extract_agent_skill(_event(), FakeStrategyContext())
|
||||
|
||||
|
||||
# ── end-to-end orchestration (mocked) ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extracts_and_persists_with_cluster_id_stamped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end (mocked): extractor emits skills → writer stamps cluster_id."""
|
||||
target = _lance_case("ac_20260517_0001", vector=[0.1] * 1024)
|
||||
supporting = [_lance_case("ac_20260517_0000")]
|
||||
existing = [_lance_skill(name="old_skill", source_case_ids=["ac_20260517_0000"])]
|
||||
emitted = [_algo_skill(name="summarise_doc"), _algo_skill(name="batch_then_synth")]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.cluster_repo"
|
||||
) as mock_cluster_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
|
||||
) as mock_case_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
|
||||
) as mock_skill_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.AgentSkillExtractor"
|
||||
) as mock_extractor_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.AgentSkillWriter"
|
||||
) as mock_writer_cls,
|
||||
):
|
||||
mock_cluster_repo.get_with_members = AsyncMock(
|
||||
return_value=_algo_cluster(members=["ac_20260517_0000", "ac_20260517_0001"])
|
||||
)
|
||||
mock_case_repo.find_by_owner_entry = AsyncMock(return_value=target)
|
||||
mock_case_repo.find_by_owner_entries = AsyncMock(return_value=supporting)
|
||||
# Small cluster path: count ≤ K → scalar fetch returns existing.
|
||||
mock_skill_repo.count_in_cluster = AsyncMock(return_value=len(existing))
|
||||
mock_skill_repo.find_in_cluster = AsyncMock(return_value=existing)
|
||||
mock_extractor_cls.return_value.aextract = AsyncMock(return_value=emitted)
|
||||
mock_writer_cls.return_value.write_main = AsyncMock(return_value=None)
|
||||
mod = importlib.import_module("everos.memory.strategies.extract_agent_skill")
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
|
||||
await extract_agent_skill(_event(), FakeStrategyContext())
|
||||
|
||||
extractor_call = mock_extractor_cls.return_value.aextract.call_args
|
||||
target_arg = extractor_call.args[0]
|
||||
assert target_arg.id == "ac_20260517_0001"
|
||||
assert target_arg.task_intent == "intent of ac_20260517_0001"
|
||||
assert [s.name for s in extractor_call.kwargs["existing_relevant_skills"]] == [
|
||||
"old_skill"
|
||||
]
|
||||
assert [c.id for c in extractor_call.kwargs["supporting_cases"]] == [
|
||||
"ac_20260517_0000"
|
||||
]
|
||||
|
||||
write_calls = mock_writer_cls.return_value.write_main.call_args_list
|
||||
assert len(write_calls) == 2
|
||||
for call, expected in zip(write_calls, emitted, strict=True):
|
||||
agent_id_arg, skill_name_arg = call.args
|
||||
fm = call.kwargs["frontmatter"]
|
||||
assert agent_id_arg == "agent_42"
|
||||
assert skill_name_arg == expected.name
|
||||
assert fm.cluster_id == "cl_xxxxxxxxxxx1"
|
||||
assert fm.name == expected.name
|
||||
assert fm.confidence == expected.confidence
|
||||
assert call.kwargs["body"] == expected.content
|
||||
|
||||
|
||||
# ── _select_existing_skills routing (cluster size × vector availability) ─
|
||||
|
||||
|
||||
async def test_select_existing_skills_small_cluster_uses_scalar_fetch() -> None:
|
||||
"""``total ≤ K`` short-circuits — no ranking needed for fully-inclusive set."""
|
||||
target = _lance_case("ac_001", vector=[0.5] * 1024)
|
||||
skills = [_lance_skill(name=f"s{i}") for i in range(3)]
|
||||
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
|
||||
) as mock_repo:
|
||||
mock_repo.count_in_cluster = AsyncMock(return_value=3)
|
||||
mock_repo.find_in_cluster = AsyncMock(return_value=skills)
|
||||
mock_repo.find_topk_relevant_in_cluster = AsyncMock()
|
||||
|
||||
got = await _select_existing_skills(
|
||||
agent_id="a", cluster_id="cl_x", target=target
|
||||
)
|
||||
|
||||
assert got == skills
|
||||
mock_repo.find_topk_relevant_in_cluster.assert_not_awaited()
|
||||
mock_repo.find_in_cluster.assert_awaited_once_with(
|
||||
owner_id="a", cluster_id="cl_x", limit=MAX_SKILLS_IN_PROMPT
|
||||
)
|
||||
|
||||
|
||||
async def test_select_existing_skills_large_cluster_with_vector_uses_topk() -> None:
|
||||
"""``total > K`` and target carries vector → cosine top-K path."""
|
||||
target = _lance_case("ac_001", vector=[0.5] * 1024)
|
||||
topk_skills = [_lance_skill(name=f"s{i}") for i in range(MAX_SKILLS_IN_PROMPT)]
|
||||
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
|
||||
) as mock_repo:
|
||||
mock_repo.count_in_cluster = AsyncMock(return_value=MAX_SKILLS_IN_PROMPT + 5)
|
||||
mock_repo.find_topk_relevant_in_cluster = AsyncMock(return_value=topk_skills)
|
||||
mock_repo.find_in_cluster = AsyncMock()
|
||||
|
||||
got = await _select_existing_skills(
|
||||
agent_id="a", cluster_id="cl_x", target=target
|
||||
)
|
||||
|
||||
assert got == topk_skills
|
||||
mock_repo.find_in_cluster.assert_not_awaited()
|
||||
call_kwargs = mock_repo.find_topk_relevant_in_cluster.await_args.kwargs
|
||||
assert call_kwargs["query_vector"] == [0.5] * 1024
|
||||
assert call_kwargs["top_k"] == MAX_SKILLS_IN_PROMPT
|
||||
|
||||
|
||||
async def test_select_existing_skills_large_cluster_recomputes_embedding() -> None:
|
||||
"""``total > K`` but case has no vector → re-embed ``task_intent`` on the fly."""
|
||||
target = _lance_case("ac_001", vector=[], task_intent="how to summarise docs")
|
||||
topk_skills = [_lance_skill(name=f"s{i}") for i in range(MAX_SKILLS_IN_PROMPT)]
|
||||
fresh_vec = [0.42] * 1024
|
||||
|
||||
mock_embedder = MagicMock()
|
||||
mock_embedder.embed = AsyncMock(return_value=fresh_vec)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.get_embedder",
|
||||
return_value=mock_embedder,
|
||||
),
|
||||
):
|
||||
mock_repo.count_in_cluster = AsyncMock(return_value=MAX_SKILLS_IN_PROMPT + 5)
|
||||
mock_repo.find_topk_relevant_in_cluster = AsyncMock(return_value=topk_skills)
|
||||
mock_repo.find_in_cluster = AsyncMock()
|
||||
|
||||
got = await _select_existing_skills(
|
||||
agent_id="a", cluster_id="cl_x", target=target
|
||||
)
|
||||
|
||||
assert got == topk_skills
|
||||
mock_embedder.embed.assert_awaited_once_with("how to summarise docs")
|
||||
call_kwargs = mock_repo.find_topk_relevant_in_cluster.await_args.kwargs
|
||||
assert call_kwargs["query_vector"] == fresh_vec
|
||||
|
||||
|
||||
async def test_select_existing_skills_falls_back_to_scalar_when_embed_fails() -> None:
|
||||
"""``total > K`` + no vector + embedder fails → scalar fetch capped at K."""
|
||||
target = _lance_case("ac_001", vector=[], task_intent="how to summarise docs")
|
||||
scalar_skills = [_lance_skill(name=f"s{i}") for i in range(MAX_SKILLS_IN_PROMPT)]
|
||||
|
||||
mock_embedder = MagicMock()
|
||||
mock_embedder.embed = AsyncMock(side_effect=EmbeddingError("provider down"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.get_embedder",
|
||||
return_value=mock_embedder,
|
||||
),
|
||||
):
|
||||
mock_repo.count_in_cluster = AsyncMock(return_value=MAX_SKILLS_IN_PROMPT + 5)
|
||||
mock_repo.find_in_cluster = AsyncMock(return_value=scalar_skills)
|
||||
mock_repo.find_topk_relevant_in_cluster = AsyncMock()
|
||||
|
||||
got = await _select_existing_skills(
|
||||
agent_id="a", cluster_id="cl_x", target=target
|
||||
)
|
||||
|
||||
assert got == scalar_skills
|
||||
mock_repo.find_topk_relevant_in_cluster.assert_not_awaited()
|
||||
mock_repo.find_in_cluster.assert_awaited_once_with(
|
||||
owner_id="a", cluster_id="cl_x", limit=MAX_SKILLS_IN_PROMPT
|
||||
)
|
||||
|
||||
|
||||
# ── _resolve_query_vector layered fallback ───────────────────────────────
|
||||
|
||||
|
||||
async def test_resolve_query_vector_prefers_persisted_vector() -> None:
|
||||
"""When ``target.vector`` is set, reuse it; never call the embedder."""
|
||||
target = _lance_case("ac_001", vector=[0.3] * 1024)
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.get_embedder"
|
||||
) as mock_get_embedder:
|
||||
got = await _resolve_query_vector(target)
|
||||
assert got == [0.3] * 1024
|
||||
mock_get_embedder.assert_not_called()
|
||||
|
||||
|
||||
async def test_resolve_query_vector_returns_empty_when_no_text_either() -> None:
|
||||
"""No persisted vector + no task_intent → ``[]`` (no policy here)."""
|
||||
target = _lance_case("ac_001", vector=[], task_intent="")
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.get_embedder"
|
||||
) as mock_get_embedder:
|
||||
got = await _resolve_query_vector(target)
|
||||
assert got == []
|
||||
mock_get_embedder.assert_not_called()
|
||||
|
||||
|
||||
async def test_resolve_query_vector_swallows_embedder_not_configured() -> None:
|
||||
"""Missing embedder config is a deployment issue, not a strategy fault."""
|
||||
target = _lance_case("ac_001", vector=[], task_intent="hello")
|
||||
mock_embedder = MagicMock()
|
||||
mock_embedder.embed = AsyncMock(
|
||||
side_effect=EmbeddingNotConfiguredError("no api key")
|
||||
)
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.get_embedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
got = await _resolve_query_vector(target)
|
||||
assert got == []
|
||||
|
||||
|
||||
# ── _select_supporting_cases ranking + cap ───────────────────────────────
|
||||
|
||||
|
||||
async def test_select_supporting_cases_ranks_by_quality_then_timestamp() -> None:
|
||||
"""Hydrated cases sort ``(quality_score desc, timestamp desc)``."""
|
||||
skills = [
|
||||
_lance_skill(name="s1", source_case_ids=["ac_a", "ac_b", "ac_c"]),
|
||||
]
|
||||
case_a = _lance_case(
|
||||
"ac_a",
|
||||
quality_score=0.4,
|
||||
timestamp=_dt.datetime(2026, 5, 1, tzinfo=_dt.UTC),
|
||||
)
|
||||
case_b = _lance_case(
|
||||
"ac_b",
|
||||
quality_score=0.9,
|
||||
timestamp=_dt.datetime(2026, 5, 1, tzinfo=_dt.UTC),
|
||||
)
|
||||
case_c = _lance_case(
|
||||
"ac_c",
|
||||
quality_score=0.9,
|
||||
timestamp=_dt.datetime(2026, 5, 10, tzinfo=_dt.UTC),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
|
||||
) as mock_case_repo:
|
||||
# Order intentionally scrambled to prove the strategy sorts.
|
||||
mock_case_repo.find_by_owner_entries = AsyncMock(
|
||||
return_value=[case_a, case_b, case_c]
|
||||
)
|
||||
|
||||
got = await _select_supporting_cases(
|
||||
skills,
|
||||
agent_id="a",
|
||||
exclude_entry_id="ac_target",
|
||||
app_id="default",
|
||||
project_id="default",
|
||||
)
|
||||
|
||||
assert [c.entry_id for c in got] == ["ac_c", "ac_b", "ac_a"]
|
||||
|
||||
|
||||
async def test_select_supporting_cases_caps_at_max_supporting() -> None:
|
||||
"""Hydrated set is truncated to ``MAX_SUPPORTING_CASES``."""
|
||||
ids = [f"ac_{i:03d}" for i in range(MAX_SUPPORTING_CASES + 3)]
|
||||
skills = [_lance_skill(name="s1", source_case_ids=ids)]
|
||||
hydrated = [
|
||||
_lance_case(eid, quality_score=0.5 + 0.01 * i) for i, eid in enumerate(ids)
|
||||
]
|
||||
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
|
||||
) as mock_case_repo:
|
||||
mock_case_repo.find_by_owner_entries = AsyncMock(return_value=hydrated)
|
||||
got = await _select_supporting_cases(
|
||||
skills,
|
||||
agent_id="a",
|
||||
exclude_entry_id="ac_target",
|
||||
app_id="default",
|
||||
project_id="default",
|
||||
)
|
||||
|
||||
assert len(got) == MAX_SUPPORTING_CASES
|
||||
|
||||
|
||||
async def test_select_supporting_cases_skips_repo_when_no_lineage_ids() -> None:
|
||||
"""No usable source ids → ``[]`` without a repo round trip."""
|
||||
skills = [_lance_skill(name="s1", source_case_ids=[])]
|
||||
with patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
|
||||
) as mock_case_repo:
|
||||
mock_case_repo.find_by_owner_entries = AsyncMock()
|
||||
got = await _select_supporting_cases(
|
||||
skills,
|
||||
agent_id="a",
|
||||
exclude_entry_id="ac_target",
|
||||
app_id="default",
|
||||
project_id="default",
|
||||
)
|
||||
assert got == []
|
||||
mock_case_repo.find_by_owner_entries.assert_not_awaited()
|
||||
|
||||
|
||||
# ── _collect_supporting_entry_ids dedup + exclude ────────────────────────
|
||||
|
||||
|
||||
def test_collect_supporting_entry_ids_dedups_and_excludes_target() -> None:
|
||||
"""Source ids fold across skills; duplicates and the target id drop out."""
|
||||
skill_a = MagicMock()
|
||||
skill_a.source_case_ids = ["ac_a", "ac_b", "ac_target"]
|
||||
skill_b = MagicMock()
|
||||
skill_b.source_case_ids = ["ac_b", "ac_c"] # ac_b duplicates skill_a's lineage
|
||||
skill_empty = MagicMock()
|
||||
skill_empty.source_case_ids = []
|
||||
|
||||
got = _collect_supporting_entry_ids(
|
||||
[skill_a, skill_b, skill_empty], exclude="ac_target"
|
||||
)
|
||||
assert got == ["ac_a", "ac_b", "ac_c"]
|
||||
|
||||
|
||||
def test_collect_supporting_entry_ids_handles_empty_input() -> None:
|
||||
"""No skills → no supporting cases."""
|
||||
assert _collect_supporting_entry_ids([], exclude="ac_anything") == []
|
||||
|
||||
|
||||
# ── partition lock (agent_id-level serialisation) ────────────────────────
|
||||
|
||||
|
||||
async def _run_serialisation_probe(
|
||||
agent_id_run_a: str, agent_id_run_b: str
|
||||
) -> list[str]:
|
||||
"""Drive two extract_agent_skill runs and record their critical-section order.
|
||||
|
||||
Mocks every I/O seam so the only async work inside the locked region
|
||||
is a tiny ``asyncio.sleep`` masquerading as the LLM call. The returned
|
||||
log is the strict enter/leave sequence both runs go through.
|
||||
"""
|
||||
log: list[str] = []
|
||||
|
||||
async def mock_aextract(case, **_kwargs):
|
||||
log.append(f"enter:{case.id}")
|
||||
await asyncio.sleep(0.01)
|
||||
log.append(f"leave:{case.id}")
|
||||
return []
|
||||
|
||||
target_a = _lance_case("ac_run_a", vector=[0.1] * 1024)
|
||||
target_b = _lance_case("ac_run_b", vector=[0.1] * 1024)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.cluster_repo"
|
||||
) as mock_cluster_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
|
||||
) as mock_case_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
|
||||
) as mock_skill_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_skill.AgentSkillExtractor"
|
||||
) as mock_extractor_cls,
|
||||
patch("everos.memory.strategies.extract_agent_skill.AgentSkillWriter"),
|
||||
):
|
||||
mock_cluster_repo.get_with_members = AsyncMock(
|
||||
return_value=_algo_cluster(members=["ac_run_a", "ac_run_b"])
|
||||
)
|
||||
mock_case_repo.find_by_owner_entry = AsyncMock(
|
||||
side_effect=lambda owner, entry, **_kw: (
|
||||
target_a if entry == "ac_run_a" else target_b
|
||||
)
|
||||
)
|
||||
mock_case_repo.find_by_owner_entries = AsyncMock(return_value=[])
|
||||
mock_skill_repo.count_in_cluster = AsyncMock(return_value=0)
|
||||
mock_skill_repo.find_in_cluster = AsyncMock(return_value=[])
|
||||
mock_extractor_cls.return_value.aextract = mock_aextract
|
||||
await asyncio.gather(
|
||||
extract_agent_skill(
|
||||
_event(agent_id=agent_id_run_a, case_entry_id="ac_run_a"),
|
||||
FakeStrategyContext(),
|
||||
),
|
||||
extract_agent_skill(
|
||||
_event(agent_id=agent_id_run_b, case_entry_id="ac_run_b"),
|
||||
FakeStrategyContext(),
|
||||
),
|
||||
)
|
||||
return log
|
||||
|
||||
|
||||
async def test_partition_lock_serialises_runs_on_same_agent() -> None:
|
||||
"""Two runs sharing ``agent_id`` must not overlap critical sections."""
|
||||
log = await _run_serialisation_probe("agent_42", "agent_42")
|
||||
assert log in (
|
||||
["enter:ac_run_a", "leave:ac_run_a", "enter:ac_run_b", "leave:ac_run_b"],
|
||||
["enter:ac_run_b", "leave:ac_run_b", "enter:ac_run_a", "leave:ac_run_a"],
|
||||
)
|
||||
|
||||
|
||||
async def test_partition_lock_lets_different_agents_run_in_parallel() -> None:
|
||||
"""Runs on distinct ``agent_id`` must overlap (no false serialisation)."""
|
||||
log = await _run_serialisation_probe("agent_42", "agent_43")
|
||||
assert log.index("enter:ac_run_a") < log.index("leave:ac_run_b")
|
||||
assert log.index("enter:ac_run_b") < log.index("leave:ac_run_a")
|
||||
@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import structlog.testing
|
||||
from everalgo.types import AtomicFact, ChatMessage, MemCell
|
||||
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.memory.events import UserPipelineStarted
|
||||
from everos.memory.strategies.extract_atomic_facts import extract_atomic_facts
|
||||
|
||||
mod = importlib.import_module("everos.memory.strategies.extract_atomic_facts")
|
||||
|
||||
|
||||
def _two_user_memcell() -> MemCell:
|
||||
return MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="hi from alice",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m2",
|
||||
role="user",
|
||||
content="hi from bob",
|
||||
timestamp=1_700_000_001_000,
|
||||
sender_id="u_bob",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m3",
|
||||
role="assistant",
|
||||
content="hello both",
|
||||
timestamp=1_700_000_002_000,
|
||||
sender_id="agent",
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_002_000,
|
||||
)
|
||||
|
||||
|
||||
def _fact(owner_id: str | None, text: str) -> AtomicFact:
|
||||
return AtomicFact(owner_id=owner_id, content=text, timestamp=1_700_000_000_000)
|
||||
|
||||
|
||||
def _event() -> UserPipelineStarted:
|
||||
return UserPipelineStarted(
|
||||
memcell_id="mc_a", session_id="s1", memcell=_two_user_memcell()
|
||||
)
|
||||
|
||||
|
||||
async def test_strategy_meta_is_attached() -> None:
|
||||
meta = extract_atomic_facts._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.name == "extract_atomic_facts"
|
||||
assert UserPipelineStarted in meta.trigger.on
|
||||
assert meta.emits == frozenset()
|
||||
assert meta.max_retries == 2
|
||||
|
||||
|
||||
async def test_extracts_once_and_fans_out_per_sender(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""One LLM call per memcell; same fact list re-written under each sender.
|
||||
|
||||
The algo prompt is subject-agnostic (only ``INPUT_TEXT`` + ``TIME``
|
||||
placeholders), so re-running it per sender would burn LLM tokens
|
||||
and let non-determinism drift the per-sender md files apart. The
|
||||
strategy calls ``aextract`` once with ``sender_id=None`` and
|
||||
broadcasts the resulting list — every user sender gets its own md
|
||||
entries pointing at the same fact bodies.
|
||||
|
||||
Per-owner batching: the strategy collects each sender's full fact
|
||||
list and issues one :meth:`append_entries` per owner (not N single
|
||||
appends), so the call shape is one batch call per sender.
|
||||
"""
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
generic_facts = [
|
||||
_fact(None, "alice mentioned a weekend trip to tokyo"),
|
||||
_fact(None, "bob said he needs hiking gear"),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.AtomicFactWriter"
|
||||
) as mock_wcls,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=generic_facts)
|
||||
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
|
||||
|
||||
await extract_atomic_facts(_event(), FakeStrategyContext())
|
||||
|
||||
# Exactly one LLM call, parameterised with sender_id=None.
|
||||
assert mock_cls.return_value.aextract.await_count == 1
|
||||
call = mock_cls.return_value.aextract.call_args
|
||||
assert call.kwargs["sender_id"] is None
|
||||
|
||||
# 2 senders → 2 batch calls; each batch carries this sender's 2 facts
|
||||
# (same generic body re-used).
|
||||
assert mock_wcls.return_value.append_entries.call_count == 2
|
||||
batch_calls = mock_wcls.return_value.append_entries.call_args_list
|
||||
batched_owners = sorted(c.args[0] for c in batch_calls)
|
||||
assert batched_owners == ["u_alice", "u_bob"]
|
||||
# Flatten items across batches: (owner, fact_text) pairs.
|
||||
flat = sorted(
|
||||
(c.args[0], sections["Fact"])
|
||||
for c in batch_calls
|
||||
for inline, sections in c.args[1]
|
||||
)
|
||||
assert flat == [
|
||||
("u_alice", "alice mentioned a weekend trip to tokyo"),
|
||||
("u_alice", "bob said he needs hiking gear"),
|
||||
("u_bob", "alice mentioned a weekend trip to tokyo"),
|
||||
("u_bob", "bob said he needs hiking gear"),
|
||||
]
|
||||
|
||||
matching = [e for e in captured if e.get("event") == "atomic_facts_extracted"]
|
||||
assert matching, "expected atomic_facts_extracted log line"
|
||||
record = matching[0]
|
||||
assert record["count"] == 4
|
||||
assert sorted(record["owner_ids"]) == ["u_alice", "u_bob"]
|
||||
|
||||
|
||||
async def test_writes_md_for_each_fact(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
facts = [
|
||||
_fact("u_alice", "alice likes hiking"),
|
||||
_fact("u_alice", "alice lives in tokyo"),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.AtomicFactWriter"
|
||||
) as mock_wcls,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=facts)
|
||||
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
|
||||
|
||||
event = UserPipelineStarted(
|
||||
memcell_id="mc_a",
|
||||
session_id="s1",
|
||||
memcell=MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="hi",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
)
|
||||
],
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
)
|
||||
await extract_atomic_facts(event, FakeStrategyContext())
|
||||
|
||||
# Single sender (u_alice) → one batch call with 2 items.
|
||||
assert mock_wcls.return_value.append_entries.call_count == 1
|
||||
batch_call = mock_wcls.return_value.append_entries.call_args
|
||||
assert batch_call.args[0] == "u_alice"
|
||||
items = batch_call.args[1]
|
||||
assert len(items) == 2
|
||||
for (inline, sections), fact in zip(items, facts, strict=True):
|
||||
assert inline["owner_id"] == "u_alice"
|
||||
assert inline["session_id"] == "s1"
|
||||
assert inline["parent_type"] == "memcell"
|
||||
assert inline["parent_id"] == "mc_a"
|
||||
assert "sender_ids" not in inline
|
||||
assert sections == {"Fact": fact.content}
|
||||
|
||||
|
||||
async def test_skips_when_memcell_has_no_messages(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
event = UserPipelineStarted(
|
||||
memcell_id="mc_b",
|
||||
session_id="s1",
|
||||
memcell=MemCell(items=[], timestamp=1_700_000_000_000),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.AtomicFactWriter"
|
||||
) as mock_wcls,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[])
|
||||
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
|
||||
ctx = FakeStrategyContext()
|
||||
await extract_atomic_facts(event, ctx)
|
||||
|
||||
matching = [e for e in captured if e.get("event") == "atomic_facts_extracted"]
|
||||
assert matching, "log line should still fire (count=0)"
|
||||
assert matching[0]["count"] == 0
|
||||
mock_wcls.return_value.append_entries.assert_not_called()
|
||||
231
tests/unit/test_memory/test_strategies/test_extract_foresight.py
Normal file
231
tests/unit/test_memory/test_strategies/test_extract_foresight.py
Normal file
@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import structlog.testing
|
||||
from everalgo.types import ChatMessage, Foresight, MemCell
|
||||
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.memory.events import UserPipelineStarted
|
||||
from everos.memory.strategies.extract_foresight import extract_foresight
|
||||
|
||||
mod = importlib.import_module("everos.memory.strategies.extract_foresight")
|
||||
|
||||
|
||||
def _two_user_memcell() -> MemCell:
|
||||
return MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="alice plans a trip",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m2",
|
||||
role="user",
|
||||
content="bob will buy tickets",
|
||||
timestamp=1_700_000_001_000,
|
||||
sender_id="u_bob",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m3",
|
||||
role="assistant",
|
||||
content="sounds good",
|
||||
timestamp=1_700_000_002_000,
|
||||
sender_id="agent",
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_002_000,
|
||||
)
|
||||
|
||||
|
||||
def _foresight(owner_id: str, text: str) -> Foresight:
|
||||
return Foresight(
|
||||
owner_id=owner_id,
|
||||
foresight=text,
|
||||
evidence="...",
|
||||
timestamp=1_700_000_000_000,
|
||||
)
|
||||
|
||||
|
||||
def _event() -> UserPipelineStarted:
|
||||
return UserPipelineStarted(
|
||||
memcell_id="mc_a", session_id="s1", memcell=_two_user_memcell()
|
||||
)
|
||||
|
||||
|
||||
async def test_strategy_meta_is_attached() -> None:
|
||||
meta = extract_foresight._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.name == "extract_foresight"
|
||||
assert UserPipelineStarted in meta.trigger.on
|
||||
assert meta.emits == frozenset()
|
||||
assert meta.max_retries == 2
|
||||
|
||||
|
||||
async def test_extracts_per_sender(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Per-sender extraction (like Episode, unlike AtomicFact's fan-out)."""
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightWriter"
|
||||
) as mock_wcls,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
# sender_ids in the strategy are sorted: alice first, bob second.
|
||||
mock_cls.return_value.aextract = AsyncMock(
|
||||
side_effect=[
|
||||
[_foresight("u_alice", "trip to tokyo")],
|
||||
[_foresight("u_bob", "buy plane tickets")],
|
||||
]
|
||||
)
|
||||
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
|
||||
|
||||
await extract_foresight(_event(), FakeStrategyContext())
|
||||
|
||||
# Per-sender semantics: one LLM call per user sender.
|
||||
assert mock_cls.return_value.aextract.await_count == 2
|
||||
sender_id_calls = [
|
||||
call.kwargs.get("sender_id")
|
||||
for call in mock_cls.return_value.aextract.call_args_list
|
||||
]
|
||||
assert sender_id_calls == ["u_alice", "u_bob"]
|
||||
|
||||
# Per-owner batching: one batch call per owner; here each owner has 1
|
||||
# foresight, so two batches each carrying 1 item.
|
||||
assert mock_wcls.return_value.append_entries.call_count == 2
|
||||
batched_owners = sorted(
|
||||
c.args[0] for c in mock_wcls.return_value.append_entries.call_args_list
|
||||
)
|
||||
assert batched_owners == ["u_alice", "u_bob"]
|
||||
|
||||
matching = [e for e in captured if e.get("event") == "foresights_extracted"]
|
||||
assert matching, "expected foresights_extracted log line"
|
||||
record = matching[0]
|
||||
assert record["count"] == 2
|
||||
assert sorted(record["owner_ids"]) == ["u_alice", "u_bob"]
|
||||
|
||||
|
||||
async def test_writes_md_for_each_foresight(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
foresights = [
|
||||
Foresight(
|
||||
owner_id="u_alice",
|
||||
foresight="trip to tokyo",
|
||||
evidence="said so",
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
Foresight(
|
||||
owner_id="u_alice",
|
||||
foresight="buy tickets",
|
||||
evidence="confirmed",
|
||||
timestamp=1_700_000_000_000,
|
||||
start_time="2023-11-15",
|
||||
duration_days=7,
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightWriter"
|
||||
) as mock_wcls,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=foresights)
|
||||
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
|
||||
|
||||
event = UserPipelineStarted(
|
||||
memcell_id="mc_a",
|
||||
session_id="s1",
|
||||
memcell=MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="planning a trip",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
)
|
||||
],
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
)
|
||||
await extract_foresight(event, FakeStrategyContext())
|
||||
|
||||
# Single sender (u_alice) → one batch call with both foresights.
|
||||
assert mock_wcls.return_value.append_entries.call_count == 1
|
||||
batch_call = mock_wcls.return_value.append_entries.call_args
|
||||
assert batch_call.args[0] == "u_alice"
|
||||
items = batch_call.args[1]
|
||||
assert len(items) == 2
|
||||
|
||||
# First foresight: no optional time fields
|
||||
inline0, sections0 = items[0]
|
||||
assert inline0["owner_id"] == "u_alice"
|
||||
assert inline0["session_id"] == "s1"
|
||||
assert inline0["parent_type"] == "memcell"
|
||||
assert inline0["parent_id"] == "mc_a"
|
||||
assert "sender_ids" not in inline0
|
||||
assert "start_time" not in inline0
|
||||
assert "duration_days" not in inline0
|
||||
assert sections0 == {"Foresight": "trip to tokyo", "Evidence": "said so"}
|
||||
|
||||
# Second foresight: has start_time + duration_days
|
||||
inline1, sections1 = items[1]
|
||||
assert inline1["start_time"] == "2023-11-15"
|
||||
assert inline1["duration_days"] == 7
|
||||
assert sections1 == {"Foresight": "buy tickets", "Evidence": "confirmed"}
|
||||
|
||||
|
||||
async def test_skips_when_memcell_has_no_messages(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
event = UserPipelineStarted(
|
||||
memcell_id="mc_b",
|
||||
session_id="s1",
|
||||
memcell=MemCell(items=[], timestamp=1_700_000_000_000),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightExtractor"
|
||||
) as mock_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightWriter"
|
||||
) as mock_wcls,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
mock_cls.return_value.aextract = AsyncMock(return_value=[])
|
||||
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
|
||||
ctx = FakeStrategyContext()
|
||||
await extract_foresight(event, ctx)
|
||||
|
||||
matching = [e for e in captured if e.get("event") == "foresights_extracted"]
|
||||
assert matching, "log line should still fire (count=0)"
|
||||
assert matching[0]["count"] == 0
|
||||
mock_wcls.return_value.append_entries.assert_not_called()
|
||||
@ -0,0 +1,387 @@
|
||||
"""Tests for :func:`extract_user_profile`.
|
||||
|
||||
Heavy mocking — the strategy threads through ``cluster_repo`` (sqlite),
|
||||
``memcell_repo`` (sqlite, payload deserialise), ``ProfileReader`` /
|
||||
``ProfileWriter`` (md), and ``ProfileExtractor`` (algo). We mock all
|
||||
seams so the test exercises the orchestration only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from everalgo.clustering import Cluster as AlgoCluster
|
||||
from everalgo.types import ChatMessage, MemCell
|
||||
from everalgo.types import Profile as AlgoProfile
|
||||
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.infra.persistence.markdown import UserProfileFrontmatter
|
||||
from everos.memory.events import ProfileClusterUpdated
|
||||
from everos.memory.strategies._partition_locks import _reset_for_tests
|
||||
from everos.memory.strategies.extract_user_profile import extract_user_profile
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_partition_locks() -> None:
|
||||
_reset_for_tests()
|
||||
|
||||
|
||||
def _event(
|
||||
*,
|
||||
owner_id: str = "u_alice",
|
||||
memcell_id: str = "mc_aaaaaaaaaaa1",
|
||||
cluster_id: str = "cl_user00000001",
|
||||
) -> ProfileClusterUpdated:
|
||||
return ProfileClusterUpdated(
|
||||
memcell_id=memcell_id,
|
||||
cluster_id=cluster_id,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
|
||||
def _algo_cluster(*, cluster_id: str, members: list[str], last_ts: int) -> AlgoCluster:
|
||||
return AlgoCluster(
|
||||
id=cluster_id,
|
||||
centroid=np.zeros(1024, dtype=np.float32),
|
||||
count=len(members),
|
||||
last_ts=last_ts,
|
||||
preview=[],
|
||||
members=members,
|
||||
)
|
||||
|
||||
|
||||
def _memcell_row(memcell_id: str, *, sender_id: str, ts_ms: int) -> MagicMock:
|
||||
"""Stand-in for a sqlite Memcell row — only ``payload_json`` is read."""
|
||||
cell = MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id=f"{memcell_id}_m1",
|
||||
role="user",
|
||||
content=f"hi from {sender_id}",
|
||||
timestamp=ts_ms,
|
||||
sender_id=sender_id,
|
||||
),
|
||||
],
|
||||
timestamp=ts_ms,
|
||||
)
|
||||
row = MagicMock()
|
||||
row.memcell_id = memcell_id
|
||||
row.payload_json = cell.model_dump_json()
|
||||
return row
|
||||
|
||||
|
||||
async def test_strategy_meta_is_attached() -> None:
|
||||
meta = extract_user_profile._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.name == "extract_user_profile"
|
||||
assert ProfileClusterUpdated in meta.trigger.on
|
||||
assert meta.emits == frozenset()
|
||||
assert meta.max_retries == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_mode_writes_profile_when_no_existing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No prior profile → ProfileExtractor invoked without ``old_profile``."""
|
||||
cluster = _algo_cluster(
|
||||
cluster_id="cl_user00000001",
|
||||
members=["mc_aaaaaaaaaaa1"],
|
||||
last_ts=1_700_000_001_000,
|
||||
)
|
||||
rows = [
|
||||
_memcell_row("mc_aaaaaaaaaaa1", sender_id="u_alice", ts_ms=1_700_000_001_000)
|
||||
]
|
||||
new_profile = AlgoProfile.model_validate(
|
||||
{
|
||||
"owner_id": "u_alice",
|
||||
"summary": "Alice is a hiker.",
|
||||
"timestamp": 1_700_000_001_000,
|
||||
"explicit_info": ["lives in tokyo"],
|
||||
"implicit_traits": ["adventurous"],
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.cluster_repo"
|
||||
) as mock_cluster_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.memcell_repo"
|
||||
) as mock_memcell_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileExtractor"
|
||||
) as mock_extractor_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileReader"
|
||||
) as mock_reader_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileWriter"
|
||||
) as mock_writer_cls,
|
||||
):
|
||||
mock_cluster_repo.list_for_owner = AsyncMock(return_value=[cluster])
|
||||
mock_memcell_repo.find_by_ids = AsyncMock(return_value=rows)
|
||||
mock_reader_cls.return_value.read = AsyncMock(return_value=None)
|
||||
mock_writer_cls.return_value.write = AsyncMock(return_value=None)
|
||||
mock_extractor_cls.return_value.aextract = AsyncMock(return_value=new_profile)
|
||||
mod = importlib.import_module("everos.memory.strategies.extract_user_profile")
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
monkeypatch.setattr(mod, "_reader", None, raising=False)
|
||||
|
||||
await extract_user_profile(_event(), FakeStrategyContext())
|
||||
|
||||
# INIT mode — old_profile is None.
|
||||
extractor_call = mock_extractor_cls.return_value.aextract.call_args
|
||||
assert extractor_call.kwargs["old_profile"] is None
|
||||
assert extractor_call.kwargs["sender_id"] == "u_alice"
|
||||
assert [mc.timestamp for mc in extractor_call.args[0]] == [1_700_000_001_000]
|
||||
|
||||
# Writer received the freshly built frontmatter.
|
||||
write_call = mock_writer_cls.return_value.write.call_args
|
||||
assert write_call.args[0] == "u_alice"
|
||||
fm = write_call.kwargs["frontmatter"]
|
||||
assert fm.user_id == "u_alice"
|
||||
assert fm.summary == "Alice is a hiker."
|
||||
assert fm.profile_timestamp_ms == 1_700_000_001_000
|
||||
assert fm.explicit_info == ["lives in tokyo"]
|
||||
assert fm.implicit_traits == ["adventurous"]
|
||||
assert write_call.kwargs["body"] == "Alice is a hiker."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_mode_rehydrates_old_profile(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Existing profile → algo Profile rehydrated and passed as old_profile."""
|
||||
cluster = _algo_cluster(
|
||||
cluster_id="cl_user00000001",
|
||||
members=["mc_aaaaaaaaaaa1"],
|
||||
last_ts=1_700_000_002_000,
|
||||
)
|
||||
rows = [
|
||||
_memcell_row("mc_aaaaaaaaaaa1", sender_id="u_alice", ts_ms=1_700_000_002_000)
|
||||
]
|
||||
existing_fm = UserProfileFrontmatter(
|
||||
id="profile_u_alice",
|
||||
user_id="u_alice",
|
||||
summary="prior summary",
|
||||
explicit_info=["prior fact"],
|
||||
implicit_traits=["prior trait"],
|
||||
profile_timestamp_ms=1_700_000_000_000,
|
||||
)
|
||||
new_profile = AlgoProfile.model_validate(
|
||||
{
|
||||
"owner_id": "u_alice",
|
||||
"summary": "updated summary",
|
||||
"timestamp": 1_700_000_002_000,
|
||||
"explicit_info": ["prior fact", "new fact"],
|
||||
"implicit_traits": ["prior trait"],
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.cluster_repo"
|
||||
) as mock_cluster_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.memcell_repo"
|
||||
) as mock_memcell_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileExtractor"
|
||||
) as mock_extractor_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileReader"
|
||||
) as mock_reader_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileWriter"
|
||||
) as mock_writer_cls,
|
||||
):
|
||||
mock_cluster_repo.list_for_owner = AsyncMock(return_value=[cluster])
|
||||
mock_memcell_repo.find_by_ids = AsyncMock(return_value=rows)
|
||||
mock_reader_cls.return_value.read = AsyncMock(
|
||||
return_value=(existing_fm, "prior summary")
|
||||
)
|
||||
mock_writer_cls.return_value.write = AsyncMock(return_value=None)
|
||||
mock_extractor_cls.return_value.aextract = AsyncMock(return_value=new_profile)
|
||||
mod = importlib.import_module("everos.memory.strategies.extract_user_profile")
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
monkeypatch.setattr(mod, "_reader", None, raising=False)
|
||||
|
||||
await extract_user_profile(_event(), FakeStrategyContext())
|
||||
|
||||
# UPDATE mode — old_profile is the rehydrated algo type carrying prior fields.
|
||||
extractor_call = mock_extractor_cls.return_value.aextract.call_args
|
||||
old = extractor_call.kwargs["old_profile"]
|
||||
assert isinstance(old, AlgoProfile)
|
||||
assert old.summary == "prior summary"
|
||||
assert old.timestamp == 1_700_000_000_000
|
||||
assert old.model_dump()["explicit_info"] == ["prior fact"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_when_no_members(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""An empty target cluster set (no fresh clusters) → no extractor call."""
|
||||
# Existing profile timestamp newer than every cluster's last_ts → no
|
||||
# target_cluster matches `last_ts > last_profile_ts`, but the current
|
||||
# cluster_id should still force inclusion. Set the current cluster id
|
||||
# to a non-existent value to drop everything.
|
||||
stale_cluster = _algo_cluster(
|
||||
cluster_id="cl_other000001",
|
||||
members=["mc_other00000"],
|
||||
last_ts=1_600_000_000_000,
|
||||
)
|
||||
existing_fm = UserProfileFrontmatter(
|
||||
id="profile_u_alice",
|
||||
user_id="u_alice",
|
||||
summary="prior",
|
||||
explicit_info=[],
|
||||
implicit_traits=[],
|
||||
profile_timestamp_ms=1_900_000_000_000,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.cluster_repo"
|
||||
) as mock_cluster_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.memcell_repo"
|
||||
) as mock_memcell_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileExtractor"
|
||||
) as mock_extractor_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileReader"
|
||||
) as mock_reader_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileWriter"
|
||||
) as mock_writer_cls,
|
||||
):
|
||||
mock_cluster_repo.list_for_owner = AsyncMock(return_value=[stale_cluster])
|
||||
mock_memcell_repo.find_by_ids = AsyncMock(return_value=[])
|
||||
mock_reader_cls.return_value.read = AsyncMock(
|
||||
return_value=(existing_fm, "prior")
|
||||
)
|
||||
mock_writer_cls.return_value.write = AsyncMock(return_value=None)
|
||||
mock_extractor_cls.return_value.aextract = AsyncMock()
|
||||
mod = importlib.import_module("everos.memory.strategies.extract_user_profile")
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
monkeypatch.setattr(mod, "_reader", None, raising=False)
|
||||
|
||||
await extract_user_profile(
|
||||
_event(cluster_id="cl_unknown00000"), FakeStrategyContext()
|
||||
)
|
||||
|
||||
mock_extractor_cls.return_value.aextract.assert_not_called()
|
||||
mock_writer_cls.return_value.write.assert_not_called()
|
||||
|
||||
|
||||
# ── partition lock (owner_id-level serialisation) ────────────────────────
|
||||
|
||||
|
||||
async def _run_serialisation_probe(
|
||||
owner_a: str, owner_b: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> list[str]:
|
||||
"""Drive two extract_user_profile runs and record entry/exit order."""
|
||||
log: list[str] = []
|
||||
|
||||
async def mock_aextract(_memcells, *, sender_id, **_kwargs):
|
||||
log.append(f"enter:{sender_id}")
|
||||
await asyncio.sleep(0.01)
|
||||
log.append(f"leave:{sender_id}")
|
||||
return AlgoProfile(
|
||||
owner_id=sender_id,
|
||||
summary="summary",
|
||||
timestamp=1_700_000_000_000,
|
||||
explicit_info=[],
|
||||
implicit_traits=[],
|
||||
)
|
||||
|
||||
cluster_a = _algo_cluster(
|
||||
cluster_id="cl_a", members=["mc_a"], last_ts=1_700_000_000_000
|
||||
)
|
||||
cluster_b = _algo_cluster(
|
||||
cluster_id="cl_b", members=["mc_b"], last_ts=1_700_000_000_000
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.cluster_repo"
|
||||
) as mock_cluster_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.memcell_repo"
|
||||
) as mock_memcell_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileReader"
|
||||
) as mock_reader_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileWriter"
|
||||
) as mock_writer_cls,
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_user_profile.ProfileExtractor"
|
||||
) as mock_extractor_cls,
|
||||
):
|
||||
mock_cluster_repo.list_for_owner = AsyncMock(
|
||||
side_effect=lambda owner, _kind, **_kw: (
|
||||
[cluster_a] if owner == owner_a else [cluster_b]
|
||||
)
|
||||
)
|
||||
mock_memcell_repo.find_by_ids = AsyncMock(
|
||||
side_effect=lambda ids: [
|
||||
_memcell_row(ids[0], sender_id="sender", ts_ms=1_700_000_000_000)
|
||||
]
|
||||
)
|
||||
mock_reader_cls.return_value.read = AsyncMock(return_value=[])
|
||||
mock_writer_cls.return_value.write = AsyncMock(return_value=None)
|
||||
mock_extractor_cls.return_value.aextract = mock_aextract
|
||||
|
||||
mod = importlib.import_module("everos.memory.strategies.extract_user_profile")
|
||||
monkeypatch.setattr(mod, "_reader", None, raising=False)
|
||||
monkeypatch.setattr(mod, "_writer", None, raising=False)
|
||||
|
||||
await asyncio.gather(
|
||||
extract_user_profile(
|
||||
_event(owner_id=owner_a, cluster_id="cl_a"), FakeStrategyContext()
|
||||
),
|
||||
extract_user_profile(
|
||||
_event(owner_id=owner_b, cluster_id="cl_b"), FakeStrategyContext()
|
||||
),
|
||||
)
|
||||
return log
|
||||
|
||||
|
||||
async def test_partition_lock_serialises_runs_on_same_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Two runs sharing ``owner_id`` must not overlap critical sections."""
|
||||
log = await _run_serialisation_probe("u_alice", "u_alice", monkeypatch)
|
||||
assert log in (
|
||||
["enter:u_alice", "leave:u_alice", "enter:u_alice", "leave:u_alice"],
|
||||
)
|
||||
# Same-owner runs always log "u_alice" twice — verify strict ordering
|
||||
# by tagging entry/leave pairs are adjacent (no interleave possible).
|
||||
assert log[0].startswith("enter:") and log[1].startswith("leave:")
|
||||
assert log[2].startswith("enter:") and log[3].startswith("leave:")
|
||||
|
||||
|
||||
async def test_partition_lock_lets_different_owners_run_in_parallel(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Runs on distinct ``owner_id`` must overlap (no false serialisation)."""
|
||||
log = await _run_serialisation_probe("u_alice", "u_bob", monkeypatch)
|
||||
assert log.index("enter:u_alice") < log.index("leave:u_bob")
|
||||
assert log.index("enter:u_bob") < log.index("leave:u_alice")
|
||||
126
tests/unit/test_memory/test_strategies/test_partition_locks.py
Normal file
126
tests/unit/test_memory/test_strategies/test_partition_locks.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Tests for :mod:`everos.memory.strategies._partition_locks`.
|
||||
|
||||
The helper is the foundation under every strategy that performs a
|
||||
read → modify → write on shared state; its own behaviour (lock reuse,
|
||||
strategy isolation, FIFO serialisation, parallel keys) is exercised
|
||||
here, in isolation from any business strategy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.memory.strategies._partition_locks import (
|
||||
_reset_for_tests,
|
||||
get_partition_lock,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_locks() -> None:
|
||||
"""Each test gets a clean registry — no inherited holders / waiters."""
|
||||
_reset_for_tests()
|
||||
|
||||
|
||||
def test_same_strategy_same_key_returns_identical_lock() -> None:
|
||||
"""Repeat lookups must reuse the lock (otherwise serialisation breaks)."""
|
||||
a = get_partition_lock("strategy_x", "k1")
|
||||
b = get_partition_lock("strategy_x", "k1")
|
||||
assert a is b
|
||||
|
||||
|
||||
def test_same_strategy_different_keys_return_distinct_locks() -> None:
|
||||
"""Different partition keys must not block each other."""
|
||||
assert get_partition_lock("strategy_x", "k1") is not get_partition_lock(
|
||||
"strategy_x", "k2"
|
||||
)
|
||||
|
||||
|
||||
def test_different_strategies_share_no_locks_for_identical_key() -> None:
|
||||
"""Strategy namespaces are independent — same key string is two locks."""
|
||||
assert get_partition_lock("strategy_x", "k1") is not get_partition_lock(
|
||||
"strategy_y", "k1"
|
||||
)
|
||||
|
||||
|
||||
def test_reset_for_tests_drops_every_lock() -> None:
|
||||
"""After reset the registry is empty; the next lookup returns a fresh lock."""
|
||||
before = get_partition_lock("strategy_x", "k1")
|
||||
_reset_for_tests()
|
||||
after = get_partition_lock("strategy_x", "k1")
|
||||
assert before is not after
|
||||
|
||||
|
||||
async def test_same_key_serialises_concurrent_acquirers() -> None:
|
||||
"""Two tasks contending the same key must not overlap critical sections."""
|
||||
log: list[str] = []
|
||||
|
||||
async def worker(tag: str) -> None:
|
||||
async with get_partition_lock("strategy_x", "k1"):
|
||||
log.append(f"enter:{tag}")
|
||||
await asyncio.sleep(0.01)
|
||||
log.append(f"leave:{tag}")
|
||||
|
||||
await asyncio.gather(worker("a"), worker("b"))
|
||||
|
||||
# The two critical sections must run one after the other (either order
|
||||
# is fine — asyncio scheduling decides who acquires first).
|
||||
assert log in (
|
||||
["enter:a", "leave:a", "enter:b", "leave:b"],
|
||||
["enter:b", "leave:b", "enter:a", "leave:a"],
|
||||
)
|
||||
|
||||
|
||||
async def test_different_keys_run_in_parallel() -> None:
|
||||
"""Two tasks on distinct keys must overlap (no false serialisation)."""
|
||||
log: list[str] = []
|
||||
|
||||
async def worker(key: str, tag: str) -> None:
|
||||
async with get_partition_lock("strategy_x", key):
|
||||
log.append(f"enter:{tag}")
|
||||
await asyncio.sleep(0.01)
|
||||
log.append(f"leave:{tag}")
|
||||
|
||||
await asyncio.gather(worker("k1", "a"), worker("k2", "b"))
|
||||
|
||||
# Both must enter before either leaves — proves no cross-key blocking.
|
||||
assert log.index("enter:a") < log.index("leave:b")
|
||||
assert log.index("enter:b") < log.index("leave:a")
|
||||
|
||||
|
||||
async def test_concurrent_acquirers_fifo_fairness() -> None:
|
||||
"""asyncio.Lock is FIFO — queued waiters acquire in arrival order."""
|
||||
log: list[str] = []
|
||||
holder_in = asyncio.Event()
|
||||
holder_release = asyncio.Event()
|
||||
|
||||
async def holder() -> None:
|
||||
async with get_partition_lock("strategy_x", "k1"):
|
||||
holder_in.set()
|
||||
await holder_release.wait()
|
||||
log.append("leave:holder")
|
||||
|
||||
async def waiter(tag: str, arrived: asyncio.Event) -> None:
|
||||
arrived.set()
|
||||
async with get_partition_lock("strategy_x", "k1"):
|
||||
log.append(f"enter:{tag}")
|
||||
|
||||
arrived_a = asyncio.Event()
|
||||
arrived_b = asyncio.Event()
|
||||
task_holder = asyncio.create_task(holder())
|
||||
await holder_in.wait() # holder owns the lock
|
||||
|
||||
# Enqueue A first, then B — Lock's deque preserves this order.
|
||||
task_a = asyncio.create_task(waiter("a", arrived_a))
|
||||
await arrived_a.wait()
|
||||
await asyncio.sleep(0) # let A actually park on the lock
|
||||
task_b = asyncio.create_task(waiter("b", arrived_b))
|
||||
await arrived_b.wait()
|
||||
await asyncio.sleep(0) # let B park on the lock
|
||||
|
||||
holder_release.set()
|
||||
await asyncio.gather(task_holder, task_a, task_b)
|
||||
|
||||
assert log == ["leave:holder", "enter:a", "enter:b"]
|
||||
56
tests/unit/test_memory/test_strategies/test_registration.py
Normal file
56
tests/unit/test_memory/test_strategies/test_registration.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Test strategy package exports and OME engine registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from everos.memory.strategies import (
|
||||
extract_agent_case,
|
||||
extract_agent_skill,
|
||||
extract_atomic_facts,
|
||||
extract_foresight,
|
||||
extract_user_profile,
|
||||
trigger_profile_clustering,
|
||||
trigger_skill_clustering,
|
||||
)
|
||||
|
||||
|
||||
def test_strategies_are_re_exported_from_package() -> None:
|
||||
for fn, name in [
|
||||
(extract_atomic_facts, "extract_atomic_facts"),
|
||||
(extract_foresight, "extract_foresight"),
|
||||
(extract_agent_case, "extract_agent_case"),
|
||||
(trigger_skill_clustering, "trigger_skill_clustering"),
|
||||
(extract_agent_skill, "extract_agent_skill"),
|
||||
(trigger_profile_clustering, "trigger_profile_clustering"),
|
||||
(extract_user_profile, "extract_user_profile"),
|
||||
]:
|
||||
assert fn._ome_strategy_meta.name == name # type: ignore[attr-defined]
|
||||
|
||||
|
||||
async def test_get_engine_registers_all_strategies(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from everos.core.persistence import MemoryRoot
|
||||
|
||||
svc = importlib.import_module("everos.service.memorize")
|
||||
|
||||
monkeypatch.setattr(
|
||||
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
|
||||
)
|
||||
monkeypatch.setattr(svc, "_ome_engine", None, raising=False)
|
||||
|
||||
engine = svc._get_engine()
|
||||
names = {m.name for m in engine._registry.all()} # noqa: SLF001 — test introspection
|
||||
assert names == {
|
||||
"extract_atomic_facts",
|
||||
"extract_foresight",
|
||||
"extract_agent_case",
|
||||
"trigger_skill_clustering",
|
||||
"extract_agent_skill",
|
||||
"trigger_profile_clustering",
|
||||
"extract_user_profile",
|
||||
}
|
||||
@ -0,0 +1,202 @@
|
||||
"""Real md round-trip tests: strategy runs → writer writes → reader finds file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from everalgo.types import AgentCase, AtomicFact, ChatMessage, Foresight, MemCell
|
||||
|
||||
from everos.core.persistence import MemoryRoot
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.infra.persistence.markdown import (
|
||||
AgentCaseReader,
|
||||
AtomicFactReader,
|
||||
ForesightReader,
|
||||
)
|
||||
from everos.memory.events import AgentPipelineStarted, UserPipelineStarted
|
||||
from everos.memory.strategies.extract_agent_case import extract_agent_case
|
||||
from everos.memory.strategies.extract_atomic_facts import extract_atomic_facts
|
||||
from everos.memory.strategies.extract_foresight import extract_foresight
|
||||
|
||||
|
||||
def _event_for(owner: str) -> UserPipelineStarted:
|
||||
return UserPipelineStarted(
|
||||
memcell_id="mc_a",
|
||||
session_id="s1",
|
||||
memcell=MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="hi",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id=owner,
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _agent_event() -> AgentPipelineStarted:
|
||||
return AgentPipelineStarted(
|
||||
memcell_id="mc_a",
|
||||
session_id="s1",
|
||||
memcell=MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="please summarise",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m2",
|
||||
role="assistant",
|
||||
content="here's the summary",
|
||||
timestamp=1_700_000_001_000,
|
||||
sender_id="agent_42",
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_001_000,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_atomic_facts_round_trip(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import importlib
|
||||
|
||||
af_mod = importlib.import_module("everos.memory.strategies.extract_atomic_facts")
|
||||
|
||||
monkeypatch.setattr(
|
||||
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
|
||||
)
|
||||
monkeypatch.setattr(af_mod, "_writer", None, raising=False)
|
||||
|
||||
facts = [
|
||||
AtomicFact(
|
||||
owner_id="u_alice",
|
||||
content="alice likes hiking",
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
AtomicFact(
|
||||
owner_id="u_alice",
|
||||
content="alice lives in tokyo",
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
|
||||
) as mock_ext,
|
||||
):
|
||||
mock_ext.return_value.aextract = AsyncMock(return_value=facts)
|
||||
await extract_atomic_facts(_event_for("u_alice"), FakeStrategyContext())
|
||||
|
||||
reader = AtomicFactReader(root=MemoryRoot(root=tmp_path))
|
||||
path = reader.path_for("u_alice")
|
||||
assert path.is_file(), f"expected md at {path}"
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "alice likes hiking" in content
|
||||
assert "alice lives in tokyo" in content
|
||||
|
||||
|
||||
async def test_foresights_round_trip(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import importlib
|
||||
|
||||
fs_mod = importlib.import_module("everos.memory.strategies.extract_foresight")
|
||||
|
||||
monkeypatch.setattr(
|
||||
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
|
||||
)
|
||||
monkeypatch.setattr(fs_mod, "_writer", None, raising=False)
|
||||
|
||||
foresights = [
|
||||
Foresight(
|
||||
owner_id="u_alice",
|
||||
foresight="plans trip to tokyo",
|
||||
evidence="said so",
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightExtractor"
|
||||
) as mock_ext,
|
||||
):
|
||||
mock_ext.return_value.aextract = AsyncMock(return_value=foresights)
|
||||
await extract_foresight(_event_for("u_alice"), FakeStrategyContext())
|
||||
|
||||
reader = ForesightReader(root=MemoryRoot(root=tmp_path))
|
||||
path = reader.path_for("u_alice")
|
||||
assert path.is_file(), f"expected md at {path}"
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "plans trip to tokyo" in content
|
||||
assert "said so" in content
|
||||
|
||||
|
||||
async def test_agent_case_round_trip(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import importlib
|
||||
|
||||
ac_mod = importlib.import_module("everos.memory.strategies.extract_agent_case")
|
||||
|
||||
monkeypatch.setattr(
|
||||
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
|
||||
)
|
||||
monkeypatch.setattr(ac_mod, "_writer", None, raising=False)
|
||||
|
||||
cases = [
|
||||
AgentCase(
|
||||
id=uuid.uuid4().hex,
|
||||
timestamp=1_700_000_001_000,
|
||||
task_intent="summarise the doc",
|
||||
approach="read then condense",
|
||||
quality_score=0.82,
|
||||
key_insight="batch-then-summarise",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
|
||||
) as mock_ext,
|
||||
):
|
||||
mock_ext.return_value.aextract = AsyncMock(return_value=cases)
|
||||
await extract_agent_case(_agent_event(), FakeStrategyContext())
|
||||
|
||||
reader = AgentCaseReader(root=MemoryRoot(root=tmp_path))
|
||||
path = reader.path_for("agent_42")
|
||||
assert path.is_file(), f"expected md at {path}"
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "summarise the doc" in content
|
||||
assert "read then condense" in content
|
||||
assert "batch-then-summarise" in content
|
||||
# quality_score must land in inline (cascade requires it via require_float).
|
||||
assert "quality_score" in content
|
||||
@ -0,0 +1,284 @@
|
||||
"""Contract: strategy-written md must round-trip through cascade handler.
|
||||
|
||||
Guards against silent-breakage class: strategy writes section keys
|
||||
(e.g. ``{"fact": ...}``) that the cascade handler reads under a different
|
||||
case (e.g. ``sections.get("Fact")``). Without this contract, the worker
|
||||
still upserts a LanceDB row but with empty ``fact`` / ``foresight``
|
||||
text, empty BM25 tokens, and a vector for the empty string — search
|
||||
fails silently. Earlier unit tests stop at the strategy boundary (mock
|
||||
the writer) or at the writer boundary (skip the strategy); neither
|
||||
catches a key-name drift.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from everalgo.types import AgentCase, AtomicFact, ChatMessage, Foresight, MemCell
|
||||
|
||||
from everos.component.embedding import EmbeddingProvider
|
||||
from everos.component.tokenizer import Tokenizer
|
||||
from everos.core.persistence import MarkdownReader, MemoryRoot
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.memory.cascade.handlers import (
|
||||
AgentCaseHandler,
|
||||
AtomicFactHandler,
|
||||
ForesightHandler,
|
||||
HandlerDeps,
|
||||
)
|
||||
from everos.memory.cascade.handlers._daily_log_base import ParsedEntry
|
||||
from everos.memory.events import AgentPipelineStarted, UserPipelineStarted
|
||||
from everos.memory.strategies.extract_agent_case import extract_agent_case
|
||||
from everos.memory.strategies.extract_atomic_facts import extract_atomic_facts
|
||||
from everos.memory.strategies.extract_foresight import extract_foresight
|
||||
|
||||
|
||||
class _StubTokenizer(Tokenizer):
|
||||
def tokenize(self, text): # type: ignore[no-untyped-def]
|
||||
return [tok for tok in text.split() if tok]
|
||||
|
||||
def tokenize_batch(self, texts): # type: ignore[no-untyped-def]
|
||||
return [self.tokenize(t) for t in texts]
|
||||
|
||||
|
||||
class _StubEmbedder(EmbeddingProvider):
|
||||
dim = 1024
|
||||
|
||||
async def embed(self, text): # type: ignore[no-untyped-def]
|
||||
return [0.0] * self.dim
|
||||
|
||||
async def embed_batch(self, texts): # type: ignore[no-untyped-def]
|
||||
return [await self.embed(t) for t in texts]
|
||||
|
||||
|
||||
def _event(owner_id: str) -> UserPipelineStarted:
|
||||
return UserPipelineStarted(
|
||||
memcell_id="mc_a",
|
||||
session_id="s1",
|
||||
memcell=MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="hi",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id=owner_id,
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _build_row_from_md(
|
||||
handler: AtomicFactHandler | ForesightHandler | AgentCaseHandler,
|
||||
md_root: Path,
|
||||
md_glob: str,
|
||||
*,
|
||||
owner_id: str = "u_alice",
|
||||
owner_type: str = "user",
|
||||
):
|
||||
md_files: list[anyio.Path] = []
|
||||
async for p in anyio.Path(md_root).glob(md_glob):
|
||||
md_files.append(p)
|
||||
assert len(md_files) == 1, f"expected exactly one md, got: {md_files}"
|
||||
md_abs = Path(md_files[0])
|
||||
rel = str(md_abs.relative_to(md_root))
|
||||
parsed = await MarkdownReader.read(md_abs)
|
||||
assert parsed.entries, "writer should have produced at least one entry"
|
||||
entry = parsed.entries[0]
|
||||
structured = entry.as_structured()
|
||||
pe = ParsedEntry(
|
||||
entry_id=entry.id,
|
||||
structured=structured,
|
||||
content_sha256=handler._content_sha256(structured), # noqa: SLF001
|
||||
)
|
||||
return await handler._build_row( # noqa: SLF001
|
||||
owner_id=owner_id,
|
||||
owner_type=owner_type,
|
||||
md_path=rel,
|
||||
entry=pe,
|
||||
)
|
||||
|
||||
|
||||
async def test_atomic_fact_strategy_md_feeds_handler_with_content(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Strategy → md → AtomicFactHandler must carry the fact text intact."""
|
||||
af_mod = importlib.import_module("everos.memory.strategies.extract_atomic_facts")
|
||||
monkeypatch.setattr(
|
||||
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
|
||||
)
|
||||
monkeypatch.setattr(af_mod, "_writer", None, raising=False)
|
||||
|
||||
facts = [
|
||||
AtomicFact(
|
||||
owner_id="u_alice",
|
||||
content="alice likes hiking",
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
]
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
|
||||
) as mock_ext,
|
||||
):
|
||||
mock_ext.return_value.aextract = AsyncMock(return_value=facts)
|
||||
await extract_atomic_facts(_event("u_alice"), FakeStrategyContext())
|
||||
|
||||
handler = AtomicFactHandler(
|
||||
HandlerDeps(
|
||||
memory_root=MemoryRoot(root=tmp_path),
|
||||
embedder=_StubEmbedder(),
|
||||
tokenizer=_StubTokenizer(),
|
||||
)
|
||||
)
|
||||
row = await _build_row_from_md(
|
||||
handler, tmp_path, "*/*/users/u_alice/.atomic_facts/atomic_fact-*.md"
|
||||
)
|
||||
# Regression guard: section key drift would land here as fact="".
|
||||
assert row.fact == "alice likes hiking"
|
||||
assert row.fact_tokens == "alice likes hiking"
|
||||
assert len(row.vector) == 1024
|
||||
|
||||
|
||||
async def test_foresight_strategy_md_feeds_handler_with_content(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Strategy → md → ForesightHandler must carry foresight + evidence text."""
|
||||
fs_mod = importlib.import_module("everos.memory.strategies.extract_foresight")
|
||||
monkeypatch.setattr(
|
||||
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
|
||||
)
|
||||
monkeypatch.setattr(fs_mod, "_writer", None, raising=False)
|
||||
|
||||
foresights = [
|
||||
Foresight(
|
||||
owner_id="u_alice",
|
||||
foresight="plans trip to tokyo",
|
||||
evidence="said so explicitly",
|
||||
timestamp=1_700_000_000_000,
|
||||
),
|
||||
]
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_foresight.ForesightExtractor"
|
||||
) as mock_ext,
|
||||
):
|
||||
mock_ext.return_value.aextract = AsyncMock(return_value=foresights)
|
||||
await extract_foresight(_event("u_alice"), FakeStrategyContext())
|
||||
|
||||
handler = ForesightHandler(
|
||||
HandlerDeps(
|
||||
memory_root=MemoryRoot(root=tmp_path),
|
||||
embedder=_StubEmbedder(),
|
||||
tokenizer=_StubTokenizer(),
|
||||
)
|
||||
)
|
||||
row = await _build_row_from_md(
|
||||
handler, tmp_path, "*/*/users/u_alice/.foresights/foresight-*.md"
|
||||
)
|
||||
# Regression guard: section key drift would land here as foresight="".
|
||||
assert row.foresight == "plans trip to tokyo"
|
||||
assert row.foresight_tokens == "plans trip to tokyo"
|
||||
assert row.evidence == "said so explicitly"
|
||||
assert row.evidence_tokens == "said so explicitly"
|
||||
assert len(row.vector) == 1024
|
||||
|
||||
|
||||
def _agent_event() -> AgentPipelineStarted:
|
||||
return AgentPipelineStarted(
|
||||
memcell_id="mc_a",
|
||||
session_id="s1",
|
||||
memcell=MemCell(
|
||||
items=[
|
||||
ChatMessage(
|
||||
id="m1",
|
||||
role="user",
|
||||
content="please summarise",
|
||||
timestamp=1_700_000_000_000,
|
||||
sender_id="u_alice",
|
||||
),
|
||||
ChatMessage(
|
||||
id="m2",
|
||||
role="assistant",
|
||||
content="here's the summary",
|
||||
timestamp=1_700_000_001_000,
|
||||
sender_id="agent_42",
|
||||
),
|
||||
],
|
||||
timestamp=1_700_000_001_000,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_agent_case_strategy_md_feeds_handler_with_content(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Strategy → md → AgentCaseHandler carries task_intent, approach, score."""
|
||||
ac_mod = importlib.import_module("everos.memory.strategies.extract_agent_case")
|
||||
monkeypatch.setattr(
|
||||
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
|
||||
)
|
||||
monkeypatch.setattr(ac_mod, "_writer", None, raising=False)
|
||||
|
||||
cases = [
|
||||
AgentCase(
|
||||
id=uuid.uuid4().hex,
|
||||
timestamp=1_700_000_001_000,
|
||||
task_intent="summarise the doc",
|
||||
approach="read + condense",
|
||||
quality_score=0.85,
|
||||
key_insight="batch-then-summarise",
|
||||
)
|
||||
]
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
|
||||
) as mock_ext,
|
||||
):
|
||||
mock_ext.return_value.aextract = AsyncMock(return_value=cases)
|
||||
await extract_agent_case(_agent_event(), FakeStrategyContext())
|
||||
|
||||
handler = AgentCaseHandler(
|
||||
HandlerDeps(
|
||||
memory_root=MemoryRoot(root=tmp_path),
|
||||
embedder=_StubEmbedder(),
|
||||
tokenizer=_StubTokenizer(),
|
||||
)
|
||||
)
|
||||
row = await _build_row_from_md(
|
||||
handler,
|
||||
tmp_path,
|
||||
"*/*/agents/agent_42/.cases/agent_case-*.md",
|
||||
owner_id="agent_42",
|
||||
owner_type="agent",
|
||||
)
|
||||
# Regression guard: section-key drift or missing quality_score inline
|
||||
# would surface as empty strings / require_float failure.
|
||||
assert row.task_intent == "summarise the doc"
|
||||
assert row.task_intent_tokens == "summarise the doc"
|
||||
assert row.approach == "read + condense"
|
||||
assert row.approach_tokens == "read + condense"
|
||||
assert row.key_insight == "batch-then-summarise"
|
||||
assert row.quality_score == 0.85
|
||||
assert row.owner_id == "agent_42"
|
||||
assert row.owner_type == "agent"
|
||||
assert len(row.vector) == 1024
|
||||
@ -0,0 +1,235 @@
|
||||
"""Tests for :func:`trigger_profile_clustering`.
|
||||
|
||||
Mirrors the skill-side test layout: mock embedder + cluster_repo +
|
||||
cluster_by_geometry, drive the strategy via :class:`FakeStrategyContext`,
|
||||
verify a single :class:`ProfileClusterUpdated` event is emitted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import structlog.testing
|
||||
from everalgo.clustering import Cluster as AlgoCluster
|
||||
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.memory.events import EpisodeExtracted, ProfileClusterUpdated
|
||||
from everos.memory.strategies._partition_locks import _reset_for_tests
|
||||
from everos.memory.strategies.trigger_profile_clustering import (
|
||||
trigger_profile_clustering,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_partition_locks() -> None:
|
||||
_reset_for_tests()
|
||||
|
||||
|
||||
def _event(
|
||||
*,
|
||||
owner_id: str = "u_alice",
|
||||
memcell_id: str = "mc_aaaaaaaaaaa1",
|
||||
episode_text: str = "alice likes hiking",
|
||||
episode_timestamp_ms: int = 1_700_000_001_000,
|
||||
) -> EpisodeExtracted:
|
||||
return EpisodeExtracted(
|
||||
memcell_id=memcell_id,
|
||||
episode_entry_id="ep_20260517_0001",
|
||||
episode_text=episode_text,
|
||||
episode_timestamp_ms=episode_timestamp_ms,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
|
||||
|
||||
async def test_strategy_meta_is_attached() -> None:
|
||||
meta = trigger_profile_clustering._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.name == "trigger_profile_clustering"
|
||||
assert EpisodeExtracted in meta.trigger.on
|
||||
assert meta.emits == frozenset({ProfileClusterUpdated})
|
||||
assert meta.max_retries == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_new_cluster_when_no_existing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Empty existing → cluster_by_geometry returns None → new cluster persisted."""
|
||||
embedder = MagicMock()
|
||||
embedder.embed = AsyncMock(return_value=[0.1] * 1024)
|
||||
ctx = FakeStrategyContext()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.get_embedder",
|
||||
return_value=embedder,
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.cluster_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.cluster_by_geometry",
|
||||
new=MagicMock(return_value=None),
|
||||
) as mock_cluster,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.mint_cluster_id",
|
||||
return_value="cl_newuser00001",
|
||||
),
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
mock_repo.list_for_owner = AsyncMock(return_value=[])
|
||||
mock_repo.upsert_with_members = AsyncMock(return_value=None)
|
||||
|
||||
await trigger_profile_clustering(_event(), ctx)
|
||||
|
||||
args, _ = mock_cluster.call_args
|
||||
new_cluster, existing = args
|
||||
assert isinstance(new_cluster, AlgoCluster)
|
||||
assert new_cluster.id == "cl_newuser00001"
|
||||
assert new_cluster.count == 1
|
||||
assert new_cluster.last_ts == 1_700_000_001_000
|
||||
assert new_cluster.members == ["mc_aaaaaaaaaaa1"]
|
||||
assert new_cluster.preview == ["alice likes hiking"]
|
||||
assert existing == []
|
||||
|
||||
upsert_args = mock_repo.upsert_with_members.call_args
|
||||
persisted = upsert_args.args[0]
|
||||
assert persisted.id == "cl_newuser00001"
|
||||
assert upsert_args.kwargs == {
|
||||
"owner_id": "u_alice",
|
||||
"owner_type": "user",
|
||||
"kind": "user_memory",
|
||||
"member_type": "memcell",
|
||||
"app_id": "default",
|
||||
"project_id": "default",
|
||||
}
|
||||
|
||||
emitted = [e for e in ctx.emitted if isinstance(e, ProfileClusterUpdated)]
|
||||
assert len(emitted) == 1
|
||||
assert emitted[0].memcell_id == "mc_aaaaaaaaaaa1"
|
||||
assert emitted[0].cluster_id == "cl_newuser00001"
|
||||
assert emitted[0].owner_id == "u_alice"
|
||||
|
||||
matching = [r for r in captured if r.get("event") == "profile_cluster_updated"]
|
||||
assert matching, "expected profile_cluster_updated log line"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_into_existing_cluster_when_algo_matches() -> None:
|
||||
"""algo returns merged Cluster → persisted under the existing id."""
|
||||
embedder = MagicMock()
|
||||
embedder.embed = AsyncMock(return_value=[0.2] * 1024)
|
||||
ctx = FakeStrategyContext()
|
||||
|
||||
existing_cluster = AlgoCluster(
|
||||
id="cl_existing0001",
|
||||
centroid=np.array([0.15] * 1024, dtype=np.float32),
|
||||
count=1,
|
||||
last_ts=1_700_000_000_000,
|
||||
preview=["earlier episode"],
|
||||
members=["mc_zzzzzzzzzzz0"],
|
||||
)
|
||||
merged_cluster = AlgoCluster(
|
||||
id="cl_existing0001",
|
||||
centroid=np.array([0.17] * 1024, dtype=np.float32),
|
||||
count=2,
|
||||
last_ts=1_700_000_001_000,
|
||||
preview=["earlier episode", "alice likes hiking"],
|
||||
members=["mc_zzzzzzzzzzz0", "mc_aaaaaaaaaaa1"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.get_embedder",
|
||||
return_value=embedder,
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.cluster_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.cluster_by_geometry",
|
||||
new=MagicMock(return_value=merged_cluster),
|
||||
),
|
||||
):
|
||||
mock_repo.list_for_owner = AsyncMock(return_value=[existing_cluster])
|
||||
mock_repo.upsert_with_members = AsyncMock(return_value=None)
|
||||
|
||||
await trigger_profile_clustering(_event(), ctx)
|
||||
|
||||
persisted = mock_repo.upsert_with_members.call_args.args[0]
|
||||
assert persisted.id == "cl_existing0001"
|
||||
assert persisted.count == 2
|
||||
|
||||
emitted = [e for e in ctx.emitted if isinstance(e, ProfileClusterUpdated)]
|
||||
assert len(emitted) == 1
|
||||
assert emitted[0].cluster_id == "cl_existing0001"
|
||||
|
||||
|
||||
# ── partition lock (owner_id-level serialisation) ────────────────────────
|
||||
|
||||
|
||||
async def _run_serialisation_probe(owner_a: str, owner_b: str) -> list[str]:
|
||||
"""Drive two trigger_profile_clustering runs and record entry/exit order."""
|
||||
log: list[str] = []
|
||||
|
||||
def mock_cluster_by_geometry(_new_cluster, _existing):
|
||||
# Sync, matching the real algo signature (must not be awaited).
|
||||
return None
|
||||
|
||||
async def mock_upsert(cluster, **_kwargs):
|
||||
# Delay inside the partition-lock critical section so two concurrent
|
||||
# runs on the same owner are observably serialised. cluster_by_geometry
|
||||
# is synchronous now, so the await point moves here.
|
||||
mid = cluster.members[0]
|
||||
log.append(f"enter:{mid}")
|
||||
await asyncio.sleep(0.01)
|
||||
log.append(f"leave:{mid}")
|
||||
|
||||
mock_embedder = MagicMock()
|
||||
mock_embedder.embed = AsyncMock(return_value=np.zeros(1024, dtype=np.float32))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.get_embedder",
|
||||
return_value=mock_embedder,
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.cluster_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_profile_clustering.cluster_by_geometry",
|
||||
new=mock_cluster_by_geometry,
|
||||
),
|
||||
):
|
||||
mock_repo.list_for_owner = AsyncMock(return_value=[])
|
||||
mock_repo.upsert_with_members = mock_upsert
|
||||
|
||||
await asyncio.gather(
|
||||
trigger_profile_clustering(
|
||||
_event(owner_id=owner_a, memcell_id="mc_run_a"),
|
||||
FakeStrategyContext(),
|
||||
),
|
||||
trigger_profile_clustering(
|
||||
_event(owner_id=owner_b, memcell_id="mc_run_b"),
|
||||
FakeStrategyContext(),
|
||||
),
|
||||
)
|
||||
return log
|
||||
|
||||
|
||||
async def test_partition_lock_serialises_runs_on_same_owner() -> None:
|
||||
"""Two runs sharing ``owner_id`` must not overlap critical sections."""
|
||||
log = await _run_serialisation_probe("u_alice", "u_alice")
|
||||
assert log in (
|
||||
["enter:mc_run_a", "leave:mc_run_a", "enter:mc_run_b", "leave:mc_run_b"],
|
||||
["enter:mc_run_b", "leave:mc_run_b", "enter:mc_run_a", "leave:mc_run_a"],
|
||||
)
|
||||
|
||||
|
||||
async def test_partition_lock_lets_different_owners_run_in_parallel() -> None:
|
||||
"""Runs on distinct ``owner_id`` must overlap (no false serialisation)."""
|
||||
log = await _run_serialisation_probe("u_alice", "u_bob")
|
||||
assert log.index("enter:mc_run_a") < log.index("leave:mc_run_b")
|
||||
assert log.index("enter:mc_run_b") < log.index("leave:mc_run_a")
|
||||
@ -0,0 +1,277 @@
|
||||
"""Tests for :func:`trigger_skill_clustering`.
|
||||
|
||||
Mock surface: ``cluster_by_llm``, ``get_embedder``, ``get_llm_client``,
|
||||
``cluster_repo`` — strategy is wired to use them as module-level imports
|
||||
so each ``patch`` swaps the symbol in the strategy module's namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import structlog.testing
|
||||
from everalgo.clustering import Cluster as AlgoCluster
|
||||
|
||||
from everos.infra.ome.testing import FakeStrategyContext
|
||||
from everos.memory.events import AgentCaseExtracted, SkillClusterUpdated
|
||||
from everos.memory.strategies._partition_locks import _reset_for_tests
|
||||
from everos.memory.strategies.trigger_skill_clustering import (
|
||||
trigger_skill_clustering,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_partition_locks() -> None:
|
||||
_reset_for_tests()
|
||||
|
||||
|
||||
def _event(
|
||||
*,
|
||||
quality_score: float = 0.8,
|
||||
case_entry_id: str = "ac_20260517_0001",
|
||||
agent_id: str = "agent_42",
|
||||
task_intent: str = "summarise the doc",
|
||||
case_timestamp_ms: int = 1_700_000_001_000,
|
||||
) -> AgentCaseExtracted:
|
||||
return AgentCaseExtracted(
|
||||
memcell_id="mc_a",
|
||||
case_entry_id=case_entry_id,
|
||||
task_intent=task_intent,
|
||||
quality_score=quality_score,
|
||||
case_timestamp_ms=case_timestamp_ms,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
|
||||
async def test_strategy_meta_is_attached() -> None:
|
||||
meta = trigger_skill_clustering._ome_strategy_meta # type: ignore[attr-defined]
|
||||
assert meta.name == "trigger_skill_clustering"
|
||||
assert AgentCaseExtracted in meta.trigger.on
|
||||
assert meta.emits == frozenset({SkillClusterUpdated})
|
||||
assert meta.max_retries == 2
|
||||
|
||||
|
||||
async def test_skips_when_quality_score_below_threshold() -> None:
|
||||
"""quality_score < 0.2 → log + early return; no embedding, no LLM, no repo call."""
|
||||
ctx = FakeStrategyContext()
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.get_embedder"
|
||||
) as mock_emb,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm"
|
||||
) as mock_cluster,
|
||||
structlog.testing.capture_logs() as captured,
|
||||
):
|
||||
await trigger_skill_clustering(_event(quality_score=0.1), ctx)
|
||||
|
||||
mock_emb.assert_not_called()
|
||||
mock_repo.list_for_owner.assert_not_called()
|
||||
mock_cluster.assert_not_called()
|
||||
assert ctx.emitted == []
|
||||
matching = [
|
||||
e for e in captured if e.get("event") == "skill_clustering_skipped_low_quality"
|
||||
]
|
||||
assert matching, "expected low-quality skip log line"
|
||||
|
||||
|
||||
async def test_creates_new_cluster_when_no_existing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Empty existing list → cluster_by_llm returns None → new cluster persisted."""
|
||||
embedder = MagicMock()
|
||||
embedder.embed = AsyncMock(return_value=[0.1] * 1024)
|
||||
ctx = FakeStrategyContext()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.get_embedder",
|
||||
return_value=embedder,
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm",
|
||||
new=AsyncMock(return_value=None),
|
||||
) as mock_cluster,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.mint_cluster_id",
|
||||
return_value="cl_newxxxx0001",
|
||||
),
|
||||
):
|
||||
mock_repo.list_for_owner = AsyncMock(return_value=[])
|
||||
mock_repo.upsert_with_members = AsyncMock(return_value=None)
|
||||
|
||||
await trigger_skill_clustering(_event(), ctx)
|
||||
|
||||
# cluster_by_llm called with the size-1 new cluster + empty existing.
|
||||
args, kwargs = mock_cluster.call_args
|
||||
new_cluster, existing = args
|
||||
assert isinstance(new_cluster, AlgoCluster)
|
||||
assert new_cluster.id == "cl_newxxxx0001"
|
||||
assert new_cluster.count == 1
|
||||
assert new_cluster.last_ts == 1_700_000_001_000
|
||||
assert new_cluster.members == ["ac_20260517_0001"]
|
||||
assert new_cluster.preview == ["summarise the doc"]
|
||||
np.testing.assert_allclose(
|
||||
np.asarray(new_cluster.centroid), np.array([0.1] * 1024, dtype=np.float32)
|
||||
)
|
||||
assert existing == []
|
||||
|
||||
# upsert called with the new cluster (since merge returned None).
|
||||
upsert_args = mock_repo.upsert_with_members.call_args
|
||||
persisted = upsert_args.args[0]
|
||||
assert persisted.id == "cl_newxxxx0001"
|
||||
assert upsert_args.kwargs == {
|
||||
"owner_id": "agent_42",
|
||||
"owner_type": "agent",
|
||||
"kind": "agent_case",
|
||||
"member_type": "case",
|
||||
"app_id": "default",
|
||||
"project_id": "default",
|
||||
}
|
||||
|
||||
emitted = [e for e in ctx.emitted if isinstance(e, SkillClusterUpdated)]
|
||||
assert len(emitted) == 1
|
||||
assert emitted[0].cluster_id == "cl_newxxxx0001"
|
||||
assert emitted[0].case_entry_id == "ac_20260517_0001"
|
||||
assert emitted[0].agent_id == "agent_42"
|
||||
|
||||
|
||||
async def test_merges_into_existing_cluster_when_algo_matches() -> None:
|
||||
"""algo returns a merged Cluster → persisted with the existing id."""
|
||||
embedder = MagicMock()
|
||||
embedder.embed = AsyncMock(return_value=[0.2] * 1024)
|
||||
ctx = FakeStrategyContext()
|
||||
|
||||
existing_cluster = AlgoCluster(
|
||||
id="cl_existing0001",
|
||||
centroid=np.array([0.15] * 1024, dtype=np.float32),
|
||||
count=2,
|
||||
last_ts=1_700_000_000_000,
|
||||
preview=["earlier intent"],
|
||||
members=["ac_20260517_0000"],
|
||||
)
|
||||
# Simulate evercore _merge: id passes through from existing, members appended.
|
||||
merged_cluster = AlgoCluster(
|
||||
id="cl_existing0001",
|
||||
centroid=np.array([0.17] * 1024, dtype=np.float32),
|
||||
count=3,
|
||||
last_ts=1_700_000_001_000,
|
||||
preview=["earlier intent", "summarise the doc"],
|
||||
members=["ac_20260517_0000", "ac_20260517_0001"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.get_embedder",
|
||||
return_value=embedder,
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm",
|
||||
new=AsyncMock(return_value=merged_cluster),
|
||||
),
|
||||
):
|
||||
mock_repo.list_for_owner = AsyncMock(return_value=[existing_cluster])
|
||||
mock_repo.upsert_with_members = AsyncMock(return_value=None)
|
||||
|
||||
await trigger_skill_clustering(_event(), ctx)
|
||||
|
||||
upsert_args = mock_repo.upsert_with_members.call_args
|
||||
persisted = upsert_args.args[0]
|
||||
assert persisted.id == "cl_existing0001"
|
||||
assert persisted.members == ["ac_20260517_0000", "ac_20260517_0001"]
|
||||
assert persisted.count == 3
|
||||
|
||||
emitted = [e for e in ctx.emitted if isinstance(e, SkillClusterUpdated)]
|
||||
assert len(emitted) == 1
|
||||
assert emitted[0].cluster_id == "cl_existing0001"
|
||||
|
||||
|
||||
# ── partition lock (agent_id-level serialisation) ────────────────────────
|
||||
|
||||
|
||||
async def _run_serialisation_probe(agent_a: str, agent_b: str) -> list[str]:
|
||||
"""Drive two trigger_skill_clustering runs and record entry/exit order.
|
||||
|
||||
The clustering LLM call is the only awaited work inside the locked
|
||||
region — replacing it with a tiny ``asyncio.sleep`` keeps the test
|
||||
fast while still proving the lock either does or does not interleave
|
||||
the two critical sections.
|
||||
"""
|
||||
log: list[str] = []
|
||||
|
||||
async def mock_cluster_by_llm(new_cluster, _existing, **_kwargs):
|
||||
log.append(f"enter:{new_cluster.members[0]}")
|
||||
await asyncio.sleep(0.01)
|
||||
log.append(f"leave:{new_cluster.members[0]}")
|
||||
return None # no merge → caller persists the size-1 cluster
|
||||
|
||||
mock_embedder = MagicMock()
|
||||
mock_embedder.embed = AsyncMock(return_value=np.zeros(1024, dtype=np.float32))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.get_embedder",
|
||||
return_value=mock_embedder,
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.get_llm_client",
|
||||
return_value=object(),
|
||||
),
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
|
||||
) as mock_repo,
|
||||
patch(
|
||||
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm",
|
||||
new=mock_cluster_by_llm,
|
||||
),
|
||||
):
|
||||
mock_repo.list_for_owner = AsyncMock(return_value=[])
|
||||
mock_repo.upsert_with_members = AsyncMock(return_value=None)
|
||||
|
||||
await asyncio.gather(
|
||||
trigger_skill_clustering(
|
||||
_event(agent_id=agent_a, case_entry_id="ac_run_a"),
|
||||
FakeStrategyContext(),
|
||||
),
|
||||
trigger_skill_clustering(
|
||||
_event(agent_id=agent_b, case_entry_id="ac_run_b"),
|
||||
FakeStrategyContext(),
|
||||
),
|
||||
)
|
||||
return log
|
||||
|
||||
|
||||
async def test_partition_lock_serialises_runs_on_same_agent() -> None:
|
||||
"""Two runs sharing ``agent_id`` must not overlap critical sections."""
|
||||
log = await _run_serialisation_probe("agent_42", "agent_42")
|
||||
assert log in (
|
||||
["enter:ac_run_a", "leave:ac_run_a", "enter:ac_run_b", "leave:ac_run_b"],
|
||||
["enter:ac_run_b", "leave:ac_run_b", "enter:ac_run_a", "leave:ac_run_a"],
|
||||
)
|
||||
|
||||
|
||||
async def test_partition_lock_lets_different_agents_run_in_parallel() -> None:
|
||||
"""Runs on distinct ``agent_id`` must overlap (no false serialisation)."""
|
||||
log = await _run_serialisation_probe("agent_42", "agent_43")
|
||||
assert log.index("enter:ac_run_a") < log.index("leave:ac_run_b")
|
||||
assert log.index("enter:ac_run_b") < log.index("leave:ac_run_a")
|
||||
Reference in New Issue
Block a user