* refactor(memory): lazy-init embedder env reads * feat(memory): reindex log to .rag/reindex.log * refactor(memory): search handles None embedder * test(memory): cover None embedder in search and index * docs(handoff): add handoff and ADR for lazy-init embedder * docs(handoff): set PR number * docs(handoff): rename to pr-100 prefix for pipeline detection * refactor(memory): split run_search to satisfy xenon rank A * fix(memory): cast search result fields to satisfy mypy * style(memory): ruff format search.py --------- Co-authored-by: opencode-agent <agent@opencode.local>
128 lines
4 KiB
Python
128 lines
4 KiB
Python
import json
|
|
import os
|
|
from argparse import Namespace
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
os.environ.setdefault("OPENAI_BASE_URL", "http://test/v1")
|
|
|
|
import src.memory.search as search_mod
|
|
from src.memory.search import _cosine_sim
|
|
|
|
|
|
class TestCosineSim:
|
|
def test_identical(self) -> None:
|
|
v = np.array([1.0, 2.0, 3.0])
|
|
assert _cosine_sim(v, v) == pytest.approx(1.0)
|
|
|
|
def test_orthogonal(self) -> None:
|
|
a = np.array([1.0, 0.0])
|
|
b = np.array([0.0, 1.0])
|
|
assert _cosine_sim(a, b) == pytest.approx(0.0)
|
|
|
|
def test_zero_vector(self) -> None:
|
|
a = np.array([0.0, 0.0])
|
|
b = np.array([1.0, 1.0])
|
|
assert _cosine_sim(a, b) == pytest.approx(0.0)
|
|
|
|
def test_parallel(self) -> None:
|
|
a = np.array([1.0, 2.0])
|
|
b = np.array([2.0, 4.0])
|
|
assert _cosine_sim(a, b) == pytest.approx(1.0)
|
|
|
|
def test_opposite(self) -> None:
|
|
a = np.array([1.0, 1.0])
|
|
b = np.array([-1.0, -1.0])
|
|
assert _cosine_sim(a, b) == pytest.approx(-1.0)
|
|
|
|
|
|
class TestSearchOutput:
|
|
def test_search_json_format(self, tmp_path: Path) -> None:
|
|
index_dir = tmp_path / ".rag"
|
|
index_dir.mkdir()
|
|
index = {
|
|
"files": [
|
|
{
|
|
"source": "test.md",
|
|
"text": "hello world",
|
|
"embedding": [1.0, 0.0, 0.0],
|
|
},
|
|
],
|
|
}
|
|
(index_dir / "index.json").write_text(json.dumps(index))
|
|
|
|
def fake_embed_texts(texts):
|
|
return [[1.0, 0.0, 0.0]]
|
|
|
|
with patch.object(search_mod, "embed_texts", fake_embed_texts):
|
|
args = Namespace(index_dir=str(index_dir), query="hello", k=5, json=True)
|
|
search_mod.run_search(args)
|
|
|
|
def test_dedup_by_source(self, tmp_path: Path, capsys) -> None:
|
|
index_dir = tmp_path / ".rag"
|
|
index_dir.mkdir()
|
|
index = {
|
|
"files": [
|
|
{
|
|
"source": "doc.md",
|
|
"text": "chunk 0",
|
|
"embedding": [1.0, 0.0, 0.0],
|
|
},
|
|
{
|
|
"source": "doc.md",
|
|
"text": "chunk 1",
|
|
"embedding": [0.9, 0.1, 0.0],
|
|
},
|
|
{
|
|
"source": "other.md",
|
|
"text": "chunk 0",
|
|
"embedding": [0.5, 0.5, 0.0],
|
|
},
|
|
],
|
|
}
|
|
(index_dir / "index.json").write_text(json.dumps(index))
|
|
|
|
def fake_embed_texts(texts):
|
|
return [[1.0, 0.0, 0.0]]
|
|
|
|
with patch.object(search_mod, "embed_texts", fake_embed_texts):
|
|
args = Namespace(index_dir=str(index_dir), query="q", k=5, json=True)
|
|
search_mod.run_search(args)
|
|
|
|
captured = capsys.readouterr()
|
|
result = json.loads(captured.out)
|
|
assert len(result) == 2
|
|
sources = [r["source"] for r in result]
|
|
assert "doc.md" in sources
|
|
assert "other.md" in sources
|
|
assert sources.count("doc.md") == 1
|
|
doc_entry = next(r for r in result if r["source"] == "doc.md")
|
|
assert doc_entry["text"] == "chunk 0"
|
|
|
|
def test_search_returns_empty_when_embedder_none(self, tmp_path: Path, capsys) -> None:
|
|
index_dir = tmp_path / ".rag"
|
|
index_dir.mkdir()
|
|
index = {
|
|
"files": [
|
|
{
|
|
"source": "test.md",
|
|
"text": "hello world",
|
|
"embedding": [1.0, 0.0, 0.0],
|
|
},
|
|
],
|
|
}
|
|
(index_dir / "index.json").write_text(json.dumps(index))
|
|
|
|
def fake_embed_texts(texts):
|
|
return None
|
|
|
|
with patch.object(search_mod, "embed_texts", fake_embed_texts):
|
|
args = Namespace(index_dir=str(index_dir), query="hello", k=5, json=True)
|
|
search_mod.run_search(args)
|
|
|
|
captured = capsys.readouterr()
|
|
result = json.loads(captured.out)
|
|
assert result == []
|