Skip to content

Commit a725bd2

Browse files
Feat/search errors and ux (#121)
* fix: distinct /api/search error codes and search UX polish * fix: search review follow-ups (503 only when dir unreadable) Fresh Claude installs were getting 503 on search when ~/.claude/projects did not exist yet. 503 now means the path is there but listdir failed. CI smoke test uses create_app(testing=True). Search page nags on empty query and highlights snippets by code point. * fix: search review follow-ups (index lock fallback, error body parse) * fix: search index completeness (exclusion pagination, parity, live backfill)
1 parent ec547fb commit a725bd2

16 files changed

Lines changed: 743 additions & 111 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
run: |
3939
python - <<'PY'
4040
from app import create_app
41-
app = create_app()
41+
app = create_app(testing=True)
4242
client = app.test_client()
4343
assert client.get("/").status_code == 200
4444
PY

api/search.py

Lines changed: 150 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import os
67
from datetime import datetime, timezone
78
from typing import Any
89

@@ -11,17 +12,16 @@
1112
from api._flask_types import FlaskReturn, json_response
1213
from api.error_codes import ErrorCode, error_response
1314
from models.search import SearchHitDict
14-
from models.session import MessageDict
1515
from utils.exclusion_rules import is_session_excluded
1616
from utils.search_index import (
1717
index_is_usable,
1818
index_search_enabled,
19+
message_searchable_text,
1920
query_index_hits,
2021
resolve_search_since_ms,
2122
search_snippet,
2223
timestamp_in_search_window_iso,
2324
timestamp_to_ms,
24-
tool_result_searchable_text,
2525
)
2626
from utils.session_cache import get_cached_session
2727
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
@@ -32,6 +32,7 @@
3232

3333
_DEFAULT_LIMIT = 50
3434
_MAX_LIMIT = 500
35+
_MAX_QUERY_LEN = 500
3536
_MAX_SEARCH_SINCE_DAYS = 36_500
3637

3738

@@ -53,10 +54,10 @@ def _parse_since_days(raw: str | None) -> int | None:
5354
return None
5455
try:
5556
days = int(str(raw).strip())
56-
except ValueError:
57-
return None
57+
except ValueError as exc:
58+
raise ValueError("Invalid since_days: must be a positive integer") from exc
5859
if days <= 0:
59-
return None
60+
raise ValueError("Invalid since_days: must be a positive integer")
6061
return min(days, _MAX_SEARCH_SINCE_DAYS)
6162

6263

@@ -71,16 +72,44 @@ def _rank_search_hits(results: list[SearchHitDict]) -> list[SearchHitDict]:
7172
)
7273

7374

74-
def _message_searchable_text(msg: MessageDict) -> str:
75-
text = msg.get("text", "") or msg.get("content", "")
76-
if not isinstance(text, str):
77-
text = ""
78-
tool_result = msg.get("tool_result")
79-
if isinstance(tool_result, dict):
80-
tool_text = tool_result_searchable_text(tool_result)
81-
if tool_text:
82-
text = f"{text}\n{tool_text}" if text else tool_text
83-
return text
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+
104+
def _projects_dir_inaccessible(projects_dir: str) -> bool:
105+
"""True when the projects path exists but cannot be listed (503 case)."""
106+
try:
107+
if not os.path.isdir(projects_dir):
108+
return False
109+
os.listdir(projects_dir)
110+
return False
111+
except OSError:
112+
return True
84113

85114

86115
def _index_hit_excluded(
@@ -104,7 +133,7 @@ def _index_hit_excluded(
104133
file_path,
105134
exc_info=True,
106135
)
107-
return False
136+
return True
108137
return is_session_excluded(rules, session, project_name)
109138

110139

@@ -116,13 +145,14 @@ def _search_via_index(
116145
*,
117146
since_ms: int | None,
118147
max_results: int,
119-
) -> list[SearchHitDict] | None:
148+
) -> tuple[list[SearchHitDict] | None, bool]:
120149
if not index_search_enabled() or not index_is_usable(projects_dir, rules):
121-
return None
150+
return None, False
122151

