Files
X-Financial/server/src/app/api/v1/endpoints/knowledge.py
caoxiaozhu 68f663f2f4 feat: 重构知识库系统,移除Hermes集成,增强RAG和同步功能
主要变更:
- 移除Hermes智能体及相关回调服务
- 新增知识库RAG、同步、调度、规范化和索引任务服务
- 重构orchestrator服务,增强运行时聊天功能
- 更新前端聊天、政策制度、设置等页面样式和逻辑
- 更新expense_claims和document_intelligence服务
- 删除llm_wiki相关服务和测试文件
- 更新docker-compose配置和启动脚本
2026-05-17 08:38:41 +00:00

458 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()