diff --git a/src/session_search.py b/src/session_search.py index 71d909e0d..143ad0832 100644 --- a/src/session_search.py +++ b/src/session_search.py @@ -67,15 +67,34 @@ def _snippet(content: str, query: str, radius: int = 60) -> str: if not query: return content[: radius * 2] - idx = content.lower().find(query.lower()) - if idx == -1: + match_start = -1 + match_length = 0 + lowered_content = content.lower() + for candidate in _query_terms(query): + idx = lowered_content.find(candidate.lower()) + if idx != -1 and (match_start == -1 or idx < match_start): + match_start = idx + match_length = len(candidate) + + if match_start == -1: return content[: radius * 2] - start = max(0, idx - radius) - end = min(len(content), idx + len(query) + radius) + start = max(0, match_start - radius) + end = min(len(content), match_start + match_length + radius) return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "") +def _query_terms(query: str) -> list[str]: + """Return meaningful quoted phrases and tokens in user-entered order.""" + terms: list[str] = [] + for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE): + term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-") + term = term.strip() + if term: + terms.append(term) + return terms + + def _sanitize_fts_query(query: str) -> str | None: """Convert free text into a conservative FTS5 MATCH query. diff --git a/tests/test_session_search.py b/tests/test_session_search.py index 72c380827..6657cbe35 100644 --- a/tests/test_session_search.py +++ b/tests/test_session_search.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import sessionmaker from core.database import Base from core.database import ChatMessage as DbChatMessage from core.database import Session as DbSession -from src.session_search import SessionSearchResult, search_session_messages +from src.session_search import SessionSearchResult, _snippet, search_session_messages def _db(with_fts=True): @@ -61,6 +61,47 @@ def _has_fts(db): ) +def test_snippet_locates_quoted_phrase_instead_of_raw_query(): + content = "A" * 100 + "The session search result is useful." + "Z" * 100 + + snippet = _snippet(content, '"session search"', radius=20) + + assert "session search" in snippet + assert snippet.startswith("...") + assert snippet.endswith("...") + + +def test_snippet_locates_punctuation_bearing_term_case_insensitively(): + content = "A" * 100 + "Use FOO_BAR.py for routing." + "Z" * 100 + + snippet = _snippet(content, "foo_bar.py!", radius=20) + + assert "FOO_BAR.py" in snippet + + +def test_snippet_uses_first_meaningful_matching_term(): + content = "A" * 100 + "second appears here; first appears later." + "Z" * 100 + + snippet = _snippet(content, "first second", radius=12) + + assert "second appears" in snippet + assert "first appears" not in snippet + + +def test_snippet_falls_back_to_content_start_when_no_term_matches(): + content = "Beginning of the transcript, followed by more detail." + + assert _snippet(content, '"missing phrase"', radius=10) == content[:20] + + +def test_snippet_preserves_boundary_markers_and_truncation(): + content = "A" * 50 + "target" + "Z" * 50 + + snippet = _snippet(content, "target", radius=5) + + assert snippet == "...AAAAAtargetZZZZZ..." + + def test_session_search_uses_fts_and_returns_context(): db = _db(with_fts=True) try: