Skip to content

feat(api): search saved documents by title and content - #48

Merged
johnnyhuy merged 1 commit into
mainfrom
agent/issue-47-document-search
Nov 14, 2026
Merged

johnnyhuy merged 1 commit into
mainfrom
agent/issue-47-document-search

Conversation

@johnnyhuy

@johnnyhuy johnnyhuy commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Add optional q search to GET /api/documents so operators can find saved documents by a literal, case-insensitive substring in the title or full content. The trimmed search combines with the source filter before counting and pagination, with blank queries preserving the current behaviour.

Part of #5. Closes #47.

Verification

All commands ran with an empty inherited environment apart from HOME and PATH. No .env or real tokens were read or changed.

  • mise run install initially failed because uv selected Python 3.14 and the pinned onnxruntime has no compatible wheel. UV_PYTHON=3.13 mise run install then passed for all three dependency sets. Python 3.13 is the version declared in mise.toml. Incidental lockfile rewrites were discarded.
  • UV_PYTHON=3.13 mise run test-backend passed, 196 tests including 24 new document search cases. Existing dependency and lifecycle deprecation warnings remain.
  • mise run test-frontend passed, 15 tests and 92 assertions.
  • UV_PYTHON=3.13 mise run dev-backend reached Application startup complete. and served HTTP 200 for /health and all search requests below. Stopped after capture.
  • git diff --check passed.

Proof

Capture method (a) succeeded using the real FastAPI routes and a temporary local SQLite database seeded with four fixture documents. Slack auth_test returned a fixture response, while Slack API calls and Socket Mode startup were patched to raise. No model, embeddings, Chroma service, remote database or live Slack connection was used. No live Slack verification is needed for this endpoint.

The complete test output, fixture setup and curl transcript are attached in the evidence comment. The curl transcript is also saved locally at .tmp/proof/documents-search-curl.txt.

Health after startup
$ curl -sS -i http://127.0.0.1:8000/health
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 20
content-type: application/json

{"status":"healthy"}

Title or content, source filter, first page
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode 'q=  vPn  ' --data-urlencode source=api --data-urlencode limit=1 --data-urlencode offset=0
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 167
content-type: application/json

{"items":[{"id":2,"title":"Remote access","source":"api","created_at":"2026-01-01T09:00:00","content_preview":"VPN for new starters."}],"total":2,"limit":1,"offset":0}

Second page keeps filtered total
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode q=vpn --data-urlencode source=api --data-urlencode limit=1 --data-urlencode offset=1
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 162
content-type: application/json

{"items":[{"id":1,"title":"VPN guide","source":"api","created_at":"2026-01-01T09:00:00","content_preview":"Reset via IT portal."}],"total":2,"limit":1,"offset":1}

Literal percent is not a wildcard
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode q=%
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 172
content-type: application/json

{"items":[{"id":4,"title":"Budget","source":"docs","created_at":"2026-01-01T09:00:00","content_preview":"100% complete. See setup_notes."}],"total":1,"limit":50,"offset":0}

Unknown phrase returns an empty page
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode q=missing
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 44
content-type: application/json

{"items":[],"total":0,"limit":50,"offset":0}

Blank query keeps all source-filtered documents
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode 'q=   ' --data-urlencode source=api
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 288
content-type: application/json

{"items":[{"id":2,"title":"Remote access","source":"api","created_at":"2026-01-01T09:00:00","content_preview":"VPN for new starters."},{"id":1,"title":"VPN guide","source":"api","created_at":"2026-01-01T09:00:00","content_preview":"Reset via IT portal."}],"total":2,"limit":50,"offset":0}

Review notes

The API behaviour changed; the dashboard UI did not change. Screenshot capture is not applicable, and no capture path was blocked. Database tests and the startup transcript use SQLite, so PostgreSQL-specific collation behaviour and performance at production scale were not measured. This is substring filtering over saved records, not semantic retrieval; no migration is needed.

Co-authored-by: OpenAI Codex <noreply@openai.com>
@johnnyhuy

Copy link
Copy Markdown
Contributor Author

Offline evidence for document search at commit e87aab3.

