Skip to content

Commit 2bbdd7d

Browse files
feat: FTS5 search index with background rebuild and 30-day default window
1 parent a05fd29 commit 2bbdd7d

10 files changed

Lines changed: 1282 additions & 47 deletions

File tree

api/search.py

Lines changed: 191 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
1-
"""Search endpoint. Brute-force substring match across all sessions."""
1+
"""Search endpoint — FTS index with live-scan fallback."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from datetime import datetime, timezone
7+
from typing import Any
28

39
from flask import Blueprint, current_app, request
410

511
from api._flask_types import FlaskReturn, json_response
612
from api.error_codes import ErrorCode, error_response
713
from models.search import SearchHitDict
14+
from models.session import MessageDict
815
from utils.exclusion_rules import is_session_excluded
16+
from utils.search_index import (
17+
index_is_usable,
18+
index_search_enabled,
19+
query_index_hits,
20+
resolve_search_since_ms,
21+
search_snippet,
22+
timestamp_in_search_window_iso,
23+
tool_result_searchable_text,
24+
)
925
from utils.session_cache import get_cached_session
1026
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
27+
from utils.session_summary_cache import get_summary, rules_fingerprint
1128

1229
search_bp = Blueprint("search", __name__)
30+
_logger = logging.getLogger(__name__)
1331

1432
_DEFAULT_LIMIT = 50
1533
_MAX_LIMIT = 500
34+
_MAX_SEARCH_SINCE_DAYS = 36_500
1635

1736

1837
def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
@@ -28,25 +47,107 @@ def _parse_limit(raw: str | None, default: int = _DEFAULT_LIMIT) -> int:
2847
return min(value, _MAX_LIMIT)
2948

3049

31-
@search_bp.route("/api/search")
32-
def search() -> FlaskReturn:
33-
query = request.args.get("q", "").strip().lower()
34-
if not query:
35-
return json_response([])
36-
50+
def _parse_since_days(raw: str | None) -> int | None:
51+
if raw is None or not str(raw).strip():
52+
return None
3753
try:
38-
max_results = _parse_limit(request.args.get("limit"))
54+
days = int(str(raw).strip())
3955
except ValueError:
40-
return error_response(
41-
ErrorCode.SEARCH_INVALID_LIMIT,
42-
"Invalid limit: must be a positive integer",
43-
400,
56+
return None
57+
if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS:
58+
return None
59+
return days
60+
61+
62+
def _message_searchable_text(msg: MessageDict) -> str:
63+
text = msg.get("text", "") or msg.get("content", "")
64+
if not isinstance(text, str):
65+
text = ""
66+
tool_result = msg.get("tool_result")
67+
if isinstance(tool_result, dict):
68+
tool_text = tool_result_searchable_text(tool_result)
69+
if tool_text:
70+
text = f"{text}\n{tool_text}" if text else tool_text
71+
return text
72+
73+
74+
def _index_hit_excluded(
75+
rules: list[Any],
76+
rules_fp: str,
77+
*,
78+
project_name: str,
79+
file_path: str,
80+
mtime: float,
81+
) -> bool:
82+
if not rules:
83+
return False
84+
cached = get_summary(file_path, mtime, rules_fp)
85+
if cached is not None and cached["is_complete"]:
86+
return cached["is_excluded"]
87+
try:
88+
session = get_cached_session(file_path)
89+
except Exception:
90+
_logger.warning(
91+
"Could not load session for exclusion check during index search: %s",
92+
file_path,
93+
exc_info=True,
4494
)
95+
return False
96+
return is_session_excluded(rules, session, project_name)
4597

46-
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
47-
projects = list_projects(base)
4898

49-
rules = current_app.config.get("EXCLUSION_RULES") or []
99+
def _search_via_index(
100+
projects_dir: str,
101+
rules: list[Any],
102+
query: str,
103+
query_lower: str,
104+
*,
105+
since_ms: int | None,
106+
max_results: int,
107+
) -> list[SearchHitDict] | None:
108+
if not index_search_enabled() or not index_is_usable(projects_dir, rules):
109+
return None
110+
111+
rules_fp = rules_fingerprint(rules)
112+
indexed = query_index_hits(query_lower, since_ms=since_ms, max_results=max_results)
113+
if not indexed["query_ok"]:
114+
return None
115+
116+
results: list[SearchHitDict] = []
117+
for hit in indexed["hits"]:
118+
if len(results) >= max_results:
119+
break
120+
if _index_hit_excluded(
121+
rules,
122+
rules_fp,
123+
project_name=hit["project_name"],
124+
file_path=hit["file_path"],
125+
mtime=hit["mtime"],
126+
):
127+
continue
128+
results.append(
129+
{
130+
"project": hit["project_name"],
131+
"session_id": hit["session_id"],
132+
"title": hit["title"],
133+
"role": hit["role"],
134+
"timestamp": hit["timestamp"],
135+
"snippet": search_snippet(hit["text"], query),
136+
}
137+
)
138+
return results
139+
140+
141+
def _search_live_scan(
142+
base: str,
143+
rules: list[Any],
144+
query: str,
145+
query_lower: str,
146+
*,
147+
since_ms: int | None,
148+
max_results: int,
149+
) -> list[SearchHitDict]:
150+
projects = list_projects(base)
50151
results: list[SearchHitDict] = []
51152
for project in projects:
52153
if len(results) >= max_results:
@@ -58,30 +159,84 @@ def search() -> FlaskReturn:
58159
try:
59160
session = get_cached_session(sess_info["path"])
60161
except Exception:
162+
_logger.warning(
163+
"Skipping session during live search: %s",
164+
sess_info["path"],
165+
exc_info=True,
166+
)
61167
continue
62168

63169
if is_session_excluded(rules, session, project["name"]):
64170
continue
65171

66172
for msg in session["messages"]:
67-
text = msg.get("text", "") or msg.get("content", "")
68-
if query in text.lower():
69-
idx = text.lower().index(query)
70-
start = max(0, idx - 80)
71-
end = min(len(text), idx + len(query) + 80)
72-
snippet = text[start:end]
73-
74-
results.append(
75-
{
76-
"project": project["name"],
77-
"session_id": session["session_id"],
78-
"title": session["title"],
79-
"role": msg["role"],
80-
"timestamp": msg.get("timestamp"),
81-
"snippet": snippet,
82-
}
83-
)
84-
if len(results) >= max_results:
85-
break
86-
87-
return json_response(results)
173+
text = _message_searchable_text(msg)
174+
if not text or query_lower not in text.lower():
175+
continue
176+
if not timestamp_in_search_window_iso(
177+
msg.get("timestamp") if isinstance(msg.get("timestamp"), str) else None,
178+
since_ms,
179+
):
180+
continue
181+
results.append(
182+
{
183+
"project": project["name"],
184+
"session_id": session["session_id"],
185+
"title": session["title"],
186+
"role": msg["role"],
187+
"timestamp": msg.get("timestamp"),
188+
"snippet": search_snippet(text, query),
189+
}
190+
)
191+
if len(results) >= max_results:
192+
break
193+
return results
194+
195+
196+
@search_bp.route("/api/search")
197+
def search() -> FlaskReturn:
198+
query = request.args.get("q", "").strip()
199+
if not query:
200+
return json_response([])
201+
202+
try:
203+
max_results = _parse_limit(request.args.get("limit"))
204+
except ValueError:
205+
return error_response(
206+
ErrorCode.SEARCH_INVALID_LIMIT,
207+
"Invalid limit: must be a positive integer",
208+
400,
209+
)
210+
211+
query_lower = query.lower()
212+
all_history = request.args.get("all_history") in ("1", "true")
213+
since_ms = resolve_search_since_ms(
214+
all_history=all_history,
215+
since_days=_parse_since_days(request.args.get("since_days")),
216+
now=datetime.now(timezone.utc),
217+
)
218+
219+
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
220+
rules = current_app.config.get("EXCLUSION_RULES") or []
221+
222+
indexed = _search_via_index(
223+
base,
224+
rules,
225+
query,
226+
query_lower,
227+
since_ms=since_ms,
228+
max_results=max_results,
229+
)
230+
if indexed is not None:
231+
return json_response(indexed)
232+
233+
return json_response(
234+
_search_live_scan(
235+
base,
236+
rules,
237+
query,
238+
query_lower,
239+
since_ms=since_ms,
240+
max_results=max_results,
241+
)
242+
)

app.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
__version__ = "0.2.0.dev0"
44

55
import argparse
6+
import logging
67
import sys
78

89
from flask import Flask
@@ -13,6 +14,7 @@
1314
from api.search import search_bp
1415
from api.sessions import sessions_bp
1516
from utils.exclusion_rules import load_rules, resolve_exclusion_rules_path
17+
from utils.session_path import get_claude_projects_dir
1618

1719
# Content-Security-Policy for all Flask responses. 'unsafe-inline' in style-src is
1820
# required because highlight.js themes apply inline styles; can be tightened with
@@ -104,6 +106,17 @@ def create_app(
104106
app.register_blueprint(export_bp)
105107
app.register_blueprint(schema_report_bp)
106108

109+
if not app.config.get("TESTING"):
110+
try:
111+
from utils.search_index import start_search_index_background
112+
113+
projects_dir = base_dir or get_claude_projects_dir()
114+
start_search_index_background(projects_dir, app.config["EXCLUSION_RULES"])
115+
except Exception:
116+
logging.getLogger(__name__).exception(
117+
"Failed to start search index background worker"
118+
)
119+
107120
@app.after_request
108121
def set_security_headers(response):
109122
# Always set — do not use setdefault; a blueprint must not weaken CSP.

benchmarks/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ The memory ceiling test (`test_large_parse_peak_memory_under_ceiling`) runs in t
3838

3939
The `benchmarks` job on **ubuntu-latest** runs pytest-benchmark (`--benchmark-json=benchmark-results.json`), then `scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json`. CI fails when any **gated** benchmark mean exceeds its baseline by more than **20%**.
4040

41-
**Gated:** parse medium/large + large peak memory; export 10/50/100 session latency + ZIP peak memory.
41+
**Gated:** parse medium/large + large peak memory; export 10/50/100 session latency + ZIP peak memory; `test_search_full_corpus` (relaxed **2.0×** threshold vs 1.2× for other gates — sub-ms search timings are noisy on ubuntu CI).
4242

43-
**Not gated (informational only):** `test_parse_session_small`, `test_search_full_corpus` (sub-ms CI noise), and the `cache` group. These may appear in `baselines.json` for reference but are skipped by `check_benchmark_regression.py`. Benchmarks without a baseline entry print a warning and do not fail the gate.
43+
**Not gated (informational only):** `test_parse_session_small` and the `cache` group.
4444

4545
Missing gated benchmarks (renamed or removed tests still listed in `baselines.json`) fail the gate.
4646

benchmarks/baselines.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. PR #108 (schema drift): parse/export latency baselines raised for per-entry field-path fingerprinting. Excluded from gate (recorded for reference): test_parse_session_small, test_search_full_corpus (sub-ms CI noise). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
2+
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. PR #108 (schema drift): parse/export latency baselines raised for per-entry field-path fingerprinting. Excluded from gate (recorded for reference): test_parse_session_small (sub-ms CI noise). Memory benchmarks use extra_info.peak_bytes (bytes); latency uses stats.mean (seconds).",
33
"updated": "2026-07-03T00:00:00Z",
44
"machine": "Linux",
55
"groups": {

scripts/check_benchmark_regression.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
from pathlib import Path
99

1010
THRESHOLD = 1.20
11+
# Sub-ms search timings are noisy on ubuntu CI; allow a wider band than parse/export.
12+
SEARCH_BENCHMARK_THRESHOLD = 2.0
13+
RELAXED_THRESHOLD_TESTS = frozenset({"test_search_full_corpus"})
1114

1215
# Sub-ms timings are too noisy for a fixed 20% gate on ubuntu CI.
1316
EXCLUDED_FROM_GATE = frozenset(
1417
{
1518
"test_parse_session_small",
16-
"test_search_full_corpus",
1719
}
1820
)
1921

@@ -153,13 +155,14 @@ def check_regression(
153155
print(f"WARN: baseline for {name!r} is zero; skipping ratio check")
154156
continue
155157
ratio = cur / base
156-
tag = "FAIL" if ratio > threshold else "ok"
158+
limit = SEARCH_BENCHMARK_THRESHOLD if name in RELAXED_THRESHOLD_TESTS else threshold
159+
tag = "FAIL" if ratio > limit else "ok"
157160
entry = entries_by_name.get(name)
158161
if metric_is_bytes(name, entry):
159162
print(f"[{tag}] {name}: {cur:.0f} bytes vs {base:.0f} bytes ({ratio:.2f}x)")
160163
else:
161164
print(f"[{tag}] {name}: {cur:.6f}s vs {base:.6f}s ({ratio:.2f}x)")
162-
if ratio > threshold:
165+
if ratio > limit:
163166
failures.append(name)
164167

165168
for name in flat:

scripts/reduce_baselines.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ def reduce_baselines(
7070
"_note": (
7171
"Gated means from ubuntu-latest CI benchmark-results.json."
7272
f"{slack_note} "
73-
"Excluded from gate (recorded for reference): test_parse_session_small, "
74-
"test_search_full_corpus (sub-ms CI noise). "
73+
"Excluded from gate (recorded for reference): test_parse_session_small "
74+
"(sub-ms CI noise). "
7575
"Memory benchmarks use extra_info.peak_bytes (bytes); "
7676
"latency uses stats.mean (seconds)."
7777
),

tests/test_api_integration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def test_session_detail_includes_thinking_blocks(client_thinking):
100100

101101

102102
def test_search_returns_results(client):
103-
resp = client.get("/api/search?q=Hello")
103+
resp = client.get("/api/search?q=Hello&all_history=1")
104104
assert resp.status_code == 200
105105
results = resp.get_json()
106106
assert isinstance(results, list)
@@ -121,7 +121,7 @@ def test_search_invalid_limit(client):
121121

122122

123123
def test_search_valid_limit(client):
124-
resp = client.get("/api/search?q=Hello&limit=5")
124+
resp = client.get("/api/search?q=Hello&limit=5&all_history=1")
125125
assert resp.status_code == 200
126126
results = resp.get_json()
127127
assert isinstance(results, list)

0 commit comments

Comments
 (0)