123152
rules_fp = rules_fingerprint(rules)
124153
results: list[SearchHitDict] = []
125154
sql_offset = 0
155+
fts_exhausted = False
126156
while len(results) < max_results:
127157
need = max_results - len(results)
128158
indexed = query_index_hits(
@@ -131,9 +161,18 @@ def _search_via_index(
131161
max_results=need,
132162
sql_offset=sql_offset,
133163
)
164+
if indexed["index_locked"]:
165+
_logger.warning(
166+
"Search index locked during query; %d hit(s) collected so far",
167+
len(results),
168+
)
169+
if results:
170+
return _rank_search_hits(results)[:max_results], False
171+
return None, False
134172
if not indexed["query_ok"]:
135-
return None
173+
return None, False
136174
if indexed["sql_rows_fetched"] == 0:
175+
fts_exhausted = indexed["sql_exhausted"]
137176
break
138177

139178
for hit in indexed["hits"]:
@@ -160,8 +199,50 @@ def _search_via_index(
160199

161200
sql_offset += indexed["sql_rows_fetched"]
162201
if indexed["sql_exhausted"]:
202+
fts_exhausted = True
163203
break
164-
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)
165246

166247

167248
def _search_live_scan(
@@ -192,7 +273,7 @@ def _search_live_scan(
192273
continue
193274

194275
for msg in session["messages"]:
195-
text = _message_searchable_text(msg)
276+
text = message_searchable_text(msg)
196277
if not text or query_lower not in text.lower():
197278
continue
198279
if not timestamp_in_search_window_iso(
@@ -215,9 +296,20 @@ def _search_live_scan(
215296

216297
@search_bp.route("/api/search")
217298
def search() -> FlaskReturn:
218-
query = request.args.get("q", "").strip()
299+
raw_query = request.args.get("q", "")
300+
query = raw_query.strip()
219301
if not query:
220-
return json_response([])
302+
return error_response(
303+
ErrorCode.SEARCH_EMPTY_QUERY,
304+
"Search query is required",
305+
400,
306+
)
307+
if len(query) > _MAX_QUERY_LEN:
308+
return error_response(
309+
ErrorCode.SEARCH_QUERY_TOO_LONG,
310+
f"Search query must be at most {_MAX_QUERY_LEN} characters",
311+
400,
312+
)
221313

222314
try:
223315
max_results = _parse_limit(request.args.get("limit"))
@@ -228,35 +320,49 @@ def search() -> FlaskReturn:
228320
400,
229321
)
230322

323+
since_days_raw = request.args.get("since_days")
324+
try:
325+
since_days = _parse_since_days(since_days_raw)
326+
except ValueError:
327+
return error_response(
328+
ErrorCode.SEARCH_INVALID_SINCE_DAYS,
329+
"Invalid since_days: must be a positive integer",
330+
400,
331+
)
332+
231333
query_lower = query.lower()
232334
all_history = request.args.get("all_history") in ("1", "true")
233335
since_ms = resolve_search_since_ms(
234336
all_history=all_history,
235-
since_days=_parse_since_days(request.args.get("since_days")),
337+
since_days=since_days,
236338
now=datetime.now(timezone.utc),
237339
)
238340

239341
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
240-
rules = current_app.config.get("EXCLUSION_RULES") or []
342+
if _projects_dir_inaccessible(base):
343+
return error_response(
344+
ErrorCode.SEARCH_PROJECTS_UNAVAILABLE,
345+
"Claude projects directory is not accessible",
346+
503,
347+
)
241348

242-
indexed = _search_via_index(
243-
base,
244-
rules,
245-
query,
246-
query_lower,
247-
since_ms=since_ms,
248-
max_results=max_results,
249-
)
250-
if indexed is not None:
251-
return json_response(indexed)
349+
rules = current_app.config.get("EXCLUSION_RULES") or []
252350

253-
return json_response(
254-
_search_live_scan(
255-
base,
256-
rules,
257-
query,
258-
query_lower,
259-
since_ms=since_ms,
260-
max_results=max_results,
351+
try:
352+
return json_response(
353+
_resolve_search_results(
354+
base,
355+
rules,
356+
query,
357+
query_lower,
358+
since_ms=since_ms,
359+
max_results=max_results,
360+
)
361+
)
362+
except Exception:
363+
_logger.exception("Unexpected error during search")
364+
return error_response(
365+
ErrorCode.INTERNAL_ERROR,
366+
"Search failed",
367+
500,
261368
)
262-
)

app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,11 @@ def validate_startup_cli(args: argparse.Namespace) -> None:
9292
def create_app(
9393
base_dir: str | None = None,
9494
exclusion_rules_path: str | None = None,
95+
*,
96+
testing: bool = False,
9597
) -> Flask:
9698
app = Flask(__name__)
99+
app.config["TESTING"] = testing
97100
app.config["CLAUDE_PROJECTS_DIR"] = base_dir
98101

99102
resolved = resolve_exclusion_rules_path(exclusion_rules_path)
@@ -106,7 +109,7 @@ def create_app(
106109
app.register_blueprint(export_bp)
107110
app.register_blueprint(schema_report_bp)
108111

109-
if not app.config.get("TESTING"):
112+
if not testing:
110113
try:
111114
from utils.search_index import start_search_index_background
112115

docs/api-reference.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk-
5656
| `code` | HTTP | Routes | Meaning |
5757
|--------|------|--------|---------|
5858
| `SEARCH_INVALID_LIMIT` | 400 | `GET /api/search` | Query param `limit` is not a positive integer |
59+
| `SEARCH_EMPTY_QUERY` | 400 | `GET /api/search` | Query param `q` is empty or whitespace |
60+
| `SEARCH_QUERY_TOO_LONG` | 400 | `GET /api/search` | Query param `q` exceeds 500 characters |
61+
| `SEARCH_INVALID_SINCE_DAYS` | 400 | `GET /api/search` | Query param `since_days` is not a positive integer |
62+
| `SEARCH_PROJECTS_UNAVAILABLE` | 503 | `GET /api/search` | Claude projects directory exists but is not readable |
5963
| `INVALID_PATH` | 400 | Session, stats, export session | Path traversal or rejected URL segment |
6064
| `SESSION_NOT_FOUND` | 404 | Session, stats, export session | File missing on disk or session excluded by rules |
6165
| `INVALID_REQUEST_BODY` | 400 | `POST /api/export` | Body is not a JSON object |
@@ -280,14 +284,18 @@ curl -s "http://127.0.0.1:5000/api/sessions/F--boost-capy/session_abc123/stats"
280284

281285
**Source:** [`api/search.py`](../api/search.py)
282286

283-
Case-insensitive substring search across all non-excluded messages in all projects. Linear scan — suitable for local history size, not indexed search.
287+
Case-insensitive substring search across all non-excluded messages in all projects. Uses a local FTS5 index when available; falls back to a live JSONL scan when the index is missing, stale, or the query cannot be served from SQLite.
284288

285289
#### Query parameters
286290

287291
| Name | Type | Default | Description |
288292
|------|------|---------|-------------|
289-
| `q` | string | `""` | Search string; whitespace stripped; empty → `[]` |
293+
| `q` | string | | Search string (required); whitespace stripped; max 500 characters |
290294
| `limit` | integer | `50` | Max results; must be ≥ 1; **capped at 500** |
295+
| `all_history` | flag | off | When `1` or `true`, search all history (no time window) |
296+
| `since_days` | integer | `30` | When `all_history` is off, include messages from the last *N* days (positive integer; capped at 36 500) |
297+
298+
By default, messages older than 30 days are excluded. Sessions without a parseable timestamp may still match when windowed.
291299

292300
#### Response — `200 OK`
293301

@@ -306,10 +314,16 @@ Case-insensitive substring search across all non-excluded messages in all projec
306314

307315
| Status | `code` | When |
308316
|--------|--------|------|
317+
| 400 | `SEARCH_EMPTY_QUERY` | `q` is empty or whitespace |
318+
| 400 | `SEARCH_QUERY_TOO_LONG` | `q` exceeds 500 characters |
309319
| 400 | `SEARCH_INVALID_LIMIT` | `limit` not a positive integer (e.g. `abc`, `0`, `1.5`) |
320+
| 400 | `SEARCH_INVALID_SINCE_DAYS` | `since_days` not a positive integer |
321+
| 503 | `SEARCH_PROJECTS_UNAVAILABLE` | Projects directory exists but is not readable |
322+
| 500 | `INTERNAL_ERROR` | Unexpected server failure |
310323

311324
```bash
312325
curl -s "http://127.0.0.1:5000/api/search?q=parser&limit=10" | jq '.[0]'
326+
curl -s "http://127.0.0.1:5000/api/search?q=" # → 400 SEARCH_EMPTY_QUERY
313327
curl -s "http://127.0.0.1:5000/api/search?q=test&limit=abc" # → 400
314328
```
315329

models/error_codes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
class ErrorCode(StrEnum):
99
SEARCH_INVALID_LIMIT = "SEARCH_INVALID_LIMIT"
10+
SEARCH_EMPTY_QUERY = "SEARCH_EMPTY_QUERY"
11+
SEARCH_QUERY_TOO_LONG = "SEARCH_QUERY_TOO_LONG"
12+
SEARCH_INVALID_SINCE_DAYS = "SEARCH_INVALID_SINCE_DAYS"
13+
SEARCH_PROJECTS_UNAVAILABLE = "SEARCH_PROJECTS_UNAVAILABLE"
1014
INVALID_PATH = "INVALID_PATH"
1115
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
1216
INVALID_REQUEST_BODY = "INVALID_REQUEST_BODY"

0 commit comments

Comments
 (0)