diff --git a/backend/app/search_client.py b/backend/app/search_client.py new file mode 100644 index 0000000..bfa9650 --- /dev/null +++ b/backend/app/search_client.py @@ -0,0 +1,43 @@ +import base64 + + +def _encode_key(key: str) -> str: + return base64.urlsafe_b64encode(key.encode("utf-8")).decode("ascii").rstrip("=") + + +class SearchClient: + def __init__(self, client, index_name: str): + self._client = client + self._index_name = index_name + + def index_document(self, key: str, path: str, title: str, content: str) -> None: + index = self._client.index(self._index_name) + index.add_documents([{ + "id": _encode_key(key), + "key": key, + "path": path, + "title": title, + "content": content, + }]) + + def delete_document(self, key: str) -> None: + index = self._client.index(self._index_name) + index.delete_document(_encode_key(key)) + + def search(self, query: str) -> list[dict]: + index = self._client.index(self._index_name) + response = index.search(query, { + "attributesToHighlight": ["content"], + "highlightPreTag": "", + "highlightPostTag": "", + }) + results = [] + for hit in response.get("hits", []): + formatted = hit.get("_formatted", {}) + results.append({ + "key": hit["key"], + "title": hit["title"], + "path": hit["path"], + "snippet": formatted.get("content", hit.get("content", "")), + }) + return results diff --git a/backend/tests/test_search_client.py b/backend/tests/test_search_client.py new file mode 100644 index 0000000..6d75f63 --- /dev/null +++ b/backend/tests/test_search_client.py @@ -0,0 +1,80 @@ +from app.search_client import SearchClient + + +class FakeIndex: + def __init__(self): + self.documents = {} + self.deleted_ids = [] + + def add_documents(self, docs): + for doc in docs: + self.documents[doc["id"]] = doc + + def delete_document(self, doc_id): + self.documents.pop(doc_id, None) + self.deleted_ids.append(doc_id) + + def search(self, query, params=None): + hits = [] + for doc in self.documents.values(): + if query in doc["content"] or query in doc["title"]: + formatted = dict(doc) + if query in doc["content"]: + formatted["content"] = doc["content"].replace(query, f"{query}") + hits.append({**doc, "_formatted": formatted}) + return {"hits": hits} + + +class FakeMeilisearchSDK: + def __init__(self): + self.indexes = {} + + def index(self, name): + if name not in self.indexes: + self.indexes[name] = FakeIndex() + return self.indexes[name] + + +def test_index_document_adds_document_with_encoded_id(): + sdk = FakeMeilisearchSDK() + client = SearchClient(sdk, index_name="documents") + + client.index_document( + "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档" + ) + + index = sdk.indexes["documents"] + assert len(index.documents) == 1 + stored = list(index.documents.values())[0] + assert stored["key"] == "产品文档/架构设计.md" + assert stored["path"] == "产品文档" + assert stored["title"] == "架构设计.md" + assert stored["content"] == "DocHub 使用 MinIO 存储文档" + + +def test_delete_document_removes_document(): + sdk = FakeMeilisearchSDK() + client = SearchClient(sdk, index_name="documents") + client.index_document("产品文档/旧文档.md", "产品文档", "旧文档.md", "旧内容") + + client.delete_document("产品文档/旧文档.md") + + index = sdk.indexes["documents"] + assert index.documents == {} + assert len(index.deleted_ids) == 1 + + +def test_search_returns_matching_documents_with_highlighted_snippet(): + sdk = FakeMeilisearchSDK() + client = SearchClient(sdk, index_name="documents") + client.index_document( + "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 支持全文检索功能" + ) + + results = client.search("检索") + + assert len(results) == 1 + assert results[0]["key"] == "产品文档/架构设计.md" + assert results[0]["title"] == "架构设计.md" + assert results[0]["path"] == "产品文档" + assert "检索" in results[0]["snippet"]