Skip to content

Commit bc5e6ea

Browse files
address PR feedback
1 parent 31ac7e2 commit bc5e6ea

11 files changed

Lines changed: 215 additions & 119 deletions

api/search.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,19 @@
2424
bp = Blueprint("search", __name__)
2525
_logger = logging.getLogger(__name__)
2626

27+
_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
28+
2729

2830
def _parse_since_days_param(raw: str | None) -> int | None:
2931
if raw is None or not str(raw).strip():
3032
return None
3133
try:
32-
return int(raw)
34+
days = int(raw)
3335
except ValueError:
3436
return None
37+
if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS:
38+
return None
39+
return days
3540

3641

3742
@bp.route("/api/search")

scripts/profile_search_breakdown.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from utils.workspace_path import resolve_workspace_path
2727

2828

29-
def profile_window(since_ms, label: str) -> None:
29+
def profile_window(since_ms: int | None, label: str) -> None:
3030
query = "export"
3131
q = query.lower()
3232
wp = resolve_workspace_path()
@@ -71,7 +71,11 @@ def profile_window(since_ms, label: str) -> None:
7171

7272
t0 = time.perf_counter()
7373
idx = _index_bubble_texts_matching_query(conn, q, composer_ids=window_ids)
74-
mode = "scoped" if window_ids is not None and len(window_ids) <= 500 else "full_scan"
74+
mode = (
75+
"full_scan_python_filter"
76+
if window_ids is not None and len(window_ids) <= 500
77+
else "full_scan"
78+
)
7579
print(
7680
f" bubble_index ({len(idx)} hits, {mode}): "
7781
f"{time.perf_counter() - t0:.2f}s"

scripts/profile_search_window.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def main() -> None:
2929
q = query.lower()
3030
wp = resolve_workspace_path()
3131
since = resolve_search_since_ms(all_history=False)
32+
assert since is not None
3233
print(
3334
f"since_ms={since} "
3435
f"({datetime.fromtimestamp(since / 1000, tz=timezone.utc).isoformat()})"
@@ -58,7 +59,7 @@ def main() -> None:
5859

5960
print(f"composers total={total} in_30d_window={in_window} unknown_ts={unknown_ts}")
6061
print(f"bubble_rows={bubble_count}")
61-
print(f"scoped_bubble_scan={in_window <= 500}")
62+
print(f"below_bubble_threshold={in_window <= 500}")
6263

6364
pw = ParseWarningCollector()
6465
for label, since_ms in [("30d", since), ("all", None)]:

services/search.py

Lines changed: 110 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from services.workspace_resolver import infer_invalid_workspace_aliases
5353
from utils.cli_chat_reader import list_cli_projects, messages_to_bubbles, traverse_blobs
5454
from utils.exclusion_rules import build_searchable_text, is_excluded_by_rules
55+
from utils.text_extract import extract_text_from_bubble
5556
from utils.path_helpers import (
5657
get_workspace_display_name,
5758
to_epoch_ms,
@@ -79,6 +80,8 @@ def resolve_search_since_ms(
7980
if all_history:
8081
return None
8182
days = since_days if since_days is not None else DEFAULT_SEARCH_WINDOW_DAYS
83+
if days > 36_500:
84+
days = 36_500
8285
if days <= 0:
8386
return None
8487
ref = now or datetime.now(timezone.utc)
@@ -152,18 +155,70 @@ def _quick_bubble_text(raw_value: object) -> str:
152155
"""Extract searchable text from a bubble KV value without full model validation."""
153156
try:
154157
if isinstance(raw_value, (bytes, bytearray)):
155-
raw_value = raw_value.decode("utf-8", errors="replace")
156-
obj = json.loads(raw_value)
158+
text_value = raw_value.decode("utf-8", errors="replace")
159+
elif isinstance(raw_value, str):
160+
text_value = raw_value
161+
else:
162+
return ""
163+
obj = json.loads(text_value)
157164
if isinstance(obj, dict):
158-
for key in ("text", "rawText", "content"):
159-
val = obj.get(key)
160-
if isinstance(val, str) and val:
161-
return val
165+
text = extract_text_from_bubble(obj)
166+
if text:
167+
return text
162168
except (json.JSONDecodeError, TypeError, ValueError):
163169
pass
164170
return ""
165171

166172

173+
def _model_config_from_composer_dict(cd: dict[str, Any]) -> dict[str, Any]:
174+
raw = cd.get("modelConfig")
175+
return raw if isinstance(raw, dict) else {}
176+
177+
178+
def _all_bubble_texts_for_composer(
179+
conn: sqlite3.Connection,
180+
composer_id: str,
181+
) -> list[str]:
182+
"""Load all bubble texts for one composer (exclusion-rule checks)."""
183+
texts: list[str] = []
184+
try:
185+
rows = conn.execute(
186+
"SELECT value FROM cursorDiskKV"
187+
" WHERE key LIKE ? AND value IS NOT NULL",
188+
(f"bubbleId:{composer_id}:%",),
189+
).fetchall()
190+
except sqlite3.Error:
191+
return texts
192+
for row in rows:
193+
text = _quick_bubble_text(row["value"])
194+
if text:
195+
texts.append(text)
196+
return texts
197+
198+
199+
def _composer_exclusion_text(
200+
*,
201+
project_name: str,
202+
title: str,
203+
model_names: list[str] | None,
204+
model_config: dict[str, Any],
205+
cd: dict[str, Any],
206+
all_bubble_texts: list[str],
207+
) -> str:
208+
return _build_exclusion_searchable(
209+
project_name=project_name,
210+
chat_title=title,
211+
model_names=model_names,
212+
content_parts=all_bubble_texts or None,
213+
metadata_parts=[
214+
_json_dump_safe(model_config),
215+
_json_dump_safe(cd.get("conversationSummary")),
216+
_json_dump_safe(cd.get("usage")),
217+
_json_dump_safe(cd.get("requestMetadata")),
218+
],
219+
)
220+
221+
167222
def _index_bubble_texts_matching_query(
168223
conn: sqlite3.Connection,
169224
query_lower: str,
@@ -219,28 +274,6 @@ def _composer_row_raw_text(row: sqlite3.Row) -> str:
219274
return str(raw)
220275

221276

222-
def _light_composer_exclusion_text(
223-
*,
224-
project_name: str,
225-
title: str,
226-
model_names: list[str] | None,
227-
model_config: dict[str, Any],
228-
cd: dict[str, Any],
229-
) -> str:
230-
"""Exclusion text from title/metadata only (no bubble scan)."""
231-
return _build_exclusion_searchable(
232-
project_name=project_name,
233-
chat_title=title,
234-
model_names=model_names,
235-
metadata_parts=[
236-
_json_dump_safe(model_config),
237-
_json_dump_safe(cd.get("conversationSummary")),
238-
_json_dump_safe(cd.get("usage")),
239-
_json_dump_safe(cd.get("requestMetadata")),
240-
],
241-
)
242-
243-
244277
def _extract_snippet(text: str, query: str, query_lower: str) -> str:
245278
"""Return a context window around the first match of *query* in *text*.
246279
@@ -444,6 +477,7 @@ def _search_global_storage_via_index(
444477
) -> list[SearchResult] | None:
445478
"""Search using local FTS index. Returns ``None`` to fall back to live scan."""
446479
from services.search_index import (
480+
query_all_composer_bubble_texts,
447481
query_composer_bubble_hits,
448482
query_composer_rows_in_window,
449483
query_composer_title_hits,
@@ -486,13 +520,16 @@ def _search_global_storage_via_index(
486520
candidate_ids.add(composer_id)
487521

488522
for composer_id in candidate_ids:
489-
row = composers_in_window.get(composer_id) if since_ms is not None else search_pool.get(composer_id)
490-
if row is None:
491-
row = query_composer_rows_in_window(None).get(composer_id)
492-
if row is None:
523+
composer_row: sqlite3.Row | None = (
524+
composers_in_window.get(composer_id) if since_ms is not None
525+
else search_pool.get(composer_id)
526+
)
527+
if composer_row is None:
528+
composer_row = query_composer_rows_in_window(None).get(composer_id)
529+
if composer_row is None:
493530
continue
494531

495-
raw_text = row["raw_json"] or ""
532+
raw_text = composer_row["raw_json"] or ""
496533
try:
497534
cd = json.loads(raw_text)
498535
except (json.JSONDecodeError, TypeError, ValueError) as exc:
@@ -512,7 +549,7 @@ def _search_global_storage_via_index(
512549
if not headers:
513550
continue
514551

515-
title = row["title"] or cd.get("name") or ""
552+
title = composer_row["title"] or cd.get("name") or ""
516553
if not isinstance(title, str):
517554
title = str(title) if title else ""
518555

@@ -525,7 +562,7 @@ def _search_global_storage_via_index(
525562
ws_name = ws_id_to_name.get(ws_id)
526563
project_name = ws_name or ("Other chats" if ws_id == "global" else ws_id)
527564

528-
model_config = cd.get("modelConfig") if isinstance(cd.get("modelConfig"), dict) else {}
565+
model_config = _model_config_from_composer_dict(cd)
529566
model_name = model_config.get("modelName")
530567
model_names = (
531568
[str(model_name)] if model_name and model_name != "default" else None
@@ -536,15 +573,6 @@ def _search_global_storage_via_index(
536573
bubble_texts: list[str] = []
537574

538575
if title_match:
539-
exclusion_text = _light_composer_exclusion_text(
540-
project_name=project_name,
541-
title=title,
542-
model_names=model_names,
543-
model_config=model_config,
544-
cd=cd,
545-
)
546-
if is_excluded_by_rules(rules, exclusion_text):
547-
continue
548576
has_match, matching_text = True, title
549577
else:
550578
has_match, matching_text = _find_match(title, [], query_lower, query)
@@ -560,20 +588,20 @@ def _search_global_storage_via_index(
560588
if not has_match:
561589
continue
562590

563-
exclusion_text = _build_exclusion_searchable(
564-
project_name=project_name,
565-
chat_title=title,
566-
model_names=model_names,
567-
content_parts=bubble_texts or None,
568-
metadata_parts=[
569-
_json_dump_safe(model_config),
570-
_json_dump_safe(cd.get("conversationSummary")),
571-
_json_dump_safe(cd.get("usage")),
572-
_json_dump_safe(cd.get("requestMetadata")),
573-
],
574-
)
575-
if is_excluded_by_rules(rules, exclusion_text):
576-
continue
591+
all_bubble_texts = query_all_composer_bubble_texts(composer_id)
592+
exclusion_text = _composer_exclusion_text(
593+
project_name=project_name,
594+
title=title,
595+
model_names=model_names,
596+
model_config=model_config,
597+
cd=cd,
598+
all_bubble_texts=all_bubble_texts,
599+
)
600+
if is_excluded_by_rules(rules, exclusion_text):
601+
continue
602+
603+
if not title_match:
604+
bubble_texts = bubble_texts or bubble_texts_by_composer.get(composer_id, [])
577605

578606
if not title:
579607
for text in bubble_texts:
@@ -709,7 +737,7 @@ def _search_global_storage_live_scan(
709737
ws_name = ws_id_to_name.get(ws_id)
710738
project_name = ws_name or ("Other chats" if ws_id == "global" else ws_id)
711739

712-
model_config = cd.get("modelConfig") if isinstance(cd.get("modelConfig"), dict) else {}
740+
model_config = _model_config_from_composer_dict(cd)
713741
model_name = model_config.get("modelName")
714742
model_names = (
715743
[str(model_name)] if model_name and model_name != "default" else None
@@ -719,15 +747,6 @@ def _search_global_storage_live_scan(
719747
bubble_texts: list[str] = []
720748

721749
if title_match:
722-
exclusion_text = _light_composer_exclusion_text(
723-
project_name=project_name,
724-
title=title,
725-
model_names=model_names,
726-
model_config=model_config,
727-
cd=cd,
728-
)
729-
if is_excluded_by_rules(rules, exclusion_text):
730-
continue
731750
has_match, matching_text = True, title
732751
else:
733752
has_match, matching_text = _find_match(
@@ -745,20 +764,20 @@ def _search_global_storage_live_scan(
745764
if not has_match:
746765
continue
747766

748-
exclusion_text = _build_exclusion_searchable(
749-
project_name=project_name,
750-
chat_title=title,
751-
model_names=model_names,
752-
content_parts=bubble_texts or None,
753-
metadata_parts=[
754-
_json_dump_safe(model_config),
755-
_json_dump_safe(cd.get("conversationSummary")),
756-
_json_dump_safe(cd.get("usage")),
757-
_json_dump_safe(cd.get("requestMetadata")),
758-
],
759-
)
760-
if is_excluded_by_rules(rules, exclusion_text):
761-
continue
767+
all_bubble_texts = _all_bubble_texts_for_composer(conn, composer_id)
768+
exclusion_text = _composer_exclusion_text(
769+
project_name=project_name,
770+
title=title,
771+
model_names=model_names,
772+
model_config=model_config,
773+
cd=cd,
774+
all_bubble_texts=all_bubble_texts,
775+
)
776+
if is_excluded_by_rules(rules, exclusion_text):
777+
continue
778+
779+
if not title_match:
780+
bubble_texts = bubble_texts or bubble_texts_by_composer.get(composer_id, [])
762781

763782
if not title:
764783
for text in bubble_texts:
@@ -965,7 +984,12 @@ def search_cli_sessions(
965984
meta = session.get("meta", {})
966985
session_id = session["session_id"]
967986
created_ms: int = to_epoch_ms(meta.get("createdAt"))
968-
if not _timestamp_in_search_window(created_ms, since_ms):
987+
try:
988+
session_ts = int(os.path.getmtime(session["db_path"]) * 1000)
989+
except OSError:
990+
session_ts = 0
991+
effective_ms = max(created_ms or 0, session_ts)
992+
if not _timestamp_in_search_window(effective_ms, since_ms):
969993
continue
970994
session_name: str = meta.get("name") or f"Session {session_id[:8]}"
971995
title_match = bool(session_name and query_lower in session_name.lower())
@@ -982,7 +1006,7 @@ def search_cli_sessions(
9821006
"workspaceFolder": ws_name,
9831007
"chatId": session_id,
9841008
"chatTitle": session_name,
985-
"timestamp": created_ms,
1009+
"timestamp": effective_ms,
9861010
"matchingText": session_name,
9871011
"type": "cli_agent",
9881012
"source": "cli",
@@ -1007,7 +1031,7 @@ def search_cli_sessions(
10071031
session["db_path"],
10081032
)
10091033

1010-
bubbles = messages_to_bubbles(messages, created_ms)
1034+
bubbles = messages_to_bubbles(messages, effective_ms)
10111035
if not bubbles:
10121036
continue
10131037

@@ -1047,7 +1071,7 @@ def search_cli_sessions(
10471071
"workspaceFolder": ws_name,
10481072
"chatId": session_id,
10491073
"chatTitle": title,
1050-
"timestamp": created_ms,
1074+
"timestamp": effective_ms,
10511075
"matchingText": matching_text,
10521076
"type": "cli_agent",
10531077
"source": "cli",

0 commit comments

Comments
 (0)