From e87aab3a25e6302a3a3958abca1411ca034edf68 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 15 Nov 2026 01:21:14 +0700 Subject: [PATCH] feat(api): search saved documents by title and content Co-authored-by: OpenAI Codex --- backend/app/main.py | 13 +++++++ backend/tests/test_documents.py | 64 +++++++++++++++++++++++++++++++++ docs/local-development.md | 21 +++++++++++ 3 files changed, 98 insertions(+) diff --git a/backend/app/main.py b/backend/app/main.py index e79373c..d4e71d8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -278,12 +278,25 @@ async def list_documents( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), source: str = None, + q: str | None = Query( + None, + description=( + "Case-insensitive literal substring in title or content. " + "Surrounding whitespace is ignored." + ), + ), db: Session = Depends(get_db), ): """List documents in the knowledge base, most recently created first.""" base = db.query(Document) if source: base = base.filter(Document.source == source) + search = q.strip() if q else "" + if search: + base = base.filter(or_( + Document.title.icontains(search, autoescape=True), + Document.content.icontains(search, autoescape=True), + )) total = base.count() documents = ( diff --git a/backend/tests/test_documents.py b/backend/tests/test_documents.py index 4fcbce9..cc707dc 100644 --- a/backend/tests/test_documents.py +++ b/backend/tests/test_documents.py @@ -134,6 +134,70 @@ def test_empty_database(self): assert data == {"items": [], "total": 0, "limit": 50, "offset": 0} +class TestSearchDocuments: + @pytest.mark.parametrize("query", ["vpn", "VpN", " \tVPN\n "]) + def test_matches_title_case_insensitively_and_trims_query(self, query): + response = client.get("/api/documents", params={"q": query}) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert [item["title"] for item in data["items"]] == ["VPN guide"] + + def test_matches_content_beyond_the_preview(self): + with TestingSession() as db: + db.add(_doc("Remote access", "x" * 150 + " reset via IT PORTAL", "docs", 20)) + db.commit() + data = client.get("/api/documents", params={"q": "it portal"}).json() + assert data["total"] == 2 + assert [item["title"] for item in data["items"]] == ["Remote access", "VPN guide"] + assert data["items"][0]["content_preview"] == "x" * 120 + + def test_filters_before_counting_and_paginating(self): + with TestingSession() as db: + db.add_all([ + _doc("VPN handbook", "VPN for new starters", "api", 20), + _doc("VPN checklist", "Follow these steps", "api", 20), + _doc("VPN troubleshooting", "A separate source", "docs", 30), + ]) + db.commit() + pages = [client.get("/api/documents", params={ + "q": "vpn", "source": "api", "limit": 1, "offset": offset, + }).json() for offset in range(4)] + assert all(page["total"] == 3 for page in pages) + assert all(page["limit"] == 1 for page in pages) + assert [page["offset"] for page in pages] == [0, 1, 2, 3] + assert [page["items"][0]["title"] for page in pages[:3]] == [ + "VPN checklist", "VPN handbook", "VPN guide", + ] + assert pages[3]["items"] == [] + + @pytest.mark.parametrize("query", ["%", "_", "/", "100%", "setup_notes", "a/b", "' OR 1=1 --"]) + @pytest.mark.parametrize("field", ["title", "content"]) + def test_treats_sql_wildcards_and_escape_characters_literally(self, query, field): + values = {"title": "Literal example", "content": "A literal example"} + values[field] += f" {query}" + with TestingSession() as db: + db.add(_doc(values["title"], values["content"], "docs", 20)) + db.commit() + data = client.get("/api/documents", params={"q": query}).json() + assert data["total"] == 1 + assert [item["title"] for item in data["items"]] == [values["title"]] + + @pytest.mark.parametrize("query", ["", " \t\n ", "\u2003\u00a0"]) + def test_blank_query_preserves_source_filter_and_existing_list(self, query): + params = {"source": "api", "limit": 1, "offset": 1} + expected = client.get("/api/documents", params=params).json() + response = client.get("/api/documents", params={**params, "q": query}) + assert response.status_code == 200 + assert response.json() == expected + + @pytest.mark.parametrize("params", [{"q": "missing"}, {"q": "vpn", "source": "docs"}]) + def test_no_matches_return_an_empty_page(self, params): + response = client.get("/api/documents", params=params) + assert response.status_code == 200 + assert response.json() == {"items": [], "total": 0, "limit": 50, "offset": 0} + + class TestDocumentDetail: def test_returns_full_content(self, seed): doc_id = seed[0] diff --git a/docs/local-development.md b/docs/local-development.md index fb7425c..fce7541 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -281,6 +281,27 @@ docker compose up -d --build bot cd backend && uv sync ``` +## Search saved documents + +`GET /api/documents` accepts an optional `q` parameter to search document titles +and full content for a case-insensitive literal substring. Surrounding whitespace +is ignored; an omitted or blank query returns the usual list. Characters such as +`%` and `_` are literal text, not search wildcards. + +```sh +curl -sS --get http://localhost:8000/api/documents \ + --data-urlencode 'q=VPN' \ + --data-urlencode 'source=api' \ + --data-urlencode 'limit=10' \ + --data-urlencode 'offset=0' +``` + +Search and source filters combine before counting and pagination. The response +keeps the existing `items`, `total`, `limit` and `offset` fields and newest-first +ordering. A search with no matches returns an empty page with `total` set to zero. This searches +saved PostgreSQL records and does not call Slack, embeddings or the model. The +dashboard does not expose the search input yet. + ## Next Steps - [Full Setup Guide](setup.md) - Complete deployment instructions