Skip to content

Commit ac694f1

Browse files
Fixed Timon's feedback on feat/search-fts-index. Summary: utils/search_index.py Added ORDER BY m.timestamp_ms DESC so index hits are newest-first Replaced the 20× over-fetch with a fixed _FTS_BATCH_SIZE = 200; _search_via_index keeps paging until results are filled or FTS is exhausted Fixed offset: sql_rows_fetched is now rows actually scanned (not full batch size), so early break no longer skips unprocessed rows api/search.py Added _rank_search_hits() for timestamp-desc ordering Index and live-scan paths both rank before returning (live scan collects all matches, then sorts and slices — correct global newest-first on fallback) Tests test_results_ordered_newest_first — verifies DESC ordering test_phrase_filter_paginates_past_decoy_token_matches — 250 token-only decoys + real phrase at end; would fail with the old offset bug 30 tests passed (ruff + mypy clean). Suggested commit message: fix: FTS search ordering and pagination for phrase filtering Add ORDER BY timestamp_ms DESC, advance SQL offset by rows scanned, and page in fixed batches until enough hits or FTS exhausted. Rank live-scan results the same way for parity. Want me to commit and push to PR #120? /humanizer commit message fix: FTS search ordering and pagination
1 parent 6339016 commit ac694f1

4 files changed

Lines changed: 114 additions & 11 deletions

File tree

api/search.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
resolve_search_since_ms,
2121
search_snippet,
2222
timestamp_in_search_window_iso,
23+
timestamp_to_ms,
2324
tool_result_searchable_text,
2425
)
2526
from utils.session_cache import get_cached_session
@@ -59,6 +60,17 @@ def _parse_since_days(raw: str | None) -> int | None:
5960
return min(days, _MAX_SEARCH_SINCE_DAYS)
6061

6162

