from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from fastapi.responses import FileResponse from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext, get_current_user, get_db, require_admin_user from app.core.agent_enums import AgentName, AgentRunSource from app.schemas.common import ErrorResponse from app.schemas.knowledge import ( KnowledgeActionResponse, KnowledgeDocumentDetailRead, KnowledgeLibraryRead, KnowledgeOnlyOfficeCallbackRead, KnowledgeOnlyOfficeCallbackWrite, KnowledgeOnlyOfficeConfigRead, LlmWikiDocumentDetailRead, LlmWikiIndexRead, LlmWikiSummaryUpdateWrite, LlmWikiSyncTaskRead, LlmWikiSyncWrite, ) from app.services.agent_runs import AgentRunService from app.services.knowledge import ( KnowledgeService, ) from app.services.knowledge_sync import KnowledgeSyncDispatchService router = APIRouter(prefix="/knowledge") @router.get( "/library", response_model=KnowledgeLibraryRead, summary="查询知识库目录", description="返回固定知识库目录与当前已上传文档列表。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", } }, ) def get_knowledge_library( _: Annotated[CurrentUserContext, Depends(get_current_user)], db: Annotated[Session, Depends(get_db)], ) -> KnowledgeLibraryRead: return KnowledgeService(db=db).list_library() @router.get( "/llm-wiki", response_model=LlmWikiIndexRead, summary="查询知识索引兼容视图", description="兼容旧前端字段。当前版本已切换到 LightRAG,不再暴露旧 LLM Wiki 草稿索引。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以查看索引信息。", }, }, ) def get_llm_wiki_index( _: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> LlmWikiIndexRead: run_service = AgentRunService(db) sync_runs = [ item for item in run_service.list_runs(agent=AgentName.HERMES.value, limit=200) if str(item.route_json.get("job_type") or "").strip() == "knowledge_index_sync" ] return LlmWikiIndexRead(documents=[], sync_run_count=len(sync_runs)) @router.get( "/llm-wiki/documents/{document_id}", response_model=LlmWikiDocumentDetailRead, summary="读取旧版 LLM Wiki 文档结果", description="兼容旧接口。当前版本知识库已切换到 LightRAG,不再提供旧 LLM Wiki 文档明细。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以查看索引信息。", }, status.HTTP_410_GONE: { "model": ErrorResponse, "description": "旧版 LLM Wiki 明细已下线。", }, }, ) def get_llm_wiki_document_detail( document_id: str, _: Annotated[CurrentUserContext, Depends(require_admin_user)], ) -> LlmWikiDocumentDetailRead: raise HTTPException( status_code=status.HTTP_410_GONE, detail=( f"文档 {document_id} 的旧版 LLM Wiki 明细已下线。" "当前版本使用 LightRAG 进行知识索引与检索,不再提供该兼容明细。" ), ) @router.patch( "/llm-wiki/documents/{document_id}", response_model=LlmWikiDocumentDetailRead, summary="更新旧版 LLM Wiki 知识总结", description="兼容旧接口。当前版本不再支持修改旧版 LLM Wiki 草稿。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以修改索引信息。", }, status.HTTP_410_GONE: { "model": ErrorResponse, "description": "旧版 LLM Wiki 编辑能力已下线。", }, }, ) def update_llm_wiki_document_summary( document_id: str, payload: LlmWikiSummaryUpdateWrite, _: Annotated[CurrentUserContext, Depends(require_admin_user)], ) -> LlmWikiDocumentDetailRead: del payload raise HTTPException( status_code=status.HTTP_410_GONE, detail=( f"文档 {document_id} 的旧版 LLM Wiki 草稿编辑能力已下线。" "当前版本请通过重新同步知识库来更新 LightRAG 索引。" ), ) @router.post( "/sync", response_model=LlmWikiSyncTaskRead, summary="????????", description="??????????? LightRAG ???????", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "????????????", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "??????????????", }, }, ) @router.post( "/llm-wiki/sync", response_model=LlmWikiSyncTaskRead, summary="????????????", description="??????????????? LightRAG ???????", ) def sync_knowledge_library( payload: LlmWikiSyncWrite, current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> LlmWikiSyncTaskRead: try: result = KnowledgeSyncDispatchService(db).queue_sync( current_user=current_user, folder=payload.folder, document_ids=payload.document_ids, source=AgentRunSource.USER_MESSAGE.value, force=payload.force, changed_only=not payload.force, ) return LlmWikiSyncTaskRead( ok=True, agent_run_id=result.agent_run_id, folder=result.folder, document_ids=result.document_ids, queued_at=result.queued_at, status=result.status, summary=result.summary, ) except Exception as exc: if isinstance(exc, ValueError): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if isinstance(exc, FileNotFoundError): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc @router.get( "/documents/{document_id}", response_model=KnowledgeDocumentDetailRead, summary="读取知识库文档详情", description="返回单个知识库文档的元信息、预览类型和预览内容。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def get_knowledge_document( document_id: str, _: Annotated[CurrentUserContext, Depends(get_current_user)], db: Annotated[Session, Depends(get_db)], ) -> KnowledgeDocumentDetailRead: try: return KnowledgeService(db=db).get_document_detail(document_id) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc @router.get( "/documents/{document_id}/onlyoffice-config", response_model=KnowledgeOnlyOfficeConfigRead, summary="读取 ONLYOFFICE 预览配置", description="为支持的 Office 文档生成 ONLYOFFICE 前端配置和临时访问令牌。", responses={ status.HTTP_400_BAD_REQUEST: { "model": ErrorResponse, "description": "ONLYOFFICE 未启用、配置不完整或文件格式不支持。", }, status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def get_knowledge_document_onlyoffice_config( document_id: str, current_user: Annotated[CurrentUserContext, Depends(get_current_user)], ) -> KnowledgeOnlyOfficeConfigRead: try: return KnowledgeService().build_onlyoffice_config(document_id, current_user) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @router.post( "/documents", response_model=KnowledgeDocumentDetailRead, status_code=status.HTTP_201_CREATED, summary="上传知识库文档", description="上传原始文件二进制内容到指定知识库目录。已有同名文件会覆盖并提升版本号。", responses={ status.HTTP_400_BAD_REQUEST: { "model": ErrorResponse, "description": "目录、文件名或文件内容不合法。", }, status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以上传知识库文件。", }, }, ) def upload_knowledge_document( content: Annotated[ bytes, Body( media_type="application/octet-stream", description="待上传的文件二进制内容。", ), ], folder: Annotated[str, Query(min_length=1, description="目标知识库目录名称。")], filename: Annotated[str, Query(min_length=1, description="原始文件名。")], current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], ) -> KnowledgeDocumentDetailRead: try: return KnowledgeService().upload_document(folder, filename, content, current_user) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @router.delete( "/documents/{document_id}", response_model=KnowledgeActionResponse, summary="删除知识库文档", description="删除知识库文档及其索引记录。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以删除知识库文件。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def delete_knowledge_document( document_id: str, _: Annotated[CurrentUserContext, Depends(require_admin_user)], ) -> KnowledgeActionResponse: try: KnowledgeService().delete_document(document_id) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc return KnowledgeActionResponse(detail="知识库文件已删除。") @router.get( "/documents/{document_id}/content", response_class=FileResponse, summary="下载或预览知识库原文", description="根据文档 ID 返回原始文件内容,可用于浏览器内联预览或下载。", responses={ status.HTTP_200_OK: { "description": "文件内容。", "content": {"application/octet-stream": {}}, }, status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def get_knowledge_document_content( document_id: str, disposition: Annotated[ str, Query( pattern="^(inline|attachment)$", description="内容展示方式,支持 `inline` 或 `attachment`。", ), ] = "inline", _: Annotated[CurrentUserContext, Depends(get_current_user)] = None, ) -> FileResponse: try: file_path, media_type, filename = KnowledgeService().get_document_content(document_id) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc _ = disposition return FileResponse(file_path, media_type=media_type, filename=filename) @router.get( "/documents/{document_id}/onlyoffice/content", response_class=FileResponse, summary="读取 ONLYOFFICE 文档源文件", description="供 ONLYOFFICE 服务通过短时访问令牌拉取原始文件内容。", responses={ status.HTTP_200_OK: { "description": "文件内容。", "content": {"application/octet-stream": {}}, }, status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "ONLYOFFICE 访问令牌无效。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def get_knowledge_document_onlyoffice_content( document_id: str, access_token: Annotated[ str, Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"), ], ) -> FileResponse: try: service = KnowledgeService() service.validate_onlyoffice_access_token(document_id, access_token) file_path, media_type, filename = service.get_document_content(document_id) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc except ValueError as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc return FileResponse(file_path, media_type=media_type, filename=filename) @router.post( "/documents/{document_id}/onlyoffice/callback", response_model=KnowledgeOnlyOfficeCallbackRead, summary="接收 ONLYOFFICE 回调", description="接收 ONLYOFFICE 文档回写回调,在状态满足要求时更新知识库文档内容。", responses={ status.HTTP_400_BAD_REQUEST: { "model": ErrorResponse, "description": "回调载荷不合法。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "知识库文件不存在。", }, }, ) def handle_knowledge_document_onlyoffice_callback( document_id: str, payload: KnowledgeOnlyOfficeCallbackWrite, ) -> KnowledgeOnlyOfficeCallbackRead: try: KnowledgeService().handle_onlyoffice_callback(document_id, payload.model_dump()) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="知识库文件不存在。", ) from exc except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc return KnowledgeOnlyOfficeCallbackRead()