from __future__ import annotations from datetime import UTC, datetime, timedelta 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, AgentPermissionLevel, AgentRunSource, AgentRunStatus from app.models.agent_asset import AgentAsset from app.schemas.common import ErrorResponse from app.schemas.knowledge import ( KnowledgeActionResponse, KnowledgeDocumentDetailRead, KnowledgeLibraryRead, KnowledgeOnlyOfficeCallbackRead, KnowledgeOnlyOfficeCallbackWrite, KnowledgeOnlyOfficeConfigRead, LlmWikiDocumentDetailRead, LlmWikiIndexRead, LlmWikiSyncTaskRead, LlmWikiSyncWrite, LlmWikiSummaryUpdateWrite, ) from app.services.agent_runs import AgentRunService from app.services.knowledge import ( KNOWLEDGE_INGEST_STATUS_FAILED, KNOWLEDGE_INGEST_STATUS_SYNCING, KnowledgeService, ) from app.services.llm_wiki import LlmWikiService from app.services.llm_wiki_tasks import llm_wiki_task_manager 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="查询 LLM Wiki 索引", description="返回知识库解析目录中的文档索引和同步次数,仅供管理员查看知识候选与规则候选草稿。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以查看 LLM Wiki 草稿内容。", }, }, ) def get_llm_wiki_index( _: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> LlmWikiIndexRead: return LlmWikiService(db).get_index() @router.get( "/llm-wiki/documents/{document_id}", response_model=LlmWikiDocumentDetailRead, summary="读取 LLM Wiki 文档解析结果", description="返回指定知识文档的解析文本、分块、知识候选与规则候选,仅供管理员查看。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以查看 LLM Wiki 草稿内容。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "指定文档尚未生成 LLM Wiki。", }, }, ) def get_llm_wiki_document_detail( document_id: str, _: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> LlmWikiDocumentDetailRead: try: return LlmWikiService(db).get_document_detail(document_id) except FileNotFoundError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="指定文档尚未生成 LLM Wiki。") from exc @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": "只有管理员可以修改 LLM Wiki 草稿内容。", }, status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, "description": "指定文档尚未生成 LLM Wiki。", }, }, ) def update_llm_wiki_document_summary( document_id: str, payload: LlmWikiSummaryUpdateWrite, _: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> LlmWikiDocumentDetailRead: try: return LlmWikiService(db).update_document_summary(document_id, payload) except FileNotFoundError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="指定文档尚未生成 LLM Wiki。") from exc except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @router.post( "/llm-wiki/sync", response_model=LlmWikiSyncTaskRead, summary="异步触发 Hermes 形成 LLM Wiki 与规则草稿", description="按知识库文档变化情况将系统 Hermes 归纳任务放入后台执行,并返回可追踪的 AgentRun 编号。", responses={ status.HTTP_401_UNAUTHORIZED: { "model": ErrorResponse, "description": "未提供知识库访问用户头。", }, status.HTTP_403_FORBIDDEN: { "model": ErrorResponse, "description": "只有管理员可以触发 LLM Wiki 同步。", }, }, ) def sync_llm_wiki( payload: LlmWikiSyncWrite, current_user: Annotated[CurrentUserContext, Depends(require_admin_user)], db: Annotated[Session, Depends(get_db)], ) -> LlmWikiSyncTaskRead: run_service = AgentRunService(db) knowledge_service = KnowledgeService(db=db) requested_ids = {str(item).strip() for item in payload.document_ids if str(item).strip()} target_document_ids = [ str(item.get("id") or "").strip() for item in knowledge_service.list_folder_documents(folder=payload.folder) if str(item.get("id") or "").strip() and (not requested_ids or str(item.get("id") or "").strip() in requested_ids) ] active_run = None for item in run_service.list_runs( agent=AgentName.HERMES.value, status=AgentRunStatus.RUNNING.value, limit=100, ): if item.route_json.get("job_type") != "llm_wiki_sync": continue if item.route_json.get("folder") != payload.folder: continue heartbeat_raw = str(item.route_json.get("heartbeat_at") or "").strip() heartbeat_at = None if heartbeat_raw: try: heartbeat_at = datetime.fromisoformat(heartbeat_raw) except ValueError: heartbeat_at = None last_seen_at = heartbeat_at or item.started_at if last_seen_at.tzinfo is None: last_seen_at = last_seen_at.replace(tzinfo=UTC) if datetime.now(UTC) - last_seen_at > timedelta(minutes=30): stale_document_ids = [ str(document_id).strip() for document_id in list(item.route_json.get("requested_document_ids") or []) if str(document_id).strip() ] if stale_document_ids: knowledge_service.set_document_ingest_statuses( stale_document_ids, status_code=KNOWLEDGE_INGEST_STATUS_FAILED, agent_run_id=item.run_id, ) run_service.merge_route_json( item.run_id, { "phase": "stale_failed", "heartbeat_at": datetime.now(UTC).isoformat(), }, status=AgentRunStatus.FAILED.value, result_summary="Hermes 归纳任务长时间无心跳,已自动标记为失败。", error_message="Hermes callback heartbeat timed out.", finished_at=datetime.now(UTC), ) continue if ( not target_document_ids or not list(item.route_json.get("requested_document_ids") or []) or bool( set(target_document_ids) & { str(document_id).strip() for document_id in list(item.route_json.get("requested_document_ids") or []) if str(document_id).strip() } ) ): active_run = item break if active_run is not None: return LlmWikiSyncTaskRead( ok=True, agent_run_id=active_run.run_id, folder=payload.folder, document_ids=[ str(item).strip() for item in list(active_run.route_json.get("requested_document_ids") or target_document_ids) if str(item).strip() ], queued_at=active_run.started_at, status=active_run.status, summary="已有 Hermes 归纳任务正在执行,已复用当前任务而不是重复创建。", ) task_asset = db.scalar( select(AgentAsset).where(AgentAsset.code == "task.hermes.llm_wiki_rule_formation") ) run = run_service.create_run( agent=AgentName.HERMES.value, source=AgentRunSource.SCHEDULE.value, user_id=current_user.username, task_id=task_asset.id if task_asset is not None else None, permission_level=AgentPermissionLevel.READ.value, status=AgentRunStatus.RUNNING.value, result_summary="Hermes 归纳任务已入队,等待后台执行。", route_json={ "job_type": "llm_wiki_sync", "phase": "queued", "folder": payload.folder, "force": payload.force, "requested_document_ids": target_document_ids, "requested_by_username": current_user.username, "requested_by_name": current_user.name, "progress": { "total_documents": len(target_document_ids), "completed_documents": 0, "failed_documents": 0, "skipped_documents": 0, "percent": 0, }, }, ) try: if target_document_ids: knowledge_service.set_document_ingest_statuses( target_document_ids, status_code=KNOWLEDGE_INGEST_STATUS_SYNCING, agent_run_id=run.run_id, ) llm_wiki_task_manager.submit_sync( agent_run_id=run.run_id, folder=payload.folder, current_user=current_user, document_ids=target_document_ids, force=payload.force, ) return LlmWikiSyncTaskRead( ok=True, agent_run_id=run.run_id, folder=payload.folder, document_ids=target_document_ids, queued_at=run.started_at, status=run.status, summary="Hermes 已进入后台归纳,可在日志管理查看进度。", ) except Exception as exc: run_service.update_run( run.run_id, status=AgentRunStatus.FAILED.value, error_message=str(exc), result_summary=str(exc), finished_at=datetime.now(UTC), ) 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()