Files
X-Financial/server/src/app/api/v1/endpoints/knowledge.py

477 lines
17 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import UTC, datetime
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,
LlmWikiDocumentDetailRead,
LlmWikiIndexRead,
LlmWikiSummaryUpdateWrite,
KnowledgeOnlyOfficeCallbackRead,
KnowledgeOnlyOfficeCallbackWrite,
KnowledgeOnlyOfficeConfigRead,
LlmWikiSyncRead,
LlmWikiSyncWrite,
)
from app.services.agent_runs import AgentRunService
from app.services.knowledge import KnowledgeService
from app.services.llm_wiki import LlmWikiService
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)],
) -> KnowledgeLibraryRead:
return KnowledgeService().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=LlmWikiSyncRead,
summary="触发 Hermes 形成 LLM Wiki 与规则草稿",
description="按知识库文档变化情况增量触发系统 Hermes形成知识候选和规则草稿。",
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)],
) -> LlmWikiSyncRead:
run_service = AgentRunService(db)
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 正在形成 LLM Wiki 与规则草稿。",
)
try:
result = LlmWikiService(db).sync_folder(
folder=payload.folder,
current_user=current_user,
document_ids=payload.document_ids,
force=payload.force,
)
run_service.record_tool_call(
run_id=run.run_id,
tool_type="llm",
tool_name="system_hermes_llm_wiki_sync",
request_json=payload.model_dump(),
response_json=result.model_dump(),
status="succeeded",
duration_ms=0,
)
run_service.update_run(
run.run_id,
status=AgentRunStatus.SUCCEEDED.value,
result_summary=result.summary,
finished_at=datetime.now(UTC),
)
return result
except Exception as exc:
run_service.record_tool_call(
run_id=run.run_id,
tool_type="llm",
tool_name="system_hermes_llm_wiki_sync",
request_json=payload.model_dump(),
response_json={"error": str(exc)},
status="failed",
duration_ms=0,
error_message=str(exc),
)
run_service.update_run(
run.run_id,
status=AgentRunStatus.FAILED.value,
error_message=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)],
) -> KnowledgeDocumentDetailRead:
try:
return KnowledgeService().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()