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.
136 lines
4.2 KiB
Python
136 lines
4.2 KiB
Python
"""Unit tests for ``memory.search.dto`` validation rules."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from everos.memory.search import (
|
|
SearchData,
|
|
SearchMethod,
|
|
SearchRequest,
|
|
SearchResponse,
|
|
)
|
|
|
|
|
|
def _minimal_request_kwargs() -> dict:
|
|
return {
|
|
"user_id": "alice",
|
|
"query": "hello",
|
|
}
|
|
|
|
|
|
def test_enable_llm_rerank_defaults_to_false() -> None:
|
|
"""HYBRID should NOT auto-trigger LLM Phase-5 rerank by default.
|
|
|
|
The caller opts in explicitly when they want the extra LLM pass;
|
|
leaving it off keeps a default HYBRID call cheap (no LLM ``chat``).
|
|
"""
|
|
req = SearchRequest(**_minimal_request_kwargs())
|
|
assert req.enable_llm_rerank is False
|
|
|
|
|
|
def test_enable_llm_rerank_accepts_true() -> None:
|
|
req = SearchRequest(**_minimal_request_kwargs(), enable_llm_rerank=True)
|
|
assert req.enable_llm_rerank is True
|
|
|
|
|
|
def test_minimal_request_uses_hybrid_default() -> None:
|
|
req = SearchRequest(**_minimal_request_kwargs())
|
|
assert req.method == SearchMethod.HYBRID
|
|
assert req.top_k == -1
|
|
assert req.include_profile is False
|
|
assert req.filters is None
|
|
assert req.radius is None
|
|
|
|
|
|
def test_top_k_zero_rejected() -> None:
|
|
with pytest.raises(ValidationError) as exc:
|
|
SearchRequest(**_minimal_request_kwargs(), top_k=0)
|
|
assert "top_k" in str(exc.value)
|
|
|
|
|
|
def test_top_k_above_100_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
SearchRequest(**_minimal_request_kwargs(), top_k=101)
|
|
|
|
|
|
def test_top_k_below_minus_one_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
SearchRequest(**_minimal_request_kwargs(), top_k=-2)
|
|
|
|
|
|
def test_top_k_minus_one_accepted() -> None:
|
|
req = SearchRequest(**_minimal_request_kwargs(), top_k=-1)
|
|
assert req.top_k == -1
|
|
|
|
|
|
def test_top_k_in_range_accepted() -> None:
|
|
req = SearchRequest(**_minimal_request_kwargs(), top_k=50)
|
|
assert req.top_k == 50
|
|
|
|
|
|
def test_radius_out_of_range_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
SearchRequest(**_minimal_request_kwargs(), radius=1.5)
|
|
with pytest.raises(ValidationError):
|
|
SearchRequest(**_minimal_request_kwargs(), radius=-0.1)
|
|
|
|
|
|
def test_neither_user_id_nor_agent_id_rejected() -> None:
|
|
"""The xor validator requires exactly one of user_id / agent_id."""
|
|
with pytest.raises(ValidationError, match="exactly one of"):
|
|
SearchRequest(query="hello") # neither set
|
|
|
|
|
|
def test_both_user_id_and_agent_id_rejected() -> None:
|
|
"""The xor validator rejects ambiguous owner identity."""
|
|
with pytest.raises(ValidationError, match="exactly one of"):
|
|
SearchRequest(user_id="alice", agent_id="agent_x", query="hello")
|
|
|
|
|
|
def test_empty_query_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
SearchRequest(user_id="alice", query="")
|
|
|
|
|
|
def test_empty_user_id_rejected() -> None:
|
|
with pytest.raises(ValidationError):
|
|
SearchRequest(user_id="", query="hello")
|
|
|
|
|
|
def test_extra_top_level_field_rejected() -> None:
|
|
"""``extra='forbid'`` keeps the contract tight."""
|
|
with pytest.raises(ValidationError):
|
|
SearchRequest(
|
|
**_minimal_request_kwargs(),
|
|
unexpected_field="x", # type: ignore[call-arg]
|
|
)
|
|
|
|
|
|
def test_filters_extra_keys_allowed() -> None:
|
|
"""FilterNode is open-shape; safety is enforced in the compiler."""
|
|
req = SearchRequest(
|
|
**_minimal_request_kwargs(),
|
|
filters={"session_id": "sess_a", "AND": [{"timestamp": {"gte": 1}}]},
|
|
)
|
|
assert req.filters is not None
|
|
dumped = req.filters.model_dump(exclude_none=True)
|
|
assert dumped["session_id"] == "sess_a"
|
|
assert dumped["AND"][0]["timestamp"]["gte"] == 1
|
|
|
|
|
|
def test_response_default_arrays_present() -> None:
|
|
"""Every ``data.*`` array must exist so callers can iterate unconditionally."""
|
|
resp = SearchResponse(request_id="0" * 32, data=SearchData())
|
|
assert resp.data.episodes == []
|
|
assert resp.data.profiles == []
|
|
assert resp.data.agent_cases == []
|
|
assert resp.data.agent_skills == []
|
|
|
|
|
|
def test_method_enum_serialises_to_lowercase() -> None:
|
|
req = SearchRequest(**_minimal_request_kwargs(), method="agentic") # type: ignore[arg-type]
|
|
assert req.method == SearchMethod.AGENTIC
|
|
assert req.method.value == "agentic"
|