"""文档 API:POST /api/v1/documents 异步入库 + 任务查询 + 文档管理(列表/详情/删除)""" from typing import Any import structlog from fastapi import APIRouter, Query from fastapi.responses import JSONResponse from app.api.response import ApiError, ok from app.config import Settings from app.core.ingest_tasks import IngestTaskManager from app.core.ingestion import Ingester from app.models.document import DocumentInput from app.services.qdrant import QdrantService from app.services.redis import RedisCache, get_cache logger = structlog.get_logger() router = APIRouter(prefix="/api/v1", tags=["document"]) # 模块级懒加载单例,避免每请求重建 Ingester 及其下游依赖 _ingester: Ingester | None = None _qdrant: QdrantService | None = None _task_manager: IngestTaskManager | None = None def _get_ingester() -> Ingester: global _ingester if _ingester is None: _ingester = Ingester() return _ingester def _get_qdrant() -> QdrantService: global _qdrant if _qdrant is None: _qdrant = QdrantService() return _qdrant def _get_task_manager() -> IngestTaskManager: """入库任务管理器懒加载单例 Redis 沿用全局缓存单例(RedisCache 读写全容错,不可用时镜像写失败仅告警); 获取缓存实例异常时传 None,退化为纯内存模式。 """ global _task_manager if _task_manager is None: try: redis: RedisCache | None = get_cache() except Exception: logger.warning("Redis 缓存不可用,入库任务状态仅保留在内存", exc_info=True) redis = None _task_manager = IngestTaskManager(ingester=_get_ingester(), redis=redis, settings=Settings()) return _task_manager @router.post("/documents") async def ingest_document(doc: DocumentInput) -> JSONResponse: """文档入库入口:登记异步任务并返回 202 + task_id,入库结果经任务查询端点获取""" if not doc.text.strip(): raise ApiError(1001, "文档内容不能为空") task_id = await _get_task_manager().submit(doc) return JSONResponse(status_code=202, content=ok({"task_id": task_id, "status": "pending"})) @router.get("/documents/tasks/{task_id}") async def get_ingest_task(task_id: str) -> dict[str, Any]: """查询入库任务状态:含 task_id/status/created_at/updated_at,done 附 result,failed 附 error""" task = await _get_task_manager().get(task_id) if task is None: raise ApiError(1004, "任务不存在") return ok(task) @router.get("/documents") async def list_documents( limit: int = Query(default=20, ge=1, le=100), offset: str | None = None, ) -> dict[str, Any]: """分页列出文档(L1 摘要),返回 items 与下一页游标 next_offset""" try: items, next_offset = await _get_qdrant().scroll_l1(limit=limit, offset=offset) except Exception as exc: logger.error("文档列表查询失败", error=str(exc)) raise ApiError(2000, f"文档列表查询失败: {exc}") from exc return ok({"items": items, "next_offset": next_offset}) @router.get("/documents/{doc_id}") async def get_document(doc_id: str) -> dict[str, Any]: """获取文档详情:L1 记录 + L2/L3 节点 + chunks 数量""" try: detail = await _get_qdrant().get_doc_detail(doc_id) except Exception as exc: logger.error("文档详情查询失败", doc_id=doc_id, error=str(exc)) raise ApiError(2000, f"文档详情查询失败: {exc}") from exc if detail is None: raise ApiError(1004, "文档不存在") return ok(detail) @router.delete("/documents/{doc_id}") async def delete_document(doc_id: str) -> dict[str, Any]: """删除文档:四层集合中该 doc_id 的所有点;幂等,不存在也返回成功(删除数全 0)""" try: deleted = await _get_qdrant().delete_by_doc_id(doc_id) except Exception as exc: logger.error("文档删除失败", doc_id=doc_id, error=str(exc)) raise ApiError(2000, f"文档删除失败: {exc}") from exc return ok({"doc_id": doc_id, "deleted": deleted, "deleted_total": sum(deleted.values())})