feat: add SearchClient wrapper for index/delete/search operations
This commit is contained in:
parent
f4126ff3ec
commit
c931b870ed
43
backend/app/search_client.py
Normal file
43
backend/app/search_client.py
Normal file
@ -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": "<em>",
|
||||||
|
"highlightPostTag": "</em>",
|
||||||
|
})
|
||||||
|
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
|
||||||
80
backend/tests/test_search_client.py
Normal file
80
backend/tests/test_search_client.py
Normal file
@ -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"<em>{query}</em>")
|
||||||
|
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 "<em>检索</em>" in results[0]["snippet"]
|
||||||
Loading…
Reference in New Issue
Block a user