Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
518 lines
18 KiB
Python
518 lines
18 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
||
from fastapi.responses import FileResponse
|
||
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_onlyoffice_callback import (
|
||
handle_onlyoffice_callback,
|
||
resolve_onlyoffice_content,
|
||
)
|
||
from app.services.knowledge_onlyoffice_security import (
|
||
OnlyOfficeReplayError,
|
||
OnlyOfficeSecurityError,
|
||
)
|
||
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(
|
||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> KnowledgeLibraryRead:
|
||
return KnowledgeService(db=db, tenant_id=current_user.tenant_id).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(
|
||
current_user: 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_for_tenant(
|
||
tenant_id=current_user.tenant_id,
|
||
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,
|
||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> KnowledgeDocumentDetailRead:
|
||
try:
|
||
return KnowledgeService(
|
||
db=db,
|
||
tenant_id=current_user.tenant_id,
|
||
).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)],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
editable: Annotated[
|
||
bool,
|
||
Query(description="是否申请租户知识文档编辑会话;默认仅预览。"),
|
||
] = False,
|
||
) -> KnowledgeOnlyOfficeConfigRead:
|
||
try:
|
||
return KnowledgeService(
|
||
db=db,
|
||
tenant_id=current_user.tenant_id,
|
||
).build_onlyoffice_config(
|
||
document_id,
|
||
current_user,
|
||
editable=editable,
|
||
)
|
||
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)],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> KnowledgeDocumentDetailRead:
|
||
try:
|
||
return KnowledgeService(
|
||
db=db,
|
||
tenant_id=current_user.tenant_id,
|
||
).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,
|
||
current_user: Annotated[CurrentUserContext, Depends(require_admin_user)],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> KnowledgeActionResponse:
|
||
try:
|
||
KnowledgeService(
|
||
db=db,
|
||
tenant_id=current_user.tenant_id,
|
||
).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,
|
||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
disposition: Annotated[
|
||
str,
|
||
Query(
|
||
pattern="^(inline|attachment)$",
|
||
description="内容展示方式,支持 `inline` 或 `attachment`。",
|
||
),
|
||
] = "inline",
|
||
) -> FileResponse:
|
||
try:
|
||
file_path, media_type, filename = KnowledgeService(
|
||
db=db,
|
||
tenant_id=current_user.tenant_id,
|
||
).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 临时访问令牌。"),
|
||
],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> FileResponse:
|
||
try:
|
||
file_path, media_type, filename = resolve_onlyoffice_content(
|
||
db=db,
|
||
storage_root=None,
|
||
document_id=document_id,
|
||
access_token=access_token,
|
||
)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="知识库文件不存在。",
|
||
) from exc
|
||
except (OnlyOfficeSecurityError, OnlyOfficeReplayError) 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,
|
||
callback_token: Annotated[
|
||
str,
|
||
Query(min_length=1, description="绑定租户与文档的一次性回调会话令牌。"),
|
||
],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> KnowledgeOnlyOfficeCallbackRead:
|
||
try:
|
||
handle_onlyoffice_callback(
|
||
db=db,
|
||
storage_root=None,
|
||
document_id=document_id,
|
||
callback_token=callback_token,
|
||
payload=payload.model_dump(),
|
||
)
|
||
except FileNotFoundError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="知识库文件不存在。",
|
||
) from exc
|
||
except OnlyOfficeReplayError as exc:
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||
except OnlyOfficeSecurityError as exc:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||
|
||
return KnowledgeOnlyOfficeCallbackRead()
|