The backend gate lists the 24 new TestSearchDocuments cases below. All commands used env -i HOME="$HOME" PATH="$PATH" to exclude inherited credentials. Neither root nor backend .env exists in this worktree.

Backend gate output, 196 passed
[test-backend] $ uv run pytest tests/test_file_extraction_units.py tests/test_l…
============================= test session starts ==============================
platform darwin -- Python 3.13.13, pytest-9.0.2, pluggy-1.6.0 -- /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend
configfile: pytest.ini (WARNING: ignoring pytest config in pyproject.toml!)
plugins: langsmith-0.5.2, anyio-4.12.0, asyncio-1.3.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 196 items

tests/test_file_extraction_units.py::test_extract_plain_text PASSED      [  0%]
tests/test_file_extraction_units.py::test_extract_markdown PASSED        [  1%]
tests/test_file_extraction_units.py::test_extract_python_file PASSED     [  1%]
tests/test_file_extraction_units.py::test_extract_json PASSED            [  2%]
tests/test_file_extraction_units.py::test_extract_csv PASSED             [  2%]
tests/test_file_extraction_units.py::test_extract_html PASSED            [  3%]
tests/test_file_extraction_units.py::test_extract_html_removes_scripts PASSED [  3%]
tests/test_file_extraction_units.py::test_extract_with_encoding_errors PASSED [  4%]
tests/test_file_extraction_units.py::test_extract_xml PASSED             [  4%]
tests/test_file_extraction_units.py::test_extract_yaml PASSED            [  5%]
tests/test_file_extraction_units.py::test_javascript_file_extraction PASSED [  5%]
tests/test_file_extraction_units.py::test_typescript_file_extraction PASSED [  6%]
tests/test_file_extraction_units.py::test_shell_script_extraction PASSED [  6%]
tests/test_file_extraction_units.py::TestConversationIds::test_thread_conversation_id_format PASSED [  7%]
tests/test_file_extraction_units.py::TestConversationIds::test_dm_conversation_id_format PASSED [  7%]
tests/test_file_extraction_units.py::TestConversationIds::test_mention_conversation_id_format PASSED [  8%]
tests/test_logging_config.py::test_json_schema_and_redaction_variants PASSED [  8%]
tests/test_logging_config.py::test_error_hash_not_stack PASSED           [  9%]
tests/test_logging_config.py::test_wide_event_one_per_request PASSED     [  9%]
tests/test_logging.py::test_json_stdout_preserves_controls_and_avoids_duplicate_handlers[wingman-backend] PASSED [ 10%]
tests/test_logging.py::test_json_stdout_preserves_controls_and_avoids_duplicate_handlers[wingman-bot] PASSED [ 10%]
tests/test_logging.py::test_text_format_and_configured_level PASSED      [ 11%]
tests/test_logging.py::test_api_keeps_backend_service_when_importing_bot PASSED [ 11%]
tests/test_agent_runtime.py::test_transport_retry_keeps_request_id PASSED [ 12%]
tests/test_agent_runtime.py::test_rag_routes_retrieved_context_and_stable_slack_identity PASSED [ 12%]
tests/test_agent_runtime.py::test_failed_job_surfaces_error PASSED       [ 13%]
tests/test_rules.py::TestMatching::test_contains_is_case_insensitive PASSED [ 13%]
tests/test_rules.py::TestMatching::test_contains_needs_the_keyword PASSED [ 14%]
tests/test_rules.py::TestMatching::test_any_keyword_can_match PASSED     [ 14%]
tests/test_rules.py::TestMatching::test_exact_needs_the_whole_question PASSED [ 15%]
tests/test_rules.py::TestMatching::test_regex_match PASSED               [ 15%]
tests/test_rules.py::TestMatching::test_blank_question_never_matches PASSED [ 16%]
tests/test_rules.py::TestMatching::test_no_rules_is_a_no_op PASSED       [ 16%]
tests/test_rules.py::TestOrderingAndEnabled::test_higher_priority_wins PASSED [ 17%]
tests/test_rules.py::TestOrderingAndEnabled::test_config_order_breaks_priority_ties PASSED [ 17%]
tests/test_rules.py::TestOrderingAndEnabled::test_disabled_rule_never_matches PASSED [ 18%]
tests/test_rules.py::TestConfigLoading::test_parse_valid_rules PASSED    [ 18%]
tests/test_rules.py::TestConfigLoading::test_parse_skips_invalid_entries PASSED [ 19%]
tests/test_rules.py::TestConfigLoading::test_parse_skips_bad_regex PASSED [ 19%]
tests/test_rules.py::TestConfigLoading::test_parse_rejects_non_list PASSED [ 20%]
tests/test_rules.py::TestConfigLoading::test_load_from_json_text PASSED  [ 20%]
tests/test_rules.py::TestConfigLoading::test_load_invalid_json_is_empty PASSED [ 21%]
tests/test_rules.py::TestConfigLoading::test_load_from_file_and_file_wins PASSED [ 21%]
tests/test_rules.py::TestConfigLoading::test_load_missing_file_is_empty PASSED [ 22%]
tests/test_rules.py::TestConfigLoading::test_from_settings_reads_both_fields PASSED [ 22%]
tests/test_rules.py::TestRagShortCircuit::test_matched_question_skips_retrieval_and_generation PASSED [ 23%]
tests/test_rules.py::TestRagShortCircuit::test_unmatched_question_still_uses_generation PASSED [ 23%]
tests/test_rules.py::TestSlackShortCircuit::test_matched_slack_question_skips_retrieval_and_carries_pinned_answer PASSED [ 24%]
tests/test_rules.py::TestSlackShortCircuit::test_unmatched_slack_question_uses_retrieval_without_pinned_answer PASSED [ 25%]
tests/test_conversations.py::TestListConversations::test_lists_summaries_most_recent_first PASSED [ 25%]
tests/test_conversations.py::TestListConversations::test_summary_counts_and_preview PASSED [ 26%]
tests/test_conversations.py::TestListConversations::test_pagination PASSED [ 26%]
tests/test_conversations.py::TestListConversations::test_channel_filter PASSED [ 27%]
tests/test_conversations.py::TestListConversations::test_channel_filter_scopes_preview_for_shared_conversation[C1] PASSED [ 27%]
tests/test_conversations.py::TestListConversations::test_channel_filter_scopes_preview_for_shared_conversation[C2] PASSED [ 28%]
tests/test_conversations.py::TestListConversations::test_unfiltered_preview_includes_latest_turn_across_channels PASSED [ 28%]
tests/test_conversations.py::TestListConversations::test_scoped_preview_breaks_timestamp_ties_by_latest_id PASSED [ 29%]
tests/test_conversations.py::TestListConversations::test_invalid_pagination_rejected PASSED [ 29%]
tests/test_conversations.py::TestListConversations::test_empty_database PASSED [ 30%]
tests/test_conversations.py::TestConversationDetail::test_returns_turns_in_chronological_order PASSED [ 30%]
tests/test_conversations.py::TestConversationDetail::test_detail_pagination PASSED [ 31%]
tests/test_conversations.py::TestConversationDetail::test_missing_conversation_is_404 PASSED [ 31%]
tests/test_conversations.py::TestConversationDetail::test_detail_invalid_pagination_rejected PASSED [ 32%]
tests/test_documents.py::TestListDocuments::test_lists_documents_most_recent_first PASSED [ 32%]
tests/test_documents.py::TestListDocuments::test_preview_and_fields PASSED [ 33%]
tests/test_documents.py::TestListDocuments::test_pagination PASSED       [ 33%]
tests/test_documents.py::TestListDocuments::test_source_filter PASSED    [ 34%]
tests/test_documents.py::TestListDocuments::test_invalid_pagination_rejected PASSED [ 34%]
tests/test_documents.py::TestListDocuments::test_empty_database PASSED   [ 35%]
tests/test_documents.py::TestSearchDocuments::test_matches_title_case_insensitively_and_trims_query[vpn] PASSED [ 35%]
tests/test_documents.py::TestSearchDocuments::test_matches_title_case_insensitively_and_trims_query[VpN] PASSED [ 36%]
tests/test_documents.py::TestSearchDocuments::test_matches_title_case_insensitively_and_trims_query[ \tVPN\n ] PASSED [ 36%]
tests/test_documents.py::TestSearchDocuments::test_matches_content_beyond_the_preview PASSED [ 37%]
tests/test_documents.py::TestSearchDocuments::test_filters_before_counting_and_paginating PASSED [ 37%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[title-%] PASSED [ 38%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[title-_] PASSED [ 38%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[title-/] PASSED [ 39%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[title-100%] PASSED [ 39%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[title-setup_notes] PASSED [ 40%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[title-a/b] PASSED [ 40%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[title-' OR 1=1 --] PASSED [ 41%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[content-%] PASSED [ 41%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[content-_] PASSED [ 42%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[content-/] PASSED [ 42%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[content-100%] PASSED [ 43%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[content-setup_notes] PASSED [ 43%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[content-a/b] PASSED [ 44%]
tests/test_documents.py::TestSearchDocuments::test_treats_sql_wildcards_and_escape_characters_literally[content-' OR 1=1 --] PASSED [ 44%]
tests/test_documents.py::TestSearchDocuments::test_blank_query_preserves_source_filter_and_existing_list[] PASSED [ 45%]
tests/test_documents.py::TestSearchDocuments::test_blank_query_preserves_source_filter_and_existing_list[ \t\n ] PASSED [ 45%]
tests/test_documents.py::TestSearchDocuments::test_blank_query_preserves_source_filter_and_existing_list[\u2003\xa0] PASSED [ 46%]
tests/test_documents.py::TestSearchDocuments::test_no_matches_return_an_empty_page[params0] PASSED [ 46%]
tests/test_documents.py::TestSearchDocuments::test_no_matches_return_an_empty_page[params1] PASSED [ 47%]
tests/test_documents.py::TestDocumentDetail::test_returns_full_content PASSED [ 47%]
tests/test_documents.py::TestDocumentDetail::test_missing_document_is_404 PASSED [ 48%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[-title] PASSED [ 48%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[-content] PASSED [ 49%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[-source] PASSED [ 50%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[ \n\t -title] PASSED [ 50%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[ \n\t -content] PASSED [ 51%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[ \n\t -source] PASSED [ 51%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[\u2003\xa0-title] PASSED [ 52%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[\u2003\xa0-content] PASSED [ 52%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[\u2003\xa0-source] PASSED [ 53%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[None-title] PASSED [ 53%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[None-content] PASSED [ 54%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[None-source] PASSED [ 54%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[123-title] PASSED [ 55%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[123-content] PASSED [ 55%]
tests/test_documents.py::TestCreateDocument::test_rejects_invalid_fields_without_writes_or_indexing[123-source] PASSED [ 56%]
tests/test_documents.py::TestCreateDocument::test_rejects_missing_required_fields[title] PASSED [ 56%]
tests/test_documents.py::TestCreateDocument::test_rejects_missing_required_fields[content] PASSED [ 57%]
tests/test_documents.py::TestCreateDocument::test_creates_readable_document_and_indexes_preserved_content[None] PASSED [ 57%]
tests/test_documents.py::TestCreateDocument::test_creates_readable_document_and_indexes_preserved_content[ \tdocs\n ] PASSED [ 58%]
tests/test_retrieval.py::test_invalid_chunk_settings_are_rejected[config0] PASSED [ 58%]
tests/test_retrieval.py::test_invalid_chunk_settings_are_rejected[config1] PASSED [ 59%]
tests/test_retrieval.py::test_invalid_chunk_settings_are_rejected[config2] PASSED [ 59%]
tests/test_retrieval.py::test_invalid_chunk_settings_are_rejected[config3] PASSED [ 60%]
tests/test_retrieval.py::test_invalid_chunk_settings_are_rejected[config4] PASSED [ 60%]
tests/test_retrieval.py::test_valid_chunk_settings_are_accepted[1000-200] PASSED [ 61%]
tests/test_retrieval.py::test_valid_chunk_settings_are_accepted[1-0] PASSED [ 61%]
tests/test_retrieval.py::test_valid_chunk_settings_are_accepted[10-0] PASSED [ 62%]
tests/test_retrieval.py::test_valid_chunk_settings_are_accepted[10-9] PASSED [ 62%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[-5-2-expected0] PASSED [ 63%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[a-5-2-expected1] PASSED [ 63%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[abcd-5-2-expected2] PASSED [ 64%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[abcde-5-2-expected3] PASSED [ 64%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[abcdef-5-2-expected4] PASSED [ 65%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[abcdefgh-5-2-expected5] PASSED [ 65%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[abcdefghi-5-2-expected6] PASSED [ 66%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[abcdefghij-5-0-expected7] PASSED [ 66%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[abcdef-5-4-expected8] PASSED [ 67%]
tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[abc-1-0-expected9] PASSED [ 67%]
tests/test_retrieval.py::test_document_indexing_avoids_redundant_tail_chunks PASSED [ 68%]
tests/test_retrieval.py::test_repeated_thread_indexing_preserves_distinct_top_k PASSED [ 68%]
tests/test_retrieval.py::test_thread_reindex_updates_content_and_embedding PASSED [ 69%]
tests/test_retrieval.py::test_thread_chunk_ids_are_scoped_by_channel PASSED [ 69%]
tests/test_retrieval.py::test_thread_indexing_omits_absent_optional_metadata PASSED [ 70%]
tests/test_retrieval.py::test_thread_indexing_uses_last_duplicate_in_batch PASSED [ 70%]
tests/test_retrieval.py::test_thread_indexing_skips_unusable_messages[messages0] PASSED [ 71%]
tests/test_retrieval.py::test_thread_indexing_skips_unusable_messages[messages1] PASSED [ 71%]
tests/test_retrieval.py::test_thread_indexing_skips_unusable_messages[messages2] PASSED [ 72%]
tests/test_retrieval.py::test_thread_indexing_skips_unusable_messages[messages3] PASSED [ 72%]
tests/test_retrieval.py::test_thread_indexing_skips_unusable_messages[messages4] PASSED [ 73%]
tests/test_retrieval.py::test_thread_indexing_skips_unusable_messages[messages5] PASSED [ 73%]
tests/test_retrieval.py::test_thread_indexing_skips_unusable_messages[messages6] PASSED [ 74%]
tests/test_retrieval.py::test_search_empty_collection_returns_no_matches[None-None] PASSED [ 75%]
tests/test_retrieval.py::test_search_empty_collection_returns_no_matches[None-CTARGET] PASSED [ 75%]
tests/test_retrieval.py::test_search_empty_collection_returns_no_matches[1.0-None] PASSED [ 76%]
tests/test_retrieval.py::test_search_empty_collection_returns_no_matches[1.0-CTARGET] PASSED [ 76%]
tests/test_retrieval.py::test_first_document_can_be_indexed_and_retrieved PASSED [ 77%]
tests/test_retrieval.py::test_missing_adapter_still_raises[add] PASSED   [ 77%]
tests/test_retrieval.py::test_missing_adapter_still_raises[search] PASSED [ 78%]
tests/test_retrieval.py::test_channel_filter_runs_before_top_k PASSED    [ 78%]
tests/test_retrieval.py::test_unscoped_search_preserves_global_ranking_and_explicit_k PASSED [ 79%]
tests/test_retrieval.py::test_distance_cutoff_is_inclusive_and_preserves_channel_scope PASSED [ 79%]
tests/test_retrieval.py::test_zero_distance_cutoff_keeps_only_exact_vector_matches PASSED [ 80%]
tests/test_retrieval.py::test_invalid_distance_config_is_rejected[-1] PASSED [ 80%]
tests/test_retrieval.py::test_invalid_distance_config_is_rejected[nan] PASSED [ 81%]
tests/test_retrieval.py::test_invalid_distance_config_is_rejected[inf] PASSED [ 81%]
tests/test_retrieval.py::test_invalid_distance_config_is_rejected[-inf] PASSED [ 82%]
tests/test_retrieval.py::test_invalid_distance_config_is_rejected[invalid] PASSED [ 82%]
tests/test_retrieval.py::test_distance_config_accepts_unset_or_nonnegative_finite_values[None] PASSED [ 83%]
tests/test_retrieval.py::test_distance_config_accepts_unset_or_nonnegative_finite_values[0] PASSED [ 83%]
tests/test_retrieval.py::test_distance_config_accepts_unset_or_nonnegative_finite_values[1.5] PASSED [ 84%]
tests/test_retrieval.py::test_empty_channel_does_not_fall_back_to_global_results PASSED [ 84%]
tests/test_retrieval.py::test_rag_payload_uses_scoped_matches[None-2-api] PASSED [ 85%]
tests/test_retrieval.py::test_rag_payload_uses_scoped_matches[None-2-slack] PASSED [ 85%]
tests/test_retrieval.py::test_rag_payload_uses_scoped_matches[1.0-1-api] PASSED [ 86%]
tests/test_retrieval.py::test_rag_payload_uses_scoped_matches[1.0-1-slack] PASSED [ 86%]
tests/test_retrieval.py::test_rag_payload_uses_scoped_matches[0.0-0-api] PASSED [ 87%]
tests/test_retrieval.py::test_rag_payload_uses_scoped_matches[0.0-0-slack] PASSED [ 87%]
tests/test_thread_indexing.py::test_saved_thread_includes_root_and_replies_in_timestamp_order PASSED [ 88%]
tests/test_thread_indexing.py::test_saved_root_only_thread_is_indexed_once[None] PASSED [ 88%]
tests/test_thread_indexing.py::test_saved_root_only_thread_is_indexed_once[1700000000.000001] PASSED [ 89%]
tests/test_thread_indexing.py::test_saved_replies_can_be_indexed_without_a_saved_root PASSED [ 89%]
tests/test_thread_indexing.py::test_missing_saved_thread_returns_404_without_indexing[CTARGET-1700000098.000001] PASSED [ 90%]
tests/test_thread_indexing.py::test_missing_saved_thread_returns_404_without_indexing[CEMPTY-1700000000.000001] PASSED [ 90%]
tests/test_thread_indexing.py::test_saved_thread_channel_filter_also_applies_to_root_lookup PASSED [ 91%]
tests/test_thread_indexing.py::test_saved_thread_indexing_failure_remains_500 PASSED [ 91%]
tests/test_thread_indexing.py::test_saved_thread_retrieval_includes_original_question PASSED [ 92%]
tests/test_retrieval_preview.py::test_preview_returns_ranked_chunks_metadata_distances_and_settings PASSED [ 92%]
tests/test_retrieval_preview.py::test_preview_applies_channel_scope_before_top_k PASSED [ 93%]
tests/test_retrieval_preview.py::test_preview_exposes_inclusive_distance_cutoff_with_transcript PASSED [ 93%]
tests/test_retrieval_preview.py::test_preview_returns_success_for_empty_results[collection] PASSED [ 94%]
tests/test_retrieval_preview.py::test_preview_returns_success_for_empty_results[channel] PASSED [ 94%]
tests/test_retrieval_preview.py::test_preview_returns_success_for_empty_results[cutoff] PASSED [ 95%]
tests/test_retrieval_preview.py::test_preview_rejects_invalid_input_before_search[payload0] PASSED [ 95%]
tests/test_retrieval_preview.py::test_preview_rejects_invalid_input_before_search[payload1] PASSED [ 96%]
tests/test_retrieval_preview.py::test_preview_rejects_invalid_input_before_search[payload2] PASSED [ 96%]
tests/test_retrieval_preview.py::test_preview_rejects_invalid_input_before_search[payload3] PASSED [ 97%]
tests/test_retrieval_preview.py::test_preview_rejects_invalid_input_before_search[payload4] PASSED [ 97%]
tests/test_retrieval_preview.py::test_preview_rejects_invalid_input_before_search[payload5] PASSED [ 98%]
tests/test_retrieval_preview.py::test_preview_rejects_invalid_input_before_search[payload6] PASSED [ 98%]
tests/test_retrieval_preview.py::test_preview_trims_question_and_channel PASSED [ 99%]
tests/test_retrieval_preview.py::test_preview_reports_unavailable_retrieval_without_exposing_error PASSED [100%]

=============================== warnings summary ===============================
app/main.py:97
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/app/main.py:97: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.12/migration/
    class MessageResponse(BaseModel):

app/main.py:124
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/app/main.py:124: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.12/migration/
    class ConversationTurn(BaseModel):

app/main.py:161
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/app/main.py:161: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.12/migration/
    class DocumentDetail(BaseModel):

app/main.py:174
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/app/main.py:174: DeprecationWarning: 
          on_event is deprecated, use lifespan event handlers instead.
  
          Read more about it in the
          [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
          
    @app.on_event("startup")

.venv/lib/python3.13/site-packages/fastapi/applications.py:4576
.venv/lib/python3.13/site-packages/fastapi/applications.py:4576
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/.venv/lib/python3.13/site-packages/fastapi/applications.py:4576: DeprecationWarning: 
          on_event is deprecated, use lifespan event handlers instead.
  
          Read more about it in the
          [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
          
    return self.router.on_event(event_type)

app/main.py:187
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/app/main.py:187: DeprecationWarning: 
          on_event is deprecated, use lifespan event handlers instead.
  
          Read more about it in the
          [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
          
    @app.on_event("shutdown")

tests/test_documents.py: 4 warnings
tests/test_thread_indexing.py: 92 warnings
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/.venv/lib/python3.13/site-packages/sqlalchemy/sql/schema.py:3624: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
    return util.wrap_callable(lambda ctx: fn(), fn)  # type: ignore

tests/test_retrieval.py::test_document_chunks_stop_after_covering_content[-5-2-expected0]
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/tests/test_retrieval.py:122: LangChainDeprecationWarning: The class `Chroma` was deprecated in LangChain 0.2.9 and will be removed in 1.0. An updated version of the class exists in the `langchain-chroma package and should be used instead. To use it run `pip install -U `langchain-chroma` and import as `from `langchain_chroma import Chroma``.
    store.vector_store = Chroma(

tests/test_retrieval.py: 384 warnings
tests/test_thread_indexing.py: 27 warnings
tests/test_retrieval_preview.py: 216 warnings
  /Users/johnny/.paseo/worktrees/0x03bguv/hopeful-hamster/backend/.venv/lib/python3.13/site-packages/chromadb/types.py:144: PydanticDeprecatedSince211: Accessing the 'model_fields' attribute on the instance is deprecated. Instead, you should access this attribute from the model class. Deprecated in Pydantic V2.11 to be removed in V3.0.
    return self.model_fields  # pydantic 2.x

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
====================== 196 passed, 731 warnings in 14.46s ======================

Frontend gate output, 15 passed
[test-frontend] $ bun run test
$ bun test --preload ./tests/setup.ts
bun test v1.3.5 (1e86cebd)

tests/retrieval.test.tsx:
(pass) retrieval waits for a non-blank question and submits trimmed optional scope [102.30ms]
(pass) retrieval omits blank scope, preserves rank and raw distances, and shows an unset cutoff [30.87ms]
(pass) retrieval disables inputs and duplicate submissions while loading [22.20ms]
(pass) retrieval shows an empty result and a zero distance cutoff [19.08ms]
(pass) retrieval replaces a failed request with a successful retry [19.58ms]
(pass) retrieval clears old results when the question or channel changes [24.44ms]
(pass) retrieval aborts on leaving the tab and ignores late responses after reopening [21.45ms]
(pass) retrieval renders chunk and metadata markup as text [13.07ms]

tests/conversations.test.tsx:
(pass) loads only on opening the tab and paginates summaries and chronological turns independently [182.68ms]
(pass) shows loading, an empty page and a list error with a working retry [13.94ms]
(pass) retries missing conversation details without discarding the list [24.73ms]
(pass) ignores stale detail responses after selecting another conversation [52.63ms]
(pass) ignores stale errors after leaving and reopening the tab [18.23ms]
(pass) renders stored markup as text and supports missing metadata [22.36ms]
(pass) encodes conversation IDs in detail request paths [2.16ms]

 15 pass
 0 fail
 92 expect() calls
Ran 15 tests across 2 files. [1043.00ms]

Offline startup fixture and exact command

A temporary .tmp/proof/fixture/sitecustomize.py supplied mock Slack authentication and blocked Slack API calls and Socket Mode. It seeded a temporary local SQLite database. The shared application singletons and FastAPI route code were used unchanged.

"""Offline startup proof only. No live credentials or Slack calls."""
from datetime import datetime
from unittest.mock import patch

patch("slack_sdk.WebClient.auth_test", return_value={
    "ok": True, "bot_id": "BTEST", "user_id": "UTEST", "team_id": "TTEST",
}).start()
patch("slack_sdk.WebClient.api_call", side_effect=AssertionError("Slack calls blocked")).start()
patch("slack_bolt.adapter.socket_mode.SocketModeHandler.start",
      side_effect=AssertionError("Socket Mode blocked")).start()

from app.database import Base, Document, SessionLocal, engine
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
    if not db.query(Document).count():
        for title, content, source in [
            ("VPN guide", "Reset via IT portal.", "api"),
            ("Remote access", "VPN for new starters.", "api"),
            ("VPN notes", "Support-only reference.", "docs"),
            ("Budget", "100% complete. See setup_notes.", "docs"),
        ]:
            db.add(Document(title=title, content=content, source=source,
                            created_at=datetime(2026, 1, 1, 9)))
        db.commit()
env -i HOME="$HOME" PATH="$PATH" UV_PYTHON=3.13 \
  PYTHONPATH="$PWD/.tmp/proof/fixture:$PWD/backend" \
  DATABASE_URL="sqlite:///$PWD/.tmp/proof/documents.sqlite3" \
  SLACK_BOT_TOKEN=xoxb-fixture-only SLACK_SIGNING_SECRET=fixture-only \
  mise run dev-backend

Observed Uvicorn running on http://127.0.0.1:8000 and Application startup complete. before running curl. Stopped with Ctrl+C after capture and observed Application shutdown complete.. Mise reported the expected exit 130 after the interrupt.

Request and response transcript
Health after startup
$ curl -sS -i http://127.0.0.1:8000/health
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 20
content-type: application/json

{"status":"healthy"}

Title or content, source filter, first page
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode 'q=  vPn  ' --data-urlencode source=api --data-urlencode limit=1 --data-urlencode offset=0
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 167
content-type: application/json

{"items":[{"id":2,"title":"Remote access","source":"api","created_at":"2026-01-01T09:00:00","content_preview":"VPN for new starters."}],"total":2,"limit":1,"offset":0}

Second page keeps filtered total
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode q=vpn --data-urlencode source=api --data-urlencode limit=1 --data-urlencode offset=1
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 162
content-type: application/json

{"items":[{"id":1,"title":"VPN guide","source":"api","created_at":"2026-01-01T09:00:00","content_preview":"Reset via IT portal."}],"total":2,"limit":1,"offset":1}

Literal percent is not a wildcard
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode q=%
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 172
content-type: application/json

{"items":[{"id":4,"title":"Budget","source":"docs","created_at":"2026-01-01T09:00:00","content_preview":"100% complete. See setup_notes."}],"total":1,"limit":50,"offset":0}

Unknown phrase returns an empty page
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode q=missing
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 44
content-type: application/json

{"items":[],"total":0,"limit":50,"offset":0}

Blank query keeps all source-filtered documents
$ curl -sS -i --get http://127.0.0.1:8000/api/documents --data-urlencode 'q=   ' --data-urlencode source=api
HTTP/1.1 200 OK
date: Sat, 14 Nov 2026 18:20:46 GMT
server: uvicorn
content-length: 288
content-type: application/json

{"items":[{"id":2,"title":"Remote access","source":"api","created_at":"2026-01-01T09:00:00","content_preview":"VPN for new starters."},{"id":1,"title":"VPN guide","source":"api","created_at":"2026-01-01T09:00:00","content_preview":"Reset via IT portal."}],"total":2,"limit":50,"offset":0}

@johnnyhuy
johnnyhuy merged commit 472d1f6 into main Nov 14, 2026
10 checks passed
@johnnyhuy
johnnyhuy deleted the agent/issue-47-document-search branch November 14, 2026 22:17
@github-actions github-actions Bot mentioned this pull request Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add literal text search to the document list API

1 participant