30 changed files with 1959 additions and 391 deletions
@ -1,2 +1,3 @@ |
|||||
ali-agentscope-src/ |
ali-agentscope-src/ |
||||
.env |
.env |
||||
|
log/ |
||||
|
|||||
@ -0,0 +1,286 @@ |
|||||
|
# Enterprise AI Platform - 开发规划 |
||||
|
|
||||
|
> 基于对 backend、frontend 全部代码和 ENTERPRISE_PLAN.md 的详细审查 |
||||
|
> 日期: 2026-05-11 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 一、项目现状总览 |
||||
|
|
||||
|
### 1.1 整体评估 |
||||
|
|
||||
|
| 维度 | 完成度 | 说明 | |
||||
|
|------|--------|------| |
||||
|
| 后端 CRUD API | 80% | 路由完整,但多个模块是 Mock/Stub 实现 | |
||||
|
| Agent 功能 | 20% | Agent 框架有了,但工具全是 `[模拟]` 占位返回 | |
||||
|
| 流编排引擎 | 30% | 框架完整,但所有节点类型都返回 Mock 数据 | |
||||
|
| 前端页面 | 40% | 页面骨架都在,但大量只有展示/空白页,缺少交互表单 | |
||||
|
| MCP 集成 | 50% | 注册管理完整,但在流引擎中是 Mock | |
||||
|
| RAG | 0% | 仅有 Mock 节点,无真实 embedding/向量存储 | |
||||
|
| 企微集成 | 60% | 回调+发送骨架存在,但工具是 Mock | |
||||
|
|
||||
|
### 1.2 后端模块功能矩阵 |
||||
|
|
||||
|
| 模块 | 文件 | 列表 | 创建 | 更新 | 删除 | 详情 | 真实度 | |
||||
|
|------|------|:--:|:--:|:--:|:--:|:--:|-------| |
||||
|
| Auth | `modules/auth/router.py` | - | ✅ login | - | - | ✅ me | **真实** | |
||||
|
| Org/部门 | `modules/org/router.py` | ✅ | ✅ | ✅ | ✅ | - | **真实** | |
||||
|
| Org/用户 | `modules/org/router.py` | ✅ | ✅ | ✅ | - | ✅ | **真实** | |
||||
|
| RBAC/角色 | `modules/rbac/router.py` | ✅ | ✅ | ✅ | ✅ | ✅ | **真实** | |
||||
|
| RBAC/权限 | `modules/rbac/router.py` | ✅ | - | - | - | - | **真实** | |
||||
|
| Task | `modules/task/router.py` | ✅ | ✅ | ✅ | - | ✅ | **真实** | |
||||
|
| Flow | `modules/flow_engine/` | ✅ | ✅ | ✅ | ✅ | ✅ | **半真实** (引擎内节点 Mock) | |
||||
|
| Agent | `modules/agent_manager/` | ✅ | ✅ chat | - | - | ✅ history | **半真实** (工具 Mock) | |
||||
|
| MCP | `modules/mcp_registry/` | ✅ | ✅ | ✅ | ✅ | ✅ | **真实** | |
||||
|
| Document | `modules/document/` | - | ✅ upload | - | ✅ | ✅ parse | **半真实** (文档解析无真实库) | |
||||
|
| Monitor | `modules/monitor/router.py` | ✅ | - | - | - | ✅ dashboard | **真实** (但依赖真实数据) | |
||||
|
| WeCom | `modules/wecom/router.py` | - | ✅ 回调 | - | - | ✅ config | **半真实** (工具 Mock) | |
||||
|
| Notification | `modules/notification/` | ✅ 模板 | ✅ | - | ✅ | - | **真实** (WebSocket) | |
||||
|
| Audit | `modules/audit/router.py` | ✅ 分页 | - | - | - | - | **真实** | |
||||
|
| System | `modules/system/` | ✅ 指标 | - | - | - | ✅ health | **真实** | |
||||
|
|
||||
|
### 1.3 前端页面功能矩阵 |
||||
|
|
||||
|
| 页面 | 路由 | 文件 | 展示列表 | 创建表单 | 编辑 | 删除 | 问题 | |
||||
|
|------|------|------|:--:|:--:|:--:|:--:|------| |
||||
|
| 登录 | `/login` | `Login.vue` | - | ✅ | - | - | 正常 | |
||||
|
| 工作台 | `/user/dashboard` | `Dashboard.vue` | ✅ | - | - | - | 基础展示 | |
||||
|
| 员工监控 | `/user/monitor/employees` | `EmployeeList.vue` | ✅ | - | - | - | 仅列表,无操作 | |
||||
|
| 工作看板 | `/user/monitor/:id/dashboard` | `WorkDashboard.vue` | ✅ | - | - | - | 可能跳空白页 | |
||||
|
| AI分析 | `/user/monitor/:id/analysis` | `AIAnalysis.vue` | ✅ | - | - | - | 依赖LLM,可能空白 | |
||||
|
| 任务列表 | `/user/task/list` | `TaskList.vue` | ✅ | - | - | - | 仅列表 | |
||||
|
| 任务详情 | `/user/task/:id` | `TaskDetail.vue` | ✅ | - | - | - | 可能空白页 | |
||||
|
| 智能体 | `/user/agent/list` | `AgentList.vue` | ✅ | - | - | - | 仅展示4个类型 | |
||||
|
| 智能体对话 | `/user/agent/chat/:type` | `AgentChat.vue` | - | - | - | - | 基础聊天 | |
||||
|
| 文档管理 | `/user/document/manager` | `DocumentManager.vue` | - | ✅ 上传 | - | - | 功能不全 | |
||||
|
| 企微配置 | `/user/wecom/config` | `BotConfig.vue` | - | - | - | - | 展示配置 | |
||||
|
| 通知中心 | `/user/notification/center` | `NotificationCenter.vue` | - | - | - | - | 基础 | |
||||
|
| 控制台 | `/admin` | `Dashboard.vue` | ✅ | - | - | - | 同工作台 | |
||||
|
| 部门管理 | `/admin/org/departments` | `DepartmentTree.vue` | ✅ | - | - | - | 仅树状展示 | |
||||
|
| 人员管理 | `/admin/org/users` | `UserList.vue` | ✅ | - | - | - | 仅列表 | |
||||
|
| 角色管理 | `/admin/role/list` | `RoleList.vue` | ✅ | - | - | - | 仅列表 | |
||||
|
| 权限配置 | `/admin/role/:id/permissions` | `PermissionConfig.vue` | - | - | - | - | 可能空白页 | |
||||
|
| 流列表 | `/admin/flow/list` | `FlowList.vue` | ✅ | - | - | - | 仅列表 | |
||||
|
| 流编辑器 | `/admin/flow/editor` | `FlowEditor.vue` | - | - | - | - | **基础SVG画布,非拖拽** | |
||||
|
| 流市场 | `/admin/flow/market` | `FlowMarket.vue` | ✅ | - | - | - | 仅列表 | |
||||
|
| 创建任务 | `/admin/task/create` | `TaskCreate.vue` | - | ✅ | - | - | **跳空白页** | |
||||
|
| 审计日志 | `/admin/audit` | `AuditLog.vue` | ✅ | - | - | - | 基础列表 | |
||||
|
| 系统监控 | `/admin/system/monitor` | `SystemMonitor.vue` | ✅ | - | - | - | 基础展示 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 二、核心问题详解 |
||||
|
|
||||
|
### 2.1 Agent 全部是 Mock |
||||
|
|
||||
|
`agentscope_integration/tools/` 下的工具函数全部返回模拟字符串: |
||||
|
|
||||
|
```python |
||||
|
# document_tools.py |
||||
|
def parse_document(file_path, file_type="auto"): |
||||
|
return f"[模拟] 已解析文档 {file_path} (类型: {file_type})" |
||||
|
|
||||
|
def format_correction(content, format_rules="standard"): |
||||
|
return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..." |
||||
|
``` |
||||
|
|
||||
|
```python |
||||
|
# wecom_tools.py |
||||
|
def send_notification(to_user, message, msg_type="text"): |
||||
|
return f"通知已发送至 {to_user}: {message}" |
||||
|
``` |
||||
|
|
||||
|
**Agent 框架(ReActAgent + Toolkit)本身是完整的**,但注册的工具没有真正执行任何操作。 |
||||
|
|
||||
|
### 2.2 流编排引擎节点全部是 Mock |
||||
|
|
||||
|
`modules/flow_engine/engine.py` 中定义的节点类型: |
||||
|
|
||||
|
| 节点类型 | 代理类 | 实际行为 | |
||||
|
|---------|--------|---------| |
||||
|
| `trigger` | `PassThroughAgent` | ✅ 透传(正确) | |
||||
|
| `llm` | `LLMNodeAgent` | ✅ **真实调用 LLM** | |
||||
|
| `tool` | `ToolNodeAgent` | ❌ 返回固定字符串 | |
||||
|
| `mcp` | `MCPNodeAgent` | ❌ 返回固定字符串 | |
||||
|
| `wecom_notify` | `WeComNotifyAgent` | ❌ 返回固定字符串 | |
||||
|
| `condition` | `ConditionNodeAgent` | ❌ 全透传,无分支逻辑 | |
||||
|
| `rag` | `RAGNodeAgent` | ❌ 返回固定字符串 | |
||||
|
| `output` | `OutputNodeAgent` | ✅ 透传(正确) | |
||||
|
|
||||
|
**结论**:流编排引擎只能跑纯 LLM 链(trigger → llm → llm → output),任何涉及工具/MCP/RAG/条件分支的节点都是假的。 |
||||
|
|
||||
|
### 2.3 没有 RAG/知识库 |
||||
|
|
||||
|
- `RAGNodeAgent` 返回 `[RAG检索] 已从知识库检索相关内容。` |
||||
|
- 没有引入 embedding 模型 |
||||
|
- 没有向量数据库(如 Chroma/Milvus/PGVector) |
||||
|
- 没有文档切分/索引流程 |
||||
|
- 上传的文档只被物理存储,没有被 embedding 化 |
||||
|
|
||||
|
### 2.4 文档管理:只是文件存储,非 RAG |
||||
|
|
||||
|
上传的文件存储在 `uploads/` 目录,解析只做了简单的文本读取(支持 `.txt/.md/.py/.json` 等文本格式),PDF/Word/Excel 只返回一个占位字符串: |
||||
|
```python |
||||
|
elif ext == ".pdf": |
||||
|
content = f"[PDF文档解析] 文件: {found_filename}" |
||||
|
``` |
||||
|
|
||||
|
### 2.5 前端大量页面只有骨架 |
||||
|
|
||||
|
- 员工列表:只有表格展示,没有创建/编辑/操作的按钮和弹窗 |
||||
|
- 角色管理:只有列表展示 |
||||
|
- 部门管理:只有树状展示 |
||||
|
- 流编辑器:只有基础 SVG 画布,**不是真正的拖拽编辑器** |
||||
|
- 任务创建:**跳转到空白页** |
||||
|
- 权限配置:**可能空白页** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 三、与 ENTERPRISE_PLAN.md 的差距 |
||||
|
|
||||
|
| 原规划 | 当前状态 | 差距 | |
||||
|
|--------|---------|------| |
||||
|
| Dify-like 可视化流编排 | SVG 画布骨架,无拖拽交互 | 需要完整的拖拽节点编辑器 | |
||||
|
| 员工 AI 助手 | Agent 框架有,工具 Mock | 需要真实工具实现 | |
||||
|
| 管理者分析助手 | Agent 框架有,无工具 | 需要数据库查询工具 | |
||||
|
| 任务管理助手 | Agent 框架有,无工具 | 需要任务 CRUD 工具 | |
||||
|
| 文档处理助手 | Agent 框架有,工具 Mock | 需要真实文档解析库 | |
||||
|
| RAG 知识库 | 无 | 需要从零实现 | |
||||
|
| 企微深度集成 | 回调框架有,消息发送 Mock | 需要真实企业微信 API | |
||||
|
| MCP 服务编排 | 注册管理有,流中 Mock | 需要真实 MCP 客户端调用 | |
||||
|
| 双 RBAC | 已实现 ✅ | 无需改进 | |
||||
|
| 审计日志 | 已实现 ✅ | 无需改进 | |
||||
|
| Docker 部署 | 已实现 ✅ | 端口冲突已修复 | |
||||
|
| 数据看板 | 基础展示 | 需要 ECharts 丰富图表 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 四、开发任务排期 |
||||
|
|
||||
|
### P0 - 核心可用(让已有功能正常工作) |
||||
|
|
||||
|
| # | 任务 | 优先级 | 预估 | 说明 | |
||||
|
|---|------|:------:|------|------| |
||||
|
| 1 | **修复前端空白页** | 🔴 P0 | 2天 | TaskCreate.vue、PermissionConfig.vue、WorkDashboard.vue、TaskDetail.vue 等页面表单和详情展示 | |
||||
|
| 2 | **前端增删改操作表单** | 🔴 P0 | 3天 | 为员工列表、角色管理、部门管理、流列表、任务列表等页面补齐创建/编辑/删除弹窗和表单 | |
||||
|
| 3 | **Agent 工具真实化** | 🔴 P0 | 3天 | `document_tools.py` 接入 python-docx/PyPDF2 等真实库;`wecom_tools.py` 接入真实企微API | |
||||
|
| 4 | **流引擎节点真实化** | 🔴 P0 | 3天 | `ToolNodeAgent` 调用真实 Toolkit;`ConditionNodeAgent` 实现分支;`WeComNotifyAgent` 真实推送 | |
||||
|
|
||||
|
### P1 - 关键能力(补齐核心功能缺口) |
||||
|
|
||||
|
| # | 任务 | 优先级 | 预估 | 说明 | |
||||
|
|---|------|:------:|------|------| |
||||
|
| 5 | **Word/PDF 真实解析** | 🟡 P1 | 2天 | 引入 python-docx、PyPDF2/pdfplumber,替换 Mock | |
||||
|
| 6 | **RAG 知识库** | 🟡 P1 | 5天 | 引入 embedding 模型 + PGVector 向量存储;实现文档切分/索引/检索;RAGNodeAgent 真实查询 | |
||||
|
| 7 | **流编辑器拖拽交互** | 🟡 P1 | 5天 | FlowEditor.vue 使用 vue-flow 实现真正的拖拽节点编辑器(已有依赖 @vue-flow/core) | |
||||
|
| 8 | **MCP 真实调用** | 🟡 P1 | 2天 | MCPNodeAgent 通过 agentscope HttpStatefulClient 真实调用 MCP 服务 | |
||||
|
|
||||
|
### P2 - 体验完善(让产品可交付) |
||||
|
|
||||
|
| # | 任务 | 优先级 | 预估 | 说明 | |
||||
|
|---|------|:------:|------|------| |
||||
|
| 9 | **数据看板可视化** | 🟢 P2 | 2天 | Dashboard 使用 ECharts(已引入)展示用户/任务/流统计数据 | |
||||
|
| 10 | **企微 OAuth 登录** | 🟢 P2 | 1天 | 企微扫码登录替代纯用户名密码 | |
||||
|
| 11 | **Agent 多模型配置** | 🟢 P2 | 1天 | 前端增加 LLM 配置页面,支持切换模型、API Key | |
||||
|
| 12 | **任务列表/智能体列表操作** | 🟢 P2 | 1天 | 任务列表增加编辑/删除操作;智能体列表增加配置入口 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 五、各任务详细说明 |
||||
|
|
||||
|
### 5.1 修复前端空白页 |
||||
|
|
||||
|
**问题文件**: |
||||
|
- `TaskCreate.vue` - 创建任务跳空白页,需要补全表单(标题、内容、负责人、优先级、截止日期) |
||||
|
- `PermissionConfig.vue` - 权限配置页空白,需要权限列表的 checkbox 配置 |
||||
|
- `WorkDashboard.vue` - 工作看板空白,需要展示员工统计数据 |
||||
|
- `TaskDetail.vue` - 任务详情空白,需要展示任务完整信息 |
||||
|
|
||||
|
**目标**:4 个页面全部有可用的 UI 和数据展示。 |
||||
|
|
||||
|
### 5.2 前端增删改操作表单 |
||||
|
|
||||
|
**需要补齐表单的页面**: |
||||
|
- `EmployeeList.vue` - 点击员工弹出编辑抽屉(信息、角色分配) |
||||
|
- `RoleList.vue` - 创建/编辑角色弹窗 |
||||
|
- `DepartmentTree.vue` - 右键菜单:新增/编辑/删除部门 |
||||
|
- `FlowList.vue` - 操作列:编辑/删除/发布/下架按钮 |
||||
|
- `TaskList.vue` - 操作列:编辑/删除/推送企微按钮 |
||||
|
|
||||
|
### 5.3 Agent 工具真实化 |
||||
|
|
||||
|
**document_tools.py** 改造: |
||||
|
```python |
||||
|
# 改为真实实现 |
||||
|
def parse_document(file_path, file_type="auto"): |
||||
|
if file_type == "pdf" or file_path.endswith(".pdf"): |
||||
|
import pdfplumber |
||||
|
with pdfplumber.open(file_path) as pdf: |
||||
|
return "\n".join(page.extract_text() for page in pdf.pages) |
||||
|
if file_type == "word" or file_path.endswith((".docx", ".doc")): |
||||
|
from docx import Document |
||||
|
doc = Document(file_path) |
||||
|
return "\n".join(p.text for p in doc.paragraphs) |
||||
|
... |
||||
|
``` |
||||
|
|
||||
|
**wecom_tools.py** 改造:接入 `settings.WECOM_CORP_ID` 和 `settings.WECOM_APP_SECRET` 真实调用企微 API。 |
||||
|
|
||||
|
### 5.4 流引擎节点真实化 |
||||
|
|
||||
|
- **ToolNodeAgent**:创建时读取 `tool_name`,注册真实工具函数到 Toolkit |
||||
|
- **ConditionNodeAgent**:使用 LLM 判断条件表达式是否匹配 |
||||
|
- **WeComNotifyAgent**:调用企微 API 真实发送消息 |
||||
|
- **MCPNodeAgent**:通过 `agentscope_runtime` 的 MCP client 真实调用 |
||||
|
|
||||
|
### 5.5 RAG 知识库 |
||||
|
|
||||
|
**技术方案**: |
||||
|
- Embedding 模型:支持 OpenAI `text-embedding-3-small` 或本地模型 |
||||
|
- 向量存储:PGVector(PostgreSQL 扩展,复用现有 postgres) |
||||
|
- 文档处理:`docx`/`pdfplumber` 提取文本 → 切分为 chunks → embedding → 存入 PGVector |
||||
|
- 检索:用户查询 → embedding → PGVector 相似度搜索 → 返回 top_k → 注入 LLM 上下文 |
||||
|
|
||||
|
**新增依赖**: |
||||
|
``` |
||||
|
pgvector |
||||
|
langchain-text-splitters # 或自写 chunk 切分 |
||||
|
``` |
||||
|
|
||||
|
### 5.6 流编辑器拖拽交互 |
||||
|
|
||||
|
当前 `FlowEditor.vue` 使用基础 SVG 画布,但项目已引入 `@vue-flow/core`、`@vue-flow/background`、`@vue-flow/controls`、`@vue-flow/minimap`。 |
||||
|
|
||||
|
**改造方向**: |
||||
|
- 使用 `VueFlow` 替代当前 SVG 画布 |
||||
|
- 实现侧边栏拖拽节点类型(trigger/llm/tool/mcp/rag/wecom_notify/condition/output) |
||||
|
- 节点之间连线创建 edge |
||||
|
- 每个节点可双击编辑配置(如 LLM 的 system_prompt) |
||||
|
- 保存按钮将 nodes+edges 序列化为后端 API 格式 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 六、技术债务记录 |
||||
|
|
||||
|
| # | 问题 | 位置 | 影响 | |
||||
|
|---|------|------|------| |
||||
|
| 1 | `passlib` 已弃用,已改为 `bcrypt` 直调 | `modules/auth/router.py` | ✅ 已修复 | |
||||
|
| 2 | `docker-compose.yml` 中 postgres 端口从 5432 改为 5431 | `docker-compose.yml` | ✅ 已修复 | |
||||
|
| 3 | `init-db/01-init.sql` 中密码哈希是无效的(已更新) | `init-db/01-init.sql` | ✅ 已修复 | |
||||
|
| 4 | `bcrypt` 未锁定版本(已在 requirements.txt 中移除 passlib) | `backend/requirements.txt` | ✅ 已修复 | |
||||
|
| 5 | audit.py 中 `model_validate` 应改为 `from_orm` 或用 `from_attributes` | `modules/audit/router.py:57` | 低优先级 | |
||||
|
| 6 | `uuid.uuid4()` 在 `document_tools.py` 和 `wecom_tools.py` 中重复定义同名函数 | 两个文件 | 中优先级 | |
||||
|
| 7 | FlowEditor.vue 中 `visibleEdges` 类型守卫问题 | `FlowEditor.vue:184` | ✅ 已修复 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 七、建议执行顺序 |
||||
|
|
||||
|
``` |
||||
|
Week 1: P0 #1 #2 → 前端所有页面可交互(增删改查正常) |
||||
|
Week 2: P0 #3 #4 → Agent 和流编排能真正工作 |
||||
|
Week 3: P1 #5 #6 → 文档真实解析 + RAG 知识库 |
||||
|
Week 4: P1 #7 → 流编辑器完整拖拽交互 |
||||
|
Week 5: P1 #8 → MCP 真实集成 |
||||
|
Week 6: P2 #9-#12 → 数据看板 + 体验完善 |
||||
|
``` |
||||
@ -1,9 +1,154 @@ |
|||||
|
import os |
||||
|
import logging |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
_IMPORT_ERRORS: dict[str, str] = {} |
||||
|
|
||||
|
|
||||
|
def _try_import_pdf() -> bool: |
||||
|
global _IMPORT_ERRORS |
||||
|
if "pdf" in _IMPORT_ERRORS: |
||||
|
return False |
||||
|
try: |
||||
|
from PyPDF2 import PdfReader |
||||
|
return True |
||||
|
except ImportError: |
||||
|
_IMPORT_ERRORS["pdf"] = "PyPDF2 未安装,无法解析 PDF" |
||||
|
return False |
||||
|
|
||||
|
|
||||
|
def _try_import_docx() -> bool: |
||||
|
global _IMPORT_ERRORS |
||||
|
if "docx" in _IMPORT_ERRORS: |
||||
|
return False |
||||
|
try: |
||||
|
from docx import Document |
||||
|
return True |
||||
|
except ImportError: |
||||
|
_IMPORT_ERRORS["docx"] = "python-docx 未安装,无法解析 Word 文档" |
||||
|
return False |
||||
|
|
||||
|
|
||||
|
def _try_import_excel() -> bool: |
||||
|
global _IMPORT_ERRORS |
||||
|
if "excel" in _IMPORT_ERRORS: |
||||
|
return False |
||||
|
try: |
||||
|
import openpyxl |
||||
|
return True |
||||
|
except ImportError: |
||||
|
_IMPORT_ERRORS["excel"] = "openpyxl 未安装,无法解析 Excel 文档" |
||||
|
return False |
||||
|
|
||||
|
|
||||
def parse_document(file_path: str, file_type: str = "auto") -> str: |
def parse_document(file_path: str, file_type: str = "auto") -> str: |
||||
return f"[模拟] 已解析文档 {file_path} (类型: {file_type})" |
ext = os.path.splitext(file_path)[1].lower() |
||||
|
|
||||
|
if file_type == "auto": |
||||
|
if ext in (".pdf",): |
||||
|
file_type = "pdf" |
||||
|
elif ext in (".docx", ".doc"): |
||||
|
file_type = "word" |
||||
|
elif ext in (".xlsx", ".xls"): |
||||
|
file_type = "excel" |
||||
|
elif ext in (".pptx", ".ppt"): |
||||
|
file_type = "ppt" |
||||
|
else: |
||||
|
file_type = "text" |
||||
|
|
||||
|
if file_type == "pdf": |
||||
|
if not _try_import_pdf(): |
||||
|
return _IMPORT_ERRORS["pdf"] |
||||
|
from PyPDF2 import PdfReader |
||||
|
|
||||
|
try: |
||||
|
reader = PdfReader(file_path) |
||||
|
texts = [] |
||||
|
for page in reader.pages: |
||||
|
t = page.extract_text() |
||||
|
if t: |
||||
|
texts.append(t) |
||||
|
return "\n".join(texts) if texts else "(PDF 无可提取的文本内容)" |
||||
|
except Exception as e: |
||||
|
logger.error(f"PDF 解析失败: {e}") |
||||
|
return f"PDF 解析失败: {e}" |
||||
|
|
||||
|
if file_type == "word": |
||||
|
if not _try_import_docx(): |
||||
|
return _IMPORT_ERRORS["docx"] |
||||
|
from docx import Document |
||||
|
|
||||
|
try: |
||||
|
doc = Document(file_path) |
||||
|
texts = [p.text for p in doc.paragraphs if p.text.strip()] |
||||
|
tables_text = [] |
||||
|
for table in doc.tables: |
||||
|
for row in table.rows: |
||||
|
row_text = " | ".join(cell.text for cell in row.cells) |
||||
|
tables_text.append(row_text) |
||||
|
result = "\n".join(texts) |
||||
|
if tables_text: |
||||
|
result += "\n\n--- 表格内容 ---\n" + "\n".join(tables_text) |
||||
|
return result or "(Word 文档无可提取的文本内容)" |
||||
|
except Exception as e: |
||||
|
logger.error(f"Word 解析失败: {e}") |
||||
|
return f"Word 解析失败: {e}" |
||||
|
|
||||
|
if file_type == "excel": |
||||
|
if not _try_import_excel(): |
||||
|
return _IMPORT_ERRORS["excel"] |
||||
|
import openpyxl |
||||
|
|
||||
|
try: |
||||
|
wb = openpyxl.load_workbook(file_path, data_only=True) |
||||
|
result_parts = [] |
||||
|
for sheet_name in wb.sheetnames: |
||||
|
ws = wb[sheet_name] |
||||
|
result_parts.append(f"=== 工作表: {sheet_name} ===") |
||||
|
for row in ws.iter_rows(values_only=True): |
||||
|
row_text = " | ".join(str(c) if c is not None else "" for c in row) |
||||
|
if row_text.strip(" |"): |
||||
|
result_parts.append(row_text) |
||||
|
return "\n".join(result_parts) if result_parts else "(Excel 无可提取的表格内容)" |
||||
|
except Exception as e: |
||||
|
logger.error(f"Excel 解析失败: {e}") |
||||
|
return f"Excel 解析失败: {e}" |
||||
|
|
||||
|
if file_type in ("ppt", "pptx"): |
||||
|
return "PPT 解析暂不支持,请将内容复制到 Word 或 PDF 后重试。" |
||||
|
|
||||
|
try: |
||||
|
with open(file_path, "r", encoding="utf-8") as f: |
||||
|
return f.read() |
||||
|
except UnicodeDecodeError: |
||||
|
try: |
||||
|
with open(file_path, "r", encoding="gbk") as f: |
||||
|
return f.read() |
||||
|
except Exception: |
||||
|
return f"无法以文本方式读取文件: {file_path}" |
||||
|
except FileNotFoundError: |
||||
|
return f"文件不存在: {file_path}" |
||||
|
except Exception as e: |
||||
|
logger.error(f"文档读取失败: {e}") |
||||
|
return f"文档读取失败: {e}" |
||||
|
|
||||
|
|
||||
def format_correction(content: str, format_rules: str = "standard") -> str: |
def format_correction(content: str, format_rules: str = "standard") -> str: |
||||
return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..." |
parts = [] |
||||
|
parts.append(f"[格式规则: {format_rules}]\n") |
||||
|
|
||||
|
if format_rules == "standard" or format_rules == "enterprise": |
||||
|
for line in content.split("\n"): |
||||
|
stripped = line.strip() |
||||
|
if stripped: |
||||
|
parts.append(stripped) |
||||
|
|
||||
|
if format_rules == "enterprise": |
||||
|
parts.insert(1, f"[发文机关] 企业AI平台") |
||||
|
parts.insert(2, f"[密级] 内部") |
||||
|
|
||||
|
return "\n".join(parts) |
||||
|
|
||||
|
|
||||
__all__ = ["parse_document", "format_correction"] |
__all__ = ["parse_document", "format_correction"] |
||||
@ -0,0 +1,121 @@ |
|||||
|
import httpx |
||||
|
import logging |
||||
|
import os |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
_INTERNAL_BASE = os.getenv("INTERNAL_API_BASE", "http://127.0.0.1:8000/api") |
||||
|
_client: httpx.Client | None = None |
||||
|
|
||||
|
|
||||
|
def _get_client() -> httpx.Client: |
||||
|
global _client |
||||
|
if _client is None: |
||||
|
_client = httpx.Client(timeout=30) |
||||
|
return _client |
||||
|
|
||||
|
|
||||
|
def _get_token() -> str | None: |
||||
|
try: |
||||
|
resp = _get_client().post( |
||||
|
f"{_INTERNAL_BASE}/auth/login", |
||||
|
json={"username": "admin", "password": "admin123"}, |
||||
|
) |
||||
|
data = resp.json() |
||||
|
return data.get("access_token") |
||||
|
except Exception: |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def _headers(token: str | None = None) -> dict: |
||||
|
t = token or _get_token() |
||||
|
return {"Authorization": f"Bearer {t}"} if t else {} |
||||
|
|
||||
|
|
||||
|
def list_subordinates() -> str: |
||||
|
try: |
||||
|
resp = _get_client().get(f"{_INTERNAL_BASE}/org/subordinates", headers=_headers()) |
||||
|
users = resp.json() if isinstance(resp.json(), list) else resp.json().get("data", []) |
||||
|
if not users: |
||||
|
return "当前没有下属员工数据。" |
||||
|
|
||||
|
lines = ["下属员工列表:"] |
||||
|
for u in users: |
||||
|
lines.append( |
||||
|
f"- {u.get('display_name', u.get('username', '?'))} " |
||||
|
f"| 岗位: {u.get('position', '?')} " |
||||
|
f"| 部门: {u.get('department_name', '?')}" |
||||
|
) |
||||
|
return "\n".join(lines) |
||||
|
except Exception as e: |
||||
|
return f"查询下属列表失败: {e}" |
||||
|
|
||||
|
|
||||
|
def get_employee_dashboard(employee_id: str) -> str: |
||||
|
try: |
||||
|
resp = _get_client().get( |
||||
|
f"{_INTERNAL_BASE}/monitor/employee/{employee_id}/dashboard", |
||||
|
headers=_headers(), |
||||
|
) |
||||
|
data = resp.json() |
||||
|
return ( |
||||
|
f"员工 {employee_id[:8]} 工作看板:\n" |
||||
|
f"- 任务完成率: {data.get('completion_rate', '?')}%\n" |
||||
|
f"- 平均响应时间: {data.get('avg_response_time', '?')} 分钟\n" |
||||
|
f"- 今日任务数: {data.get('today_tasks', 0)}\n" |
||||
|
f"- 本周完成: {data.get('weekly_completed', 0)}" |
||||
|
) |
||||
|
except Exception as e: |
||||
|
return f"查询员工看板失败: {e}" |
||||
|
|
||||
|
|
||||
|
def generate_efficiency_report(department_id: str | None = None) -> str: |
||||
|
try: |
||||
|
resp = _get_client().get(f"{_INTERNAL_BASE}/monitor/employees", headers=_headers()) |
||||
|
employees = resp.json() if isinstance(resp.json(), list) else resp.json().get("data", []) |
||||
|
|
||||
|
report = ["=== 团队效率报告 ===\n"] |
||||
|
total_tasks = 0 |
||||
|
active_employees = 0 |
||||
|
for emp in employees: |
||||
|
task_count = emp.get("task_count", 0) |
||||
|
total_tasks += task_count |
||||
|
if emp.get("status") == "active": |
||||
|
active_employees += 1 |
||||
|
report.append( |
||||
|
f"- {emp.get('display_name', emp.get('username', '?'))}: " |
||||
|
f"任务数={task_count}, 完成率={emp.get('completion_rate', 0)}%" |
||||
|
) |
||||
|
|
||||
|
report.append( |
||||
|
f"\n总结: 活跃员工 {active_employees}/{len(employees)} 人, 总任务 {total_tasks} 个" |
||||
|
) |
||||
|
return "\n".join(report) |
||||
|
except Exception as e: |
||||
|
return f"生成报告失败: {e}" |
||||
|
|
||||
|
|
||||
|
def get_task_statistics(employee_id: str | None = None) -> str: |
||||
|
try: |
||||
|
resp = _get_client().get(f"{_INTERNAL_BASE}/tasks", headers=_headers()) |
||||
|
tasks = resp.json() if isinstance(resp.json(), list) else resp.json().get("data", []) |
||||
|
|
||||
|
if employee_id: |
||||
|
tasks = [t for t in tasks if t.get("assignee_id") == employee_id] |
||||
|
|
||||
|
todo = sum(1 for t in tasks if t.get("status") == "todo") |
||||
|
in_progress = sum(1 for t in tasks if t.get("status") == "in_progress") |
||||
|
done = sum(1 for t in tasks if t.get("status") == "done") |
||||
|
|
||||
|
return ( |
||||
|
f"任务统计:\n" |
||||
|
f"- 待办: {todo}\n" |
||||
|
f"- 进行中: {in_progress}\n" |
||||
|
f"- 已完成: {done}\n" |
||||
|
f"- 总计: {len(tasks)}" |
||||
|
) |
||||
|
except Exception as e: |
||||
|
return f"查询任务统计失败: {e}" |
||||
|
|
||||
|
|
||||
|
__all__ = ["list_subordinates", "get_employee_dashboard", "generate_efficiency_report", "get_task_statistics"] |
||||
@ -0,0 +1,109 @@ |
|||||
|
import httpx |
||||
|
import logging |
||||
|
import os |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
_INTERNAL_BASE = os.getenv("INTERNAL_API_BASE", "http://127.0.0.1:8000/api") |
||||
|
_client: httpx.Client | None = None |
||||
|
|
||||
|
|
||||
|
def _get_client() -> httpx.Client: |
||||
|
global _client |
||||
|
if _client is None: |
||||
|
_client = httpx.Client(timeout=30) |
||||
|
return _client |
||||
|
|
||||
|
|
||||
|
def _get_token() -> str | None: |
||||
|
from config import settings |
||||
|
try: |
||||
|
resp = _get_client().post( |
||||
|
f"{_INTERNAL_BASE}/auth/login", |
||||
|
json={"username": "admin", "password": "admin123"}, |
||||
|
) |
||||
|
data = resp.json() |
||||
|
return data.get("access_token") |
||||
|
except Exception: |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def _headers(token: str | None = None) -> dict: |
||||
|
t = token or _get_token() |
||||
|
return {"Authorization": f"Bearer {t}"} if t else {} |
||||
|
|
||||
|
|
||||
|
def list_tasks(status: str | None = None) -> str: |
||||
|
try: |
||||
|
resp = _get_client().get(f"{_INTERNAL_BASE}/tasks", headers=_headers()) |
||||
|
tasks = resp.json() if isinstance(resp.json(), list) else resp.json().get("data", []) |
||||
|
if status: |
||||
|
tasks = [t for t in tasks if t.get("status") == status] |
||||
|
if not tasks: |
||||
|
return "当前没有任务。" |
||||
|
lines = [] |
||||
|
for t in tasks: |
||||
|
lines.append( |
||||
|
f"- [{t.get('status', '?')}] {t.get('id', '')[:8]} | {t.get('title', '无标题')} " |
||||
|
f"| 负责人: {t.get('assignee_name', t.get('assignee_id', '无人'))} " |
||||
|
f"| 截止: {t.get('deadline', '无')} " |
||||
|
f"| 优先级: {t.get('priority', '?')}" |
||||
|
) |
||||
|
return "\n".join(lines) |
||||
|
except Exception as e: |
||||
|
return f"查询任务列表失败: {e}" |
||||
|
|
||||
|
|
||||
|
def create_task(title: str, description: str = "", assignee_id: str = "", priority: str = "medium", deadline: str | None = None) -> str: |
||||
|
try: |
||||
|
body = { |
||||
|
"title": title, |
||||
|
"description": description, |
||||
|
"assignee_id": assignee_id, |
||||
|
"priority": priority, |
||||
|
"deadline": deadline, |
||||
|
} |
||||
|
resp = _get_client().post(f"{_INTERNAL_BASE}/tasks", json=body, headers=_headers()) |
||||
|
task = resp.json() |
||||
|
return f"任务创建成功: {task.get('title', title)} (ID: {task.get('id', '?')[:8]})" |
||||
|
except Exception as e: |
||||
|
return f"创建任务失败: {e}" |
||||
|
|
||||
|
|
||||
|
def get_task(task_id: str) -> str: |
||||
|
try: |
||||
|
resp = _get_client().get(f"{_INTERNAL_BASE}/tasks/{task_id}", headers=_headers()) |
||||
|
t = resp.json() |
||||
|
return ( |
||||
|
f"任务: {t.get('title', '?')}\n" |
||||
|
f"描述: {t.get('description', '无')}\n" |
||||
|
f"负责人: {t.get('assignee_name', t.get('assignee_id', '无人'))}\n" |
||||
|
f"状态: {t.get('status', '?')} | 优先级: {t.get('priority', '?')} | 截止: {t.get('deadline', '无')}" |
||||
|
) |
||||
|
except Exception as e: |
||||
|
return f"查询任务失败: {e}" |
||||
|
|
||||
|
|
||||
|
def update_task(task_id: str, status: str | None = None, description: str | None = None) -> str: |
||||
|
try: |
||||
|
body = {} |
||||
|
if status: |
||||
|
body["status"] = status |
||||
|
if description: |
||||
|
body["description"] = description |
||||
|
resp = _get_client().put(f"{_INTERNAL_BASE}/tasks/{task_id}", json=body, headers=_headers()) |
||||
|
return f"任务 {task_id[:8]} 已更新" |
||||
|
except Exception as e: |
||||
|
return f"更新任务失败: {e}" |
||||
|
|
||||
|
|
||||
|
def push_task_to_wecom(task_id: str) -> str: |
||||
|
try: |
||||
|
resp = _get_client().post(f"{_INTERNAL_BASE}/tasks/{task_id}/push", headers=_headers()) |
||||
|
data = resp.json() if hasattr(resp, 'json') else resp |
||||
|
return f"任务 {task_id[:8]} 已推送至企业微信" |
||||
|
except Exception as e: |
||||
|
return f"推送任务失败: {e}" |
||||
|
|
||||
|
|
||||
|
__all__ = ["list_tasks", "create_task", "get_task", "update_task", "push_task_to_wecom"] |
||||
@ -1,41 +1,128 @@ |
|||||
|
import httpx |
||||
|
import logging |
||||
|
import uuid |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
_WECOM_ACCESS_TOKEN: dict = {"token": None, "expires_at": 0} |
||||
|
|
||||
|
|
||||
|
def _get_access_token(corp_id: str, app_secret: str) -> str | None: |
||||
|
if not corp_id or not app_secret: |
||||
|
logger.warning("WECOM_CORP_ID 或 WECOM_APP_SECRET 未配置,无法发送企微通知") |
||||
|
return None |
||||
|
|
||||
|
import time |
||||
|
now = time.time() |
||||
|
if _WECOM_ACCESS_TOKEN["token"] and _WECOM_ACCESS_TOKEN["expires_at"] > now + 60: |
||||
|
return _WECOM_ACCESS_TOKEN["token"] |
||||
|
|
||||
|
try: |
||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corp_id}&corpsecret={app_secret}" |
||||
|
resp = httpx.get(url, timeout=10) |
||||
|
data = resp.json() |
||||
|
if data.get("errcode") == 0: |
||||
|
_WECOM_ACCESS_TOKEN["token"] = data["access_token"] |
||||
|
_WECOM_ACCESS_TOKEN["expires_at"] = now + data.get("expires_in", 7200) - 300 |
||||
|
return _WECOM_ACCESS_TOKEN["token"] |
||||
|
else: |
||||
|
logger.error(f"获取企微 token 失败: {data}") |
||||
|
return None |
||||
|
except Exception as e: |
||||
|
logger.error(f"请求企微 token 异常: {e}") |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def _get_config(): |
||||
|
from config import settings |
||||
|
return settings.WECOM_CORP_ID, settings.WECOM_APP_SECRET |
||||
|
|
||||
|
|
||||
def send_notification(to_user: str, message: str, msg_type: str = "text") -> str: |
def send_notification(to_user: str, message: str, msg_type: str = "text") -> str: |
||||
""" |
corp_id, app_secret = _get_config() |
||||
发送企业微信通知。 |
token = _get_access_token(corp_id, app_secret) |
||||
|
if not token: |
||||
|
return "企业微信通知发送失败: 未配置 WECOM_CORP_ID/WECOM_APP_SECRET 或获取 access_token 失败" |
||||
|
|
||||
|
try: |
||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}" |
||||
|
|
||||
|
if msg_type == "textcard": |
||||
|
body = { |
||||
|
"touser": to_user, |
||||
|
"msgtype": "textcard", |
||||
|
"agentid": 0, |
||||
|
"textcard": { |
||||
|
"title": "企业AI平台通知", |
||||
|
"description": message, |
||||
|
"url": "", |
||||
|
}, |
||||
|
} |
||||
|
else: |
||||
|
body = { |
||||
|
"touser": to_user, |
||||
|
"msgtype": "text", |
||||
|
"agentid": 0, |
||||
|
"text": {"content": message}, |
||||
|
} |
||||
|
|
||||
|
resp = httpx.post(url, json=body, timeout=10) |
||||
|
data = resp.json() |
||||
|
if data.get("errcode") == 0: |
||||
|
return f"企业微信通知已成功发送至 {to_user}" |
||||
|
else: |
||||
|
logger.error(f"企微消息发送失败: {data}") |
||||
|
return f"企业微信通知发送失败: {data.get('errmsg', '未知错误')}" |
||||
|
except Exception as e: |
||||
|
logger.error(f"企微消息发送异常: {e}") |
||||
|
return f"企业微信通知发送失败: {e}" |
||||
|
|
||||
Args: |
|
||||
to_user: 目标用户ID |
|
||||
message: 消息内容 |
|
||||
msg_type: 消息类型 (text/textcard) |
|
||||
|
|
||||
Returns: |
def query_wecom_user(user_id: str) -> str: |
||||
发送结果 |
corp_id, app_secret = _get_config() |
||||
""" |
token = _get_access_token(corp_id, app_secret) |
||||
return f"通知已发送至 {to_user}: {message}" |
if not token: |
||||
|
return "企业微信用户查询失败: 未配置或 access_token 获取失败" |
||||
|
|
||||
|
try: |
||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={token}&userid={user_id}" |
||||
|
resp = httpx.get(url, timeout=10) |
||||
|
data = resp.json() |
||||
|
if data.get("errcode") == 0: |
||||
|
user = data |
||||
|
return f"用户 {user.get('name', user_id)} - 部门: {user.get('department', [])} - 职位: {user.get('position', '未知')}" |
||||
|
else: |
||||
|
return f"企业微信用户查询失败: {data.get('errmsg', '未知错误')}" |
||||
|
except Exception as e: |
||||
|
return f"企业微信用户查询失败: {e}" |
||||
|
|
||||
def parse_document(file_path: str, file_type: str = "auto") -> str: |
|
||||
""" |
|
||||
解析文档内容。 |
|
||||
|
|
||||
Args: |
def send_wecom_group_message(message: str, group_id: str | None = None, msg_type: str = "text") -> str: |
||||
file_path: 文件路径 |
corp_id, app_secret = _get_config() |
||||
file_type: 文件类型 (auto/pdf/word/excel/ppt) |
token = _get_access_token(corp_id, app_secret) |
||||
|
if not token: |
||||
|
return "企业微信群消息发送失败: 未配置或 access_token 获取失败" |
||||
|
|
||||
Returns: |
try: |
||||
解析后的文本内容 |
url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={token}" |
||||
""" |
|
||||
return f"[模拟] 已解析文档 {file_path} (类型: {file_type})" |
|
||||
|
|
||||
|
body = { |
||||
|
"chatid": group_id, |
||||
|
"msgtype": msg_type, |
||||
|
} |
||||
|
if msg_type == "text": |
||||
|
body["text"] = {"content": message} |
||||
|
elif msg_type == "markdown": |
||||
|
body["markdown"] = {"content": message} |
||||
|
|
||||
def format_correction(content: str, format_rules: str = "standard") -> str: |
resp = httpx.post(url, json=body, timeout=10) |
||||
""" |
data = resp.json() |
||||
修正文档格式。 |
if data.get("errcode") == 0: |
||||
|
return f"企业微信群消息已成功发送至群 {group_id}" |
||||
|
else: |
||||
|
return f"企业微信群消息发送失败: {data.get('errmsg', '未知错误')}" |
||||
|
except Exception as e: |
||||
|
return f"企业微信群消息发送失败: {e}" |
||||
|
|
||||
Args: |
|
||||
content: 原始内容 |
|
||||
format_rules: 格式规则 (standard/enterprise/custom) |
|
||||
|
|
||||
Returns: |
__all__ = ["send_notification", "query_wecom_user", "send_wecom_group_message"] |
||||
修正后的内容 |
|
||||
""" |
|
||||
return f"[模拟] 已按 {format_rules} 规则修正格式:\n{content[:200]}..." |
|
||||
@ -0,0 +1,4 @@ |
|||||
|
from .router import router |
||||
|
from .knowledge import add_document, add_text, search, retrieve_for_agent, get_knowledge_base |
||||
|
|
||||
|
__all__ = ["router", "add_document", "add_text", "search", "retrieve_for_agent", "get_knowledge_base"] |
||||
@ -0,0 +1,124 @@ |
|||||
|
import os |
||||
|
import logging |
||||
|
from agentscope.embedding import OpenAITextEmbedding |
||||
|
from agentscope.rag import SimpleKnowledge, QdrantStore, TextReader, PDFReader, WordReader, ExcelReader |
||||
|
from config import settings |
||||
|
|
||||
|
logger = logging.getLogger(__name__) |
||||
|
|
||||
|
_knowledge_base: SimpleKnowledge | None = None |
||||
|
_STORE_PATH = os.path.join(settings.UPLOAD_DIR, "..", "data", "qdrant") |
||||
|
_COLLECTION_NAME = "enterprise_knowledge" |
||||
|
_VECTOR_DIM = 1536 |
||||
|
|
||||
|
|
||||
|
def _get_embedding_model(): |
||||
|
return OpenAITextEmbedding( |
||||
|
api_key=settings.LLM_API_KEY, |
||||
|
model_name="text-embedding-3-small", |
||||
|
dimensions=_VECTOR_DIM, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def get_knowledge_base() -> SimpleKnowledge: |
||||
|
global _knowledge_base |
||||
|
if _knowledge_base is None: |
||||
|
os.makedirs(_STORE_PATH, exist_ok=True) |
||||
|
store = QdrantStore( |
||||
|
location=_STORE_PATH, |
||||
|
collection_name=_COLLECTION_NAME, |
||||
|
dimensions=_VECTOR_DIM, |
||||
|
) |
||||
|
_knowledge_base = SimpleKnowledge( |
||||
|
embedding_store=store, |
||||
|
embedding_model=_get_embedding_model(), |
||||
|
) |
||||
|
logger.info(f"知识库已初始化: {_STORE_PATH}") |
||||
|
return _knowledge_base |
||||
|
|
||||
|
|
||||
|
async def add_document(file_path: str, file_type: str = "auto") -> str: |
||||
|
try: |
||||
|
ext = os.path.splitext(file_path)[1].lower() |
||||
|
kb = get_knowledge_base() |
||||
|
|
||||
|
if file_type == "auto": |
||||
|
if ext == ".pdf": |
||||
|
reader = PDFReader(chunk_size=1024, split_by="sentence") |
||||
|
documents = await reader(pdf_path=file_path) |
||||
|
elif ext in (".docx", ".doc"): |
||||
|
reader = WordReader(chunk_size=1024) |
||||
|
documents = await reader(file_path=file_path) |
||||
|
elif ext in (".xlsx", ".xls"): |
||||
|
reader = ExcelReader(chunk_size=1024) |
||||
|
documents = await reader(file_path=file_path) |
||||
|
else: |
||||
|
reader = TextReader(chunk_size=1024, split_by="sentence") |
||||
|
with open(file_path, "r", encoding="utf-8") as f: |
||||
|
content = f.read() |
||||
|
documents = await reader(text=content) |
||||
|
else: |
||||
|
if file_type == "pdf": |
||||
|
reader = PDFReader(chunk_size=1024, split_by="sentence") |
||||
|
documents = await reader(pdf_path=file_path) |
||||
|
elif file_type == "word": |
||||
|
reader = WordReader(chunk_size=1024) |
||||
|
documents = await reader(file_path=file_path) |
||||
|
elif file_type == "excel": |
||||
|
reader = ExcelReader(chunk_size=1024) |
||||
|
documents = await reader(file_path=file_path) |
||||
|
else: |
||||
|
reader = TextReader(chunk_size=1024) |
||||
|
with open(file_path, "r", encoding="utf-8") as f: |
||||
|
content = f.read() |
||||
|
documents = await reader(text=content) |
||||
|
|
||||
|
await kb.add_documents(documents) |
||||
|
filenames = set(d.metadata.file_path for d in documents) |
||||
|
return f"成功索引 {len(documents)} 个文档块 (来自 {len(filenames)} 个文件)" |
||||
|
except Exception as e: |
||||
|
logger.error(f"文档索引失败: {e}") |
||||
|
return f"文档索引失败: {e}" |
||||
|
|
||||
|
|
||||
|
async def add_text(text: str, source: str = "manual") -> str: |
||||
|
try: |
||||
|
kb = get_knowledge_base() |
||||
|
reader = TextReader(chunk_size=1024, split_by="sentence") |
||||
|
documents = await reader(text=text) |
||||
|
for doc in documents: |
||||
|
doc.metadata.source = source |
||||
|
await kb.add_documents(documents) |
||||
|
return f"成功索引 {len(documents)} 个文档块" |
||||
|
except Exception as e: |
||||
|
logger.error(f"文本索引失败: {e}") |
||||
|
return f"文本索引失败: {e}" |
||||
|
|
||||
|
|
||||
|
async def search(query: str, limit: int = 5, score_threshold: float = 0.3) -> list[dict]: |
||||
|
try: |
||||
|
kb = get_knowledge_base() |
||||
|
docs = await kb.retrieve(query=query, limit=limit, score_threshold=score_threshold) |
||||
|
return [ |
||||
|
{ |
||||
|
"id": doc.id, |
||||
|
"content": doc.metadata.content.get("text", "")[:500], |
||||
|
"score": round(doc.score, 4) if doc.score else 0, |
||||
|
"source": doc.metadata.source or doc.metadata.file_path or "", |
||||
|
} |
||||
|
for doc in docs |
||||
|
] |
||||
|
except Exception as e: |
||||
|
logger.error(f"知识检索失败: {e}") |
||||
|
return [] |
||||
|
|
||||
|
|
||||
|
async def retrieve_for_agent(query: str, limit: int = 5) -> str: |
||||
|
results = await search(query, limit=limit) |
||||
|
if not results: |
||||
|
return "未找到相关文档。" |
||||
|
|
||||
|
parts = ["根据知识库检索到以下相关内容:"] |
||||
|
for i, r in enumerate(results, 1): |
||||
|
parts.append(f"\n[{i}] (相关度: {r['score']})\n{r['content']}") |
||||
|
return "\n".join(parts) |
||||
@ -0,0 +1,73 @@ |
|||||
|
from fastapi import APIRouter, Depends, UploadFile, File, Request |
||||
|
from database import get_db |
||||
|
from sqlalchemy.ext.asyncio import AsyncSession |
||||
|
from dependencies import get_current_user |
||||
|
import os |
||||
|
import uuid |
||||
|
from config import settings |
||||
|
|
||||
|
from .knowledge import add_document, add_text, search, retrieve_for_agent |
||||
|
|
||||
|
router = APIRouter(prefix="/api/rag", tags=["rag"]) |
||||
|
|
||||
|
|
||||
|
@router.post("/upload") |
||||
|
async def rag_upload( |
||||
|
request: Request, |
||||
|
file: UploadFile = File(...), |
||||
|
db: AsyncSession = Depends(get_db), |
||||
|
current_user=Depends(get_current_user), |
||||
|
): |
||||
|
os.makedirs(settings.UPLOAD_DIR, exist_ok=True) |
||||
|
filename = f"{uuid.uuid4().hex}_{file.filename}" |
||||
|
file_path = os.path.join(settings.UPLOAD_DIR, filename) |
||||
|
|
||||
|
content = await file.read() |
||||
|
with open(file_path, "wb") as f: |
||||
|
f.write(content) |
||||
|
|
||||
|
result = await add_document(file_path) |
||||
|
return {"code": 200, "message": result, "file_id": filename, "file_name": file.filename} |
||||
|
|
||||
|
|
||||
|
@router.post("/index-text") |
||||
|
async def rag_index_text( |
||||
|
request: Request, |
||||
|
payload: dict, |
||||
|
db: AsyncSession = Depends(get_db), |
||||
|
current_user=Depends(get_current_user), |
||||
|
): |
||||
|
text = payload.get("text", "") |
||||
|
source = payload.get("source", "manual") |
||||
|
if not text: |
||||
|
return {"code": 400, "message": "文本内容不能为空"} |
||||
|
result = await add_text(text, source) |
||||
|
return {"code": 200, "message": result} |
||||
|
|
||||
|
|
||||
|
@router.get("/search") |
||||
|
async def rag_search( |
||||
|
request: Request, |
||||
|
q: str = "", |
||||
|
limit: int = 5, |
||||
|
db: AsyncSession = Depends(get_db), |
||||
|
current_user=Depends(get_current_user), |
||||
|
): |
||||
|
if not q: |
||||
|
return {"code": 400, "message": "查询内容不能为空"} |
||||
|
results = await search(q, limit=limit) |
||||
|
return {"code": 200, "data": results, "query": q} |
||||
|
|
||||
|
|
||||
|
@router.get("/retrieve") |
||||
|
async def rag_retrieve( |
||||
|
request: Request, |
||||
|
q: str = "", |
||||
|
limit: int = 5, |
||||
|
db: AsyncSession = Depends(get_db), |
||||
|
current_user=Depends(get_current_user), |
||||
|
): |
||||
|
if not q: |
||||
|
return {"code": 400, "message": "查询内容不能为空"} |
||||
|
result = await retrieve_for_agent(q, limit=limit) |
||||
|
return {"code": 200, "data": result} |
||||
@ -0,0 +1,29 @@ |
|||||
|
<template> |
||||
|
<path |
||||
|
:d="`M0,0 C${offset},0 ${targetX - sourceX - offset},${targetY - sourceY} ${targetX - sourceX},${targetY - sourceY}`" |
||||
|
class="flow-edge-path" |
||||
|
/> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { computed } from 'vue' |
||||
|
|
||||
|
const props = defineProps<{ |
||||
|
id: string |
||||
|
sourceX: number |
||||
|
sourceY: number |
||||
|
targetX: number |
||||
|
targetY: number |
||||
|
}>() |
||||
|
|
||||
|
const offset = computed(() => Math.abs(props.targetX - props.sourceX) * 0.5) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.flow-edge-path { |
||||
|
fill: none; |
||||
|
stroke: #b1b1b7; |
||||
|
stroke-width: 2; |
||||
|
stroke-linecap: round; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,91 @@ |
|||||
|
<template> |
||||
|
<div :class="['flow-node', 'node-type-' + (data?.type || ''), { selected }]"> |
||||
|
<div class="node-header" :style="{ backgroundColor: data?.color || '#409EFF' }"> |
||||
|
{{ data?.label || id }} |
||||
|
</div> |
||||
|
<div class="node-body"> |
||||
|
<p class="node-type-label">{{ data?.typeDesc || '' }}</p> |
||||
|
</div> |
||||
|
<button class="node-delete-btn" @click="$emit('delete')" title="删除">×</button> |
||||
|
|
||||
|
<Handle type="target" :position="Position.Top" class="node-handle" /> |
||||
|
<Handle type="source" :position="Position.Bottom" class="node-handle" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { Handle, Position } from '@vue-flow/core' |
||||
|
|
||||
|
defineProps<{ |
||||
|
id: string |
||||
|
data?: any |
||||
|
selected?: boolean |
||||
|
}>() |
||||
|
|
||||
|
defineEmits<{ |
||||
|
delete: [] |
||||
|
}>() |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.flow-node { |
||||
|
border: 2px solid #e4e7ed; |
||||
|
border-radius: 8px; |
||||
|
background: #fff; |
||||
|
min-width: 160px; |
||||
|
position: relative; |
||||
|
transition: box-shadow 0.2s, border-color 0.2s; |
||||
|
} |
||||
|
.flow-node.selected { |
||||
|
border-color: #409EFF; |
||||
|
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25); |
||||
|
} |
||||
|
.flow-node:hover { |
||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); |
||||
|
} |
||||
|
.node-header { |
||||
|
padding: 8px 14px; |
||||
|
border-radius: 6px 6px 0 0; |
||||
|
color: #fff; |
||||
|
font-size: 13px; |
||||
|
font-weight: 600; |
||||
|
white-space: nowrap; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
} |
||||
|
.node-body { |
||||
|
padding: 6px 14px 10px; |
||||
|
} |
||||
|
.node-type-label { |
||||
|
margin: 0; |
||||
|
font-size: 11px; |
||||
|
color: #909399; |
||||
|
} |
||||
|
.node-delete-btn { |
||||
|
position: absolute; |
||||
|
top: -10px; |
||||
|
right: -10px; |
||||
|
width: 20px; |
||||
|
height: 20px; |
||||
|
border-radius: 50%; |
||||
|
background: #F56C6C; |
||||
|
color: #fff; |
||||
|
border: none; |
||||
|
font-size: 14px; |
||||
|
line-height: 1; |
||||
|
cursor: pointer; |
||||
|
display: none; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
.flow-node:hover .node-delete-btn { |
||||
|
display: flex; |
||||
|
} |
||||
|
.node-handle { |
||||
|
width: 10px; |
||||
|
height: 10px; |
||||
|
background: #409EFF; |
||||
|
border: 2px solid #fff; |
||||
|
border-radius: 50%; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,176 @@ |
|||||
|
<template> |
||||
|
<div class="settings-page"> |
||||
|
<el-page-header @back="$router.back()" content="系统配置" /> |
||||
|
|
||||
|
<el-card style="margin-top: 20px"> |
||||
|
<template #header>LLM 模型配置</template> |
||||
|
<el-form :model="form" label-width="140px" style="max-width: 600px"> |
||||
|
<el-form-item label="API 地址"> |
||||
|
<el-input v-model="form.api_base" placeholder="https://api.openai.com/v1" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="API Key"> |
||||
|
<el-input v-model="form.api_key" type="password" show-password placeholder="sk-..." /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="默认模型"> |
||||
|
<el-select v-model="form.model" style="width: 100%"> |
||||
|
<el-option label="GPT-4o-mini" value="gpt-4o-mini" /> |
||||
|
<el-option label="GPT-4o" value="gpt-4o" /> |
||||
|
<el-option label="GPT-4-turbo" value="gpt-4-turbo" /> |
||||
|
<el-option label="GPT-3.5-turbo" value="gpt-3.5-turbo" /> |
||||
|
<el-option label="DeepSeek-V3" value="deepseek-chat" /> |
||||
|
<el-option label="DeepSeek-R1" value="deepseek-reasoner" /> |
||||
|
<el-option label="Qwen-Max" value="qwen-max" /> |
||||
|
<el-option label="自定义" value="__custom__" /> |
||||
|
</el-select> |
||||
|
</el-form-item> |
||||
|
<el-form-item v-if="form.model === '__custom__'" label="自定义模型名"> |
||||
|
<el-input v-model="form.custom_model" placeholder="请输入模型名称" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="Max Tokens"> |
||||
|
<el-input-number v-model="form.max_tokens" :min="100" :max="128000" :step="100" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="温度 (创建任务)"> |
||||
|
<el-slider v-model="form.temperature_task" :min="0" :max="2" :step="0.1" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="温度 (企业分析)"> |
||||
|
<el-slider v-model="form.temperature_analysis" :min="0" :max="2" :step="0.1" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item> |
||||
|
<el-button type="primary" @click="saveConfig" :loading="saving">保存配置</el-button> |
||||
|
<el-button @click="testConnection" :loading="testing">测试连接</el-button> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-card style="margin-top: 20px"> |
||||
|
<template #header>企微配置</template> |
||||
|
<el-form :model="form" label-width="140px" style="max-width: 600px"> |
||||
|
<el-form-item label="Corp ID"> |
||||
|
<el-input v-model="form.wecom_corp_id" placeholder="企业微信 Corp ID" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="App Secret"> |
||||
|
<el-input v-model="form.wecom_app_secret" type="password" show-password /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="Agent ID"> |
||||
|
<el-input-number v-model="form.wecom_agent_id" :min="0" :max="9999999" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="Token"> |
||||
|
<el-input v-model="form.wecom_token" placeholder="回调验证 Token" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item label="Encoding AES Key"> |
||||
|
<el-input v-model="form.wecom_encoding_aes" placeholder="回调加密 Key" /> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
</el-card> |
||||
|
|
||||
|
<el-card style="margin-top: 20px"> |
||||
|
<template #header>RAG 知识库</template> |
||||
|
<div style="display: flex; gap: 16px; align-items: flex-start"> |
||||
|
<el-upload |
||||
|
:http-request="uploadDoc" |
||||
|
:show-file-list="false" |
||||
|
accept=".pdf,.docx,.doc,.xlsx,.xls,.txt,.md" |
||||
|
> |
||||
|
<el-button type="primary">上传文档到知识库</el-button> |
||||
|
</el-upload> |
||||
|
<el-input |
||||
|
v-model="ragQuery" |
||||
|
placeholder="输入问题检索知识库" |
||||
|
style="width: 300px" |
||||
|
@keyup.enter="searchRag" |
||||
|
/> |
||||
|
<el-button @click="searchRag" :loading="ragSearching">检索</el-button> |
||||
|
</div> |
||||
|
<div v-if="ragResults.length > 0" style="margin-top: 16px"> |
||||
|
<div v-for="(r, i) in ragResults" :key="i" style="padding: 8px 0; border-bottom: 1px solid #ebeef5"> |
||||
|
<div style="font-size: 12px; color: #909399">相关度: {{ (r.score * 100).toFixed(1) }}% | 来源: {{ r.source }}</div> |
||||
|
<div style="margin-top: 4px; font-size: 13px">{{ r.content }}</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</el-card> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, reactive } from 'vue' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import { systemApi, ragApi } from '@/api' |
||||
|
import api from '@/api' |
||||
|
|
||||
|
const saving = ref(false) |
||||
|
const testing = ref(false) |
||||
|
const ragQuery = ref('') |
||||
|
const ragSearching = ref(false) |
||||
|
const ragResults = ref<any[]>([]) |
||||
|
|
||||
|
const form = reactive({ |
||||
|
api_base: '', |
||||
|
api_key: '', |
||||
|
model: 'gpt-4o-mini', |
||||
|
custom_model: '', |
||||
|
max_tokens: 4096, |
||||
|
temperature_task: 0.7, |
||||
|
temperature_analysis: 0.5, |
||||
|
wecom_corp_id: '', |
||||
|
wecom_app_secret: '', |
||||
|
wecom_agent_id: 0, |
||||
|
wecom_token: '', |
||||
|
wecom_encoding_aes: '', |
||||
|
}) |
||||
|
|
||||
|
async function saveConfig() { |
||||
|
saving.value = true |
||||
|
try { |
||||
|
const finalModel = form.model === '__custom__' ? form.custom_model : form.model |
||||
|
await systemApi.health() |
||||
|
ElMessage.success('配置保存成功') |
||||
|
localStorage.setItem('llm_config', JSON.stringify({ |
||||
|
api_base: form.api_base, |
||||
|
api_key: form.api_key, |
||||
|
model: finalModel, |
||||
|
max_tokens: form.max_tokens, |
||||
|
temperature_task: form.temperature_task, |
||||
|
temperature_analysis: form.temperature_analysis, |
||||
|
})) |
||||
|
} catch { ElMessage.warning('保存失败') } |
||||
|
finally { saving.value = false } |
||||
|
} |
||||
|
|
||||
|
async function testConnection() { |
||||
|
testing.value = true |
||||
|
try { |
||||
|
await systemApi.health() |
||||
|
ElMessage.success('服务连接正常') |
||||
|
} catch { |
||||
|
ElMessage.error('服务连接失败') |
||||
|
} |
||||
|
testing.value = false |
||||
|
} |
||||
|
|
||||
|
async function uploadDoc(options: any) { |
||||
|
try { |
||||
|
await ragApi.upload(options.file) |
||||
|
ElMessage.success('文档已上传到知识库') |
||||
|
} catch { |
||||
|
ElMessage.error('上传失败') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function searchRag() { |
||||
|
if (!ragQuery.value) return |
||||
|
ragSearching.value = true |
||||
|
try { |
||||
|
const res: any = await ragApi.search(ragQuery.value, 5) |
||||
|
ragResults.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []) |
||||
|
} finally { |
||||
|
ragSearching.value = false |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.settings-page { |
||||
|
max-width: 900px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
</style> |
||||
Binary file not shown.
Loading…
Reference in new issue