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:
@@ -234,3 +234,152 @@ async def test_search_hybrid_rrf(service: QdrantService) -> None:
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].payload is not None
|
||||
assert filtered[0].payload["doc_id"] == "doc-h2"
|
||||
|
||||
|
||||
# ---------- L1 metadata 存储与读取 ----------
|
||||
|
||||
|
||||
async def test_upsert_l1_metadata_roundtrip(service: QdrantService) -> None:
|
||||
"""upsert_l1 写入 metadata 后,get_l1_metadata 返回写入的 dict"""
|
||||
meta = {
|
||||
"raw_file_path": "/data/uploads/report.pdf",
|
||||
"original_filename": "年报.pdf",
|
||||
"original_size_bytes": "2048",
|
||||
}
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-meta",
|
||||
title="带元数据文档",
|
||||
summary="总结",
|
||||
category="tech",
|
||||
tags=["x"],
|
||||
dense_vector=_dense(0.9),
|
||||
metadata=meta,
|
||||
)
|
||||
assert await service.get_l1_metadata("doc-meta") == meta
|
||||
|
||||
|
||||
async def test_upsert_l1_without_metadata_stores_empty_dict(service: QdrantService) -> None:
|
||||
"""upsert_l1 不传 metadata(旧签名)→ payload 存空 dict,get_l1_metadata 返回 {}"""
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-no-meta",
|
||||
title="无元数据文档",
|
||||
summary="总结",
|
||||
category="tech",
|
||||
tags=["x"],
|
||||
dense_vector=_dense(0.9),
|
||||
)
|
||||
assert await service.get_l1_metadata("doc-no-meta") == {}
|
||||
|
||||
|
||||
async def test_get_l1_metadata_doc_not_found(service: QdrantService) -> None:
|
||||
"""文档不存在 → get_l1_metadata 返回 None"""
|
||||
assert await service.get_l1_metadata("doc-missing") is None
|
||||
|
||||
|
||||
async def test_get_l1_metadata_legacy_payload_without_field(service: QdrantService) -> None:
|
||||
"""存量旧文档:payload 完全没有 metadata 字段(绕过 upsert_l1 直接写点)→ 返回 None 不报错"""
|
||||
import uuid
|
||||
|
||||
from qdrant_client import models
|
||||
|
||||
point = models.PointStruct(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_URL, "legacy-doc:l1")),
|
||||
vector={"dense": _dense(0.4)},
|
||||
payload={"doc_id": "legacy-doc", "title": "旧文档", "category": "tech", "tags": [], "text": "旧总结"},
|
||||
)
|
||||
await service.client.upsert(collection_name=COLLECTION_L1, points=[point])
|
||||
assert await service.get_l1_metadata("legacy-doc") is None
|
||||
|
||||
|
||||
# ---------- get_doc_detail file 字段 ----------
|
||||
|
||||
|
||||
async def test_get_doc_detail_file_field_with_raw_path(service: QdrantService) -> None:
|
||||
"""L1 metadata 含 raw_file_path → file 字段含 filename/size_bytes/url"""
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-file",
|
||||
title="文件文档",
|
||||
summary="总结",
|
||||
category="tech",
|
||||
tags=["x"],
|
||||
dense_vector=_dense(0.9),
|
||||
metadata={
|
||||
"raw_file_path": "/data/uploads/report.pdf",
|
||||
"original_filename": "年报.pdf",
|
||||
"original_size_bytes": "4096",
|
||||
},
|
||||
)
|
||||
detail = await service.get_doc_detail("doc-file")
|
||||
assert detail is not None
|
||||
file_info = detail["file"]
|
||||
assert file_info == {
|
||||
"filename": "年报.pdf",
|
||||
"size_bytes": 4096,
|
||||
"url": "/api/v1/documents/doc-file/file",
|
||||
}
|
||||
|
||||
|
||||
async def test_get_doc_detail_file_field_filename_fallback_to_path_name(service: QdrantService) -> None:
|
||||
"""metadata 缺 original_filename 时,filename 回退为 raw_file_path 的 Path.name"""
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-file-fb",
|
||||
title="文件文档",
|
||||
summary="总结",
|
||||
category="tech",
|
||||
tags=["x"],
|
||||
dense_vector=_dense(0.9),
|
||||
metadata={"raw_file_path": "/data/uploads/notes.md", "original_size_bytes": "100"},
|
||||
)
|
||||
detail = await service.get_doc_detail("doc-file-fb")
|
||||
assert detail is not None
|
||||
assert detail["file"]["filename"] == "notes.md"
|
||||
assert detail["file"]["size_bytes"] == 100
|
||||
|
||||
|
||||
async def test_get_doc_detail_file_none_when_no_metadata(service: QdrantService) -> None:
|
||||
"""无 metadata(文本入库,存空 dict)→ file=None"""
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-text",
|
||||
title="纯文本文档",
|
||||
summary="总结",
|
||||
category="tech",
|
||||
tags=["x"],
|
||||
dense_vector=_dense(0.9),
|
||||
)
|
||||
detail = await service.get_doc_detail("doc-text")
|
||||
assert detail is not None
|
||||
assert detail["file"] is None
|
||||
|
||||
|
||||
async def test_get_doc_detail_file_none_when_no_raw_path(service: QdrantService) -> None:
|
||||
"""metadata 无 raw_file_path → file=None"""
|
||||
await service.upsert_l1(
|
||||
doc_id="doc-meta-no-path",
|
||||
title="文档",
|
||||
summary="总结",
|
||||
category="tech",
|
||||
tags=["x"],
|
||||
dense_vector=_dense(0.9),
|
||||
metadata={"original_filename": "x.pdf", "original_size_bytes": "10"},
|
||||
)
|
||||
detail = await service.get_doc_detail("doc-meta-no-path")
|
||||
assert detail is not None
|
||||
assert detail["file"] is None
|
||||
|
||||
|
||||
async def test_get_doc_detail_file_none_for_legacy_doc(service: QdrantService) -> None:
|
||||
"""存量旧文档(payload 无 metadata 字段,绕过 upsert_l1 直接写点)→ file=None 不报错"""
|
||||
import uuid
|
||||
|
||||
from qdrant_client import models
|
||||
|
||||
point = models.PointStruct(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_URL, "legacy-detail:l1")),
|
||||
vector={"dense": _dense(0.3)},
|
||||
payload={"doc_id": "legacy-detail", "title": "旧文档", "category": "tech", "tags": [], "text": "旧总结"},
|
||||
)
|
||||
await service.client.upsert(collection_name=COLLECTION_L1, points=[point])
|
||||
detail = await service.get_doc_detail("legacy-detail")
|
||||
assert detail is not None
|
||||
assert detail["file"] is None
|
||||
assert detail["l1"]["doc_id"] == "legacy-detail"
|
||||
|
||||
Reference in New Issue
Block a user