63+
def _rank_search_hits(results: list[SearchHitDict]) -> list[SearchHitDict]:
64+
"""Sort hits by timestamp descending (missing timestamps last)."""
65+
return sorted(
66+
results,
67+
key=lambda hit: timestamp_to_ms(
68+
hit["timestamp"] if isinstance(hit.get("timestamp"), str) else None
69+
),
70+
reverse=True,
71+
)
72+
73+
6274
def _message_searchable_text(msg: MessageDict) -> str:
6375
text = msg.get("text", "") or msg.get("content", "")
6476
if not isinstance(text, str):
@@ -149,7 +161,7 @@ def _search_via_index(
149161
sql_offset += indexed["sql_rows_fetched"]
150162
if indexed["sql_exhausted"]:
151163
break
152-
return results
164+
return _rank_search_hits(results)[:max_results]
153165

154166

155167
def _search_live_scan(
@@ -164,12 +176,8 @@ def _search_live_scan(
164176
projects = list_projects(base)
165177
results: list[SearchHitDict] = []
166178
for project in projects:
167-
if len(results) >= max_results:
168-
break
169179
sessions = list_sessions(project["path"])
170180
for sess_info in sessions:
171-
if len(results) >= max_results:
172-
break
173181
try:
174182
session = get_cached_session(sess_info["path"])
175183
except Exception:
@@ -202,9 +210,7 @@ def _search_live_scan(
202210
"snippet": search_snippet(text, query),
203211
}
204212
)
205-
if len(results) >= max_results:
206-
break
207-
return results
213+
return _rank_search_hits(results)[:max_results]
208214

209215

210216
@search_bp.route("/api/search")

tests/test_search.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,12 @@ def test_search_falls_back_when_index_query_fails(tmp_path, monkeypatch):
147147
client = _seed_indexed_client(tmp_path, monkeypatch, timestamp=recent_ts)
148148
with patch(
149149
"api.search.query_index_hits",
150-
return_value={"hits": [], "query_ok": False},
150+
return_value={
151+
"hits": [],
152+
"query_ok": False,
153+
"sql_rows_fetched": 0,
154+
"sql_exhausted": True,
155+
},
151156
):
152157
resp = client.get("/api/search?q=Hello")
153158
assert resp.status_code == 200

tests/test_search_index.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import pytest
1313

14+
from api.search import _search_via_index
1415
from utils.search_index import (
1516
build_search_index,
1617
index_is_usable,
@@ -238,6 +239,93 @@ def test_undated_message_in_window(self, tmp_path, monkeypatch):
238239

239240

240241
class TestQueryIndexHits:
242+
def test_results_ordered_newest_first(self, tmp_path, monkeypatch):
243+
cache_root = tmp_path / "cache"
244+
cache_root.mkdir()
245+
projects = tmp_path / "projects"
246+
_write_session(
247+
projects / "order-proj" / "session.jsonl",
248+
[
249+
{
250+
"type": "user",
251+
"timestamp": "2026-05-19T10:00:00Z",
252+
"message": {"content": [{"type": "text", "text": "order alpha token"}]},
253+
},
254+
{
255+
"type": "user",
256+
"timestamp": "2026-06-19T10:00:00Z",
257+
"message": {"content": [{"type": "text", "text": "order beta token"}]},
258+
},
259+
{
260+
"type": "user",
261+
"timestamp": "2026-07-19T10:00:00Z",
262+
"message": {"content": [{"type": "text", "text": "order gamma token"}]},
263+
},
264+
],
265+
)
266+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
267+
patches = _index_patches(cache_root)
268+
with patches[0]:
269+
build_search_index(str(projects), [], force=True)
270+
hits = query_index_hits("order", since_ms=None, max_results=10)
271+
assert hits["query_ok"] is True
272+
assert len(hits["hits"]) == 3
273+
timestamps = [hit["timestamp"] for hit in hits["hits"]]
274+
assert timestamps == sorted(timestamps, reverse=True)
275+
276+
def test_phrase_filter_paginates_past_decoy_token_matches(self, tmp_path, monkeypatch):
277+
"""Multi-word phrase matches beyond one FTS batch are not skipped."""
278+
cache_root = tmp_path / "cache"
279+
cache_root.mkdir()
280+
projects = tmp_path / "projects"
281+
lines: list[dict[str, object]] = []
282+
for i in range(250):
283+
lines.append(
284+
{
285+
"type": "user",
286+
"timestamp": f"2026-07-{(i % 28) + 1:02d}T10:00:00Z",
287+
"message": {
288+
"content": [
289+
{
290+
"type": "text",
291+
"text": f"phrase target decoy unique token only {i}",
292+
}
293+
]
294+
},
295+
}
296+
)
297+
phrase = "decoy unique token only phrase target"
298+
lines.append(
299+
{
300+
"type": "user",
301+
"timestamp": "2025-12-01T10:00:00Z",
302+
"message": {
303+
"content": [
304+
{
305+
"type": "text",
306+
"text": phrase,
307+
}
308+
]
309+
},
310+
}
311+
)
312+
_write_session(projects / "page-proj" / "session.jsonl", lines)
313+
monkeypatch.setenv("CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR", str(cache_root))
314+
patches = _index_patches(cache_root)
315+
with patches[0]:
316+
build_search_index(str(projects), [], force=True)
317+
results = _search_via_index(
318+
str(projects),
319+
[],
320+
phrase,
321+
phrase.lower(),
322+
since_ms=None,
323+
max_results=1,
324+
)
325+
assert results is not None
326+
assert len(results) == 1
327+
assert phrase in results[0]["snippet"] or "phrase target" in results[0]["snippet"]
328+
241329
def test_fts_failure_returns_query_not_ok(self, indexed_tree):
242330
@contextmanager
243331
def _broken_conn(*, readonly: bool = True):

utils/search_index.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
_usability_cache: dict[tuple[str, str], tuple[bool, float]] = {}
5959
_usability_cache_lock = threading.Lock()
6060
_USABILITY_CACHE_TTL_SECONDS = 30.0
61+
_FTS_BATCH_SIZE = 200
6162

6263

6364
class IndexMessageHitDict(TypedDict):
@@ -628,7 +629,7 @@ def query_index_hits(
628629
if not fts_q:
629630
return empty
630631

631-
sql_limit = max(max_results * 20, max_results)
632+
sql_limit = max(max_results, _FTS_BATCH_SIZE)
632633
with _index_db_conn(readonly=True) as conn:
633634
if conn is None:
634635
return {"hits": [], "query_ok": False, "sql_rows_fetched": 0, "sql_exhausted": True}
@@ -640,6 +641,7 @@ def query_index_hits(
640641
" JOIN sessions s"
641642
" ON s.session_id = m.session_id AND s.project_name = m.project_name"
642643
" WHERE messages_fts MATCH ?"
644+
" ORDER BY m.timestamp_ms DESC"
643645
" LIMIT ? OFFSET ?",
644646
(fts_q, sql_limit, sql_offset),
645647
).fetchall()
@@ -648,7 +650,9 @@ def query_index_hits(
648650
return {"hits": [], "query_ok": False, "sql_rows_fetched": 0, "sql_exhausted": True}
649651

650652
hits: list[IndexMessageHitDict] = []
653+
rows_scanned = 0
651654
for row in rows:
655+
rows_scanned += 1
652656
text = row["text"] or ""
653657
if query_lower not in text.lower():
654658
continue
@@ -672,7 +676,7 @@ def query_index_hits(
672676
return {
673677
"hits": hits,
674678
"query_ok": True,
675-
"sql_rows_fetched": len(rows),
679+
"sql_rows_fetched": rows_scanned,
676680
"sql_exhausted": len(rows) < sql_limit,
677681
}
678682

0 commit comments

Comments
 (0)