ReMe/tests/unit/test_search_empty_response.py
fly liu c773fa7b77
Some checks failed
CI / Python tests / Unit Tests - py3.13 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.14 (push) Has been cancelled
CI and Release / Docker / Build and test / amd64 (push) Has been cancelled
CI and Release / Docker / Build and test / arm64 (push) Has been cancelled
CI / Python packages / Build and verify distributions (push) Has been cancelled
CI / Python quality / GitHub Actions (push) Has been cancelled
CI / Python quality / Pre-commit (push) Has been cancelled
CI / Python tests / Unit Tests - py3.11 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.12 (push) Has been cancelled
CI / Windows / CLI smoke - py3.11 (push) Has been cancelled
Deploy / Documentation / Build documentation (push) Has been cancelled
Security / CodeQL / Analyze javascript-typescript (push) Has been cancelled
Security / CodeQL / Analyze python (push) Has been cancelled
Deploy / Documentation / deploy (push) Has been cancelled
CI and Release / Docker / Publish multi-platform tags (push) Has been cancelled
fix(search): explain empty results in metadata (#647)
* fix(search): explain empty search results

* fix(codex): ignore empty search notices

* fix(integrations): skip empty search results in auto recall

* fix(search): explain empty results in metadata
2026-10-23 15:53:44 +08:00

267 lines
11 KiB
Python

"""Empty search metadata explains missing results without changing answer compatibility."""
# pylint: disable=protected-access
import asyncio
import json
from unittest.mock import AsyncMock, Mock
import pytest
from reme.components import ApplicationContext
from reme.components.client.http_client import HttpClient
from reme.components.file_store import BaseFileStore
from reme.components.runtime_context import RuntimeContext
from reme.components.service.cli_service import CliService
from reme.schema import FileChunk
from reme.steps.index import SearchStep
from reme.steps.index._source_format import ALL_RETURNED_MESSAGE, NO_RESULTS_MESSAGE
def _chunk(chunk_id="match", score=2.0):
"""Create a scored hit with an embedding that must not appear in results."""
return FileChunk(
id=chunk_id,
path=f"daily/{chunk_id}.md",
text=f"memory {chunk_id}",
start_line=1,
end_line=2,
embedding=[0.1, 0.2],
scores={"score": score},
)
def _store(chunks=(), mode="keyword"):
"""Mock retrieval boundaries without starting indexes or model providers."""
store = Mock(spec=BaseFileStore)
store.vector_search = AsyncMock(return_value=list(chunks) if mode in {"vector", "hybrid"} else [])
store.keyword_search = AsyncMock(return_value=list(chunks) if mode in {"keyword", "hybrid"} else [])
return store
def _step(tmp_path, store, *, app_context=None, vector_weight=0.0):
"""Use an isolated application, sharing it explicitly for repeated calls."""
return SearchStep(
app_context=app_context if app_context is not None else ApplicationContext(workspace_dir=str(tmp_path)),
file_store=store,
vector_weight=vector_weight,
expand_links=False,
)
@pytest.mark.parametrize(
"mode,weight,score,min_score",
[
("keyword", 0.7, None, 0.0),
("keyword", 0.0, 0.1, 1.0),
("vector", 1.0, 0.1, 1.0),
("hybrid", 0.7, 0.1, 1.0),
],
ids=["no-matches", "keyword-below-threshold", "vector-below-threshold", "hybrid-below-threshold"],
)
@pytest.mark.parametrize("tool_context_id", ["", "ctx-1"], ids=["without-context", "with-context"])
def test_search_without_eligible_results_explains_empty_metadata(
tmp_path,
mode,
weight,
score,
min_score,
tool_context_id,
):
"""No matches and score-filtered hits share the same empty-result behavior."""
chunks = [] if score is None else [_chunk(score=score)]
response = asyncio.run(
_step(tmp_path, _store(chunks, mode), vector_weight=weight)(
RuntimeContext(
query="missing" if score is None else "memory",
min_score=min_score,
tool_context_id=tool_context_id,
),
),
)
assert response.success
assert response.answer == ""
assert response.metadata["empty_result"] == {"reason": "no_results", "message": NO_RESULTS_MESSAGE}
assert response.metadata["results"] == []
assert response.metadata["link_expansion"] == {}
assert response.metadata["counts"] == {
"vector": int(score is not None and mode in {"vector", "hybrid"}),
"keyword": int(score is not None and mode in {"keyword", "hybrid"}),
"returned": 0,
"hybrid": score is not None and mode == "hybrid",
}
if tool_context_id:
assert response.metadata["dedup"]["skipped_seen"] == 0
else:
assert "dedup" not in response.metadata
def test_search_all_seen_explains_empty_metadata_across_step_instances(tmp_path):
"""Fresh Step instances share previously returned content through the application."""
async def run():
app_context = ApplicationContext(workspace_dir=str(tmp_path))
store = _store([_chunk()])
first = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
second = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
assert first.metadata["counts"]["returned"] == 1
assert "empty_result" not in first.metadata
assert second.success
assert second.answer == ""
assert second.metadata["empty_result"] == {"reason": "all_returned", "message": ALL_RETURNED_MESSAGE}
assert second.metadata["results"] == []
assert second.metadata["link_expansion"] == {}
assert second.metadata["counts"] == {"vector": 0, "keyword": 1, "returned": 0, "hybrid": False}
assert second.metadata["dedup"]["seen_before"] == 1
assert second.metadata["dedup"]["skipped_seen"] == 1
asyncio.run(run())
@pytest.mark.parametrize("min_score", [0.0, 3.0], ids=["no-matches", "below-threshold"])
def test_search_history_does_not_turn_missing_results_into_all_returned(tmp_path, min_score):
"""Past hits do not explain a new query with no eligible candidates."""
async def run():
app_context = ApplicationContext(workspace_dir=str(tmp_path))
store = _store([_chunk()])
first = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
assert first.metadata["counts"]["returned"] == 1
if not min_score:
store.keyword_search.return_value = []
response = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="another query", min_score=min_score, tool_context_id="ctx-1"),
)
assert response.success
assert response.answer == ""
assert response.metadata["empty_result"] == {"reason": "no_results", "message": NO_RESULTS_MESSAGE}
assert response.metadata["results"] == []
assert response.metadata["counts"]["returned"] == 0
assert response.metadata["dedup"]["seen_before"] == 1
assert response.metadata["dedup"]["skipped_seen"] == 0
asyncio.run(run())
@pytest.mark.parametrize("tool_context_id", ["", "ctx-1"], ids=["without-context", "with-context"])
def test_search_hit_keeps_answer_and_structured_results(tmp_path, tool_context_id):
"""A normal hit keeps its exact rendering and excludes the embedding from metadata."""
chunk = _chunk()
response = asyncio.run(
_step(tmp_path, _store([chunk]))(RuntimeContext(query="memory", tool_context_id=tool_context_id)),
)
assert response.success
assert "empty_result" not in response.metadata
assert response.answer == "========== daily/match.md:1-2 [score=2.0000] ==========\nmemory match"
assert response.metadata["results"] == [chunk.model_dump(exclude_none=True, exclude={"embedding"})]
assert response.metadata["link_expansion"] == {}
assert response.metadata["counts"] == {"vector": 0, "keyword": 1, "returned": 1, "hybrid": False}
def test_search_partial_dedup_and_context_isolation_keep_hit_answers(tmp_path):
"""Unseen content still renders normally and another context can retrieve seen content."""
async def run():
app_context = ApplicationContext(workspace_dir=str(tmp_path))
store = _store([_chunk()])
await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
store.keyword_search.return_value = [_chunk(), _chunk("new")]
response = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-1"),
)
assert response.success
assert "empty_result" not in response.metadata
assert response.answer == "========== daily/new.md:1-2 [score=2.0000] ==========\nmemory new"
assert [result["id"] for result in response.metadata["results"]] == ["new"]
assert response.metadata["dedup"]["skipped_seen"] == 1
other = await _step(tmp_path, store, app_context=app_context)(
RuntimeContext(query="memory", tool_context_id="ctx-2"),
)
assert [result["id"] for result in other.metadata["results"]] == ["match", "new"]
assert "memory match" in other.answer
assert "memory new" in other.answer
assert other.metadata["dedup"]["skipped_seen"] == 0
assert "empty_result" not in other.metadata
asyncio.run(run())
@pytest.mark.parametrize("next_query", ["memory", ""], ids=["normal-hit", "invalid-query"])
def test_search_reused_response_does_not_keep_empty_reason(tmp_path, next_query):
"""A later hit or error must not inherit an earlier empty search explanation."""
async def run():
store = _store()
step = _step(tmp_path, store)
context = RuntimeContext(query="missing")
context.response.metadata["other_step"] = "preserved"
first = await step(context)
assert first.answer == ""
assert first.metadata["empty_result"]["reason"] == "no_results"
store.keyword_search.return_value = [_chunk()]
context.update({"query": next_query})
response = await step(context)
assert "empty_result" not in response.metadata
assert response.metadata["other_step"] == "preserved"
if next_query:
assert response.success
assert "memory match" in response.answer
assert response.metadata["counts"]["returned"] == 1
else:
assert not response.success
assert response.answer == "Error: query cannot be empty"
asyncio.run(run())
@pytest.mark.parametrize("all_returned", [False, True], ids=["no-results", "all-returned"])
@pytest.mark.parametrize("show_metadata", [False, True], ids=["default-display", "show-metadata"])
def test_empty_search_explanation_uses_existing_cli_metadata_display(tmp_path, capsys, all_returned, show_metadata):
"""Both CLI paths expose reasons only when metadata display is requested."""
async def search():
store = _store([_chunk()] if all_returned else [])
step = _step(tmp_path, store)
if all_returned:
await step(RuntimeContext(query="memory", tool_context_id="ctx-1"))
return await step(RuntimeContext(query="memory", tool_context_id="ctx-1"))
response = asyncio.run(search())
expected = {
"reason": "all_returned" if all_returned else "no_results",
"message": ALL_RETURNED_MESSAGE if all_returned else NO_RESULTS_MESSAGE,
}
assert response.answer == ""
assert response.success
assert response.metadata["empty_result"] == expected
http_text = HttpClient(show_metadata=show_metadata)._format_for_display(response.model_dump_json())
app = Mock(start=AsyncMock(), run_job=AsyncMock(return_value=response), close=AsyncMock())
CliService(job="search", job_args={"query": "memory"}, show_metadata=show_metadata).start_service(app)
output = capsys.readouterr()
assert output.err == ""
app.run_job.assert_awaited_once_with("search", query="memory")
app.close.assert_awaited_once()
if show_metadata:
assert json.loads(http_text.removeprefix("\n✅ "))["empty_result"] == expected
assert json.loads(output.out)["empty_result"] == expected
else:
assert http_text == "\n✅"
assert output.out == "\n"