From 03b1810a903a892cd9132207b2fcdfb3768cd786 Mon Sep 17 00:00:00 2001
From: Nelson <1475262689@qq.com>
Date: Tue, 7 Jul 2026 18:32:01 +0800
Subject: [PATCH] Implement open knowledge access scope
---
.../apps/admin-web/src/App.vue | 189 ++-----
.../apps/admin-web/src/main.ts | 2 +
.../apps/admin-web/src/services/api.ts | 10 +-
.../apps/admin-web/src/styles.css | 56 +-
.../apps/admin-web/src/types/api.ts | 11 -
.../apps/backend/app/api/admin_knowledge.py | 1 +
.../apps/backend/app/api/admin_users.py | 85 +--
.../apps/backend/app/core/config.py | 3 +-
.../apps/backend/app/schemas/admin.py | 10 -
.../backend/app/services/feishu_service.py | 505 +++++++++++++++---
.../backend/app/services/knowledge_service.py | 18 +-
.../backend/app/services/model_service.py | 75 ++-
ai_knowledge_base_v2/docker-compose.dev.yml | 4 +-
13 files changed, 587 insertions(+), 382 deletions(-)
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue
index 94018c8..5e629c2 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue
+++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue
@@ -1,6 +1,6 @@
@@ -717,10 +675,9 @@ function buildChatQuery() {
-
+
保存
- 知识库权限
删除
@@ -876,9 +833,9 @@ function buildChatQuery() {
-
+
+
@@ -959,6 +917,7 @@ function buildChatQuery() {
编辑
测试
启用
+ 删除
@@ -994,13 +953,19 @@ function buildChatQuery() {
+ 至
+
搜索
@@ -1047,62 +1012,6 @@ function buildChatQuery() {
-
-
-
-
- 用户
- {{ permissionUser.name || '-' }}
-
-
- 手机号
- {{ permissionUser.phone }}
-
-
- 每日额度
- {{ permissionUser.dailyChatLimit }}
-
-
-
-
-
-
-
-
-
-
- {{ row.status === 1 ? "启用" : "禁用" }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- 保存权限
- 取消
-
-
-
-
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/main.ts b/ai_knowledge_base_v2/apps/admin-web/src/main.ts
index 8dde85c..de35329 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/main.ts
+++ b/ai_knowledge_base_v2/apps/admin-web/src/main.ts
@@ -33,11 +33,13 @@ import "element-plus/theme-chalk/el-switch.css";
import "element-plus/theme-chalk/el-table.css";
import "element-plus/theme-chalk/el-tabs.css";
import { createApp } from "vue";
+import zhCn from "element-plus/es/locale/lang/zh-cn";
import App from "./App.vue";
import "./styles.css";
const app = createApp(App);
+app.config.globalProperties.$ELEMENT = { locale: zhCn };
[
ElButton,
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
index 6e50706..47692ff 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
+++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
@@ -10,7 +10,6 @@ import type {
DashboardStats,
KnowledgeItem,
ModelItem,
- UserKnowledgePermissionDetail,
} from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api";
@@ -77,13 +76,6 @@ export const api = {
updateUser: (id: number, payload: Record) =>
request(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request(`/admin/user/${id}`, { method: "DELETE" }),
- userKnowledgePermissions: (id: number) =>
- request(`/admin/user/${id}/knowledge-permissions`),
- saveUserKnowledgePermissions: (id: number, payload: Record) =>
- request(`/admin/user/${id}/knowledge-permissions`, {
- method: "PUT",
- body: JSON.stringify(payload),
- }),
knowledge: () => request("/admin/knowledge/list"),
createKnowledge: (payload: Record) =>
request("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
@@ -103,6 +95,8 @@ export const api = {
request(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
enableModel: (modelId: number) =>
request("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }),
+ deleteModel: (modelId: number) =>
+ request(`/admin/model/${modelId}`, { method: "DELETE" }),
testModel: (modelId: number) =>
request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, {
method: "POST",
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css
index d955a24..5276519 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css
+++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css
@@ -377,42 +377,6 @@ textarea {
margin-top: 12px;
}
-.permission-summary {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
- margin-bottom: 14px;
-}
-
-.permission-summary div {
- padding: 12px;
- border: 1px solid #dfe8e5;
- border-radius: 8px;
- background: #f8fbfa;
-}
-
-.permission-summary span {
- display: block;
- color: #6b7d77;
- font-size: 12px;
-}
-
-.permission-summary strong {
- display: block;
- margin-top: 6px;
- overflow: hidden;
- color: #1f2d2a;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.drawer-actions {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- margin-top: 16px;
-}
-
.el-drawer .el-date-editor.el-input {
width: 100%;
}
@@ -437,10 +401,16 @@ textarea {
width: 150px;
}
-.record-date-range {
- width: 580px !important;
- max-width: 100%;
- flex: 0 0 580px;
+.record-date-item {
+ width: 180px;
+}
+
+.record-date-sep {
+ color: #909399;
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ padding: 0 2px;
}
.record-filter-actions {
@@ -465,8 +435,10 @@ textarea {
.chat-summary span {
display: block;
- color: #6b7d77;
- font-size: 12px;
+ font-size: 22px;
+ font-weight: 600;
+ color: #2a6b56;
+ margin-top: 4px;
}
.chat-summary strong {
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
index 89ea6a6..7a29cb9 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
+++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
@@ -43,17 +43,6 @@ export interface KnowledgeItem {
updatedAt?: string | null;
}
-export interface UserKnowledgePermissionItem {
- knowledgeId: number;
- effectiveAt?: string | null;
- expiredAt?: string | null;
-}
-
-export interface UserKnowledgePermissionDetail {
- user: AdminUser;
- permissions: UserKnowledgePermissionItem[];
-}
-
export interface ModelItem {
id: number;
provider: string;
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
index b9c1020..f8c1c06 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
@@ -69,6 +69,7 @@ def delete_knowledge(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
knowledge = _get_knowledge(db, knowledge_id)
+ # 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
permissions = db.scalars(
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
).all()
diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_users.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
index 36fe6ad..d7c03d5 100644
--- a/ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
+++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
@@ -1,7 +1,5 @@
from __future__ import annotations
-from datetime import datetime
-
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -10,9 +8,8 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
-from app.models.knowledge import Knowledge, UserKnowledgePermission
from app.models.user import User
-from app.schemas.admin import AdminUserKnowledgePermissionSaveRequest, AdminUserUpdateRequest
+from app.schemas.admin import AdminUserUpdateRequest
from app.services.admin_service import OperationLogService
router = APIRouter()
@@ -63,72 +60,6 @@ def update_user(
return api_success(_user_dict(user))
-@router.get("/user/{user_id}/knowledge-permissions")
-def user_knowledge_permissions(
- user_id: int,
- db: Session = Depends(get_db),
- current_admin: Admin = Depends(get_current_admin),
-) -> dict:
- user = _get_user(db, user_id)
- permissions = db.scalars(
- select(UserKnowledgePermission)
- .where(UserKnowledgePermission.user_id == user.id)
- .order_by(UserKnowledgePermission.knowledge_id.asc())
- ).all()
- return api_success(
- {
- "user": _user_dict(user),
- "permissions": [_permission_dict(permission) for permission in permissions],
- }
- )
-
-
-@router.put("/user/{user_id}/knowledge-permissions")
-def save_user_knowledge_permissions(
- user_id: int,
- payload: AdminUserKnowledgePermissionSaveRequest,
- db: Session = Depends(get_db),
- current_admin: Admin = Depends(get_current_admin),
-) -> dict:
- user = _get_user(db, user_id)
- normalized = {item.knowledgeId: item for item in payload.permissions}
- if normalized:
- existing_ids = set(db.scalars(select(Knowledge.id).where(Knowledge.id.in_(normalized.keys()))).all())
- missing_ids = sorted(set(normalized.keys()) - existing_ids)
- if missing_ids:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"知识库不存在: {','.join(str(item) for item in missing_ids)}",
- )
-
- old_permissions = db.scalars(
- select(UserKnowledgePermission).where(UserKnowledgePermission.user_id == user.id)
- ).all()
- for permission in old_permissions:
- db.delete(permission)
- db.flush()
-
- for item in normalized.values():
- db.add(
- UserKnowledgePermission(
- user_id=user.id,
- knowledge_id=item.knowledgeId,
- effective_at=_without_timezone(item.effectiveAt),
- expired_at=_without_timezone(item.expiredAt),
- created_by=current_admin.id,
- )
- )
- OperationLogService.write(
- db,
- admin_id=current_admin.id,
- module="user",
- action="knowledge_permission",
- target_id=user.id,
- )
- db.commit()
- return user_knowledge_permissions(user.id, db, current_admin)
-
-
@router.delete("/user/{user_id}")
def delete_user(
user_id: int,
@@ -165,17 +96,3 @@ def _user_dict(user: User) -> dict:
"lastLoginAt": user.last_login_at,
"createdAt": user.created_at,
}
-
-
-def _permission_dict(permission: UserKnowledgePermission) -> dict:
- return {
- "knowledgeId": permission.knowledge_id,
- "effectiveAt": permission.effective_at,
- "expiredAt": permission.expired_at,
- }
-
-
-def _without_timezone(value: datetime | None) -> datetime | None:
- if value is None:
- return None
- return value.replace(tzinfo=None)
diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py
index 329f10c..a458893 100644
--- a/ai_knowledge_base_v2/apps/backend/app/core/config.py
+++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py
@@ -37,7 +37,8 @@ class Settings(BaseSettings):
mock_rag_enabled: bool = True
mock_model_enabled: bool = True
feishu_mock_enabled: bool = True
- feishu_search_url: str = ""
+ feishu_app_id: str = ""
+ feishu_app_secret: str = ""
feishu_timeout_seconds: int = 20
feishu_retry_count: int = 2
diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
index 8530beb..913dc3d 100644
--- a/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
+++ b/ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
@@ -39,16 +39,6 @@ class AdminUserUpdateRequest(BaseModel):
expiredAt: datetime | None = None
-class AdminUserKnowledgePermissionItem(BaseModel):
- knowledgeId: int = Field(gt=0)
- effectiveAt: datetime | None = None
- expiredAt: datetime | None = None
-
-
-class AdminUserKnowledgePermissionSaveRequest(BaseModel):
- permissions: list[AdminUserKnowledgePermissionItem] = Field(default_factory=list)
-
-
class KnowledgeSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
feishuSpaceId: str = Field(min_length=1, max_length=100)
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py
index 8f952a9..b0cb478 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import time
from typing import TYPE_CHECKING, Any
import httpx
@@ -11,49 +12,277 @@ from app.services.knowledge_service import KnowledgeScope
if TYPE_CHECKING:
from app.services.rag_service import RetrievedChunk
+# 飞书 API 基地址
+FEISHU_OPEN_API = "https://open.feishu.cn"
+
+# tenant_access_token 缓存
+_token_cache: dict[str, Any] = {"token": "", "expires_at": 0.0}
+
+
+def _get_tenant_token() -> str:
+ """获取飞书 tenant_access_token,自动缓存"""
+ settings = get_settings()
+ now = time.time()
+
+ if _token_cache["token"] and now < _token_cache["expires_at"]:
+ return _token_cache["token"]
+
+ url = f"{FEISHU_OPEN_API}/open-apis/auth/v3/tenant_access_token/internal"
+ payload = {
+ "app_id": settings.feishu_app_id,
+ "app_secret": settings.feishu_app_secret,
+ }
+
+ try:
+ resp = httpx.post(url, json=payload, timeout=settings.feishu_timeout_seconds)
+ resp.raise_for_status()
+ data = resp.json()
+
+ if data.get("code") != 0:
+ raise ExternalServiceError(
+ f"获取飞书Token失败:{data.get('msg', '未知错误')}",
+ provider="feishu",
+ )
+
+ token = data["tenant_access_token"]
+ expire = data.get("expire", 7200)
+
+ _token_cache["token"] = token
+ _token_cache["expires_at"] = now + expire - 300 # 提前5分钟过期
+
+ return token
+ except httpx.HTTPError as exc:
+ raise ExternalServiceError(f"获取飞书Token网络错误:{exc}", provider="feishu") from exc
+
+
+def _feishu_get(path: str, params: dict | None = None) -> dict:
+ """封装飞书 GET 请求"""
+ token = _get_tenant_token()
+ url = f"{FEISHU_OPEN_API}{path}"
+ headers = {"Authorization": f"Bearer {token}"}
+ settings = get_settings()
+
+ resp = httpx.get(url, headers=headers, params=params, timeout=settings.feishu_timeout_seconds)
+ resp.raise_for_status()
+ data = resp.json()
+
+ if data.get("code") != 0:
+ raise ExternalServiceError(
+ f"飞书API调用失败 [{path}]:{data.get('msg', '未知错误')}",
+ provider="feishu",
+ )
+ return data
+
+
+def _feishu_post(path: str, payload: dict | None = None) -> dict:
+ """封装飞书 POST 请求"""
+ token = _get_tenant_token()
+ url = f"{FEISHU_OPEN_API}{path}"
+ headers = {"Authorization": f"Bearer {token}"}
+ settings = get_settings()
+
+ resp = httpx.post(url, headers=headers, json=payload, timeout=settings.feishu_timeout_seconds)
+ resp.raise_for_status()
+ data = resp.json()
+
+ if data.get("code") != 0:
+ raise ExternalServiceError(
+ f"飞书API调用失败 [{path}]:{data.get('msg', '未知错误')}",
+ provider="feishu",
+ )
+ return data
+
+
+def _extract_text(elements: list[dict]) -> str:
+ """从 elements 列表中提取纯文本"""
+ texts = []
+ for elem in elements:
+ text_run = elem.get("text_run", {})
+ content = text_run.get("content", "")
+ if content:
+ texts.append(content)
+ return "".join(texts)
+
+
+def _parse_docx_content(blocks: list[dict]) -> str:
+ """将飞书文档 block 列表解析为纯文本"""
+ texts = []
+ for block in blocks:
+ block_type = block.get("block_type", 0)
+
+ # Page block (block_type=1): 标题可能在 page.elements
+ if block_type == 1:
+ elements = block.get("page", {}).get("elements", [])
+ content = _extract_text(elements)
+ if content:
+ texts.append(content)
+ texts.append("\n")
+ # 子内容在 children 里,但子 block 也会遍历到
+
+ # 文本块 (block_type=2 is text paragraph in new API)
+ elif block_type == 2:
+ elements = block.get("text", {}).get("elements", [])
+ content = _extract_text(elements)
+ if content:
+ texts.append(content)
+ texts.append("\n")
+
+ # 标题块 (3=heading1, 4=heading2, 5=heading3, 6=heading4, 7=heading5, 8=heading6)
+ elif 3 <= block_type <= 8:
+ heading_level = block_type - 2 # 3->h1, 4->h2, etc.
+ key = f"heading{heading_level}"
+ elements = block.get(key, {}).get("elements", [])
+ content = _extract_text(elements)
+ if content:
+ texts.append(f"{'#' * heading_level} {content}")
+ texts.append("\n")
+
+ # 引用块 (15=quote)
+ elif block_type == 15:
+ elements = block.get("quote", {}).get("elements", [])
+ content = _extract_text(elements)
+ if content:
+ texts.append(f" > {content}")
+ texts.append("\n")
+
+ # 列表块 - bullet (9), ordered (10)
+ elif block_type in (9, 10):
+ key = "bullet" if block_type == 9 else "ordered"
+ elements = block.get(key, {}).get("elements", [])
+ content = _extract_text(elements)
+ if content:
+ prefix = "-" if block_type == 9 else "1."
+ texts.append(f" {prefix} {content}")
+ texts.append("\n")
+
+ # 其他 block 类型也尝试提取文本
+ else:
+ for sub_key in ("text", "quote", "bullet", "ordered"):
+ if sub_key in block:
+ elements = block.get(sub_key, {}).get("elements", [])
+ content = _extract_text(elements)
+ if content:
+ texts.append(content)
+ texts.append("\n")
+ break
+
+ return "".join(texts).strip()
+
+
+def _get_docx_content(doc_token: str) -> str:
+ """读取单个飞书云文档内容"""
+ data = _feishu_get(f"/open-apis/docx/v1/documents/{doc_token}/blocks")
+ items = data.get("data", {}).get("items", [])
+ return _parse_docx_content(items)
+
+
+def _get_wiki_node_info(node_token: str) -> dict:
+ """获取 Wiki 节点信息,返回 space_id 和 obj_token"""
+ data = _feishu_get("/open-apis/wiki/v2/spaces/get_node", params={"token": node_token})
+ node = data.get("data", {}).get("node", {})
+ return {
+ "space_id": node.get("space_id", ""),
+ "obj_token": node.get("obj_token", ""), # 文档的实际 token
+ "obj_type": node.get("obj_type", ""), # docx / doc / sheet
+ "title": node.get("title", ""),
+ "node_token": node.get("node_token", ""),
+ }
+
+
+def _get_wiki_children(node_token: str, page_size: int = 50) -> list[dict]:
+ """获取 Wiki 节点的子节点列表"""
+ all_children = []
+ page_token = ""
+ while True:
+ params: dict[str, Any] = {
+ "page_size": page_size,
+ "parent_node_token": node_token,
+ }
+ if page_token:
+ params["page_token"] = page_token
+
+ data = _feishu_get("/open-apis/wiki/v2/spaces/nodes/get_child_nodes", params=params)
+ children = data.get("data", {}).get("items", [])
+ all_children.extend(children)
+
+ page_token = data.get("data", {}).get("page_token", "")
+ if not page_token:
+ break
+
+ return all_children
+
+
+def _get_space_node_tree(space_id: str) -> list[dict]:
+ """获取知识空间下所有根节点"""
+ all_nodes = []
+ page_token = ""
+ while True:
+ params: dict[str, Any] = {
+ "page_size": 50,
+ "space_id": space_id,
+ }
+ if page_token:
+ params["page_token"] = page_token
+
+ data = _feishu_get("/open-apis/wiki/v2/spaces/nodes", params=params)
+ items = data.get("data", {}).get("items", [])
+ all_nodes.extend(items)
+
+ page_token = data.get("data", {}).get("page_token", "")
+ if not page_token:
+ break
+
+ return all_nodes
+
class FeishuKnowledgeService:
- _mock_documents = [
- {
- "title": "一期产品目标",
- "keywords": {"一期", "目标", "问答", "登录", "知识库", "飞书", "用户端", "后台"},
- "content": "一期要交付企业飞书知识库 AI 问答系统,核心包括用户登录、AI 问答、权限内知识库回答和后台管理能力。",
- },
- {
- "title": "权限过滤规则",
- "keywords": {"权限", "授权", "越权", "用户", "知识库", "过期", "禁用"},
- "content": "RAG 检索前必须先过滤用户授权知识库,只允许未禁用、未过期、当前用户有权限的知识库参与回答。",
- },
- {
- "title": "无命中兜底规则",
- "keywords": {"无命中", "兜底", "编造", "检索", "答案", "命中"},
- "content": "当知识库没有命中相关内容时,系统必须固定返回无命中兜底文案。",
- },
- {
- "title": "技术实现边界",
- "keywords": {"模型", "prompt", "大模型", "日志", "sse", "流式", "追溯"},
- "content": "后端需要组装系统 Prompt、检索片段、历史上下文和当前问题,通过 SSE 流式输出,并记录 AI 请求日志。",
- },
- ]
+ """飞书知识库检索服务 - 直接调用飞书官方 API"""
+
+ # 文档内容缓存: node_token -> (content, timestamp)
+ _content_cache: dict[str, tuple[str, float]] = {}
+ _CACHE_TTL = 300 # 缓存 5 分钟
@classmethod
- def retrieve(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]:
+ def retrieve(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
if not scopes:
return []
settings = get_settings()
if settings.feishu_mock_enabled:
return cls._retrieve_mock(question, scopes)
- return cls._retrieve_remote(question, scopes)
+ return cls._retrieve_from_feishu(question, scopes)
@classmethod
- def _retrieve_mock(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]:
+ def _retrieve_mock(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
from app.services.rag_service import RetrievedChunk
+ mock_documents = [
+ {
+ "title": "一期产品目标",
+ "keywords": {"一期", "目标", "问答", "登录", "知识库", "飞书", "用户端", "后台"},
+ "content": "一期要交付企业飞书知识库 AI 问答系统,核心包括用户登录、AI 问答、基于已开放知识库回答和后台管理能力。",
+ },
+ {
+ "title": "知识库开放过滤规则",
+ "keywords": {"权限", "开放", "用户", "知识库", "禁用", "可见"},
+ "content": "RAG 检索前必须先过滤知识库开放状态,只允许已开放、未禁用的知识库参与回答,已开放知识库默认所有用户可见。",
+ },
+ {
+ "title": "无命中兜底规则",
+ "keywords": {"无命中", "兜底", "编造", "检索", "答案", "命中"},
+ "content": "当知识库没有命中相关内容时,系统必须固定返回无命中兜底文案。",
+ },
+ {
+ "title": "技术实现边界",
+ "keywords": {"模型", "prompt", "大模型", "日志", "sse", "流式", "追溯"},
+ "content": "后端需要组装系统 Prompt、检索片段、历史上下文和当前问题,通过 SSE 流式输出,并记录 AI 请求日志。",
+ },
+ ]
+
normalized_question = question.lower()
matched_documents = []
- for document in cls._mock_documents:
- if any(keyword.lower() in normalized_question for keyword in document["keywords"]):
+ for document in mock_documents:
+ if any(keyword in normalized_question for keyword in document["keywords"]):
matched_documents.append(document)
if not matched_documents:
@@ -72,51 +301,195 @@ class FeishuKnowledgeService:
]
@classmethod
- def _retrieve_remote(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]:
+ def _retrieve_from_feishu(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
+ """从飞书知识库检索文档内容"""
from app.services.rag_service import RetrievedChunk
- settings = get_settings()
- if not settings.feishu_search_url:
- raise ExternalServiceError("飞书检索地址未配置", provider="feishu")
+ # 收集所有文档片段
+ all_chunks: list[tuple[KnowledgeScope, str, str, str | None]] = []
- payload = {
- "query": question,
- "knowledgeScopes": [
- {
- "id": scope.id,
- "spaceId": scope.feishu_space_id,
- "nodeId": scope.feishu_node_id,
- "name": scope.name,
- }
- for scope in scopes
- ],
- }
- data = cls._post_with_retry(settings.feishu_search_url, payload)
- chunks = data.get("chunks", [])
+ for scope in scopes:
+ try:
+ documents = cls._load_documents(scope)
+ except ExternalServiceError:
+ continue
+
+ for title, content, source_url in documents:
+ all_chunks.append((scope, title, content, source_url))
+
+ if not all_chunks:
+ return []
+
+ # 关键词匹配评分(简单但够用的检索方式)
+ scored_chunks = cls._score_and_rank(question, all_chunks)
+
+ # 返回 top 5 相关片段
return [
RetrievedChunk(
- knowledge_id=int(item.get("knowledgeId") or 0),
- knowledge_name=str(item.get("knowledgeName") or "飞书知识库"),
- title=str(item.get("title") or "未命名片段"),
- content=str(item.get("content") or ""),
- source_url=item.get("sourceUrl"),
+ knowledge_id=scope.id,
+ knowledge_name=scope.name,
+ title=title,
+ content=content,
+ source_url=source_url,
)
- for item in chunks
- if item.get("content")
+ for scope, title, content, source_url in scored_chunks[:5]
]
- @staticmethod
- def _post_with_retry(url: str, payload: dict[str, Any]) -> dict[str, Any]:
- settings = get_settings()
- last_error: Exception | None = None
- for _ in range(settings.feishu_retry_count + 1):
+ @classmethod
+ def _load_documents(cls, scope: KnowledgeScope) -> list[tuple[str, str, str | None]]:
+ """加载指定知识库 scope 下的所有文档"""
+ node_id = scope.feishu_node_id
+ base_url = f"https://zcn7hk6n047c.feishu.cn"
+
+ if not node_id:
+ return []
+
+ # 1. 判断 node 类型:先查 wiki 节点信息
+ try:
+ node_info = cls._get_node_info_with_cache(node_id)
+ except ExternalServiceError:
+ # 可能是普通 docx token,直接尝试读取
try:
- response = httpx.post(url, json=payload, timeout=settings.feishu_timeout_seconds)
- response.raise_for_status()
- data = response.json()
- if not isinstance(data, dict):
- raise ExternalServiceError("飞书检索响应格式不正确", provider="feishu")
- return data
- except (httpx.HTTPError, ValueError, ExternalServiceError) as exc:
- last_error = exc
- raise ExternalServiceError(f"飞书检索失败:{last_error}", provider="feishu")
+ content = cls._get_document_content_with_cache(node_id)
+ title = node_id
+ return [(title, content, f"{base_url}/docx/{node_id}")]
+ except ExternalServiceError:
+ return []
+
+ obj_type = node_info.get("obj_type", "")
+ obj_token = node_info.get("obj_token", "")
+ title = node_info.get("title", node_id)
+ space_id = node_info.get("space_id", scope.feishu_space_id)
+
+ # 如果节点本身就是一个文档
+ if obj_type == "docx":
+ content = cls._get_document_content_with_cache(obj_token)
+ source_url = f"{base_url}/wiki/{node_id}"
+ return [(title, content, source_url)]
+
+ # 如果节点是文件夹,获取子节点
+ children = cls._get_wiki_children_with_cache(node_id)
+
+ documents = []
+ for child in children:
+ child_node_token = child.get("node_token", "")
+ child_obj_type = child.get("obj_type", "")
+ child_obj_token = child.get("obj_token", "")
+ child_title = child.get("title", "")
+
+ if child_obj_type == "docx" and child_obj_token:
+ try:
+ content = cls._get_document_content_with_cache(child_obj_token)
+ source_url = f"{base_url}/wiki/{child_node_token}"
+ documents.append((child_title, content, source_url))
+ except ExternalServiceError:
+ continue
+
+ # 也读取节点本身的文档(如果有)
+ if obj_type == "docx" and obj_token:
+ try:
+ content = cls._get_document_content_with_cache(obj_token)
+ source_url = f"{base_url}/wiki/{node_id}"
+ documents.insert(0, (title, content, source_url))
+ except ExternalServiceError:
+ pass
+
+ return documents
+
+ @classmethod
+ def _get_node_info_with_cache(cls, node_token: str) -> dict:
+ """带缓存获取节点信息"""
+ cache_key = f"node_info:{node_token}"
+ now = time.time()
+
+ if cache_key in cls._content_cache:
+ cached_data, ts = cls._content_cache[cache_key]
+ if now - ts < cls._CACHE_TTL:
+ return eval(cached_data) # noqa: S307
+
+ info = _get_wiki_node_info(node_token)
+ cls._content_cache[cache_key] = (str(info), now)
+ return info
+
+ @classmethod
+ def _get_document_content_with_cache(cls, doc_token: str) -> str:
+ """带缓存读取文档内容"""
+ now = time.time()
+
+ if doc_token in cls._content_cache:
+ content, ts = cls._content_cache[doc_token]
+ if now - ts < cls._CACHE_TTL:
+ return content
+
+ content = _get_docx_content(doc_token)
+ cls._content_cache[doc_token] = (content, now)
+ return content
+
+ @classmethod
+ def _get_wiki_children_with_cache(cls, node_token: str) -> list[dict]:
+ """带缓存获取子节点"""
+ cache_key = f"children:{node_token}"
+ now = time.time()
+
+ if cache_key in cls._content_cache:
+ cached_data, ts = cls._content_cache[cache_key]
+ if now - ts < cls._CACHE_TTL:
+ return eval(cached_data) # noqa: S307
+
+ children = _get_wiki_children(node_token)
+ cls._content_cache[cache_key] = (str(children), now)
+ return children
+
+ @classmethod
+ def _score_and_rank(
+ cls,
+ question: str,
+ chunks: list[tuple[KnowledgeScope, str, str, str | None]],
+ ) -> list[tuple[KnowledgeScope, str, str, str | None]]:
+ """基于关键词匹配对文档片段打分排序"""
+ question_lower = question.lower()
+
+ # 提取问题中的关键词(按字/词拆分)
+ question_chars = set(question_lower)
+ # 2-gram
+ question_bigrams = set(question_lower[i : i + 2] for i in range(len(question_lower) - 1))
+ # 3-gram
+ question_trigrams = set(question_lower[i : i + 3] for i in range(len(question_lower) - 2))
+
+ scored = []
+ for scope, title, content, source_url in chunks:
+ content_lower = content.lower()
+ title_lower = title.lower()
+
+ # 计算匹配分
+ score = 0.0
+
+ # 标题匹配(权重高)
+ for char in question_chars:
+ if char in title_lower:
+ score += 3.0
+ for bigram in question_bigrams:
+ if bigram in title_lower:
+ score += 5.0
+ for trigram in question_trigrams:
+ if trigram in title_lower:
+ score += 8.0
+
+ # 内容匹配
+ for char in question_chars:
+ if char in content_lower:
+ score += 1.0
+ for bigram in question_bigrams:
+ if bigram in content_lower:
+ score += 2.0
+ for trigram in question_trigrams:
+ if trigram in content_lower:
+ score += 3.0
+
+ if score > 0:
+ scored.append((scope, title, content, source_url, score))
+
+ # 按分数降序排列
+ scored.sort(key=lambda x: x[4], reverse=True)
+
+ return [(s, t, c, u) for s, t, c, u, _ in scored]
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_service.py b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_service.py
index 9521a05..6e4649f 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_service.py
@@ -1,13 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
-from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
-from app.models.knowledge import Knowledge, UserKnowledgePermission
+from app.models.knowledge import Knowledge
from app.models.user import User
@@ -23,20 +22,7 @@ class KnowledgeScope:
class KnowledgeAccessService:
@staticmethod
def get_allowed_knowledge(db: Session, user: User) -> list[KnowledgeScope]:
- now = datetime.now(UTC).replace(tzinfo=None)
- rows = db.execute(
- select(Knowledge)
- .join(UserKnowledgePermission, UserKnowledgePermission.knowledge_id == Knowledge.id)
- .where(
- UserKnowledgePermission.user_id == user.id,
- Knowledge.status == 1,
- (UserKnowledgePermission.effective_at.is_(None))
- | (UserKnowledgePermission.effective_at <= now),
- (UserKnowledgePermission.expired_at.is_(None))
- | (UserKnowledgePermission.expired_at >= now),
- )
- .order_by(Knowledge.id.asc())
- ).scalars()
+ rows = db.scalars(select(Knowledge).where(Knowledge.status == 1).order_by(Knowledge.id.asc()))
scopes = [
KnowledgeScope(
id=knowledge.id,
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py
index cfb07d5..0834fe7 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py
@@ -85,7 +85,7 @@ def _mock_answer(rag_result: RagResult) -> str:
f"- {chunk.content}\n 来源:{chunk.knowledge_name} / {chunk.title}" for chunk in rag_result.chunks
)
return (
- "根据当前已授权知识库,整理到的信息如下:\n\n"
+ "根据当前已开放知识库,整理到的信息如下:\n\n"
f"{summaries}\n\n"
"当前仍是 mock RAG + mock 模型阶段,后续会把检索服务替换为飞书实时检索,"
"把模型服务替换为后台启用的大模型配置。"
@@ -136,6 +136,8 @@ def _call_configured_model(model: ModelConfig, rag_result: RagResult, *, allow_n
return _call_anthropic_messages(model, rag_result)
if api_type == "gemini_generate_content":
return _call_gemini_generate_content(model, rag_result)
+ if api_type == "minimax":
+ return _call_minimax(model, rag_result)
return _call_openai_compatible_model(model, rag_result)
@@ -166,7 +168,8 @@ def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) ->
timeout=model.timeout_second,
)
response.raise_for_status()
- return _extract_openai_answer(response.json())
+ data = response.json()
+ return _extract_openai_answer(data)
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc
@@ -225,7 +228,15 @@ def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) ->
timeout=model.timeout_second,
)
response.raise_for_status()
- return _extract_gemini_answer(response.json())
+ data = response.json()
+ # Gemini 有时返回 HTTP 200 但 body 里有 error
+ if "error" in data:
+ err = data["error"]
+ msg = err.get("message", str(err))
+ raise ExternalServiceError(f"Gemini 调用失败:{msg}", provider="model")
+ return _extract_gemini_answer(data)
+ except ExternalServiceError:
+ raise
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc
@@ -260,6 +271,64 @@ def _extract_anthropic_answer(data: dict[str, Any]) -> str:
return answer
+def _call_minimax(model: ModelConfig, rag_result: RagResult) -> str:
+ """调用 MiniMax 官方 API (非 OpenAI 兼容协议)"""
+ import urllib.parse
+
+ extra = _load_extra_params(model.extra_params)
+ group_id = extra.pop("group_id", "")
+ if not group_id:
+ raise ExternalServiceError("MiniMax 调用需要 group_id,请在模型高级参数中配置:{\"group_id\": \"xxx\"}", provider="model")
+
+ base_url = (model.base_url or "https://api.minimaxi.com/v1/text/chatcompletion_pro").rstrip("/")
+ # MiniMax 的 baseUrl 在快速添加模板里已经包含完整路径,但如果用户自定义 baseUrl 没有 query,需要拼接
+ if "?" not in base_url:
+ base_url = f"{base_url}?GroupId={urllib.parse.quote(group_id)}"
+ elif "GroupId" not in base_url:
+ base_url = f"{base_url}&GroupId={urllib.parse.quote(group_id)}"
+
+ payload: dict[str, Any] = {
+ "model": model.model_name,
+ "tokens_to_generate": model.max_token or 1024,
+ "reply_constraints": {"sender_type": "BOT", "sender_name": "AI知识库助手"},
+ "messages": [
+ {"sender_type": "USER", "sender_name": "用户", "text": rag_result.prompt},
+ ],
+ "bot_setting": [
+ {
+ "bot_name": "AI知识库助手",
+ "content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。",
+ }
+ ],
+ }
+ _put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
+ _put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
+
+ headers = {"Authorization": f"Bearer {model.api_key}", "Content-Type": "application/json"}
+
+ try:
+ response = httpx.post(base_url, json=payload, headers=headers, timeout=model.timeout_second)
+ response.raise_for_status()
+ return _extract_minimax_answer(response.json())
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
+ raise ExternalServiceError(f"MiniMax 模型调用失败:{exc}", provider="model") from exc
+
+
+def _extract_minimax_answer(data: dict[str, Any]) -> str:
+ choices = data.get("choices")
+ if not isinstance(choices, list) or not choices:
+ raise ValueError("MiniMax 响应缺少 choices")
+ first = choices[0]
+ messages = first.get("messages", [])
+ if not isinstance(messages, list):
+ raise ValueError("MiniMax 响应缺少 messages")
+ texts = [m.get("text", "") for m in messages if isinstance(m, dict)]
+ answer = "".join(texts).strip()
+ if not answer:
+ raise ValueError("MiniMax 响应缺少回答内容")
+ return answer
+
+
def _extract_gemini_answer(data: dict[str, Any]) -> str:
candidates = data.get("candidates")
if not isinstance(candidates, list) or not candidates:
diff --git a/ai_knowledge_base_v2/docker-compose.dev.yml b/ai_knowledge_base_v2/docker-compose.dev.yml
index df4c0ce..919c31c 100644
--- a/ai_knowledge_base_v2/docker-compose.dev.yml
+++ b/ai_knowledge_base_v2/docker-compose.dev.yml
@@ -34,7 +34,9 @@ services:
MOCK_SMS_CODE: "123456"
MOCK_RAG_ENABLED: "true"
MOCK_MODEL_ENABLED: "true"
- FEISHU_MOCK_ENABLED: "true"
+ FEISHU_MOCK_ENABLED: "${FEISHU_MOCK_ENABLED:-true}"
+ FEISHU_APP_ID: "${FEISHU_APP_ID:-}"
+ FEISHU_APP_SECRET: "${FEISHU_APP_SECRET:-}"
AUTO_CREATE_TABLES: "false"
ports:
- "8100:8100"