mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-02 07:13:50 +00:00
* feat(benchmark): add golden answer validation and session review for LongMemEval - Introduce GoldenCheckStep to validate LongMemEval golden answers using structured verdicts - Add SessionReviewStep to extract query/answer-relevant evidence from all sessions - Implement concurrent session processing with configurable concurrency limits - Create check_golden job configuration with lme_review and lme_judge agent wrappers - Add Qwen3.7-plus model configuration for enhanced processing capabilities - Include python_execute tool integration for agent-based reasoning and date validation - Generate comprehensive JSON output with session summaries and validation verdicts - Add run_check_golden.py script for batch processing across all LongMemEval samples - Configure proper logging initialization with console and file output options - Update component registry and file I/O modules to support new benchmark features * feat(scripts): add script to summarize LongMemEval check_golden verdicts - Parse check_golden.json files across all LongMemEval samples - Calculate accuracy metrics for golden answers and session IDs - Provide breakdown by question type with percentage calculations - Add command line options for listing bad samples and JSON output - Include progress tracking showing completed vs pending samples - Display confidence scores and date sanity checks statistics * refactor(benchmark): move golden check scripts to longmemeval directory - Moved run_check_golden.py from scripts/ to benchmark/longmemeval/ - Moved stats_check_golden.py from scripts/ to benchmark/longmemeval/ - Updated path resolution to use parents[2] instead of parent.parent - Added new --list-run-failed option to stats script - Added logging directory constant and functions for tracking launched samples - Enhanced stats output with launched count and run failure information - Improved error reporting with run failure details and log file paths * feat(benchmark): add LongMemEval agentic answer workflow with session extraction - Add LmeAgenticAnswerStep, LmeAutoMemoryStep, and LmeExtractSessionStep to __init__.py - Create shared helper render_with_source for displaying search results with session_id - Implement agentic_answer step with vector_search, bm25_search, and extract_session_by_id tools - Add auto_memory step to convert each session into search-friendly daily notes - Create extract_session step to retrieve and analyze raw session content by session_id - Update jinli_lme.yaml with auto_memory, vector_search, bm25_search, and agentic_answer jobs - Configure lme_memory, lme_extract, and lme_agentic_answer agent wrappers - Enhance search steps with include_source option to show session_id metadata - Add proper session_id tracking and collision handling in daily note generation * feat(benchmark): add LongMemEval agentic answer evaluation pipeline - Add session_id tracking to agentic_answer.py result metadata - Introduce run_agentic_answer.py driver for complete pipeline execution - Implement auto_memory, update_index, and agentic_answer job orchestration - Add concurrent execution with configurable limits and staggering - Create aggregation script for collecting tool-call trails and results - Add stats_agentic_answer.py for comprehensive result analysis - Implement resume capability with existing output detection - Generate aggregate.json with per-sample breakdown and tool call summaries * feat(steps): add ClearPathsStep for cleaning workspace outputs before rebuild - Introduce ClearPathsStep to remove stale workspace files/directories - Add support for specifying paths and config_keys as targets to clear - Implement safety checks to prevent deletion of files outside workspace - Add logging for cleared paths and warnings for invalid paths - Configure clear_paths_step in jinli_lme.yaml to clean daily_dir - Add clear_paths_step to clean mem_answer.json before rebuilds * feat(benchmark): add resume functionality to agentic answer runner - Replace --force flag with --resume flag for controlling job execution - By default every job reruns with clean rebuild behavior using config clear steps - Add --resume option to skip samples whose output already exists and continue interrupted batches - Update documentation to reflect new default clean rebuild behavior - Modify job skipping logic to honor resume flag instead of force flag - Update dry-run output to show correct todo jobs based on resume status - Change default example command to use --resume for continuing interrupted runs * feat(benchmark): generate JSONL output for check golden records - Add write_check_golden_list function to create JSONL file - Write all readable check_golden records as JSONL format - Include check_golden_list path in stats output - Display generated JSONL file path in summary report - Maintain UTF-8 encoding with non-ASCII character support * refactor(benchmark): rename answer judge step and integrate LME LLM judge - Rename AnswerJudgeStep to LmeLlmJudgeStep and update imports - Add new llm_judge configuration in jinli_lme.yaml - Update run_agentic_answer.py to include llm_judge in pipeline - Modify LmeLlmJudgeStep to read from query.json and answer.json - Write LLM judgement results back to mem_answer.json - Add command line options for start/end sample range selection - Update aggregate.json generation to include LLM judgement data - Add resume capability for llm_judge job based on judgement presence * refactor(benchmark): rename answer judge step and integrate LME LLM judge - Rename AnswerJudgeStep to LmeLlmJudgeStep and update imports - Add new llm_judge configuration in jinli_lme.yaml - Update run_agentic_answer.py to include llm_judge in pipeline - Modify LmeLlmJudgeStep to read from query.json and answer.json - Write LLM judgement results back to mem_answer.json - Add command line options for start/end sample range selection - Update aggregate.json generation to include LLM judgement data - Add resume capability for llm_judge job based on judgement presence * feat(steps): add wait_for_paths_step to block until workspace files exist - Introduce WaitForPathsStep class that polls for required workspace-relative paths - Add step registration with 'wait_for_paths_step' backend identifier - Implement path validation to ensure targets are within workspace boundaries - Add polling mechanism with configurable intervals via poll_seconds parameter - Include logging functionality with log_every_seconds parameter for status updates - Add metadata tracking of waited paths and duration in response object - Register step in index module and expose in public API - Configure step in jinli_lme.yaml to wait for session_review.json before golden check - Add script rename from run_check_golden.py to run_golden_check.py with enhanced options * feat(benchmark): enhance longmemeval benchmarking with concurrency and progress tracking - Add benchmark extra dependency group with portalocker requirement - Introduce concurrent execution support for golden_check and session_review workflows - Add progress reporting interval option with real-time status updates - Implement global throttling mechanism for session review requests using file locks - Enhance golden check validation with current schema verification - Add active task tracking and graceful shutdown handling - Rename check_golden scripts to golden_check for consistency - Update statistics reporting with correct/incorrect terminology instead of reasonable - Add stale format detection and compatibility handling for verdict fields - Include both_correct rate calculation in accuracy metrics - Add concurrency and staggering options for better resource management * ci(workflow): add Windows smoke test workflow - Create new workflow file .github/workflows/windows-smoke.yml - Configure workflow to trigger on push and pull request events - Set up Python environment with version 3.11 - Install package dependencies using pip - Run version job as smoke test for CLI functionality - Enable concurrency control to prevent duplicate runs - Use matrix strategy for Python version testing * feat(benchmark): add retry mechanism and health check for session review - Added retry configuration options (retry_initial_seconds, retry_max_seconds, retry_max_attempts) to jinli_lme.yaml - Implemented exponential backoff retry logic with configurable parameters in session_review step - Added output_is_healthy function to verify session_review.json integrity and absence of failed reviews - Updated resume functionality to skip only healthy outputs instead of all existing files - Integrated JSON parsing and validation to check for failed reviews in output files - Enhanced error handling and logging for retry attempts and recovery scenarios * feat(benchmark): add LongMemEval session review statistics script - Create stats_session_review.py to summarize session_review.json artifacts - Add command line options for listing failed, missing, and run failed samples - Implement JSON output mode for programmatic consumption - Calculate and display health statistics including total samples, healthy outputs, failed sessions - Provide detailed failure information with session IDs and error messages - Generate re-run commands for samples with failed reviews - Add percentage calculations for better statistical overview - Include support for multiple output formats and detailed logging * feat(benchmark): add LongMemEval output cleanup script and enhance golden check retry logic - Added clean_sample_outputs.py script to remove generated LongMemEval files while preserving source inputs - Implemented configurable retry mechanism in golden_check.py with exponential backoff strategy - Added retry parameters (initial/max seconds and max attempts) to control failure recovery behavior - Integrated asyncio support for asynchronous sleep during retry intervals - Configured default retry settings in jinli_lme.yaml with 5s initial and 300s maximum intervals - Preserved core files (query.json, answer.json, session/) while cleaning generated artifacts * feat(benchmark): add AppleDouble file cleanup to sample output cleaner - Remove AppleDouble files starting with '._' recursively including under session/ - Add is_under helper function to check if path is inside parent directory - Track targets in set to avoid duplicate processing - Include AppleDouble files in cleanup targets when not already covered by existing targets - Maintain dry-run mode as default behavior with --apply flag for actual deletion * refactor(benchmark): update LongMemEval sample output cleaning script - Add time and Iterator imports for enhanced functionality - Add --progress-every argument to control progress reporting frequency - Replace is_under function with iter_sample_targets generator - Implement detailed progress tracking with timing measurements - Add sample-by-sample processing with elapsed time reporting - Include AppleDouble file detection within session directory - Update target counting and deletion statistics display - Add conditional progress updates based on progress-every setting - Improve dry-run mode with would-delete indication * chore(benchmark): increase initial interval for session review step - Changed START_INTERVAL_SECONDS from 1.0 to 3.0 seconds - Adjusted timing parameters for better benchmark stability * refactor(benchmark): implement coordinated retry mechanism for session reviews - Add retry gate condition to coordinate concurrent review attempts - Implement wait_for_healthy_start_slot to handle sequential retries - Create mark_retrying and mark_recovered functions to track retry states - Update reply_with_retry to accept index parameter for coordination - Add has_prior_retry logic to prevent race conditions during recovery - Ensure proper cleanup of retry state on success or failure - Maintain backward compatibility while adding coordination features * chore(benchmark): adjust session review start interval timeout - Changed START_INTERVAL_SECONDS from 3.0 to 5.0 seconds - Increased initial delay for session review benchmark step - Updated timeout configuration for improved stability * refactor(benchmark): update session review concurrency and throttling mechanism - Replace global throttle with per-process concurrency control - Add concurrency parameter with default value of 30 in config - Add start_interval_seconds parameter with default value of 2 seconds - Change default concurrency from 3 to 1 in command line interface - Update documentation to reflect new throttling behavior - Implement semaphore-based concurrency limiting for review tasks - Modify retry mechanism to use local locking instead of global files - Remove portalocker dependency for cross-process throttling * refactor(config): update session review configuration and concurrency settings - Removed deprecated retry configuration parameters from jinli_lme.yaml - Increased MAX_CONCURRENCY from 30 to 60 in session_review.py - Reduced START_INTERVAL_SECONDS from 2.0 to 1.0 in session_review.py - Cleaned up redundant backend specifications in configuration file - Simplified agent wrapper configurations by removing obsolete retry settings * feat(benchmark): enhance LME auto memory step with advanced scheduling and error handling - Add datetime parsing functionality for LongMemEval timestamps with regex pattern - Implement configurable concurrency limits with MAX_CONCURRENCY of 60 - Introduce retry mechanism with exponential backoff for agent interactions - Add session filtering based on date comparison with question_date validation - Create rate limiting with start interval control between requests - Implement sophisticated retry coordination using asyncio conditions - Add comprehensive error tracking for failed and filtered session extracts - Remove deprecated concurrency parameter from jinli_lme.yaml configuration - Add structured output validation in session review step - Include detailed metadata reporting with session statistics and errors * fix(benchmark): adjust default concurrency for auto_memory job - Changed default concurrency from 3 to 1 for auto_memory job to prevent API overload - Updated help text to reflect new default value of 1 for concurrency parameter - Modified documentation to clarify concurrency behavior varies by job type * refactor(search): replace hardcoded candidate multiplier with constant - Introduced _CANDIDATE_MULTIPLIER constant set to 10 - Replaced hardcoded factor of 5 with _CANDIDATE_MULTIPLIER in BM25 search - Replaced hardcoded factor of 5 with _CANDIDATE_MULTIPLIER in vector search - Updated test to verify both search steps use ten times limit for candidates - Imported VectorSearchStep and Bm25SearchStep in test module - Added comprehensive test case for candidate count calculation logic * feat(lme): add data inspection error handling with fallback mechanism - Implemented non-retryable data inspection error markers detection - Added _is_data_inspection_error method to identify inspection failures - Created fallback handling for data inspection errors in auto memory extraction - Added fallback handling for data inspection errors in session review - Extended failed extracts tracking with non-retryable and fallback flags - Separated fallback extracts from regular failed extracts in reporting - Enhanced error logging with specific data inspection failure messages - Updated metrics to track fallback extractions and reviews separately - Maintained existing retry logic for other exception types * feat(benchmark): enhance session review statistics with fallback tracking - Add support for identifying and listing non-retryable fallback reviews - Introduce --list-fallback argument to display fallback review details - Separate retryable failures from non-retryable fallbacks in reporting - Track fallback samples and sessions separately from failed ones - Update console output to show both retryable and non-retryable categories - Include fallback details in JSON output with reasons and session info - Modify failure counting logic to distinguish between retryable and fallback reviews * feat(benchmark): add question_id tracking and enhanced fallback reporting - Add question_id function to extract query.question_id from data - Initialize question_id_by_id dictionary to store question IDs by index - Store question_id for each sample during data processing - Enhance fallback output to include question IDs and session information - Format sample labels with question IDs when available - Display session IDs associated with each fallback case * feat(benchmark): add question_id support and improve bad sample reporting - Add question_id_for function to extract question_id from multiple sources - Add sample_label function to format samples as idx(question_id) when available - Store question_id in data dictionary during processing - Change bad_golden and bad_sessions to store full records instead of just indices - Update list_bad output to show formatted labels with question_id information - Improve error reporting with more detailed sample identification * feat(benchmark): enhance golden check stats with structured output - Add related_session_ids function to extract session IDs from verdict records - Create grouped_records function to group records by question type - Replace flat list output with JSON-formatted grouped records in list_bad option - Replace flat list output with JSON-formatted grouped records in list_bad_sessions option - Maintain Chinese labels while adding structured data presentation - Improve readability of bad verdict record display with hierarchical grouping * feat(benchmark): update data structure for question indexing - Replace sample_label with _idx field for index tracking - Add question_id field to store _question_id values - Maintain backward compatibility with empty string defaults - Preserve existing session_id functionality - Update data mapping to include new fields in grouped results * refactor(benchmark): streamline golden answer verification process - Replace relevance filtering with comprehensive information extraction - Remove is_relevant field and simplify session summary structure - Change relevant_info to extracted_info for clarity - Update golden check logic to work with full extractions instead of filtered summaries - Simplify prompt instructions to focus on complete information extraction - Remove redundant schema validation and structured output requirements - Adjust statistics calculation to match new extraction approach - Update metadata field names to reflect extraction rather than relevance checking * feat(benchmark): add selective file deletion option to clean_sample_outputs - Add --filename argument to delete only specific root-level files - Modify iter_sample_targets function to accept optional filenames filter - Implement validation for root-level filename constraints - Update function calls to pass filenames parameter - Add example usage for selective file deletion in documentation * feat(benchmark): add error count metrics to golden check statistics - Added golden_bad, session_bad, and both_bad calculation fields - Updated console output format to include error counts per question type - Modified table display to show both accuracy rates and error numbers - Enhanced statistical summary with additional error breakdown metrics * test(search): update search step tests with include_source parameter - Added include_source=False parameter to VectorSearchStep initialization - Added include_source=False parameter to Bm25SearchStep initialization - Maintained existing RuntimeContext parameters for both search steps - Updated test calls to match new constructor signature with include_source option
438 lines
16 KiB
Python
438 lines
16 KiB
Python
"""Unit tests for SearchStep without embedding or LLM dependencies."""
|
|
|
|
import asyncio
|
|
|
|
from reme.components.file_store import BaseFileStore
|
|
from reme.components import ApplicationContext
|
|
from reme.components.runtime_context import RuntimeContext
|
|
from reme.enumeration import LinkScopeEnum
|
|
from reme.schema import FileChunk, FileLink, FileNode
|
|
from reme.steps.index import AddDraftStep, Bm25SearchStep, ReadAllDraftStep, SearchStep, VectorSearchStep
|
|
|
|
|
|
class FakeSearchStore(BaseFileStore):
|
|
"""Minimal file_store for SearchStep: static search results and empty graph links."""
|
|
|
|
def __init__(
|
|
self,
|
|
vector_results: list[FileChunk] | None = None,
|
|
keyword_results: list[FileChunk] | None = None,
|
|
):
|
|
super().__init__(name="fake_search_store")
|
|
self.vector_results = vector_results or []
|
|
self.keyword_results = keyword_results or []
|
|
self.calls: list[tuple[str, str, int, dict]] = []
|
|
|
|
async def upsert(self, files: list[tuple[FileNode, list[FileChunk]]]) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def delete(self, path: str | list[str]) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def clear(self) -> None:
|
|
raise NotImplementedError
|
|
|
|
async def get_nodes(self, paths: list[str] | None = None) -> list[FileNode]:
|
|
return []
|
|
|
|
async def get_outlinks(
|
|
self,
|
|
path: str,
|
|
scope: LinkScopeEnum = LinkScopeEnum.REAL,
|
|
) -> list[FileLink]:
|
|
return []
|
|
|
|
async def get_inlinks(
|
|
self,
|
|
path: str,
|
|
scope: LinkScopeEnum = LinkScopeEnum.REAL,
|
|
) -> list[FileLink]:
|
|
return []
|
|
|
|
async def vector_search(self, query: str, limit: int, search_filter: dict) -> list[FileChunk]:
|
|
self.calls.append(("vector", query, limit, search_filter))
|
|
return self.vector_results[:limit]
|
|
|
|
async def keyword_search(self, query: str, limit: int, search_filter: dict) -> list[FileChunk]:
|
|
self.calls.append(("keyword", query, limit, search_filter))
|
|
return self.keyword_results[:limit]
|
|
|
|
|
|
def _chunk(
|
|
chunk_id: str,
|
|
path: str,
|
|
text: str,
|
|
score_key: str,
|
|
score: float,
|
|
line: int = 1,
|
|
) -> FileChunk:
|
|
return FileChunk(
|
|
id=chunk_id,
|
|
path=path,
|
|
text=text,
|
|
start_line=line,
|
|
end_line=line,
|
|
scores={score_key: score, "score": score},
|
|
)
|
|
|
|
|
|
def test_search_step_rrf_merges_vector_and_keyword_by_chunk_id():
|
|
"""Hybrid search fuses same-id hits once and keeps per-branch scores in metadata."""
|
|
|
|
async def run():
|
|
shared_v = _chunk("shared", "daily/a.md", "shared vector text", "vector", 0.92, line=3)
|
|
vector_only = _chunk("vector-only", "daily/b.md", "vector text", "vector", 0.71)
|
|
keyword_only = _chunk("keyword-only", "digest/c.md", "keyword text", "keyword", 8.0)
|
|
shared_k = _chunk("shared", "daily/a.md", "shared keyword text", "keyword", 7.0, line=3)
|
|
store = FakeSearchStore(
|
|
vector_results=[shared_v, vector_only],
|
|
keyword_results=[keyword_only, shared_k],
|
|
)
|
|
step = SearchStep(file_store=store, vector_weight=0.5, candidate_multiplier=2, expand_links=False)
|
|
ctx = RuntimeContext(query="alpha", limit=3, search_filter={"path_prefix": "daily/"})
|
|
|
|
resp = await step(ctx)
|
|
|
|
assert resp.success is True
|
|
assert resp.metadata["counts"] == {"vector": 2, "keyword": 2, "returned": 3, "hybrid": True}
|
|
assert [r["id"] for r in resp.metadata["results"]] == ["shared", "keyword-only", "vector-only"]
|
|
shared = resp.metadata["results"][0]
|
|
assert shared["scores"]["vector"] == 0.92
|
|
assert shared["scores"]["keyword"] == 7.0
|
|
assert shared["scores"]["score"] > resp.metadata["results"][1]["scores"]["score"]
|
|
assert "daily/a.md:3-3" in resp.answer
|
|
assert "vector=0.9200" in resp.answer
|
|
assert "keyword=7.0000" in resp.answer
|
|
assert {call[0] for call in store.calls} == {"vector", "keyword"}
|
|
assert all(call[2] == 6 for call in store.calls)
|
|
assert all(call[3] == {"path_prefix": "daily/"} for call in store.calls)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_lme_plain_search_steps_use_ten_times_limit_candidates():
|
|
"""LME vector/bm25 tools fetch a wider candidate pool before truncating."""
|
|
|
|
async def run():
|
|
store = FakeSearchStore()
|
|
|
|
vector = VectorSearchStep(file_store=store, include_source=False)
|
|
bm25 = Bm25SearchStep(file_store=store, include_source=False)
|
|
|
|
await vector(RuntimeContext(query="alpha", limit=3))
|
|
await bm25(RuntimeContext(query="alpha", limit=3))
|
|
|
|
assert store.calls == [
|
|
("vector", "alpha", 30, {}),
|
|
("keyword", "alpha", 30, {}),
|
|
]
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_draft_steps_accumulate_by_tool_context_id():
|
|
"""Drafts are stored in app metadata and isolated by injected tool_context_id."""
|
|
|
|
async def run():
|
|
app_context = ApplicationContext()
|
|
add = AddDraftStep(app_context=app_context)
|
|
read = ReadAllDraftStep(app_context=app_context)
|
|
|
|
await add(RuntimeContext(text="first", tool_context_id="ctx-1"))
|
|
await add(RuntimeContext(text="second", tool_context_id="ctx-1"))
|
|
await add(RuntimeContext(text="other", tool_context_id="ctx-2"))
|
|
|
|
resp = await read(RuntimeContext(tool_context_id="ctx-1"))
|
|
|
|
assert resp.answer == "first\nsecond"
|
|
assert resp.metadata["draft_count"] == 2
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_keyword_only_uses_keyword_scores_and_min_score():
|
|
"""When vector has no hits, SearchStep returns keyword results directly and applies min_score."""
|
|
|
|
async def run():
|
|
high = _chunk("high", "daily/high.md", "strong keyword hit", "keyword", 4.0)
|
|
low = _chunk("low", "daily/low.md", "weak keyword hit", "keyword", 0.2)
|
|
store = FakeSearchStore(keyword_results=[high, low])
|
|
step = SearchStep(file_store=store, expand_links=False)
|
|
ctx = RuntimeContext(query="keyword", limit=5, min_score=1.0)
|
|
|
|
resp = await step(ctx)
|
|
|
|
assert resp.metadata["counts"] == {"vector": 0, "keyword": 2, "returned": 1, "hybrid": False}
|
|
assert [r["id"] for r in resp.metadata["results"]] == ["high"]
|
|
assert "keyword=4.0000" not in resp.answer
|
|
assert "score=4.0000" in resp.answer
|
|
assert "daily/low.md" not in resp.answer
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_plain_search_steps_apply_min_score_before_truncation():
|
|
"""Vector-only and BM25-only tools should not return hits below ``min_score``."""
|
|
|
|
async def run():
|
|
vector_store = FakeSearchStore(
|
|
vector_results=[
|
|
_chunk("vector-high", "daily/high.md", "strong vector hit", "vector", 0.9),
|
|
_chunk("vector-low", "daily/low.md", "weak vector hit", "vector", 0.2),
|
|
],
|
|
)
|
|
keyword_store = FakeSearchStore(
|
|
keyword_results=[
|
|
_chunk("keyword-high", "daily/high.md", "strong keyword hit", "keyword", 4.0),
|
|
_chunk("keyword-low", "daily/low.md", "weak keyword hit", "keyword", 0.2),
|
|
],
|
|
)
|
|
|
|
vector = await VectorSearchStep(file_store=vector_store, include_source=False)(
|
|
RuntimeContext(query="alpha", limit=5, min_score=0.5),
|
|
)
|
|
keyword = await Bm25SearchStep(file_store=keyword_store, include_source=False)(
|
|
RuntimeContext(query="alpha", limit=5, min_score=1.0),
|
|
)
|
|
|
|
assert [result["id"] for result in vector.metadata["results"]] == ["vector-high"]
|
|
assert [result["id"] for result in keyword.metadata["results"]] == ["keyword-high"]
|
|
assert vector.answer == "strong vector hit"
|
|
assert keyword.answer == "strong keyword hit"
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_tool_context_deduplicates_returned_chunks_only():
|
|
"""When tool_context_id is supplied, repeated searches skip previously returned chunks."""
|
|
|
|
async def run():
|
|
chunks = [
|
|
_chunk("a", "daily/a.md", "first", "keyword", 5.0),
|
|
_chunk("b", "daily/b.md", "second", "keyword", 4.0),
|
|
_chunk("c", "daily/c.md", "third", "keyword", 3.0),
|
|
]
|
|
store = FakeSearchStore(keyword_results=chunks)
|
|
step = SearchStep(file_store=store, expand_links=False)
|
|
|
|
first = await step(RuntimeContext(query="alpha", limit=2, tool_context_id="ctx-1"))
|
|
second = await step(RuntimeContext(query="alpha", limit=2, tool_context_id="ctx-1"))
|
|
third = await step(RuntimeContext(query="alpha", limit=2))
|
|
|
|
assert [r["id"] for r in first.metadata["results"]] == ["a", "b"]
|
|
assert first.metadata["dedup"] == {
|
|
"tool_context_id": "ctx-1",
|
|
"seen_before": 0,
|
|
"skipped_seen": 0,
|
|
"seen_after": 2,
|
|
"expired": 0,
|
|
"ttl_seconds": 86400.0,
|
|
}
|
|
assert [r["id"] for r in second.metadata["results"]] == ["c"]
|
|
assert second.metadata["dedup"]["seen_before"] == 2
|
|
assert second.metadata["dedup"]["skipped_seen"] == 2
|
|
assert second.metadata["dedup"]["seen_after"] == 3
|
|
assert [r["id"] for r in third.metadata["results"]] == ["a", "b"]
|
|
assert "dedup" not in third.metadata
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_passes_metadata_filter_to_store():
|
|
"""Search filters can target chunk metadata such as conversation_date."""
|
|
|
|
async def run():
|
|
hit = _chunk("hit", "daily/2023-01-19/event.md", "historical hit", "keyword", 3.0)
|
|
store = FakeSearchStore(keyword_results=[hit])
|
|
step = SearchStep(file_store=store, expand_links=False)
|
|
search_filter = {"metadata": {"conversation_date": "2023-01-19"}}
|
|
|
|
resp = await step(RuntimeContext(query="Jon job", limit=5, search_filter=search_filter))
|
|
|
|
assert resp.success is True
|
|
assert resp.metadata["results"][0]["id"] == "hit"
|
|
assert all(call[3] == search_filter for call in store.calls)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_tool_context_seen_chunks_expire_after_ttl():
|
|
"""Seen chunk ids under a tool_context_id are reusable after the configured TTL."""
|
|
|
|
async def run():
|
|
now = 1000.0
|
|
chunks = [
|
|
_chunk("a", "daily/a.md", "first", "keyword", 5.0),
|
|
_chunk("b", "daily/b.md", "second", "keyword", 4.0),
|
|
]
|
|
store = FakeSearchStore(keyword_results=chunks)
|
|
step = SearchStep(
|
|
file_store=store,
|
|
expand_links=False,
|
|
seen_ttl_hours=1,
|
|
clock=lambda: now,
|
|
)
|
|
|
|
first = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
|
|
now = 4601.0
|
|
second = await step(RuntimeContext(query="alpha", limit=1, tool_context_id="ctx-1"))
|
|
|
|
assert [r["id"] for r in first.metadata["results"]] == ["a"]
|
|
assert [r["id"] for r in second.metadata["results"]] == ["a"]
|
|
assert second.metadata["dedup"]["expired"] == 1
|
|
assert second.metadata["dedup"]["seen_before"] == 0
|
|
assert second.metadata["dedup"]["ttl_seconds"] == 3600.0
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_empty_query_fails_before_store_calls():
|
|
"""Empty queries fail fast and do not call file_store search methods."""
|
|
|
|
async def run():
|
|
store = FakeSearchStore()
|
|
step = SearchStep(file_store=store)
|
|
resp = await step(RuntimeContext(query=" ", limit=5))
|
|
|
|
assert resp.success is False
|
|
assert resp.answer == "Error: query cannot be empty"
|
|
assert not store.calls
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_start_end_date_promoted_into_search_filter():
|
|
"""start_date and end_date from context are promoted into search_filter passed to store."""
|
|
|
|
async def run():
|
|
hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
|
|
store = FakeSearchStore(keyword_results=[hit])
|
|
step = SearchStep(file_store=store, expand_links=False)
|
|
ctx = RuntimeContext(
|
|
query="hello",
|
|
limit=5,
|
|
start_date="2024-01-01",
|
|
end_date="2024-06-30",
|
|
)
|
|
|
|
await step(ctx)
|
|
|
|
assert len(store.calls) == 2
|
|
for _, _, _, sf in store.calls:
|
|
assert sf["start_date"] == "2024-01-01"
|
|
assert sf["end_date"] == "2024-06-30"
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_invalid_date_is_ignored():
|
|
"""Invalid date strings are silently ignored (removed from filter) and search proceeds."""
|
|
|
|
async def run():
|
|
hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
|
|
store = FakeSearchStore(keyword_results=[hit])
|
|
step = SearchStep(file_store=store, expand_links=False)
|
|
ctx = RuntimeContext(
|
|
query="hello",
|
|
limit=5,
|
|
start_date="abc",
|
|
)
|
|
|
|
resp = await step(ctx)
|
|
|
|
assert resp.success is True
|
|
assert len(store.calls) == 2
|
|
for _, _, _, sf in store.calls:
|
|
assert "start_date" not in sf
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_non_normalized_date_is_canonicalized():
|
|
"""Valid but non-canonical dates like '2024-1-5' are normalized to '2024-01-05'."""
|
|
|
|
async def run():
|
|
hit = _chunk("hit", "daily/a.md", "some text", "keyword", 5.0)
|
|
store = FakeSearchStore(keyword_results=[hit])
|
|
step = SearchStep(file_store=store, expand_links=False)
|
|
ctx = RuntimeContext(
|
|
query="hello",
|
|
limit=5,
|
|
start_date="2024-1-5",
|
|
end_date="2024-6-1",
|
|
)
|
|
|
|
await step(ctx)
|
|
|
|
for _, _, _, sf in store.calls:
|
|
assert sf["start_date"] == "2024-01-05"
|
|
assert sf["end_date"] == "2024-06-01"
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_date_in_search_filter_not_overridden_by_context():
|
|
"""Explicit search_filter dates take precedence over top-level context dates."""
|
|
|
|
async def run():
|
|
hit = _chunk("hit", "daily/a.md", "text", "keyword", 3.0)
|
|
store = FakeSearchStore(keyword_results=[hit])
|
|
step = SearchStep(file_store=store, expand_links=False)
|
|
ctx = RuntimeContext(
|
|
query="hello",
|
|
limit=5,
|
|
start_date="2024-01-01",
|
|
end_date="2024-06-30",
|
|
search_filter={"start_date": "2023-07-01", "end_date": "2023-12-31"},
|
|
)
|
|
|
|
await step(ctx)
|
|
|
|
for _, _, _, sf in store.calls:
|
|
assert sf["start_date"] == "2023-07-01"
|
|
assert sf["end_date"] == "2023-12-31"
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_strict_date_filter_propagated_to_search_filter():
|
|
"""strict_date_filter=True is passed through to file_store via search_filter."""
|
|
|
|
async def run():
|
|
hit = _chunk("hit", "daily/2024-03-01/a.md", "text", "keyword", 3.0)
|
|
store = FakeSearchStore(keyword_results=[hit])
|
|
step = SearchStep(file_store=store, expand_links=False, strict_date_filter=True)
|
|
ctx = RuntimeContext(
|
|
query="hello",
|
|
limit=5,
|
|
start_date="2024-01-01",
|
|
end_date="2024-06-30",
|
|
)
|
|
|
|
await step(ctx)
|
|
|
|
for _, _, _, sf in store.calls:
|
|
assert sf["strict_date_filter"] is True
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_search_step_non_strict_date_filter_not_in_search_filter():
|
|
"""strict_date_filter defaults to False and is not added to search_filter."""
|
|
|
|
async def run():
|
|
hit = _chunk("hit", "daily/2024-03-01/a.md", "text", "keyword", 3.0)
|
|
store = FakeSearchStore(keyword_results=[hit])
|
|
step = SearchStep(file_store=store, expand_links=False)
|
|
ctx = RuntimeContext(
|
|
query="hello",
|
|
limit=5,
|
|
start_date="2024-01-01",
|
|
)
|
|
|
|
await step(ctx)
|
|
|
|
for _, _, _, sf in store.calls:
|
|
assert "strict_date_filter" not in sf
|
|
|
|
asyncio.run(run())
|