Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
64 changes: 64 additions & 0 deletions backend/tests/test_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
21 changes: 21 additions & 0 deletions docs/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading