feat: add document file download and admin page file display
1. 新增将文档元数据存入L1向量库的功能 2. 新增文档文件下载API接口,支持路径安全校验 3. 后端文档详情接口新增文件信息返回字段 4. 管理后台页面新增原始文件信息展示与下载链接
This commit is contained in:
+53
-3
@@ -9,6 +9,7 @@
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -103,18 +104,50 @@ class QdrantService:
|
||||
tags: list[str],
|
||||
dense_vector: list[float],
|
||||
sparse_vector: SparseVectorTuple | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""写入 L1 文档总结,payload 含 doc_id/title/category/tags/text(=summary)"""
|
||||
"""写入 L1 文档总结,payload 含 doc_id/title/category/tags/text(=summary)/metadata
|
||||
|
||||
metadata 默认 None 时存空 dict,保证字段始终存在;存量旧文档读取时按缺失处理。
|
||||
"""
|
||||
vector: dict[str, Any] = {VECTOR_DENSE: dense_vector}
|
||||
if sparse_vector is not None:
|
||||
vector[VECTOR_SPARSE] = models.SparseVector(indices=sparse_vector[0], values=sparse_vector[1])
|
||||
point = models.PointStruct(
|
||||
id=_point_id(f"{doc_id}:l1"),
|
||||
vector=vector,
|
||||
payload={"doc_id": doc_id, "title": title, "category": category, "tags": tags, "text": summary},
|
||||
payload={
|
||||
"doc_id": doc_id,
|
||||
"title": title,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"text": summary,
|
||||
"metadata": metadata or {},
|
||||
},
|
||||
)
|
||||
await self._client.upsert(collection_name=COLLECTION_L1, points=[point])
|
||||
|
||||
async def get_l1_metadata(self, doc_id: str) -> dict[str, str] | None:
|
||||
"""按 doc_id 查 L1 点,返回其 payload.metadata
|
||||
|
||||
文档不存在或 payload 无 metadata 字段时返回 None。
|
||||
"""
|
||||
doc_filter = self.build_filter(doc_ids=[doc_id])
|
||||
records, _ = await self._client.scroll(
|
||||
collection_name=COLLECTION_L1,
|
||||
scroll_filter=doc_filter,
|
||||
limit=1,
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
)
|
||||
if not records:
|
||||
return None
|
||||
payload = records[0].payload or {}
|
||||
meta = payload.get("metadata")
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
return meta
|
||||
|
||||
async def upsert_nodes(self, collection: str, nodes: list[dict[str, Any]]) -> None:
|
||||
"""批量写入 L2/L3 大纲节点
|
||||
|
||||
@@ -252,7 +285,12 @@ class QdrantService:
|
||||
return items, str(next_offset) if next_offset is not None else None
|
||||
|
||||
async def get_doc_detail(self, doc_id: str) -> dict[str, Any] | None:
|
||||
"""获取文档详情:L1 记录 + L2/L3 全部节点 + chunks 数量,文档不存在返回 None"""
|
||||
"""获取文档详情:L1 记录 + L2/L3 全部节点 + chunks 数量 + file 文件信息
|
||||
|
||||
file 字段从 L1 payload.metadata 提取(raw_file_path/original_filename/original_size_bytes):
|
||||
有 raw_file_path 时返回 {filename, size_bytes, url},否则 None。
|
||||
文档不存在返回 None。
|
||||
"""
|
||||
doc_filter = self.build_filter(doc_ids=[doc_id])
|
||||
l1_records, _ = await self._client.scroll(
|
||||
collection_name=COLLECTION_L1,
|
||||
@@ -271,11 +309,23 @@ class QdrantService:
|
||||
count_filter=doc_filter,
|
||||
exact=True,
|
||||
)
|
||||
|
||||
# 从 L1 metadata 提取原始文件信息,无 raw_file_path 时 file=None
|
||||
meta = (l1_records[0].payload or {}).get("metadata") or {}
|
||||
raw_path = meta.get("raw_file_path", "")
|
||||
file_info: dict[str, Any] | None = None
|
||||
if raw_path:
|
||||
file_info = {
|
||||
"filename": meta.get("original_filename", Path(raw_path).name),
|
||||
"size_bytes": int(meta.get("original_size_bytes", "0") or "0"),
|
||||
"url": f"/api/v1/documents/{doc_id}/file",
|
||||
}
|
||||
return {
|
||||
"l1": l1_records[0].payload or {},
|
||||
"l2_nodes": l2_nodes,
|
||||
"l3_nodes": l3_nodes,
|
||||
"chunks_count": chunks_count.count,
|
||||
"file": file_info,
|
||||
}
|
||||
|
||||
async def delete_by_doc_id(self, doc_id: str) -> dict[str, int]:
|
||||
|
||||
Reference in New Issue
Block a user