2026-05-11 05:18:16 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-05-16 06:14:08 +00:00
|
|
|
from datetime import UTC, datetime, timedelta
|
2026-05-11 05:18:16 +00:00
|
|
|
from typing import Annotated
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
|
|
|
|
from fastapi.responses import FileResponse
|
2026-05-15 06:56:17 +00:00
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.orm import Session
|
2026-05-11 05:18:16 +00:00
|
|
|
|
2026-05-15 06:56:17 +00:00
|
|
|
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
|
2026-05-11 05:18:16 +00:00
|
|
|
from app.schemas.common import ErrorResponse
|
|
|
|
|
from app.schemas.knowledge import (
|
|
|
|
|
KnowledgeActionResponse,
|
|
|
|
|
KnowledgeDocumentDetailRead,
|
|
|
|
|
KnowledgeLibraryRead,
|
|
|
|
|
KnowledgeOnlyOfficeCallbackRead,
|
|
|
|
|
KnowledgeOnlyOfficeCallbackWrite,
|
|
|
|
|
KnowledgeOnlyOfficeConfigRead,
|
2026-05-15 09:33:59 +00:00
|
|
|
LlmWikiDocumentDetailRead,
|
|
|
|
|
LlmWikiIndexRead,
|
|
|
|
|
LlmWikiSyncTaskRead,
|
2026-05-15 06:56:17 +00:00
|
|
|
LlmWikiSyncWrite,
|
2026-05-15 09:33:59 +00:00
|
|
|
LlmWikiSummaryUpdateWrite,
|
2026-05-11 05:18:16 +00:00
|
|
|
)
|
2026-05-15 06:56:17 +00:00
|
|
|
from app.services.agent_runs import AgentRunService
|
2026-05-16 06:14:08 +00:00
|
|
|
from app.services.knowledge import (
|
|
|
|
|
KNOWLEDGE_INGEST_STATUS_FAILED,
|
|
|
|
|
KNOWLEDGE_INGEST_STATUS_SYNCING,
|
|
|
|
|
KnowledgeService,
|
|
|
|
|
)
|
2026-05-15 06:56:17 +00:00
|
|
|
from app.services.llm_wiki import LlmWikiService
|
2026-05-15 09:33:59 +00:00
|
|
|
from app.services.llm_wiki_tasks import llm_wiki_task_manager
|
2026-05-11 05:18:16 +00:00
|
|
|
|
|
|
|
|
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)],
|
2026-05-15 09:33:59 +00:00
|
|
|
db: Annotated[Session, Depends(get_db)],
|
2026-05-11 05:18:16 +00:00
|
|
|
) -> KnowledgeLibraryRead:
|
2026-05-15 09:33:59 +00:00
|
|
|
return KnowledgeService(db=db).list_library()
|
2026-05-11 05:18:16 +00:00
|
|
|
|
|
|
|
|
|
2026-05-15 06:56:17 +00:00
|
|
|
@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",
|
2026-05-15 09:33:59 +00:00
|
|
|
response_model=LlmWikiSyncTaskRead,
|
|
|
|
|
summary="异步触发 Hermes 形成 LLM Wiki 与规则草稿",
|
|
|
|
|
description="按知识库文档变化情况将系统 Hermes 归纳任务放入后台执行,并返回可追踪的 AgentRun 编号。",
|
2026-05-15 06:56:17 +00:00
|
|
|
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)],
|
2026-05-15 09:33:59 +00:00
|
|
|
) -> LlmWikiSyncTaskRead:
|
2026-05-15 06:56:17 +00:00
|
|
|
run_service = AgentRunService(db)
|
2026-05-15 09:33:59 +00:00
|
|
|
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)
|
|
|
|
|
]
|
2026-05-16 06:14:08 +00:00
|
|
|
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 归纳任务正在执行,已复用当前任务而不是重复创建。",
|
|
|
|
|
)
|
2026-05-15 06:56:17 +00:00
|
|
|
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,
|
2026-05-15 09:33:59 +00:00
|
|
|
result_summary="Hermes 归纳任务已入队,等待后台执行。",
|
|
|
|
|
route_json={
|
|
|
|
|
"job_type": "llm_wiki_sync",
|
|
|
|
|
"phase": "queued",
|
|
|
|
|
"folder": payload.folder,
|
|
|
|
|
"force": payload.force,
|
|
|
|
|
"requested_document_ids": target_document_ids,
|
2026-05-16 06:14:08 +00:00
|
|
|
"requested_by_username": current_user.username,
|
|
|
|
|
"requested_by_name": current_user.name,
|
2026-05-15 09:33:59 +00:00
|
|
|
"progress": {
|
|
|
|
|
"total_documents": len(target_document_ids),
|
|
|
|
|
"completed_documents": 0,
|
|
|
|
|
"failed_documents": 0,
|
|
|
|
|
"skipped_documents": 0,
|
|
|
|
|
"percent": 0,
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-15 06:56:17 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-15 09:33:59 +00:00
|
|
|
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,
|
2026-05-15 06:56:17 +00:00
|
|
|
folder=payload.folder,
|
|
|
|
|
current_user=current_user,
|
2026-05-15 09:33:59 +00:00
|
|
|
document_ids=target_document_ids,
|
2026-05-15 06:56:17 +00:00
|
|
|
force=payload.force,
|
|
|
|
|
)
|
2026-05-15 09:33:59 +00:00
|
|
|
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 已进入后台归纳,可在日志管理查看进度。",
|
2026-05-15 06:56:17 +00:00
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
run_service.update_run(
|
|
|
|
|
run.run_id,
|
|
|
|
|
status=AgentRunStatus.FAILED.value,
|
|
|
|
|
error_message=str(exc),
|
2026-05-15 09:33:59 +00:00
|
|
|
result_summary=str(exc),
|
2026-05-15 06:56:17 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 05:18:16 +00:00
|
|
|
@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)],
|
2026-05-15 09:33:59 +00:00
|
|
|
db: Annotated[Session, Depends(get_db)],
|
2026-05-11 05:18:16 +00:00
|
|
|
) -> KnowledgeDocumentDetailRead:
|
|
|
|
|
try:
|
2026-05-15 09:33:59 +00:00
|
|
|
return KnowledgeService(db=db).get_document_detail(document_id)
|
2026-05-11 05:18:16 +00:00
|
|
|
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
|
2026-05-09 05:59:46 +00:00
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
2026-05-11 05:18:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
2026-05-09 05:59:46 +00:00
|
|
|
|
2026-05-11 05:18:16 +00:00
|
|
|
_ = 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
|
2026-05-09 05:59:46 +00:00
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
2026-05-11 05:18:16 +00:00
|
|
|
|
|
|
|
|
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
|
2026-05-09 05:59:46 +00:00
|
|
|
|
|
|
|
|
return KnowledgeOnlyOfficeCallbackRead()
|