Skip to content

Commit c71f856

Browse files
fix: search index completeness (exclusion pagination, parity, live backfill)
1 parent 96a4ce5 commit c71f856

4 files changed

Lines changed: 289 additions & 55 deletions

File tree

api/search.py

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,16 @@
1212
from api._flask_types import FlaskReturn, json_response
1313
from api.error_codes import ErrorCode, error_response
1414
from models.search import SearchHitDict
15-
from models.session import MessageDict
1615
from utils.exclusion_rules import is_session_excluded
1716
from utils.search_index import (
1817
index_is_usable,
1918
index_search_enabled,
19+
message_searchable_text,
2020
query_index_hits,
2121
resolve_search_since_ms,
2222
search_snippet,
2323
timestamp_in_search_window_iso,
2424
timestamp_to_ms,
25-
tool_result_searchable_text,
2625
)
2726
from utils.session_cache import get_cached_session
2827
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
@@ -73,6 +72,35 @@ def _rank_search_hits(results: list[SearchHitDict]) -> list[SearchHitDict]:
7372
)
7473

7574

75+
def _hit_dedup_key(hit: SearchHitDict) -> tuple[str, str, str | None, str]:
76+
ts = hit.get("timestamp")
77+
return (
78+
hit["project"],
79+
hit["session_id"],
80+
ts if isinstance(ts, str) else None,
81+
hit["role"],
82+
)
83+
84+
85+
def _merge_search_hits(
86+
primary: list[SearchHitDict],
87+
extra: list[SearchHitDict],
88+
*,
89+
max_results: int,
90+
) -> list[SearchHitDict]:
91+
seen = {_hit_dedup_key(hit) for hit in primary}
92+
merged = list(primary)
93+
for hit in extra:
94+
key = _hit_dedup_key(hit)
95+
if key in seen:
96+
continue
97+
merged.append(hit)
98+
seen.add(key)
99+
if len(merged) >= max_results:
100+
break
101+
return _rank_search_hits(merged)[:max_results]
102+
103+
76104
def _projects_dir_inaccessible(projects_dir: str) -> bool:
77105
"""True when the projects path exists but cannot be listed (503 case)."""
78106
try:
@@ -84,18 +112,6 @@ def _projects_dir_inaccessible(projects_dir: str) -> bool:
84112
return True
85113

86114

87-
def _message_searchable_text(msg: MessageDict) -> str:
88-
text = msg.get("text", "") or msg.get("content", "")
89-
if not isinstance(text, str):
90-
text = ""
91-
tool_result = msg.get("tool_result")
92-
if isinstance(tool_result, dict):
93-
tool_text = tool_result_searchable_text(tool_result)
94-
if tool_text:
95-
text = f"{text}\n{tool_text}" if text else tool_text
96-
return text
97-
98-
99115
def _index_hit_excluded(
100116
rules: list[Any],
101117
rules_fp: str,
@@ -129,13 +145,14 @@ def _search_via_index(
129145
*,
130146
since_ms: int | None,
131147
max_results: int,
132-
) -> list[SearchHitDict] | None:
148+
) -> tuple[list[SearchHitDict] | None, bool]:
133149
if not index_search_enabled() or not index_is_usable(projects_dir, rules):
134-
return None
150+
return None, False
135151

