feat: add document file download API and related features
1. add `/api/v1/documents/{doc_id}/file` endpoint for downloading original document files
2. add file field to document detail API response based on metadata
3. add comprehensive tests for file download API and metadata integration
4. update API documentation in CLAUDE.md
This commit is contained in:
+80
-4
@@ -3,13 +3,27 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from app.config import settings
|
||||
from app.core.chunker import Chunker
|
||||
from app.core.ingestion import Ingester, IngestionError
|
||||
from app.core.sparse import SparseEncoder
|
||||
from app.models.document import DocumentInput, DocumentSummary, SummaryLevel
|
||||
from app.models.knowledge import CategoryResult
|
||||
from app.services.qdrant import COLLECTION_L2, COLLECTION_L3
|
||||
from app.services.qdrant import COLLECTION_L2, COLLECTION_L3, QdrantService
|
||||
|
||||
_DIM = settings.embedding_dimension
|
||||
|
||||
|
||||
def _fake_vec(index: int, dim: int) -> list[float]:
|
||||
"""构造确定性伪向量:第 0 维放 index 标识,其余补 0,长度对齐 dim"""
|
||||
vec = [0.0] * dim
|
||||
if dim > 0:
|
||||
vec[0] = float(index)
|
||||
if dim > 1:
|
||||
vec[1] = 1.0
|
||||
return vec
|
||||
|
||||
|
||||
class FakeSummarizer:
|
||||
@@ -37,14 +51,18 @@ class FakeClassifier:
|
||||
|
||||
|
||||
class FakeEmbedding:
|
||||
"""按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本"""
|
||||
"""按输入数量返回伪向量的假 EmbeddingService,记录每次调用的文本
|
||||
|
||||
def __init__(self) -> None:
|
||||
dim 默认 2(FakeQdrant 不校验维度);接真实 Qdrant 时需传 settings.embedding_dimension。
|
||||
"""
|
||||
|
||||
def __init__(self, dim: int = 2) -> None:
|
||||
self.dim = dim
|
||||
self.calls: list[list[str]] = []
|
||||
|
||||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
self.calls.append(list(texts))
|
||||
return [[float(i), 1.0] for i in range(len(texts))]
|
||||
return [_fake_vec(i, self.dim) for i in range(len(texts))]
|
||||
|
||||
|
||||
class FakeQdrant:
|
||||
@@ -65,6 +83,7 @@ class FakeQdrant:
|
||||
tags: list[str],
|
||||
dense_vector: list[float],
|
||||
sparse_vector: Any = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
if self.fail_on == "l1":
|
||||
raise RuntimeError("qdrant down")
|
||||
@@ -77,6 +96,7 @@ class FakeQdrant:
|
||||
"tags": tags,
|
||||
"dense_vector": dense_vector,
|
||||
"sparse_vector": sparse_vector,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -271,3 +291,59 @@ class TestQdrantFailure:
|
||||
assert len(qdrant.l1_calls) == 1
|
||||
assert {c for c, _ in qdrant.nodes_calls} == {COLLECTION_L2, COLLECTION_L3}
|
||||
assert qdrant.chunks_calls == []
|
||||
|
||||
|
||||
class TestMetadataIntegration:
|
||||
"""L1 metadata 端到端:真实内存 Qdrant + 假总结/分类/向量化,验证 metadata 透传与 get_doc_detail.file"""
|
||||
|
||||
@pytest.fixture
|
||||
async def real_service(self) -> QdrantService:
|
||||
svc = QdrantService(client=AsyncQdrantClient(location=":memory:"))
|
||||
await svc.ensure_collections()
|
||||
return svc
|
||||
|
||||
def _make_real_ingester(self, service: QdrantService, summary: DocumentSummary) -> Ingester:
|
||||
return Ingester(
|
||||
summarizer=FakeSummarizer(summary), # type: ignore[arg-type]
|
||||
classifier=FakeClassifier(_category()), # type: ignore[arg-type]
|
||||
chunker=Chunker(),
|
||||
embedding=FakeEmbedding(dim=_DIM), # type: ignore[arg-type]
|
||||
sparse=SparseEncoder(),
|
||||
qdrant=service,
|
||||
)
|
||||
|
||||
async def test_ingest_with_metadata_populates_file_field(self, real_service: QdrantService) -> None:
|
||||
"""DocumentInput 带 metadata 入库后,get_doc_detail.file 含正确信息"""
|
||||
doc = DocumentInput(
|
||||
text="这是一段用于测试 metadata 透传的正文内容。" * 5,
|
||||
title="带文件元数据的文档",
|
||||
metadata={
|
||||
"raw_file_path": "/data/uploads/spec.md",
|
||||
"original_filename": "spec.md",
|
||||
"original_size_bytes": "5120",
|
||||
},
|
||||
)
|
||||
ingester = self._make_real_ingester(real_service, _structured_summary())
|
||||
result = await ingester.ingest(doc)
|
||||
|
||||
# FakeQdrant 已被真实 service 取代:l1_calls 不再可用,直接查 Qdrant
|
||||
detail = await real_service.get_doc_detail(result.document_id)
|
||||
assert detail is not None
|
||||
assert detail["file"] == {
|
||||
"filename": "spec.md",
|
||||
"size_bytes": 5120,
|
||||
"url": f"/api/v1/documents/{result.document_id}/file",
|
||||
}
|
||||
# L1 payload 也应带 metadata 字段
|
||||
assert detail["l1"]["metadata"] == doc.metadata
|
||||
|
||||
async def test_ingest_without_metadata_file_is_none(self, real_service: QdrantService) -> None:
|
||||
"""文本入库(metadata 为空 dict)→ get_doc_detail.file=None"""
|
||||
doc = DocumentInput(text="纯文本入库,无文件元数据。" * 5, title="纯文本")
|
||||
ingester = self._make_real_ingester(real_service, _structured_summary())
|
||||
result = await ingester.ingest(doc)
|
||||
|
||||
detail = await real_service.get_doc_detail(result.document_id)
|
||||
assert detail is not None
|
||||
assert detail["file"] is None
|
||||
assert detail["l1"]["metadata"] == {}
|
||||
|
||||
Reference in New Issue
Block a user