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:
2026-07-31 22:46:50 +08:00
parent e2e8e6829d
commit f92eff6f65
7 changed files with 580 additions and 6 deletions
+80 -4
View File
@@ -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 默认 2FakeQdrant 不校验维度);接真实 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"] == {}