136152
rules_fp = rules_fingerprint(rules)
137153
results: list[SearchHitDict] = []
138154
sql_offset = 0
155+
fts_exhausted = False
139156
while len(results) < max_results:
140157
need = max_results - len(results)
141158
indexed = query_index_hits(
@@ -150,11 +167,12 @@ def _search_via_index(
150167
len(results),
151168
)
152169
if results:
153-
return _rank_search_hits(results)[:max_results]
154-
return None
170+
return _rank_search_hits(results)[:max_results], False
171+
return None, False
155172
if not indexed["query_ok"]:
156-
return None
173+
return None, False
157174
if indexed["sql_rows_fetched"] == 0:
175+
fts_exhausted = indexed["sql_exhausted"]
158176
break
159177

160178
for hit in indexed["hits"]:
@@ -181,8 +199,50 @@ def _search_via_index(
181199

182200
sql_offset += indexed["sql_rows_fetched"]
183201
if indexed["sql_exhausted"]:
202+
fts_exhausted = True
184203
break
185-
return _rank_search_hits(results)[:max_results]
204+
return _rank_search_hits(results)[:max_results], fts_exhausted
205+
206+
207+
def _resolve_search_results(
208+
base: str,
209+
rules: list[Any],
210+
query: str,
211+
query_lower: str,
212+
*,
213+
since_ms: int | None,
214+
max_results: int,
215+
) -> list[SearchHitDict]:
216+
indexed, _fts_exhausted = _search_via_index(
217+
base,
218+
rules,
219+
query,
220+
query_lower,
221+
since_ms=since_ms,
222+
max_results=max_results,
223+
)
224+
if indexed is None:
225+
return _search_live_scan(
226+
base,
227+
rules,
228+
query,
229+
query_lower,
230+
since_ms=since_ms,
231+
max_results=max_results,
232+
)
233+
234+
if len(indexed) >= max_results:
235+
return indexed
236+
237+
live = _search_live_scan(
238+
base,
239+
rules,
240+
query,
241+
query_lower,
242+
since_ms=since_ms,
243+
max_results=max_results,
244+
)
245+
return _merge_search_hits(indexed, live, max_results=max_results)
186246

187247

188248
def _search_live_scan(
@@ -213,7 +273,7 @@ def _search_live_scan(
213273
continue
214274

215275
for msg in session["messages"]:
216-
text = _message_searchable_text(msg)
276+
text = message_searchable_text(msg)
217277
if not text or query_lower not in text.lower():
218278
continue
219279
if not timestamp_in_search_window_iso(
@@ -289,19 +349,8 @@ def search() -> FlaskReturn:
289349
rules = current_app.config.get("EXCLUSION_RULES") or []
290350

291351
try:
292-
indexed = _search_via_index(
293-
base,
294-
rules,
295-
query,
296-
query_lower,
297-
since_ms=since_ms,
298-
max_results=max_results,
299-
)
300-
if indexed is not None:
301-
return json_response(indexed)
302-
303352
return json_response(
304-
_search_live_scan(
353+
_resolve_search_results(
305354
base,
306355
rules,
307356
query,

tests/test_error_propagation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def test_search_internal_error_does_not_leak(client_single, monkeypatch):
212212
def _boom(*_args, **_kwargs):
213213
raise RuntimeError("internal_secret_search_token")
214214

215-
monkeypatch.setattr("api.search._search_via_index", lambda *_a, **_kw: None)
215+
monkeypatch.setattr("api.search._search_via_index", lambda *_a, **_kw: (None, False))
216216
monkeypatch.setattr("api.search._search_live_scan", _boom)
217217

218218
resp = client_single.get("/api/search?q=Hello&all_history=1")

tests/test_search_index.py

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111

1212
import pytest
1313

14-
from api.search import _search_via_index
14+
from api.search import _resolve_search_results, _search_via_index
15+
from utils.exclusion_rules import load_rules
1516
from utils.search_index import (
1617
build_search_index,
1718
index_is_usable,
@@ -314,7 +315,7 @@ def test_phrase_filter_paginates_past_decoy_token_matches(self, tmp_path, monkey
314315
patches = _index_patches(cache_root)
315316
with patches[0]:
316317
build_search_index(str(projects), [], force=True)
317-
results = _search_via_index(
318+
results, _fts_exhausted = _search_via_index(
318319
str(projects),
319320
[],
320321
phrase,
@@ -386,6 +387,130 @@ def test_background_worker_starts_once(self, indexed_tree, monkeypatch):
386387
assert mock_thread.return_value.start.call_count == 1
387388

388389

390+
class TestIndexSearchCompleteness:
391+
def test_partial_batch_not_marked_exhausted(self, tmp_path, monkeypatch):
392+
"""sql_exhausted stays false until every row in the SQL batch is scanned."""
393+
cache_root = tmp_path / "cache"
394+
cache_root.mkdir()
395+
projects = tmp_path / "projects"
396+
lines: list[dict[str, object]] = []
397+
for i in range(120):
398+
lines.append(
399+
{
400+
"type": "user",
401+
"timestamp": f"2026-07-{(i % 28) + 1:02d}T10:00:00Z",
402+
"message": {
403+
"content": [{"type": "text", "text": f"batch sentinel token {i}"}],
404+
},
405+
}
406+
)
407+
_write_session(projects / "batch-proj" / "session.jsonl", lines)
408+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
409+
patches = _index_patches(cache_root)
410+
with patches[0]:
411+
build_search_index(str(projects), [], force=True)
412+
result = query_index_hits(
413+
"batch sentinel token",
414+
since_ms=None,
415+
max_results=50,
416+
sql_offset=0,
417+
)
418+
assert result["query_ok"] is True
419+
assert len(result["hits"]) == 50
420+
assert result["sql_rows_fetched"] < 120
421+
assert result["sql_exhausted"] is False
422+
423+
def test_index_search_fills_limit_after_excluded_hits(self, tmp_path, monkeypatch):
424+
cache_root = tmp_path / "cache"
425+
cache_root.mkdir()
426+
projects = tmp_path / "projects"
427+
term = "quota-fill-sentinel"
428+
excl_lines: list[dict[str, object]] = []
429+
for i in range(80):
430+
excl_lines.append(
431+
{
432+
"type": "user",
433+
"timestamp": f"2026-08-{(i % 28) + 1:02d}T10:00:00Z",
434+
"message": {"content": [{"type": "text", "text": f"{term} excluded"}]},
435+
}
436+
)
437+
good_lines: list[dict[str, object]] = []
438+
for i in range(60):
439+
good_lines.append(
440+
{
441+
"type": "user",
442+
"timestamp": f"2026-05-{(i % 28) + 1:02d}T10:00:00Z",
443+
"message": {"content": [{"type": "text", "text": f"{term} good"}]},
444+
}
445+
)
446+
_write_session(projects / "excl-proj" / "session.jsonl", excl_lines)
447+
_write_session(projects / "good-proj" / "session.jsonl", good_lines)
448+
rules_path = tmp_path / "rules.txt"
449+
rules_path.write_text("excl-proj\n", encoding="utf-8")
450+
rules = load_rules(str(rules_path))
451+
452+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
453+
patches = _index_patches(cache_root)
454+
with patches[0]:
455+
build_search_index(str(projects), rules, force=True)
456+
results, _fts_exhausted = _search_via_index(
457+
str(projects),
458+
rules,
459+
term,
460+
term.lower(),
461+
since_ms=None,
462+
max_results=50,
463+
)
464+
assert results is not None
465+
assert len(results) == 50
466+
assert all(hit["project"] == "good-proj" for hit in results)
467+
468+
def test_system_content_index_and_live_scan_parity(self, tmp_path, monkeypatch):
469+
cache_root = tmp_path / "cache"
470+
cache_root.mkdir()
471+
projects = tmp_path / "projects"
472+
sentinel = "system-role-sentinel-xyz"
473+
_write_session(
474+
projects / "sys-proj" / "session.jsonl",
475+
[
476+
{
477+
"type": "user",
478+
"timestamp": "2026-06-01T10:00:00Z",
479+
"message": {"content": [{"type": "text", "text": "hello"}]},
480+
},
481+
{
482+
"type": "system",
483+
"timestamp": "2026-06-01T10:00:01Z",
484+
"content": f"compact boundary note {sentinel}",
485+
},
486+
{
487+
"type": "assistant",
488+
"timestamp": "2026-06-01T10:00:02Z",
489+
"message": {"content": [{"type": "text", "text": "ack"}]},
490+
},
491+
],
492+
)
493+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
494+
patches = _index_patches(cache_root)
495+
with patches[0]:
496+
build_search_index(str(projects), [], force=True)
497+
indexed = query_index_hits(sentinel, since_ms=None, max_results=5)
498+
assert indexed["query_ok"] is True
499+
assert len(indexed["hits"]) == 1
500+
assert indexed["hits"][0]["role"] == "system"
501+
502+
resolved = _resolve_search_results(
503+
str(projects),
504+
[],
505+
sentinel,
506+
sentinel.lower(),
507+
since_ms=None,
508+
max_results=5,
509+
)
510+
assert len(resolved) == 1
511+
assert resolved[0]["role"] == "system"
512+
513+
389514
class TestResolveSearchSinceMs:
390515
def test_all_history_none(self):
391516
assert resolve_search_since_ms(all_history=True) is None

0 commit comments

Comments
 (0)