Compare commits
34 Commits
main
...
feature/pr
| Author | SHA1 | Date |
|---|---|---|
|
|
714dc8c480 | |
|
|
99c17d57b1 | |
|
|
307a5b4ef4 | |
|
|
ad7000efd4 | |
|
|
2e428aa1cc | |
|
|
fcc8869fea | |
|
|
2972c5174e | |
|
|
ee220b0b10 | |
|
|
6b6b7fb5e7 | |
|
|
9739aa2016 | |
|
|
83bc1d0830 | |
|
|
c4ad6eb8ce | |
|
|
d3ae92dec5 | |
|
|
c432f457b8 | |
|
|
e179abd0e5 | |
|
|
e10cbc2321 | |
|
|
d30043e5e3 | |
|
|
aa02ab79d2 | |
|
|
6b21ba8351 | |
|
|
5fbb72aa82 | |
|
|
3cf7d02daf | |
|
|
c005066162 | |
|
|
e4dbcda150 | |
|
|
c06e0dd15c | |
|
|
7ac00389c7 | |
|
|
c1f5d3229f | |
|
|
932d4d15ab | |
|
|
d4b0bc3101 | |
|
|
fba9b9313c | |
|
|
f80f8f72bf | |
|
|
8c259cee30 | |
|
|
9d8ecf0bb2 | |
|
|
ff35538a01 | |
|
|
eb93636227 |
|
|
@ -0,0 +1,150 @@
|
|||
# v0.7.0 窗口1:意图规则 + Prompt 模板 - 进度文档
|
||||
|
||||
## 1. 任务概述
|
||||
|
||||
实现 v0.7.0 迭代中**意图规则**和 **Prompt 模板**的测试与监控功能,包括前端页面和后端 API。
|
||||
|
||||
## 2. 需求文档引用
|
||||
|
||||
- spec/ai-service-admin/requirements.md - 第10节(v0.7.0),AC-ASA-53 ~ AC-ASA-58
|
||||
- spec/ai-service/requirements.md - 第13节(v0.7.0),AC-AISVC-96 ~ AC-AISVC-100
|
||||
|
||||
## 3. 总体进度
|
||||
|
||||
- [x] 后端任务(4个)
|
||||
- [x] T16.13-T16.14: 意图规则测试 API
|
||||
- [x] T16.15-T16.17: 意图规则监控 API
|
||||
- [x] T16.18-T16.19: Prompt 模板预览 API
|
||||
- [x] T16.20-T16.21: Prompt 模板监控 API
|
||||
- [x] 前端任务(5个)
|
||||
- [x] P13-09: 规则测试对话框
|
||||
- [x] P13-10-P13-11: 意图规则监控页面
|
||||
- [x] P13-12: 模板预览对话框
|
||||
- [x] P13-13: Prompt 模板监控页面
|
||||
- [x] P13-01: API 服务层
|
||||
|
||||
## 4. Phase 详细进度
|
||||
|
||||
### Phase 1: 后端 API 实现
|
||||
|
||||
#### 4.1 意图规则测试 API (T16.13-T16.14)
|
||||
- 文件:`ai-service/app/services/intent/tester.py`(新建)✅
|
||||
- API:`POST /admin/intent-rules/{ruleId}/test` ✅
|
||||
- 状态:**已完成**
|
||||
|
||||
#### 4.2 意图规则监控 API (T16.15-T16.17)
|
||||
- 文件:`ai-service/app/services/monitoring/intent_monitor.py`(新建)✅
|
||||
- 文件:`ai-service/app/api/admin/monitoring.py`(新建)✅
|
||||
- API:
|
||||
- `GET /admin/monitoring/intent-rules` ✅
|
||||
- `GET /admin/monitoring/intent-rules/{ruleId}/hits` ✅
|
||||
- 状态:**已完成**
|
||||
|
||||
#### 4.3 Prompt 模板预览 API (T16.18-T16.19)
|
||||
- 文件:`ai-service/app/services/monitoring/prompt_monitor.py`(新建)✅
|
||||
- API:`POST /admin/prompt-templates/{tplId}/preview` ✅
|
||||
- 状态:**已完成**
|
||||
|
||||
#### 4.4 Prompt 模板监控 API (T16.20-T16.21)
|
||||
- API:`GET /admin/monitoring/prompt-templates` ✅
|
||||
- 状态:**已完成**
|
||||
|
||||
### Phase 2: 前端实现
|
||||
|
||||
#### 4.5 API 服务层 (P13-01)
|
||||
- 文件:`ai-service-admin/src/api/monitoring.ts`(更新)✅
|
||||
- 状态:**已完成**
|
||||
|
||||
#### 4.6 意图规则测试对话框 (P13-09)
|
||||
- 文件:`ai-service-admin/src/views/admin/intent-rule/components/TestDialog.vue`(新建)✅
|
||||
- 状态:**已完成**
|
||||
|
||||
#### 4.7 意图规则监控页面 (P13-10-P13-11)
|
||||
- 文件:`ai-service-admin/src/views/admin/monitoring/IntentRules.vue`(新建)✅
|
||||
- 路由:`/admin/monitoring/intent-rules` ✅
|
||||
- 状态:**已完成**
|
||||
|
||||
#### 4.8 Prompt 模板预览对话框 (P13-12)
|
||||
- 文件:`ai-service-admin/src/views/admin/prompt-template/components/PreviewDialog.vue`(新建)✅
|
||||
- 状态:**已完成**
|
||||
|
||||
#### 4.9 Prompt 模板监控页面 (P13-13)
|
||||
- 文件:`ai-service-admin/src/views/admin/monitoring/PromptTemplates.vue`(新建)✅
|
||||
- 路由:`/admin/monitoring/prompt-templates` ✅
|
||||
- 状态:**已完成**
|
||||
|
||||
## 5. 技术上下文
|
||||
|
||||
### 项目结构
|
||||
- **前端**:`ai-service-admin/` - Vue 3 + Element Plus + TypeScript
|
||||
- **后端**:`ai-service/` - Python FastAPI + SQLModel + PostgreSQL
|
||||
|
||||
### 核心约定
|
||||
- 多租户隔离:所有 API 必须通过 `X-Tenant-Id` header 获取租户 ID
|
||||
- 缓存策略:使用 Redis 缓存统计数据(TTL 60秒)
|
||||
- Token 计数:使用 `tiktoken` 库,编码器为 `cl100k_base`
|
||||
|
||||
### 新增文件清单
|
||||
|
||||
**后端文件**:
|
||||
1. `ai-service/app/services/intent/tester.py` - 意图规则测试服务
|
||||
2. `ai-service/app/services/monitoring/__init__.py` - 监控模块初始化
|
||||
3. `ai-service/app/services/monitoring/intent_monitor.py` - 意图规则监控服务
|
||||
4. `ai-service/app/services/monitoring/prompt_monitor.py` - Prompt 模板监控服务
|
||||
5. `ai-service/app/api/admin/monitoring.py` - 监控 API 路由
|
||||
|
||||
**前端文件**:
|
||||
1. `ai-service-admin/src/views/admin/intent-rule/components/TestDialog.vue` - 意图规则测试对话框
|
||||
2. `ai-service-admin/src/views/admin/monitoring/IntentRules.vue` - 意图规则监控页面
|
||||
3. `ai-service-admin/src/views/admin/monitoring/PromptTemplates.vue` - Prompt 模板监控页面
|
||||
4. `ai-service-admin/src/views/admin/prompt-template/components/PreviewDialog.vue` - Prompt 模板预览对话框
|
||||
|
||||
**修改文件**:
|
||||
1. `ai-service/app/api/admin/__init__.py` - 添加 monitoring_router 导出
|
||||
2. `ai-service/app/api/admin/intent_rules.py` - 添加测试 API 端点
|
||||
3. `ai-service/app/api/admin/prompt_templates.py` - 添加预览 API 端点
|
||||
4. `ai-service/app/main.py` - 注册监控路由
|
||||
5. `ai-service-admin/src/api/monitoring.ts` - 添加所有监控 API 函数
|
||||
6. `ai-service-admin/src/views/admin/intent-rule/index.vue` - 添加测试按钮
|
||||
7. `ai-service-admin/src/views/admin/prompt-template/index.vue` - 添加预览按钮
|
||||
8. `ai-service-admin/src/router/index.ts` - 添加监控页面路由
|
||||
|
||||
## 6. 会话历史
|
||||
|
||||
### 会话 1 (2026-02-27)
|
||||
- 完成:阅读必读文件,创建进度文档
|
||||
- 问题:无
|
||||
- 解决方案:无
|
||||
|
||||
### 会话 2 (2026-02-27)
|
||||
- 完成:实现所有后端 API 和前端页面
|
||||
- 问题:无
|
||||
- 解决方案:无
|
||||
|
||||
## 7. 下一步行动
|
||||
|
||||
**任务已完成**。建议进行以下验证:
|
||||
1. 启动后端服务,验证 API 端点可访问
|
||||
2. 启动前端服务,验证页面功能正常
|
||||
3. 进行端到端测试
|
||||
|
||||
## 8. 待解决问题
|
||||
|
||||
暂无
|
||||
|
||||
## 9. 最终验收标准
|
||||
|
||||
### 后端验收标准
|
||||
- [x] [AC-AISVC-96] 意图规则测试 API 返回匹配结果和冲突检测
|
||||
- [x] [AC-AISVC-97] 意图规则监控统计 API 返回规则命中统计
|
||||
- [x] [AC-AISVC-98] 规则命中记录 API 返回详细命中记录
|
||||
- [x] [AC-AISVC-99] Prompt 模板预览 API 返回渲染结果和 Token 统计
|
||||
- [x] [AC-AISVC-100] Prompt 模板监控统计 API 返回使用统计
|
||||
|
||||
### 前端验收标准
|
||||
- [x] [AC-ASA-53] 意图规则测试对话框支持输入测试消息并展示结果
|
||||
- [x] [AC-ASA-54] 意图规则监控页面展示规则命中统计表格
|
||||
- [x] [AC-ASA-55] 点击规则行展示详细命中记录
|
||||
- [x] [AC-ASA-56] Prompt 模板预览对话框展示渲染结果
|
||||
- [x] [AC-ASA-57] 修改变量值实时更新渲染结果
|
||||
- [x] [AC-ASA-58] Prompt 模板监控页面展示使用统计
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
# v0.7.0 窗口2:话术流程 + 输出护栏 - 进度文档
|
||||
|
||||
## 1. 任务概述
|
||||
实现 v0.7.0 迭代中话术流程和输出护栏的测试与监控功能,包括前端页面和后端 API。
|
||||
|
||||
## 2. 需求文档引用
|
||||
- spec/ai-service-admin/requirements.md - 第10节(v0.7.0),AC-ASA-59 ~ AC-ASA-64
|
||||
- spec/ai-service/requirements.md - 第13节(v0.7.0),AC-AISVC-101 ~ AC-AISVC-107
|
||||
|
||||
## 3. 总体进度
|
||||
- [x] 后端任务(4个)
|
||||
- [x] T16.22-T16.24: 话术流程模拟测试 API
|
||||
- [x] T16.25-T16.27: 话术流程监控 API
|
||||
- [x] T16.28-T16.29: 输出护栏测试 API
|
||||
- [x] T16.30-T16.32: 输出护栏监控 API
|
||||
- [x] 前端任务(5个)
|
||||
- [x] P13-14: 流程模拟对话框
|
||||
- [x] P13-15-P13-16: 话术流程监控页面
|
||||
- [x] P13-17: 护栏测试对话框
|
||||
- [x] P13-18-P13-19: 输出护栏监控页面
|
||||
- [x] P13-01: API 服务层扩展
|
||||
|
||||
## 4. Phase 详细进度
|
||||
|
||||
### Phase 1: 话术流程模拟测试 API (T16.22-T16.24)
|
||||
**状态**: 已完成
|
||||
**文件修改记录**:
|
||||
- 新建: ai-service/app/services/flow/tester.py - ScriptFlowTester 类
|
||||
- 修改: ai-service/app/api/admin/script_flows.py - 添加 POST /{flowId}/simulate 端点
|
||||
|
||||
### Phase 2: 话术流程监控 API (T16.25-T16.27)
|
||||
**状态**: 已完成
|
||||
**文件修改记录**:
|
||||
- 新建: ai-service/app/services/monitoring/flow_monitor.py - FlowMonitor 类
|
||||
- 修改: ai-service/app/api/admin/monitoring.py - 添加 GET /script-flows 和 GET /script-flows/{flowId}/executions 端点
|
||||
|
||||
### Phase 3: 输出护栏测试 API (T16.28-T16.29)
|
||||
**状态**: 已完成
|
||||
**文件修改记录**:
|
||||
- 新建: ai-service/app/services/guardrail/tester.py - GuardrailTester 类
|
||||
- 修改: ai-service/app/api/admin/guardrails.py - 添加 POST /test 端点
|
||||
|
||||
### Phase 4: 输出护栏监控 API (T16.30-T16.32)
|
||||
**状态**: 已完成
|
||||
**文件修改记录**:
|
||||
- 新建: ai-service/app/services/monitoring/guardrail_monitor.py - GuardrailMonitor 类
|
||||
- 修改: ai-service/app/api/admin/monitoring.py - 添加 GET /guardrails 和 GET /guardrails/{wordId}/blocks 端点
|
||||
- 修改: ai-service/app/services/monitoring/__init__.py - 导出新模块
|
||||
|
||||
### Phase 5: 前端实现 (P13-14 ~ P13-19, P13-01)
|
||||
**状态**: 已完成
|
||||
**文件修改记录**:
|
||||
- 新建: ai-service-admin/src/views/admin/script-flow/components/SimulateDialog.vue
|
||||
- 新建: ai-service-admin/src/views/admin/monitoring/ScriptFlows.vue
|
||||
- 新建: ai-service-admin/src/views/admin/guardrail/components/TestDialog.vue
|
||||
- 新建: ai-service-admin/src/views/admin/monitoring/Guardrails.vue
|
||||
- 扩展: ai-service-admin/src/api/monitoring.ts - 添加流程和护栏监控 API
|
||||
- 扩展: ai-service-admin/src/api/script-flow.ts - 添加流程模拟 API
|
||||
- 扩展: ai-service-admin/src/api/guardrail.ts - 添加护栏测试 API
|
||||
- 修改: ai-service-admin/src/views/admin/script-flow/index.vue - 添加模拟按钮
|
||||
- 修改: ai-service-admin/src/views/admin/guardrail/components/ForbiddenWordsTab.vue - 添加测试按钮
|
||||
- 修改: ai-service-admin/src/router/index.ts - 添加监控页面路由
|
||||
|
||||
## 5. 技术上下文
|
||||
|
||||
### 项目结构
|
||||
- **前端**: `ai-service-admin/` - Vue 3 + Element Plus + TypeScript
|
||||
- **后端**: `ai-service/` - Python FastAPI + SQLModel + PostgreSQL
|
||||
|
||||
### 核心约定
|
||||
- 所有 API 必须支持多租户隔离(`tenant_id` 参数)
|
||||
- 流程模拟不应修改数据库状态(只读操作)
|
||||
- 护栏测试应复用现有的 `OutputFilter` 逻辑
|
||||
- 监控数据异步更新,不阻塞主流程
|
||||
|
||||
### 关键代码示例
|
||||
- 流程引擎: `app/services/flow/engine.py` - `_match_next_step()` 方法
|
||||
- 护栏过滤: `app/services/guardrail/output_filter.py` - `filter()` 方法
|
||||
- 禁词服务: `app/services/guardrail/word_service.py` - `get_enabled_words_for_filtering()` 方法
|
||||
|
||||
### 模块依赖
|
||||
- FlowEngine: 流程状态机引擎
|
||||
- OutputFilter: 输出护栏过滤器
|
||||
- ForbiddenWordService: 禁词管理服务
|
||||
- ScriptFlowService: 话术流程管理服务
|
||||
|
||||
## 6. 会话历史
|
||||
### 会话 1 (2026-02-27)
|
||||
- 完成:所有后端 API 和前端页面实现
|
||||
- 问题:无
|
||||
- 解决方案:无
|
||||
|
||||
## 7. 下一步行动
|
||||
**任务已完成**
|
||||
|
||||
## 8. 待解决问题
|
||||
暂无
|
||||
|
||||
## 9. 最终验收标准
|
||||
- [x] [AC-AISVC-101] 流程模拟测试接口返回完整的模拟执行结果
|
||||
- [x] [AC-AISVC-102] 流程模拟支持覆盖率分析和问题检测
|
||||
- [x] [AC-AISVC-103] 流程监控统计接口返回激活次数、完成率等统计
|
||||
- [x] [AC-AISVC-104] 流程执行记录接口支持分页查询
|
||||
- [x] [AC-AISVC-105] 护栏测试接口返回详细的检测结果
|
||||
- [x] [AC-AISVC-106] 护栏监控统计接口返回拦截次数等统计
|
||||
- [x] [AC-AISVC-107] 禁词拦截记录接口支持分页查询
|
||||
- [x] [AC-ASA-59] 流程模拟对话框支持步骤可视化
|
||||
- [x] [AC-ASA-60] 话术流程监控页面展示流程激活统计
|
||||
- [x] [AC-ASA-61] 流程执行记录详情弹窗支持分页
|
||||
- [x] [AC-ASA-62] 护栏测试对话框展示禁词检测结果
|
||||
- [x] [AC-ASA-63] 输出护栏监控页面展示护栏拦截统计
|
||||
- [x] [AC-ASA-64] 护栏拦截记录详情弹窗支持分页
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
# v0.7.0 窗口3:Dashboard + 流程测试 + 对话追踪 - 进度文档
|
||||
|
||||
## 1. 任务概述
|
||||
|
||||
实现 v0.7.0 迭代中的**核心监控基础设施**,包括 Dashboard 统计增强、完整流程测试台(12步执行链路)、对话追踪与导出功能。这是整个监控系统的核心支撑。
|
||||
|
||||
## 2. 需求文档引用
|
||||
|
||||
- spec/ai-service-admin/requirements.md - 第10节(v0.7.0),AC-ASA-45 ~ AC-ASA-52, AC-ASA-65 ~ AC-ASA-68
|
||||
- spec/ai-service/requirements.md - 第13节(v0.7.0),AC-AISVC-91 ~ AC-AISVC-95, AC-AISVC-108 ~ AC-AISVC-110
|
||||
|
||||
## 3. 总体进度
|
||||
|
||||
- [x] 后端任务(6个)
|
||||
- [x] T16.1-T16.5: 监控数据模型与基础设施
|
||||
- [x] T16.6-T16.8: Dashboard 统计增强
|
||||
- [x] T16.9-T16.12: Orchestrator 监控增强
|
||||
- [x] T16.33-T16.36: 对话追踪服务
|
||||
- [x] T16.37-T16.39: 对话导出服务
|
||||
- [x] 前端任务(4个)
|
||||
- [x] P13-02-P13-04: Dashboard 统计卡片增强
|
||||
- [x] P13-05-P13-08: 完整流程测试台
|
||||
- [x] P13-20-P13-23: 对话追踪页面
|
||||
- [x] P13-24-P13-25: 监控导航菜单
|
||||
|
||||
## 4. Phase 详细进度
|
||||
|
||||
### Phase 1: 监控数据模型与基础设施 (T16.1-T16.5)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 修改: ai-service/app/models/entities.py
|
||||
- 扩展 ChatMessage 实体,新增监控字段: prompt_template_id, intent_rule_id, flow_instance_id, guardrail_triggered, guardrail_words
|
||||
- 新增 FlowTestRecord 实体(流程测试记录)
|
||||
- 新增 FlowTestStepResult 模型
|
||||
- 新增 ExportTask 实体(导出任务)
|
||||
- 新增 ExportTaskStatus 枚举
|
||||
- 新增 ConversationDetail 模型
|
||||
|
||||
### Phase 2: Redis 统计缓存层 (T16.2)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 创建: ai-service/app/services/monitoring/cache.py
|
||||
- MonitoringCache 类:Redis 缓存层
|
||||
- incr_counter: 原子计数器
|
||||
- get/set_dashboard_stats: Dashboard 缓存
|
||||
- add_to_leaderboard/get_leaderboard: 排行榜
|
||||
- ✅ 修改: ai-service/app/core/config.py
|
||||
- 新增 redis_url, redis_enabled, dashboard_cache_ttl, stats_counter_ttl 配置
|
||||
- ✅ 修改: ai-service/pyproject.toml
|
||||
- 新增 redis>=5.0.0 依赖
|
||||
|
||||
### Phase 3: Dashboard 统计增强 (T16.6-T16.8)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 创建: ai-service/app/services/monitoring/dashboard_service.py
|
||||
- DashboardService 类
|
||||
- get_enhanced_stats: 获取增强统计
|
||||
- _get_intent_rule_stats: 意图规则统计
|
||||
- _get_template_stats: 模板使用统计
|
||||
- _get_flow_stats: 流程激活统计
|
||||
- _get_guardrail_stats: 护栏拦截统计
|
||||
- ✅ 修改: ai-service/app/api/admin/dashboard.py
|
||||
- 扩展 GET /admin/dashboard/stats
|
||||
- 新增 start_date, end_date, include_enhanced 参数
|
||||
- 返回增强监控统计
|
||||
|
||||
### Phase 4: Orchestrator 监控增强 (T16.9-T16.12)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 创建: ai-service/app/services/monitoring/recorder.py
|
||||
- MonitoringRecorder 类:执行记录器
|
||||
- StepMetrics 数据类:步骤指标
|
||||
- start_step/end_step: 步骤计时
|
||||
- record_intent_hit: 记录意图命中
|
||||
- record_template_usage: 记录模板使用
|
||||
- record_flow_activation: 记录流程激活
|
||||
- record_guardrail_block: 记录护栏拦截
|
||||
- save_test_record: 保存测试记录
|
||||
|
||||
### Phase 5: 对话追踪服务 (T16.33-T16.36)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 修改: ai-service/app/api/admin/monitoring.py
|
||||
- GET /admin/monitoring/conversations: 对话列表
|
||||
- GET /admin/monitoring/conversations/{message_id}: 对话详情
|
||||
|
||||
### Phase 6: 对话导出服务 (T16.37-T16.39)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 修改: ai-service/app/api/admin/monitoring.py
|
||||
- POST /admin/monitoring/conversations/export: 创建导出任务
|
||||
- GET /admin/monitoring/conversations/export/{task_id}: 获取导出状态
|
||||
- GET /admin/monitoring/conversations/export/{task_id}/download: 下载导出文件
|
||||
|
||||
### Phase 7: 流程测试 API
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 创建: ai-service/app/api/admin/flow_test.py
|
||||
- POST /admin/test/flow-execution: 执行完整12步流程测试
|
||||
- GET /admin/test/flow-execution/{test_id}: 获取测试结果
|
||||
- GET /admin/test/flow-executions: 列出测试记录
|
||||
- POST /admin/test/compare: 对比测试
|
||||
- ✅ 修改: ai-service/app/api/admin/__init__.py
|
||||
- ✅ 修改: ai-service/app/main.py
|
||||
|
||||
### Phase 8: 数据库迁移
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 创建: ai-service/scripts/migrations/002_add_monitoring_fields.sql
|
||||
- chat_messages 表新增监控字段
|
||||
- 创建 flow_test_records 表
|
||||
- 创建 export_tasks 表
|
||||
- 创建相关索引
|
||||
|
||||
### Phase 9: 前端 Dashboard 统计卡片增强 (P13-02-P13-04)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 修改: ai-service-admin/src/api/dashboard.ts
|
||||
- 新增 DashboardStats 接口类型定义
|
||||
- 新增 IntentRuleStat, PromptTemplateStat, ScriptFlowStat, GuardrailWordStat 类型
|
||||
- 扩展 getDashboardStats 支持时间范围参数
|
||||
- ✅ 修改: ai-service-admin/src/views/dashboard/index.vue
|
||||
- 新增时间范围筛选器(日期选择器 + 快捷选项)
|
||||
- 新增四个监控统计卡片:意图规则命中、Prompt 模板、话术流程、护栏拦截
|
||||
- 卡片支持点击跳转到对应监控页面
|
||||
- 显示 Top 3 排行数据
|
||||
|
||||
### Phase 10: 前端完整流程测试台 (P13-05-P13-08)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 创建: ai-service-admin/src/api/flow-test.ts
|
||||
- FlowExecutionRequest/Response 接口
|
||||
- FlowExecutionStep 接口
|
||||
- executeFlowTest, getFlowTestResult, listFlowTests, compareFlowTest 函数
|
||||
- ✅ 修改: ai-service-admin/src/views/rag-lab/index.vue
|
||||
- 新增"完整流程测试"模式切换开关
|
||||
- 新增流程配置开关(意图识别、话术流程、RAG检索、输出护栏、上下文记忆)
|
||||
- 新增 12 步执行流程时间线展示
|
||||
- 支持步骤展开查看详细输入输出
|
||||
- 显示最终响应和置信度
|
||||
|
||||
### Phase 11: 前端对话追踪页面 (P13-20-P13-23)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 修改: ai-service-admin/src/api/monitoring.ts
|
||||
- 新增 ConversationItem, ConversationDetail 接口
|
||||
- 新增 ExportTaskResponse, ExportRequest 接口
|
||||
- 新增 listConversations, getConversationDetail 函数
|
||||
- 新增 createExportTask, getExportStatus, getExportDownloadUrl 函数
|
||||
- ✅ 创建: ai-service-admin/src/views/admin/monitoring/ConversationTracking.vue
|
||||
- 对话列表页面(支持会话ID、时间范围、流程、护栏筛选)
|
||||
- 对话详情抽屉(显示用户消息、AI回复、触发规则、使用模板、话术流程)
|
||||
- 执行链路时间线展示(12步流程详情)
|
||||
- 导出功能(支持 JSON/CSV 格式)
|
||||
|
||||
### Phase 12: 前端监控导航菜单 (P13-24-P13-25)
|
||||
**状态**: ✅ 已完成
|
||||
**文件修改记录**:
|
||||
- ✅ 修改: ai-service-admin/src/router/index.ts
|
||||
- 新增 /admin/monitoring/conversations 路由
|
||||
|
||||
## 5. 技术上下文
|
||||
|
||||
### 项目结构
|
||||
- **前端**: `ai-service-admin/` - Vue 3 + Element Plus + TypeScript
|
||||
- **后端**: `ai-service/` - Python FastAPI + SQLModel + PostgreSQL + Redis
|
||||
|
||||
### 核心约定
|
||||
- 多租户隔离: 所有数据访问必须带 tenant_id 过滤
|
||||
- 实体使用 SQLModel 定义,支持 Pydantic 验证
|
||||
- API 使用 FastAPI Router 组织
|
||||
|
||||
### 新增依赖
|
||||
- redis>=5.0.0: Redis 异步客户端
|
||||
|
||||
## 6. 会话历史
|
||||
|
||||
### 会话 1 (2026-02-27)
|
||||
- 完成: 阅读需求文档和设计文档,创建进度文档
|
||||
- 完成: 所有后端任务实现
|
||||
- 问题: metadata 字段名与 SQLModel 父类冲突
|
||||
- 解决方案: 重命名为 step_metadata
|
||||
|
||||
### 会话 2 (2026-02-27)
|
||||
- 完成: 所有前端任务实现
|
||||
- Dashboard 统计卡片增强
|
||||
- RAG 实验室完整流程测试台
|
||||
- 对话追踪页面
|
||||
- 监控导航路由
|
||||
|
||||
### 会话 3 (2026-02-28) - 验收会话
|
||||
- 完成: 前端验收标准 AC-ASA-59 ~ AC-ASA-64 验收通过
|
||||
- AC-ASA-59: 流程模拟对话框 - 步骤可视化 ✅
|
||||
- AC-ASA-60: 话术流程监控页面 - 流程激活统计 ✅
|
||||
- AC-ASA-61: 流程执行记录详情弹窗 - 分页支持 ✅
|
||||
- AC-ASA-62: 护栏测试对话框 - 禁词检测结果 ✅
|
||||
- AC-ASA-63: 输出护栏监控页面 - 护栏拦截统计 ✅
|
||||
- AC-ASA-64: 护栏拦截记录详情弹窗 - 分页支持 ✅
|
||||
- 完成: 后端验收标准 AC-AISVC-91 ~ AC-AISVC-95, AC-AISVC-108 ~ AC-AISVC-110 验收通过
|
||||
- AC-AISVC-91/92: Dashboard统计增强 - 四个监控统计卡片+时间筛选 ✅
|
||||
- AC-AISVC-93/94/95: 完整流程测试 - 12步执行时间线+步骤详情 ✅
|
||||
- AC-AISVC-108/109/110: 对话追踪 - 列表+详情+导出 ✅
|
||||
- 修复问题:
|
||||
- flow_test.py 导入错误: Orchestrator → OrchestratorService
|
||||
- flow_test.py ChatRequest 导入路径修正
|
||||
- flow_test.py ChatResponse.sources 属性不存在
|
||||
- 数据库迁移: 创建 add_chat_message_fields.py 添加缺失字段
|
||||
- 前后端字段不一致: order → step_no, wait_for_input → wait_input
|
||||
|
||||
## 7. 下一步行动
|
||||
|
||||
**任务已全部完成**
|
||||
|
||||
## 8. 待解决问题
|
||||
|
||||
暂无
|
||||
|
||||
## 9. 最终验收标准
|
||||
|
||||
### Dashboard 统计增强 (AC-AISVC-91, AC-AISVC-92)
|
||||
- [x] GET /admin/dashboard/stats 返回意图规则命中率
|
||||
- [x] GET /admin/dashboard/stats 返回 Prompt 模板使用次数
|
||||
- [x] GET /admin/dashboard/stats 返回话术流程激活次数
|
||||
- [x] GET /admin/dashboard/stats 返回护栏拦截次数
|
||||
- [x] 支持时间范围筛选参数
|
||||
- [x] 前端展示四个监控统计卡片
|
||||
- [x] 前端支持时间范围筛选
|
||||
|
||||
### 完整流程测试 (AC-AISVC-93, AC-AISVC-94, AC-AISVC-95)
|
||||
- [x] POST /admin/test/flow-execution 执行完整12步流程
|
||||
- [x] 返回每一步的详细执行结果
|
||||
- [x] 支持对比测试
|
||||
- [x] 前端展示12步执行时间线
|
||||
- [x] 前端支持步骤详情展开
|
||||
|
||||
### 对话追踪 (AC-AISVC-108, AC-AISVC-109, AC-AISVC-110)
|
||||
- [x] GET /admin/monitoring/conversations 返回对话列表
|
||||
- [x] GET /admin/monitoring/conversations/{id} 返回执行链路详情
|
||||
- [x] POST /admin/monitoring/conversations/export 导出对话记录
|
||||
- [x] 前端对话列表页面
|
||||
- [x] 前端对话详情展示
|
||||
- [x] 前端导出功能(JSON/CSV)
|
||||
|
|
@ -165,3 +165,5 @@ ai-service/uploads/
|
|||
ai-service/config/
|
||||
*.local
|
||||
|
||||
/.trae/
|
||||
/.claude/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,511 @@
|
|||
# AI 中台对接文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本文档描述 Python AI 中台对渠道侧(Java 主框架)暴露的 HTTP 接口规范,用于智能客服对话生成和服务健康检查。
|
||||
|
||||
### 1.1 服务信息
|
||||
|
||||
- **服务名称**: AI Service (Python AI 中台)
|
||||
- **服务地址**: `http://ai-service:8080`
|
||||
- **协议**: HTTP/1.1
|
||||
- **数据格式**: JSON / SSE (Server-Sent Events)
|
||||
- **字符编码**: UTF-8
|
||||
- **契约版本**: v1.1.0
|
||||
|
||||
### 1.2 核心能力
|
||||
|
||||
- ✅ 智能对话生成(基于 LLM + RAG)
|
||||
- ✅ 多租户隔离(基于 `X-Tenant-Id`)
|
||||
- ✅ 会话上下文管理(基于 `sessionId`)
|
||||
- ✅ 流式/非流式双模式输出
|
||||
- ✅ 置信度评估与转人工建议
|
||||
- ✅ 服务健康检查
|
||||
|
||||
---
|
||||
|
||||
## 2. 认证与租户隔离
|
||||
|
||||
### 2.1 API Key 认证(必填)
|
||||
|
||||
所有接口请求(除健康检查外)必须在 HTTP Header 中携带 API Key:
|
||||
|
||||
```http
|
||||
X-API-Key: <your_api_key>
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- API Key 用于身份认证和访问控制
|
||||
- 缺失或无效的 API Key 将返回 `401 Unauthorized`
|
||||
- API Key 由 AI 中台管理员分配,请妥善保管
|
||||
- 以下路径无需 API Key:`/health`、`/ai/health`、`/docs`
|
||||
|
||||
### 2.2 租户标识(必填)
|
||||
|
||||
所有接口请求必须在 HTTP Header 中携带租户 ID:
|
||||
|
||||
```http
|
||||
X-Tenant-Id: <tenant_id>
|
||||
```
|
||||
|
||||
**租户 ID 格式规范**:`name@ash@year`
|
||||
|
||||
示例:
|
||||
- `szmp@ash@2026` - 深圳某项目 2026 年
|
||||
- `abc123@ash@2025` - ABC 项目 2025 年
|
||||
|
||||
**说明**:
|
||||
- 租户 ID 用于数据隔离(知识库、会话历史、配置等)
|
||||
- 缺失或格式错误的租户 ID 将返回 `400 Bad Request`
|
||||
- 不同租户的数据完全隔离,不可跨租户访问
|
||||
- 租户不存在时会自动创建
|
||||
|
||||
---
|
||||
|
||||
## 3. 接口列表
|
||||
|
||||
| 接口路径 | 方法 | 功能 | 响应模式 |
|
||||
|---------|------|------|---------|
|
||||
| `/ai/chat` | POST | 生成 AI 回复 | JSON / SSE |
|
||||
| `/ai/health` | GET | 健康检查 | JSON |
|
||||
|
||||
---
|
||||
|
||||
## 4. 接口详细说明
|
||||
|
||||
### 4.1 生成 AI 回复
|
||||
|
||||
**接口路径**: `POST /ai/chat`
|
||||
|
||||
**功能描述**: 根据用户消息和会话历史生成 AI 回复,支持 RAG 检索增强、上下文管理、置信度评估。
|
||||
|
||||
#### 4.1.1 请求参数
|
||||
|
||||
**Headers**:
|
||||
```http
|
||||
Content-Type: application/json
|
||||
X-API-Key: <your_api_key>
|
||||
X-Tenant-Id: <tenant_id>
|
||||
Accept: application/json # 或 text/event-stream(流式输出)
|
||||
```
|
||||
|
||||
**Body** (JSON):
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| `sessionId` | string | ✅ | 会话 ID,用于关联同一会话的对话历史 |
|
||||
| `currentMessage` | string | ✅ | 当前用户消息内容 |
|
||||
| `channelType` | string | ✅ | 渠道类型,枚举值:`wechat`、`douyin`、`jd` |
|
||||
| `history` | array | ❌ | 历史消息列表(可选,AI 中台会自动管理会话历史) |
|
||||
| `metadata` | object | ❌ | 扩展元数据(可选) |
|
||||
|
||||
**history 数组元素结构**:
|
||||
```json
|
||||
{
|
||||
"role": "user | assistant",
|
||||
"content": "消息内容"
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例**:
|
||||
```json
|
||||
{
|
||||
"sessionId": "kf_001_wx123456_1708765432000",
|
||||
"currentMessage": "我想了解产品价格",
|
||||
"channelType": "wechat",
|
||||
"metadata": {
|
||||
"channelUserId": "wx123456",
|
||||
"extra": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.1.2 响应格式
|
||||
|
||||
##### 模式 1: JSON 响应(非流式)
|
||||
|
||||
**状态码**: `200 OK`
|
||||
|
||||
**响应体**:
|
||||
```json
|
||||
{
|
||||
"reply": "您好,我们的产品价格根据套餐不同有所差异...",
|
||||
"confidence": 0.92,
|
||||
"shouldTransfer": false,
|
||||
"transferReason": null,
|
||||
"metadata": {
|
||||
"retrieval_count": 3,
|
||||
"rag_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| `reply` | string | ✅ | AI 生成的回复内容 |
|
||||
| `confidence` | number | ✅ | 置信度评分(0.0-1.0),越高表示回答越可靠 |
|
||||
| `shouldTransfer` | boolean | ✅ | 是否建议转人工(true=建议转人工) |
|
||||
| `transferReason` | string | ❌ | 转人工原因(可选) |
|
||||
| `metadata` | object | ❌ | 响应元数据(可选) |
|
||||
|
||||
##### 模式 2: SSE 流式响应
|
||||
|
||||
**触发条件**: 请求头包含 `Accept: text/event-stream`
|
||||
|
||||
**响应头**:
|
||||
```http
|
||||
Content-Type: text/event-stream
|
||||
Cache-Control: no-cache
|
||||
Connection: keep-alive
|
||||
```
|
||||
|
||||
**事件流格式**:
|
||||
|
||||
1. **增量消息事件** (可多次发送)
|
||||
```
|
||||
event: message
|
||||
data: {"delta": "您好,"}
|
||||
|
||||
event: message
|
||||
data: {"delta": "我们的产品"}
|
||||
```
|
||||
|
||||
2. **最终结果事件** (发送一次后关闭连接)
|
||||
```
|
||||
event: final
|
||||
data: {"reply": "完整回复内容", "confidence": 0.92, "shouldTransfer": false}
|
||||
```
|
||||
|
||||
3. **错误事件** (发生错误时发送)
|
||||
```
|
||||
event: error
|
||||
data: {"code": "INTERNAL_ERROR", "message": "错误描述"}
|
||||
```
|
||||
|
||||
**事件序列保证**:
|
||||
- `message*` (0 或多次) → `final` (1 次) → 连接关闭
|
||||
- 或 `message*` (0 或多次) → `error` (1 次) → 连接关闭
|
||||
|
||||
#### 4.1.3 错误响应
|
||||
|
||||
**401 Unauthorized** - 认证失败
|
||||
```json
|
||||
{
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "Missing required header: X-API-Key",
|
||||
"details": []
|
||||
}
|
||||
```
|
||||
|
||||
**400 Bad Request** - 请求参数错误
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_REQUEST",
|
||||
"message": "缺少必填字段: sessionId",
|
||||
"details": []
|
||||
}
|
||||
```
|
||||
|
||||
**400 Bad Request** - 租户 ID 格式错误
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_TENANT_ID",
|
||||
"message": "Invalid tenant ID format. Expected: name@ash@year (e.g., szmp@ash@2026)",
|
||||
"details": []
|
||||
}
|
||||
```
|
||||
|
||||
**500 Internal Server Error** - 服务内部错误
|
||||
```json
|
||||
{
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "LLM 调用失败",
|
||||
"details": []
|
||||
}
|
||||
```
|
||||
|
||||
**503 Service Unavailable** - 服务不可用
|
||||
```json
|
||||
{
|
||||
"code": "SERVICE_UNAVAILABLE",
|
||||
"message": "向量数据库连接失败",
|
||||
"details": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 健康检查
|
||||
|
||||
**接口路径**: `GET /ai/health`
|
||||
|
||||
**功能描述**: 检查 AI 服务是否正常运行,用于服务监控和负载均衡健康探测。
|
||||
|
||||
#### 4.2.1 请求参数
|
||||
|
||||
无需请求参数,无需认证头。
|
||||
|
||||
#### 4.2.2 响应格式
|
||||
|
||||
**200 OK** - 服务正常
|
||||
```json
|
||||
{
|
||||
"status": "healthy"
|
||||
}
|
||||
```
|
||||
|
||||
**503 Service Unavailable** - 服务不健康
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 调用示例
|
||||
|
||||
### 5.1 Java 调用示例(非流式)
|
||||
|
||||
```java
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
public class AIServiceClient {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final String aiServiceUrl = "http://ai-service:8080";
|
||||
private final String apiKey = "your_api_key_here";
|
||||
|
||||
public ChatResponse generateReply(String tenantId, ChatRequest request) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-API-Key", apiKey);
|
||||
headers.set("X-Tenant-Id", tenantId);
|
||||
|
||||
HttpEntity<ChatRequest> entity = new HttpEntity<>(request, headers);
|
||||
|
||||
ResponseEntity<ChatResponse> response = restTemplate.postForEntity(
|
||||
aiServiceUrl + "/ai/chat",
|
||||
entity,
|
||||
ChatResponse.class
|
||||
);
|
||||
|
||||
return response.getBody();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Java 调用示例(流式)
|
||||
|
||||
```java
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public class AIServiceStreamClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
private final String apiKey = "your_api_key_here";
|
||||
|
||||
public Flux<ServerSentEvent<String>> generateReplyStream(
|
||||
String tenantId,
|
||||
ChatRequest request
|
||||
) {
|
||||
return webClient.post()
|
||||
.uri("/ai/chat")
|
||||
.header("X-API-Key", apiKey)
|
||||
.header("X-Tenant-Id", tenantId)
|
||||
.header("Accept", "text/event-stream")
|
||||
.bodyValue(request)
|
||||
.retrieve()
|
||||
.bodyToFlux(ServerSentEvent.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 cURL 调用示例
|
||||
|
||||
```bash
|
||||
# 非流式调用
|
||||
curl -X POST http://ai-service:8080/ai/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your_api_key_here" \
|
||||
-H "X-Tenant-Id: szmp@ash@2026" \
|
||||
-d '{
|
||||
"sessionId": "kf_001_wx123456_1708765432000",
|
||||
"currentMessage": "我想了解产品价格",
|
||||
"channelType": "wechat"
|
||||
}'
|
||||
|
||||
# 流式调用
|
||||
curl -X POST http://ai-service:8080/ai/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your_api_key_here" \
|
||||
-H "X-Tenant-Id: szmp@ash@2026" \
|
||||
-H "Accept: text/event-stream" \
|
||||
-d '{
|
||||
"sessionId": "kf_001_wx123456_1708765432000",
|
||||
"currentMessage": "我想了解产品价格",
|
||||
"channelType": "wechat"
|
||||
}'
|
||||
|
||||
# 健康检查(无需认证)
|
||||
curl http://ai-service:8080/ai/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 业务逻辑说明
|
||||
|
||||
### 6.1 会话管理
|
||||
|
||||
- **会话标识**: `sessionId` 用于唯一标识一个对话会话
|
||||
- **自动持久化**: AI 中台会自动保存会话历史,无需调用方每次传递完整历史
|
||||
- **可选历史**: 调用方可通过 `history` 字段提供外部历史,AI 中台会合并处理
|
||||
- **租户隔离**: 相同 `sessionId` 在不同 `tenantId` 下视为不同会话
|
||||
|
||||
### 6.2 RAG 检索增强
|
||||
|
||||
- **自动触发**: AI 中台会根据用户问题自动判断是否需要检索知识库
|
||||
- **多知识库**: 支持按知识库类型(产品知识、FAQ、话术模板等)分类检索
|
||||
- **置信度评估**: 检索结果质量会影响 `confidence` 评分
|
||||
- **兜底策略**: 检索失败或无结果时,AI 会基于通用知识回答并降低置信度
|
||||
|
||||
### 6.3 转人工建议
|
||||
|
||||
`shouldTransfer` 字段由以下因素决定:
|
||||
|
||||
- ✅ 置信度低于阈值(默认 0.6)
|
||||
- ✅ 检索无结果或结果质量差
|
||||
- ✅ 用户明确要求人工服务
|
||||
- ✅ 意图识别命中"转人工"规则
|
||||
|
||||
**注意**: `shouldTransfer=true` 仅为建议,最终是否转人工由调用方(Java 主框架)决策。
|
||||
|
||||
### 6.4 意图识别与规则引擎
|
||||
|
||||
- **前置处理**: 用户消息会先经过意图识别
|
||||
- **固定回复**: 命中固定规则时直接返回预设话术(跳过 LLM 调用)
|
||||
- **话术流程**: 命中流程规则时进入多轮引导对话
|
||||
- **定向检索**: 命中 RAG 规则时使用指定知识库检索
|
||||
|
||||
### 6.5 输出护栏
|
||||
|
||||
- **禁词过滤**: AI 回复会自动过滤禁词(竞品名称、敏感词等)
|
||||
- **替换策略**: 支持星号替换、文本替换、整条拦截三种策略
|
||||
- **行为约束**: Prompt 中注入行为规则(如"不承诺具体赔偿金额")
|
||||
|
||||
---
|
||||
|
||||
## 7. 性能与限制
|
||||
|
||||
### 7.1 性能指标
|
||||
|
||||
| 指标 | 非流式 | 流式 |
|
||||
|-----|-------|------|
|
||||
| 首字响应时间 | 1-3 秒 | 200-500 毫秒 |
|
||||
| 完整响应时间 | 2-5 秒 | 3-8 秒 |
|
||||
| 并发支持 | 100+ QPS | 50+ QPS |
|
||||
|
||||
### 7.2 限制说明
|
||||
|
||||
- **消息长度**: 单条消息最大 4000 字符
|
||||
- **历史长度**: 建议历史消息不超过 20 轮(AI 中台会自动截断)
|
||||
- **超时设置**: 建议调用方设置 10 秒超时(非流式)、30 秒超时(流式)
|
||||
- **重试策略**: 503 错误建议指数退避重试,500 错误建议降级处理
|
||||
|
||||
---
|
||||
|
||||
## 8. 错误码参考
|
||||
|
||||
| 错误码 | HTTP 状态码 | 说明 | 处理建议 |
|
||||
|-------|-----------|------|---------|
|
||||
| `UNAUTHORIZED` | 401 | 认证失败(缺少或无效 API Key) | 检查 X-API-Key 请求头 |
|
||||
| `INVALID_REQUEST` | 400 | 请求参数错误 | 检查必填字段和参数格式 |
|
||||
| `MISSING_TENANT_ID` | 400 | 缺少租户 ID | 添加 X-Tenant-Id 请求头 |
|
||||
| `INVALID_TENANT_ID` | 400 | 租户 ID 格式错误 | 使用正确格式:name@ash@year |
|
||||
| `INTERNAL_ERROR` | 500 | 服务内部错误 | 降级处理或重试 |
|
||||
| `LLM_ERROR` | 500 | LLM 调用失败 | 降级处理或重试 |
|
||||
| `SERVICE_UNAVAILABLE` | 503 | 服务不可用 | 指数退避重试 |
|
||||
| `QDRANT_ERROR` | 503 | 向量库不可用 | 指数退避重试 |
|
||||
| `STREAMING_ERROR` | 200 (SSE) | 流式传输错误 | 关闭连接并重试 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 最佳实践
|
||||
|
||||
### 9.1 API Key 管理
|
||||
|
||||
- API Key 由 AI 中台管理员通过管理后台分配
|
||||
- 建议为不同环境(开发/测试/生产)使用不同的 API Key
|
||||
- API Key 应存储在配置文件或环境变量中,不要硬编码
|
||||
- 定期轮换 API Key 以提高安全性
|
||||
|
||||
### 9.2 会话 ID 生成规范
|
||||
|
||||
建议格式: `{业务前缀}_{租户ID}_{渠道用户ID}_{时间戳}`
|
||||
|
||||
示例: `kf_001_wx123456_1708765432000`
|
||||
|
||||
### 9.3 流式 vs 非流式选择
|
||||
|
||||
- **流式**: 适用于 Web/App 实时对话场景,用户体验更好
|
||||
- **非流式**: 适用于批量处理、异步任务、API 集成场景
|
||||
|
||||
### 9.4 降级策略建议
|
||||
|
||||
```java
|
||||
public ChatResponse generateReplyWithFallback(String tenantId, ChatRequest request) {
|
||||
try {
|
||||
return aiServiceClient.generateReply(tenantId, request);
|
||||
} catch (ServiceUnavailableException e) {
|
||||
// 降级策略 1: 返回固定话术
|
||||
return ChatResponse.builder()
|
||||
.reply("抱歉,当前咨询量较大,请稍后再试或转人工服务。")
|
||||
.confidence(0.0)
|
||||
.shouldTransfer(true)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
// 降级策略 2: 直接转人工
|
||||
return ChatResponse.builder()
|
||||
.reply("系统繁忙,正在为您转接人工客服...")
|
||||
.confidence(0.0)
|
||||
.shouldTransfer(true)
|
||||
.transferReason("AI 服务异常")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.5 监控指标建议
|
||||
|
||||
- ✅ 接口响应时间(P50/P95/P99)
|
||||
- ✅ 接口成功率
|
||||
- ✅ 置信度分布
|
||||
- ✅ 转人工率
|
||||
- ✅ 错误码分布
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更日志
|
||||
|
||||
| 版本 | 日期 | 变更内容 |
|
||||
|-----|------|---------|
|
||||
| v1.1.0 | 2026-02-27 | 新增流式输出支持、意图识别、输出护栏 |
|
||||
| v1.0.0 | 2026-02-20 | 初始版本,支持基础对话生成和健康检查 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 联系方式
|
||||
|
||||
- **技术支持**: AI 中台开发团队
|
||||
- **问题反馈**: 提交 Issue 到项目仓库
|
||||
- **文档更新**: 参考 `spec/ai-service/openapi.provider.yaml`
|
||||
|
||||
---
|
||||
|
||||
**文档生成时间**: 2026-02-27
|
||||
**契约版本**: v1.1.0
|
||||
**维护状态**: ✅ 活跃维护
|
||||
|
|
@ -6,10 +6,15 @@
|
|||
- `spec/<module>/requirements.md`(当前模块需求)
|
||||
- `spec/<module>/openapi.provider.yaml`(本模块提供)
|
||||
- `spec/<module>/openapi.deps.yaml`(本模块依赖,如存在)
|
||||
- **版本化迭代规则**(CRITICAL):
|
||||
- 读取 `requirements.md` 的 frontmatter,识别 `active_version` 字段(如 `”0.6.0-0.7.0”`)
|
||||
- **仅关注活跃版本的 AC**:历史版本(折叠在 `<details>` 中)的 AC 可跳过,不影响当前实现
|
||||
- 在代码注释、测试用例、commit message 中引用的 AC 编号,必须在活跃版本范围内
|
||||
- 若需要追加新需求,按 `docs/spec-product-zh.md` 第 4 节”版本化迭代规则”执行
|
||||
- **长会话/复杂任务接续**:若当前任务满足 `docs/session-handoff-protocol.md` 中的触发条件,**必须**先读取并持续更新 `docs/progress/{module}-{feature}-progress.md`。
|
||||
- 若上述任一文档缺失、冲突或内容不明确:
|
||||
- **禁止开始实现**
|
||||
- 必须在 `spec/<module>/tasks.md` 记录“待澄清”并停止
|
||||
- 必须在 `spec/<module>/tasks.md` 记录”待澄清”并停止
|
||||
|
||||
## 1. 提交与同步(Git Cadence,必须)
|
||||
- **提交粒度**:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ ENV VITE_APP_BASE_API=$VITE_APP_BASE_API
|
|||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install && npm install @rollup/rollup-linux-x64-musl --save-optional
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Service Admin</title>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
"element-plus": "^2.6.1",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
"vue-router": "^4.3.0",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
|
|
@ -895,6 +896,12 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"license": "BSD-3-Clause",
|
||||
|
|
@ -1053,6 +1060,18 @@
|
|||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vuedraggable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sortablejs": "1.14.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,17 @@
|
|||
"element-plus": "^2.6.1",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
"vue-router": "^4.3.0",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"sass-embedded": "^1.77.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^5.1.4",
|
||||
"vue-tsc": "^2.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-musl": "^4.18.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#409EFF"/>
|
||||
<text x="16" y="22" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="white" text-anchor="middle">AI</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 255 B |
|
|
@ -17,7 +17,7 @@
|
|||
<el-icon><Odometer /></el-icon>
|
||||
<span>控制台</span>
|
||||
</router-link>
|
||||
<router-link to="/kb" class="nav-item" :class="{ active: isActive('/kb') }">
|
||||
<router-link to="/admin/knowledge-bases" class="nav-item" :class="{ active: isActive('/admin/knowledge-bases') }">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span>知识库</span>
|
||||
</router-link>
|
||||
|
|
@ -38,6 +38,26 @@
|
|||
<el-icon><ChatDotSquare /></el-icon>
|
||||
<span>LLM 配置</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/prompt-templates" class="nav-item" :class="{ active: isActive('/admin/prompt-templates') }">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>Prompt 模板</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/intent-rules" class="nav-item" :class="{ active: isActive('/admin/intent-rules') }">
|
||||
<el-icon><Aim /></el-icon>
|
||||
<span>意图规则</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/script-flows" class="nav-item" :class="{ active: isActive('/admin/script-flows') }">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>话术流程</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/guardrails" class="nav-item" :class="{ active: isActive('/admin/guardrails') }">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>输出护栏</span>
|
||||
</router-link>
|
||||
<router-link to="/admin/metadata-schemas" class="nav-item" :class="{ active: isActive('/admin/metadata-schemas') }">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>元数据配置</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -66,11 +86,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useTenantStore } from '@/stores/tenant'
|
||||
import { getTenantList, type Tenant } from '@/api/tenant'
|
||||
import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare } from '@element-plus/icons-vue'
|
||||
import { Odometer, FolderOpened, Cpu, Monitor, Connection, ChatDotSquare, Document, Aim, Share, Warning, Setting } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,73 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export function getDashboardStats() {
|
||||
export interface DashboardStatsParams {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
include_enhanced?: boolean
|
||||
}
|
||||
|
||||
export interface IntentRuleStat {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
hitCount: number
|
||||
hitRate: number
|
||||
}
|
||||
|
||||
export interface PromptTemplateStat {
|
||||
templateId: string
|
||||
templateName: string
|
||||
usageCount: number
|
||||
}
|
||||
|
||||
export interface ScriptFlowStat {
|
||||
flowId: string
|
||||
flowName: string
|
||||
activationCount: number
|
||||
completionRate: number
|
||||
}
|
||||
|
||||
export interface GuardrailWordStat {
|
||||
word: string
|
||||
category: string
|
||||
hitCount: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
knowledgeBases: number
|
||||
totalDocuments: number
|
||||
totalMessages: number
|
||||
totalSessions: number
|
||||
totalTokens: number
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
aiRequestsCount: number
|
||||
avgLatencyMs: number
|
||||
lastLatencyMs: number | null
|
||||
lastRequestTime: string | null
|
||||
slowRequestsCount: number
|
||||
errorRequestsCount: number
|
||||
p95LatencyMs: number
|
||||
p99LatencyMs: number
|
||||
minLatencyMs: number
|
||||
maxLatencyMs: number
|
||||
latencyThresholdMs: number
|
||||
intentRuleHitRate: number
|
||||
intentRuleHitCount: number
|
||||
topIntentRules: IntentRuleStat[]
|
||||
promptTemplateUsageCount: number
|
||||
topPromptTemplates: PromptTemplateStat[]
|
||||
scriptFlowActivationCount: number
|
||||
scriptFlowCompletionRate: number
|
||||
topScriptFlows: ScriptFlowStat[]
|
||||
guardrailBlockCount: number
|
||||
guardrailBlockRate: number
|
||||
topGuardrailWords: GuardrailWordStat[]
|
||||
}
|
||||
|
||||
export function getDashboardStats(params?: DashboardStatsParams): Promise<DashboardStats> {
|
||||
return request({
|
||||
url: '/admin/dashboard/stats',
|
||||
method: 'get'
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
DecompositionTemplate,
|
||||
DecompositionTemplateDetail,
|
||||
DecompositionTemplateCreate,
|
||||
DecompositionTemplateUpdate,
|
||||
DecompositionTemplateListResponse
|
||||
} from '@/types/decomposition-template'
|
||||
|
||||
export const decompositionTemplateApi = {
|
||||
list: (params?: { scene?: string; status?: string }) =>
|
||||
request<DecompositionTemplateListResponse>({
|
||||
method: 'GET',
|
||||
url: '/admin/decomposition-templates',
|
||||
params
|
||||
}),
|
||||
|
||||
get: (id: string) =>
|
||||
request<DecompositionTemplateDetail>({
|
||||
method: 'GET',
|
||||
url: `/admin/decomposition-templates/${id}`
|
||||
}),
|
||||
|
||||
create: (data: DecompositionTemplateCreate) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'POST',
|
||||
url: '/admin/decomposition-templates',
|
||||
data
|
||||
}),
|
||||
|
||||
update: (id: string, data: DecompositionTemplateUpdate) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'PUT',
|
||||
url: `/admin/decomposition-templates/${id}`,
|
||||
data
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request({ method: 'DELETE', url: `/admin/decomposition-templates/${id}` }),
|
||||
|
||||
activate: (id: string) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'POST',
|
||||
url: `/admin/decomposition-templates/${id}/activate`
|
||||
}),
|
||||
|
||||
archive: (id: string) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'POST',
|
||||
url: `/admin/decomposition-templates/${id}/archive`
|
||||
}),
|
||||
|
||||
rollback: (id: string, version: number) =>
|
||||
request<DecompositionTemplate>({
|
||||
method: 'POST',
|
||||
url: `/admin/decomposition-templates/${id}/rollback`,
|
||||
data: { version }
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export interface FlowExecutionRequest {
|
||||
message: string
|
||||
session_id?: string
|
||||
user_id?: string
|
||||
enable_flow?: boolean
|
||||
enable_intent?: boolean
|
||||
enable_rag?: boolean
|
||||
enable_guardrail?: boolean
|
||||
enable_memory?: boolean
|
||||
compare_mode?: boolean
|
||||
}
|
||||
|
||||
export interface FlowExecutionStep {
|
||||
step: number
|
||||
name: string
|
||||
status: 'success' | 'failed' | 'skipped'
|
||||
duration_ms: number
|
||||
input?: Record<string, any>
|
||||
output?: Record<string, any>
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface FlowExecutionResponse {
|
||||
test_id: string
|
||||
session_id: string
|
||||
status: string
|
||||
steps: FlowExecutionStep[]
|
||||
final_response: {
|
||||
reply: string
|
||||
confidence: number | null
|
||||
should_transfer: boolean
|
||||
sources?: any[]
|
||||
} | null
|
||||
total_duration_ms: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface FlowTestRecord {
|
||||
testId: string
|
||||
sessionId: string
|
||||
status: string
|
||||
stepCount: number
|
||||
totalDurationMs: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface FlowTestListResponse {
|
||||
data: FlowTestRecord[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CompareRequest {
|
||||
message: string
|
||||
baseline_config?: Record<string, any>
|
||||
test_config?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
baseline: {
|
||||
sessionId: string
|
||||
reply: string
|
||||
confidence: number | null
|
||||
durationMs: number
|
||||
steps: FlowExecutionStep[]
|
||||
}
|
||||
test: {
|
||||
sessionId: string
|
||||
reply: string
|
||||
confidence: number | null
|
||||
durationMs: number
|
||||
steps: FlowExecutionStep[]
|
||||
}
|
||||
comparison: {
|
||||
durationDiffMs: number
|
||||
confidenceDiff: number
|
||||
}
|
||||
}
|
||||
|
||||
export function executeFlowTest(data: FlowExecutionRequest): Promise<FlowExecutionResponse> {
|
||||
return request({
|
||||
url: '/admin/test/flow-execution',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getFlowTestResult(testId: string): Promise<FlowExecutionResponse> {
|
||||
return request({
|
||||
url: `/admin/test/flow-execution/${testId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function listFlowTests(params?: {
|
||||
session_id?: string
|
||||
status?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<FlowTestListResponse> {
|
||||
return request({
|
||||
url: '/admin/test/flow-executions',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function compareFlowTest(data: CompareRequest): Promise<CompareResult> {
|
||||
return request({
|
||||
url: '/admin/test/compare',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
ForbiddenWord,
|
||||
ForbiddenWordCreate,
|
||||
ForbiddenWordUpdate,
|
||||
ForbiddenWordListResponse,
|
||||
BehaviorRule,
|
||||
BehaviorRuleCreate,
|
||||
BehaviorRuleUpdate,
|
||||
BehaviorRuleListResponse
|
||||
} from '@/types/guardrail'
|
||||
|
||||
export interface GuardrailTestRequest {
|
||||
testTexts: string[]
|
||||
}
|
||||
|
||||
export interface GuardrailTestResponse {
|
||||
results: GuardrailTestResult[]
|
||||
summary: {
|
||||
totalTests: number
|
||||
triggeredCount: number
|
||||
blockedCount: number
|
||||
triggerRate: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface GuardrailTestResult {
|
||||
originalText: string
|
||||
triggered: boolean
|
||||
triggeredWords: TriggeredWordInfo[]
|
||||
filteredText: string
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
export interface TriggeredWordInfo {
|
||||
word: string
|
||||
category: string
|
||||
strategy: string
|
||||
replacement?: string
|
||||
fallbackReply?: string
|
||||
}
|
||||
|
||||
export function listForbiddenWords(params?: {
|
||||
category?: string
|
||||
is_enabled?: boolean
|
||||
}): Promise<ForbiddenWordListResponse> {
|
||||
return request({
|
||||
url: '/admin/guardrails/forbidden-words',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getForbiddenWord(wordId: string): Promise<ForbiddenWord> {
|
||||
return request({
|
||||
url: `/admin/guardrails/forbidden-words/${wordId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createForbiddenWord(data: ForbiddenWordCreate): Promise<ForbiddenWord> {
|
||||
return request({
|
||||
url: '/admin/guardrails/forbidden-words',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateForbiddenWord(wordId: string, data: ForbiddenWordUpdate): Promise<ForbiddenWord> {
|
||||
return request({
|
||||
url: `/admin/guardrails/forbidden-words/${wordId}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteForbiddenWord(wordId: string): Promise<void> {
|
||||
return request({
|
||||
url: `/admin/guardrails/forbidden-words/${wordId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function listBehaviorRules(params?: {
|
||||
category?: string
|
||||
}): Promise<BehaviorRuleListResponse> {
|
||||
return request({
|
||||
url: '/admin/guardrails/behavior-rules',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getBehaviorRule(ruleId: string): Promise<BehaviorRule> {
|
||||
return request({
|
||||
url: `/admin/guardrails/behavior-rules/${ruleId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createBehaviorRule(data: BehaviorRuleCreate): Promise<BehaviorRule> {
|
||||
return request({
|
||||
url: '/admin/guardrails/behavior-rules',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBehaviorRule(ruleId: string, data: BehaviorRuleUpdate): Promise<BehaviorRule> {
|
||||
return request({
|
||||
url: `/admin/guardrails/behavior-rules/${ruleId}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteBehaviorRule(ruleId: string): Promise<void> {
|
||||
return request({
|
||||
url: `/admin/guardrails/behavior-rules/${ruleId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function testGuardrail(data: GuardrailTestRequest): Promise<GuardrailTestResponse> {
|
||||
return request({
|
||||
url: '/admin/guardrails/test',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export type {
|
||||
ForbiddenWord,
|
||||
ForbiddenWordCreate,
|
||||
ForbiddenWordUpdate,
|
||||
ForbiddenWordListResponse,
|
||||
BehaviorRule,
|
||||
BehaviorRuleCreate,
|
||||
BehaviorRuleUpdate,
|
||||
BehaviorRuleListResponse
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
IntentRule,
|
||||
IntentRuleCreate,
|
||||
IntentRuleUpdate,
|
||||
IntentRuleListResponse
|
||||
} from '@/types/intent-rule'
|
||||
|
||||
export function listIntentRules(params?: {
|
||||
response_type?: string
|
||||
is_enabled?: boolean
|
||||
}): Promise<IntentRuleListResponse> {
|
||||
return request({
|
||||
url: '/admin/intent-rules',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getIntentRule(ruleId: string): Promise<IntentRule> {
|
||||
return request({
|
||||
url: `/admin/intent-rules/${ruleId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createIntentRule(data: IntentRuleCreate): Promise<IntentRule> {
|
||||
return request({
|
||||
url: '/admin/intent-rules',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateIntentRule(ruleId: string, data: IntentRuleUpdate): Promise<IntentRule> {
|
||||
return request({
|
||||
url: `/admin/intent-rules/${ruleId}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteIntentRule(ruleId: string): Promise<void> {
|
||||
return request({
|
||||
url: `/admin/intent-rules/${ruleId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export type {
|
||||
IntentRule,
|
||||
IntentRuleCreate,
|
||||
IntentRuleUpdate,
|
||||
IntentRuleListResponse
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
KnowledgeBase,
|
||||
KnowledgeBaseCreate,
|
||||
KnowledgeBaseUpdate,
|
||||
KnowledgeBaseListResponse,
|
||||
Document,
|
||||
DocumentListResponse,
|
||||
IndexJob
|
||||
} from '@/types/knowledge-base'
|
||||
|
||||
export function listKnowledgeBases(params?: {
|
||||
kb_type?: string
|
||||
is_enabled?: boolean
|
||||
}): Promise<KnowledgeBaseListResponse> {
|
||||
return request({
|
||||
url: '/admin/kb/knowledge-bases',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getKnowledgeBase(kbId: string): Promise<KnowledgeBase> {
|
||||
return request({
|
||||
url: `/admin/kb/knowledge-bases/${kbId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createKnowledgeBase(data: KnowledgeBaseCreate): Promise<KnowledgeBase> {
|
||||
return request({
|
||||
url: '/admin/kb/knowledge-bases',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateKnowledgeBase(kbId: string, data: KnowledgeBaseUpdate): Promise<KnowledgeBase> {
|
||||
return request({
|
||||
url: `/admin/kb/knowledge-bases/${kbId}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteKnowledgeBase(kbId: string): Promise<void> {
|
||||
return request({
|
||||
url: `/admin/kb/knowledge-bases/${kbId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function listDocuments(params: {
|
||||
kb_id?: string
|
||||
status?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<DocumentListResponse> {
|
||||
return request({
|
||||
url: '/admin/kb/documents',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getIndexJob(jobId: string): Promise<IndexJob> {
|
||||
return request({
|
||||
url: `/admin/kb/index/jobs/${jobId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDocument(docId: string): Promise<{ success: boolean; message: string }> {
|
||||
return request({
|
||||
url: `/admin/kb/documents/${docId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export type {
|
||||
KnowledgeBase,
|
||||
KnowledgeBaseCreate,
|
||||
KnowledgeBaseUpdate,
|
||||
KnowledgeBaseListResponse,
|
||||
Document,
|
||||
DocumentListResponse,
|
||||
IndexJob
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
MetadataFieldDefinition,
|
||||
MetadataFieldCreateRequest,
|
||||
MetadataFieldUpdateRequest,
|
||||
MetadataFieldListResponse,
|
||||
MetadataPayload,
|
||||
MetadataScope
|
||||
} from '@/types/metadata'
|
||||
|
||||
export const metadataSchemaApi = {
|
||||
list: (status?: 'draft' | 'active' | 'deprecated') =>
|
||||
request<MetadataFieldListResponse>({ method: 'GET', url: '/admin/metadata-schemas', params: status ? { status } : {} }),
|
||||
|
||||
get: (id: string) =>
|
||||
request<MetadataFieldDefinition>({ method: 'GET', url: `/admin/metadata-schemas/${id}` }),
|
||||
|
||||
create: (data: MetadataFieldCreateRequest) =>
|
||||
request<MetadataFieldDefinition>({ method: 'POST', url: '/admin/metadata-schemas', data }),
|
||||
|
||||
update: (id: string, data: MetadataFieldUpdateRequest) =>
|
||||
request<MetadataFieldDefinition>({ method: 'PUT', url: `/admin/metadata-schemas/${id}`, data }),
|
||||
|
||||
delete: (id: string) =>
|
||||
request({ method: 'DELETE', url: `/admin/metadata-schemas/${id}` }),
|
||||
|
||||
getByScope: (scope: MetadataScope, includeDeprecated = false) =>
|
||||
request<MetadataFieldListResponse>({
|
||||
method: 'GET',
|
||||
url: '/admin/metadata-schemas',
|
||||
params: { scope, include_deprecated: includeDeprecated }
|
||||
}),
|
||||
|
||||
validate: (metadata: MetadataPayload, scope?: MetadataScope) =>
|
||||
request<{ valid: boolean; errors?: { field_key: string; message: string }[] }>({
|
||||
method: 'POST',
|
||||
url: '/admin/metadata-schemas/validate',
|
||||
data: { metadata, scope }
|
||||
}),
|
||||
|
||||
checkCompatibility: (oldScope: MetadataScope, newScope: MetadataScope, metadata: MetadataPayload) =>
|
||||
request<{
|
||||
compatible: boolean;
|
||||
conflicts: { field_key: string; reason: string }[];
|
||||
preserved_keys: string[]
|
||||
}>({
|
||||
method: 'POST',
|
||||
url: '/admin/metadata-schemas/check-compatibility',
|
||||
data: { old_scope: oldScope, new_scope: newScope, metadata }
|
||||
})
|
||||
}
|
||||
|
||||
export type {
|
||||
MetadataFieldDefinition,
|
||||
MetadataFieldCreateRequest,
|
||||
MetadataFieldUpdateRequest,
|
||||
MetadataFieldListResponse,
|
||||
MetadataPayload
|
||||
}
|
||||
|
|
@ -1,6 +1,37 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export function listSessions(params: any) {
|
||||
export interface Session {
|
||||
sessionId: string
|
||||
tenantId: string
|
||||
messageCount: number
|
||||
status: string
|
||||
channelType: string
|
||||
startTime: string
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
sessionId: string
|
||||
messages: Array<{
|
||||
role: string
|
||||
content: string
|
||||
timestamp: string
|
||||
}>
|
||||
trace?: {
|
||||
retrieval?: Array<{
|
||||
score: number
|
||||
source?: string
|
||||
content: string
|
||||
}>
|
||||
tools?: Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
data: Session[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export function listSessions(params?: { page?: number; pageSize?: number; status?: string }): Promise<SessionListResponse> {
|
||||
return request({
|
||||
url: '/admin/sessions',
|
||||
method: 'get',
|
||||
|
|
@ -8,9 +39,409 @@ export function listSessions(params: any) {
|
|||
})
|
||||
}
|
||||
|
||||
export function getSessionDetail(sessionId: string) {
|
||||
export function getSessionDetail(sessionId: string): Promise<SessionDetail> {
|
||||
return request({
|
||||
url: `/admin/sessions/${sessionId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export interface IntentRuleTestRequest {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface IntentRuleTestResult {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
results: IntentRuleTestCase[]
|
||||
summary: {
|
||||
totalTests: number
|
||||
matchedCount: number
|
||||
matchRate: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface IntentRuleTestCase {
|
||||
message: string
|
||||
matched: boolean
|
||||
matchedKeywords: string[]
|
||||
matchedPatterns: string[]
|
||||
matchType: string | null
|
||||
priority: number
|
||||
priorityRank: number
|
||||
conflictRules: ConflictRule[]
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export interface ConflictRule {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
priority: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface IntentRuleStatsResponse {
|
||||
totalHits: number
|
||||
totalConversations: number
|
||||
hitRate: number
|
||||
rules: IntentRuleStatItem[]
|
||||
}
|
||||
|
||||
export interface IntentRuleStatItem {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
hitCount: number
|
||||
hitRate: number
|
||||
avgResponseTime: number
|
||||
lastHitTime: string | null
|
||||
responseType: string
|
||||
}
|
||||
|
||||
export interface IntentRuleHitsResponse {
|
||||
records: IntentRuleHitRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface IntentRuleHitRecord {
|
||||
conversationId: string
|
||||
sessionId: string
|
||||
userMessage: string
|
||||
matchedKeywords: string[]
|
||||
matchedPatterns: string[]
|
||||
responseType: string
|
||||
executionResult: string
|
||||
hitTime: string
|
||||
}
|
||||
|
||||
export interface PromptPreviewRequest {
|
||||
variables?: Record<string, string>
|
||||
sampleHistory?: Array<{ role: string; content: string }>
|
||||
sampleMessage?: string
|
||||
}
|
||||
|
||||
export interface PromptPreviewResponse {
|
||||
templateId: string
|
||||
templateName: string
|
||||
version: number
|
||||
rawContent: string
|
||||
variables: Array<{ name: string; value: string }>
|
||||
renderedContent: string
|
||||
estimatedTokens: number
|
||||
tokenCount: {
|
||||
systemPrompt: number
|
||||
history: number
|
||||
currentMessage: number
|
||||
total: number
|
||||
}
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface PromptTemplateStatsResponse {
|
||||
totalUsage: number
|
||||
templates: PromptTemplateStatItem[]
|
||||
sceneBreakdown: Record<string, number>
|
||||
}
|
||||
|
||||
export interface PromptTemplateStatItem {
|
||||
templateId: string
|
||||
templateName: string
|
||||
scene: string
|
||||
usageCount: number
|
||||
avgTokens: number
|
||||
avgPromptTokens: number
|
||||
avgCompletionTokens: number
|
||||
lastUsedTime: string | null
|
||||
}
|
||||
|
||||
export interface FlowStatsResponse {
|
||||
totalActivations: number
|
||||
totalCompletions: number
|
||||
completionRate: number
|
||||
flows: FlowStatItem[]
|
||||
}
|
||||
|
||||
export interface FlowStatItem {
|
||||
flowId: string
|
||||
flowName: string
|
||||
activationCount: number
|
||||
completionCount: number
|
||||
completionRate: number
|
||||
avgDuration: number
|
||||
avgStepsCompleted: number
|
||||
dropOffPoints: DropOffPoint[]
|
||||
lastActivatedAt: string | null
|
||||
}
|
||||
|
||||
export interface DropOffPoint {
|
||||
stepNo: number
|
||||
dropOffCount: number
|
||||
dropOffRate: number
|
||||
}
|
||||
|
||||
export interface FlowExecutionsResponse {
|
||||
data: FlowExecutionRecord[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface FlowExecutionRecord {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
flowId: string
|
||||
flowName: string
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
status: string
|
||||
startedAt: string
|
||||
updatedAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export interface GuardrailStatsResponse {
|
||||
totalBlocks: number
|
||||
totalTriggers: number
|
||||
blockRate: number
|
||||
words: GuardrailWordStats[]
|
||||
categoryBreakdown: Record<string, number>
|
||||
}
|
||||
|
||||
export interface GuardrailWordStats {
|
||||
wordId: string
|
||||
word: string
|
||||
category: string
|
||||
strategy: string
|
||||
hitCount: number
|
||||
blockCount: number
|
||||
lastHitAt: string | null
|
||||
}
|
||||
|
||||
export interface GuardrailBlocksResponse {
|
||||
data: GuardrailBlockRecord[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GuardrailBlockRecord {
|
||||
recordId: string
|
||||
sessionId: string
|
||||
originalText: string
|
||||
filteredText: string
|
||||
strategy: string
|
||||
blockedAt: string
|
||||
}
|
||||
|
||||
export function testIntentRule(ruleId: string, data: IntentRuleTestRequest): Promise<IntentRuleTestResult> {
|
||||
return request({
|
||||
url: `/admin/intent-rules/${ruleId}/test`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getIntentRuleStats(params?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
responseType?: string
|
||||
}): Promise<IntentRuleStatsResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/intent-rules',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getIntentRuleHits(
|
||||
ruleId: string,
|
||||
params?: { page?: number; pageSize?: number }
|
||||
): Promise<IntentRuleHitsResponse> {
|
||||
return request({
|
||||
url: `/admin/monitoring/intent-rules/${ruleId}/hits`,
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function previewPromptTemplate(
|
||||
tplId: string,
|
||||
data: PromptPreviewRequest
|
||||
): Promise<PromptPreviewResponse> {
|
||||
return request({
|
||||
url: `/admin/prompt-templates/${tplId}/preview`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getPromptTemplateStats(params?: {
|
||||
scene?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<PromptTemplateStatsResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/prompt-templates',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getFlowStats(params?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<FlowStatsResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/script-flows',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getFlowExecutions(
|
||||
flowId: string,
|
||||
params?: { page?: number; pageSize?: number }
|
||||
): Promise<FlowExecutionsResponse> {
|
||||
return request({
|
||||
url: `/admin/monitoring/script-flows/${flowId}/executions`,
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getGuardrailStats(params?: {
|
||||
category?: string
|
||||
}): Promise<GuardrailStatsResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/guardrails',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getGuardrailBlocks(
|
||||
wordId: string,
|
||||
params?: { page?: number; pageSize?: number }
|
||||
): Promise<GuardrailBlocksResponse> {
|
||||
return request({
|
||||
url: `/admin/monitoring/guardrails/${wordId}/blocks`,
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export interface ConversationItem {
|
||||
id: string
|
||||
sessionId: string
|
||||
userMessage: string
|
||||
aiReply: string | null
|
||||
hasFlow: boolean
|
||||
hasGuardrail: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ConversationListResponse {
|
||||
data: ConversationItem[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ConversationDetail {
|
||||
conversationId: string
|
||||
sessionId: string
|
||||
userMessage: string
|
||||
aiReply: string | null
|
||||
triggeredRules: Array<{
|
||||
id: string
|
||||
name: string
|
||||
responseType: string
|
||||
}>
|
||||
usedTemplate: {
|
||||
id: string
|
||||
name: string
|
||||
} | null
|
||||
usedFlow: {
|
||||
id: string
|
||||
flowId: string
|
||||
status: string
|
||||
currentStep: number
|
||||
} | null
|
||||
executionTimeMs: number | null
|
||||
confidence: number | null
|
||||
shouldTransfer: boolean
|
||||
guardrailTriggered: boolean
|
||||
guardrailWords: string[] | null
|
||||
executionSteps: Array<{
|
||||
step: number
|
||||
name: string
|
||||
status: string
|
||||
duration_ms: number
|
||||
input?: Record<string, any>
|
||||
output?: Record<string, any>
|
||||
error?: string
|
||||
}> | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ExportTaskResponse {
|
||||
taskId: string
|
||||
status: string
|
||||
format: string
|
||||
createdAt: string
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
totalRows?: number
|
||||
completedAt?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
format?: 'json' | 'csv'
|
||||
session_id?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function listConversations(params?: {
|
||||
session_id?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
has_flow?: boolean
|
||||
has_guardrail?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<ConversationListResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/conversations',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getConversationDetail(messageId: string): Promise<ConversationDetail> {
|
||||
return request({
|
||||
url: `/admin/monitoring/conversations/${messageId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createExportTask(data: ExportRequest): Promise<ExportTaskResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/conversations/export',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getExportStatus(taskId: string): Promise<ExportTaskResponse> {
|
||||
return request({
|
||||
url: `/admin/monitoring/conversations/export/${taskId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getExportDownloadUrl(taskId: string): string {
|
||||
return `/admin/monitoring/conversations/export/${taskId}/download`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
PromptTemplate,
|
||||
PromptTemplateDetail,
|
||||
PromptTemplateCreate,
|
||||
PromptTemplateUpdate,
|
||||
PromptTemplateListResponse,
|
||||
PublishRequest,
|
||||
RollbackRequest
|
||||
} from '@/types/prompt-template'
|
||||
|
||||
export function listPromptTemplates(params?: { scene?: string }): Promise<PromptTemplateListResponse> {
|
||||
return request({
|
||||
url: '/admin/prompt-templates',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getPromptTemplate(tplId: string): Promise<PromptTemplateDetail> {
|
||||
return request({
|
||||
url: `/admin/prompt-templates/${tplId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createPromptTemplate(data: PromptTemplateCreate): Promise<PromptTemplate> {
|
||||
return request({
|
||||
url: '/admin/prompt-templates',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePromptTemplate(tplId: string, data: PromptTemplateUpdate): Promise<PromptTemplate> {
|
||||
return request({
|
||||
url: `/admin/prompt-templates/${tplId}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePromptTemplate(tplId: string): Promise<void> {
|
||||
return request({
|
||||
url: `/admin/prompt-templates/${tplId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function publishPromptTemplate(tplId: string, data: PublishRequest): Promise<{ success: boolean; message: string }> {
|
||||
return request({
|
||||
url: `/admin/prompt-templates/${tplId}/publish`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function rollbackPromptTemplate(tplId: string, data: RollbackRequest): Promise<{ success: boolean; message: string }> {
|
||||
return request({
|
||||
url: `/admin/prompt-templates/${tplId}/rollback`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export type {
|
||||
PromptTemplate,
|
||||
PromptTemplateDetail,
|
||||
PromptTemplateCreate,
|
||||
PromptTemplateUpdate,
|
||||
PromptTemplateListResponse
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import request from '@/utils/request'
|
||||
import type {
|
||||
ScriptFlow,
|
||||
ScriptFlowDetail,
|
||||
ScriptFlowCreate,
|
||||
ScriptFlowUpdate,
|
||||
ScriptFlowListResponse
|
||||
} from '@/types/script-flow'
|
||||
|
||||
export interface FlowSimulateRequest {
|
||||
userInputs: string[]
|
||||
}
|
||||
|
||||
export interface FlowSimulateResponse {
|
||||
flowId: string
|
||||
flowName: string
|
||||
simulation: FlowSimulationStep[]
|
||||
result: {
|
||||
completed: boolean
|
||||
totalSteps: number
|
||||
totalDurationMs: number
|
||||
finalMessage: string | null
|
||||
}
|
||||
coverage: {
|
||||
totalSteps: number
|
||||
coveredSteps: number
|
||||
coverageRate: number
|
||||
uncoveredSteps: number[]
|
||||
}
|
||||
issues: string[]
|
||||
}
|
||||
|
||||
export interface FlowSimulationStep {
|
||||
stepNo: number
|
||||
botMessage: string
|
||||
userInput: string
|
||||
matchedCondition: {
|
||||
type: string
|
||||
gotoStep: number
|
||||
keywords?: string[]
|
||||
pattern?: string
|
||||
} | null
|
||||
nextStep: number | null
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export function listScriptFlows(params?: {
|
||||
is_enabled?: boolean
|
||||
}): Promise<ScriptFlowListResponse> {
|
||||
return request({
|
||||
url: '/admin/script-flows',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getScriptFlow(flowId: string): Promise<ScriptFlowDetail> {
|
||||
return request({
|
||||
url: `/admin/script-flows/${flowId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createScriptFlow(data: ScriptFlowCreate): Promise<ScriptFlow> {
|
||||
return request({
|
||||
url: '/admin/script-flows',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateScriptFlow(flowId: string, data: ScriptFlowUpdate): Promise<ScriptFlow> {
|
||||
return request({
|
||||
url: `/admin/script-flows/${flowId}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteScriptFlow(flowId: string): Promise<void> {
|
||||
return request({
|
||||
url: `/admin/script-flows/${flowId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function simulateScriptFlow(flowId: string, data: FlowSimulateRequest): Promise<FlowSimulateResponse> {
|
||||
return request({
|
||||
url: `/admin/script-flows/${flowId}/simulate`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export type {
|
||||
ScriptFlow,
|
||||
ScriptFlowDetail,
|
||||
ScriptFlowCreate,
|
||||
ScriptFlowUpdate,
|
||||
ScriptFlowListResponse
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
<template>
|
||||
<div class="metadata-field-renderer">
|
||||
<el-form-item
|
||||
:label="field.label"
|
||||
:prop="propPath"
|
||||
:required="field.required && !isDeprecated"
|
||||
:class="{ 'is-deprecated': isDeprecated }"
|
||||
>
|
||||
<template #label>
|
||||
<div class="field-label-wrapper">
|
||||
<span>{{ field.label }}</span>
|
||||
<el-tag v-if="isDeprecated" type="danger" size="small" class="deprecated-tag">
|
||||
已废弃
|
||||
</el-tag>
|
||||
<el-tooltip v-if="field.description" :content="field.description" placement="top">
|
||||
<el-icon class="field-help"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isDeprecated" class="deprecated-notice">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
此字段已废弃,仅保留历史数据展示,不可编辑
|
||||
</template>
|
||||
</el-alert>
|
||||
<div class="deprecated-value" v-if="modelValue !== undefined && modelValue !== null && modelValue !== ''">
|
||||
当前值: {{ formatValue(modelValue) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<el-input
|
||||
v-if="field.type === 'string'"
|
||||
:model-value="modelValue as string"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="modelValue as number"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
style="width: 100%"
|
||||
/>
|
||||
|
||||
<el-switch
|
||||
v-else-if="field.type === 'boolean'"
|
||||
:model-value="modelValue as boolean"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-else-if="field.type === 'enum'"
|
||||
:model-value="modelValue as string"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-else-if="field.type === 'array_enum'"
|
||||
:model-value="modelValue as string[]"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
multiple
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<div v-if="fieldHint" class="field-hint">{{ fieldHint }}</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { QuestionFilled } from '@element-plus/icons-vue'
|
||||
import type { MetadataFieldDefinition, MetadataPayload } from '@/types/metadata'
|
||||
|
||||
const props = defineProps<{
|
||||
field: MetadataFieldDefinition
|
||||
modelValue: string | number | boolean | string[] | undefined
|
||||
propPath?: string
|
||||
disabled?: boolean
|
||||
isNewObject?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | number | boolean | string[] | undefined): void
|
||||
}>()
|
||||
|
||||
const isDeprecated = computed(() => props.field.status === 'deprecated')
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (props.field.default !== undefined) {
|
||||
return `默认: ${props.field.default}`
|
||||
}
|
||||
return `请输入${props.field.label}`
|
||||
})
|
||||
|
||||
const fieldHint = computed(() => {
|
||||
const hints: string[] = []
|
||||
if (props.field.is_filterable) {
|
||||
hints.push('可作为过滤条件')
|
||||
}
|
||||
if (props.field.is_rank_feature) {
|
||||
hints.push('可作为排序特征')
|
||||
}
|
||||
return hints.join(' | ')
|
||||
})
|
||||
|
||||
const formatValue = (value: string | number | boolean | string[] | undefined): string => {
|
||||
if (value === undefined || value === null) return '无'
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metadata-field-renderer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-label-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deprecated-tag {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.is-deprecated :deep(.el-form-item__label) {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.deprecated-notice {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.deprecated-value {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
<template>
|
||||
<div class="metadata-form">
|
||||
<div v-if="loading" class="loading-wrapper">
|
||||
<el-skeleton :rows="3" animated />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="allFields.length === 0" class="empty-fields">
|
||||
<el-empty description="暂无适用的元数据字段" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<el-row :gutter="16">
|
||||
<el-col
|
||||
v-for="field in visibleFields"
|
||||
:key="field.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="colSpan"
|
||||
>
|
||||
<MetadataFieldRenderer
|
||||
:field="field"
|
||||
:model-value="localMetadata[field.field_key]"
|
||||
:prop-path="`metadata.${field.field_key}`"
|
||||
:disabled="disabled || (field.status === 'deprecated' && !showDeprecatedEditable)"
|
||||
:is-new-object="isNewObject"
|
||||
@update:model-value="handleFieldUpdate(field.field_key, $event)"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div v-if="deprecatedFields.length > 0 && showDeprecated" class="deprecated-section">
|
||||
<el-divider content-position="left">
|
||||
<el-tag type="danger" size="small">已废弃字段</el-tag>
|
||||
</el-divider>
|
||||
<el-row :gutter="16">
|
||||
<el-col
|
||||
v-for="field in deprecatedFields"
|
||||
:key="field.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="colSpan"
|
||||
>
|
||||
<MetadataFieldRenderer
|
||||
:field="field"
|
||||
:model-value="localMetadata[field.field_key]"
|
||||
:prop-path="`metadata.${field.field_key}`"
|
||||
disabled
|
||||
:is-new-object="isNewObject"
|
||||
@update:model-value="handleFieldUpdate(field.field_key, $event)"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MetadataFieldRenderer from './MetadataFieldRenderer.vue'
|
||||
import { metadataSchemaApi } from '@/api/metadata-schema'
|
||||
import type { MetadataFieldDefinition, MetadataPayload, MetadataScope } from '@/types/metadata'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scope: MetadataScope
|
||||
modelValue?: MetadataPayload
|
||||
disabled?: boolean
|
||||
isNewObject?: boolean
|
||||
showDeprecated?: boolean
|
||||
showDeprecatedEditable?: boolean
|
||||
colSpan?: number
|
||||
}>(), {
|
||||
modelValue: () => ({}),
|
||||
disabled: false,
|
||||
isNewObject: true,
|
||||
showDeprecated: true,
|
||||
showDeprecatedEditable: false,
|
||||
colSpan: 8
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: MetadataPayload): void
|
||||
(e: 'fields-loaded', fields: MetadataFieldDefinition[]): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const allFields = ref<MetadataFieldDefinition[]>([])
|
||||
const localMetadata = ref<MetadataPayload>({})
|
||||
|
||||
const activeFields = computed(() => {
|
||||
return allFields.value.filter(f => f.status === 'active')
|
||||
})
|
||||
|
||||
const deprecatedFields = computed(() => {
|
||||
return allFields.value.filter(f => f.status === 'deprecated')
|
||||
})
|
||||
|
||||
const visibleFields = computed(() => {
|
||||
if (props.isNewObject) {
|
||||
return activeFields.value
|
||||
}
|
||||
return allFields.value.filter(f => f.status !== 'draft')
|
||||
})
|
||||
|
||||
const loadFields = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await metadataSchemaApi.getByScope(props.scope, props.showDeprecated)
|
||||
allFields.value = res.items || []
|
||||
emit('fields-loaded', allFields.value)
|
||||
|
||||
applyDefaults()
|
||||
} catch (error: any) {
|
||||
console.error('加载元数据字段失败', error)
|
||||
ElMessage.error('加载元数据字段失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyDefaults = () => {
|
||||
const defaults: MetadataPayload = {}
|
||||
activeFields.value.forEach(field => {
|
||||
if (field.default !== undefined && localMetadata.value[field.field_key] === undefined) {
|
||||
defaults[field.field_key] = field.default
|
||||
}
|
||||
})
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
localMetadata.value = { ...defaults, ...localMetadata.value }
|
||||
emit('update:modelValue', { ...localMetadata.value })
|
||||
}
|
||||
}
|
||||
|
||||
const handleFieldUpdate = (fieldKey: string, value: string | number | boolean | string[] | undefined) => {
|
||||
localMetadata.value[fieldKey] = value
|
||||
emit('update:modelValue', { ...localMetadata.value })
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal) {
|
||||
localMetadata.value = { ...newVal }
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
watch(() => props.scope, () => {
|
||||
loadFields()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadFields()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
validate: async () => {
|
||||
const errors: { field_key: string; message: string }[] = []
|
||||
|
||||
activeFields.value.forEach(field => {
|
||||
if (field.required) {
|
||||
const value = localMetadata.value[field.field_key]
|
||||
if (value === undefined || value === null || value === '' ||
|
||||
(Array.isArray(value) && value.length === 0)) {
|
||||
errors.push({
|
||||
field_key: field.field_key,
|
||||
message: `${field.label} 为必填项`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'enum' || field.type === 'array_enum') {
|
||||
const value = localMetadata.value[field.field_key]
|
||||
if (value !== undefined && value !== null && field.options) {
|
||||
if (field.type === 'enum' && !field.options.includes(value as string)) {
|
||||
errors.push({
|
||||
field_key: field.field_key,
|
||||
message: `${field.label} 的值不在有效选项中`
|
||||
})
|
||||
}
|
||||
if (field.type === 'array_enum' && Array.isArray(value)) {
|
||||
const invalidValues = value.filter(v => !field.options!.includes(v))
|
||||
if (invalidValues.length > 0) {
|
||||
errors.push({
|
||||
field_key: field.field_key,
|
||||
message: `${field.label} 包含无效选项: ${invalidValues.join(', ')}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
},
|
||||
getMetadata: () => ({ ...localMetadata.value }),
|
||||
getFields: () => allFields.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metadata-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-fields {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.deprecated-section {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="类型切换确认"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="type-change-handler">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="change-alert"
|
||||
>
|
||||
<template #title>
|
||||
检测到类型变更,部分元数据字段可能需要处理
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div v-if="conflicts.length > 0" class="conflicts-section">
|
||||
<h4 class="section-title">需要处理的字段</h4>
|
||||
<el-table :data="conflicts" stripe size="small">
|
||||
<el-table-column prop="label" label="字段名" width="120" />
|
||||
<el-table-column label="当前值" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatValue(row.old_value) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="冲突原因" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.conflict_type === 'removed' ? 'danger' : 'warning'" size="small">
|
||||
{{ row.conflict_type === 'removed' ? '字段不存在' : '类型不匹配' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处理方式" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.action" size="small" style="width: 100%">
|
||||
<el-option label="移除" value="remove" />
|
||||
<el-option
|
||||
v-if="row.map_to"
|
||||
:label="`映射到 ${row.map_to}`"
|
||||
value="map"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-if="preserved.length > 0" class="preserved-section">
|
||||
<h4 class="section-title">
|
||||
<el-icon class="success-icon"><CircleCheckFilled /></el-icon>
|
||||
保留的字段 ({{ preserved.length }})
|
||||
</h4>
|
||||
<div class="preserved-tags">
|
||||
<el-tag
|
||||
v-for="item in preserved"
|
||||
:key="item.field_key"
|
||||
type="success"
|
||||
size="small"
|
||||
class="preserved-tag"
|
||||
>
|
||||
{{ item.field_key }}: {{ formatValue(item.value) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消切换</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">
|
||||
确认切换
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { CircleCheckFilled } from '@element-plus/icons-vue'
|
||||
import type { MetadataFieldDefinition, MetadataPayload, MetadataScope, TypeChangeConflict } from '@/types/metadata'
|
||||
|
||||
interface ConflictItem extends TypeChangeConflict {
|
||||
action: 'remove' | 'map'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
oldScope: MetadataScope
|
||||
newScope: MetadataScope
|
||||
currentMetadata: MetadataPayload
|
||||
oldFields: MetadataFieldDefinition[]
|
||||
newFields: MetadataFieldDefinition[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'confirm', result: { metadata: MetadataPayload; removed: string[] }): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (val) => emit('update:show', val)
|
||||
})
|
||||
|
||||
const conflicts = ref<ConflictItem[]>([])
|
||||
const preserved = ref<{ field_key: string; value: string | number | boolean | string[] | undefined }[]>([])
|
||||
|
||||
const analyzeCompatibility = () => {
|
||||
conflicts.value = []
|
||||
preserved.value = []
|
||||
|
||||
const newFieldKeys = new Set(props.newFields.map(f => f.field_key))
|
||||
const newFieldTypes = new Map(props.newFields.map(f => [f.field_key, f.type]))
|
||||
|
||||
Object.entries(props.currentMetadata).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) return
|
||||
|
||||
if (!newFieldKeys.has(key)) {
|
||||
const oldField = props.oldFields.find(f => f.field_key === key)
|
||||
conflicts.value.push({
|
||||
field_key: key,
|
||||
label: oldField?.label || key,
|
||||
conflict_type: 'removed',
|
||||
old_value: value,
|
||||
suggested_action: 'remove',
|
||||
action: 'remove'
|
||||
})
|
||||
} else {
|
||||
const newType = newFieldTypes.get(key)
|
||||
const oldField = props.oldFields.find(f => f.field_key === key)
|
||||
const oldType = oldField?.type
|
||||
|
||||
if (oldType !== newType) {
|
||||
conflicts.value.push({
|
||||
field_key: key,
|
||||
label: oldField?.label || key,
|
||||
conflict_type: 'type_mismatch',
|
||||
old_value: value,
|
||||
suggested_action: 'remove',
|
||||
action: 'remove'
|
||||
})
|
||||
} else {
|
||||
preserved.value.push({ field_key: key, value })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatValue = (value: string | number | boolean | string[] | undefined): string => {
|
||||
if (value === undefined || value === null) return '无'
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const newMetadata: MetadataPayload = {}
|
||||
const removed: string[] = []
|
||||
|
||||
preserved.value.forEach(item => {
|
||||
newMetadata[item.field_key] = item.value
|
||||
})
|
||||
|
||||
conflicts.value.forEach(item => {
|
||||
if (item.action === 'remove') {
|
||||
removed.push(item.field_key)
|
||||
} else if (item.action === 'map' && item.map_to) {
|
||||
newMetadata[item.map_to] = item.old_value
|
||||
}
|
||||
})
|
||||
|
||||
emit('confirm', { metadata: newMetadata, removed })
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(() => props.show, (show) => {
|
||||
if (show) {
|
||||
analyzeCompatibility()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.type-change-handler {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.change-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 16px 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.conflicts-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preserved-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preserved-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preserved-tag {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as MetadataForm } from './MetadataForm.vue'
|
||||
export { default as MetadataFieldRenderer } from './MetadataFieldRenderer.vue'
|
||||
export { default as TypeChangeHandler } from './TypeChangeHandler.vue'
|
||||
|
|
@ -40,6 +40,78 @@ const routes: Array<RouteRecordRaw> = [
|
|||
name: 'LLMConfig',
|
||||
component: () => import('@/views/admin/llm/index.vue'),
|
||||
meta: { title: 'LLM 模型配置' }
|
||||
},
|
||||
{
|
||||
path: '/admin/prompt-templates',
|
||||
name: 'PromptTemplate',
|
||||
component: () => import('@/views/admin/prompt-template/index.vue'),
|
||||
meta: { title: 'Prompt 模板管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/knowledge-bases',
|
||||
name: 'KnowledgeBase',
|
||||
component: () => import('@/views/admin/knowledge-base/index.vue'),
|
||||
meta: { title: '多知识库管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/metadata-schemas',
|
||||
name: 'MetadataSchema',
|
||||
component: () => import('@/views/admin/metadata-schema/index.vue'),
|
||||
meta: { title: '元数据模式配置' }
|
||||
},
|
||||
{
|
||||
path: '/admin/intent-rules',
|
||||
name: 'IntentRule',
|
||||
component: () => import('@/views/admin/intent-rule/index.vue'),
|
||||
meta: { title: '意图规则管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/script-flows',
|
||||
name: 'ScriptFlow',
|
||||
component: () => import('@/views/admin/script-flow/index.vue'),
|
||||
meta: { title: '话术流程管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/guardrails',
|
||||
name: 'Guardrail',
|
||||
component: () => import('@/views/admin/guardrail/index.vue'),
|
||||
meta: { title: '输出护栏管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/decomposition-templates',
|
||||
name: 'DecompositionTemplate',
|
||||
component: () => import('@/views/admin/decomposition-template/index.vue'),
|
||||
meta: { title: '拆解模板管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/intent-rules',
|
||||
name: 'IntentRuleMonitoring',
|
||||
component: () => import('@/views/admin/monitoring/IntentRules.vue'),
|
||||
meta: { title: '意图规则监控' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/prompt-templates',
|
||||
name: 'PromptTemplateMonitoring',
|
||||
component: () => import('@/views/admin/monitoring/PromptTemplates.vue'),
|
||||
meta: { title: 'Prompt 模板监控' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/script-flows',
|
||||
name: 'ScriptFlowMonitoring',
|
||||
component: () => import('@/views/admin/monitoring/ScriptFlows.vue'),
|
||||
meta: { title: '话术流程监控' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/guardrails',
|
||||
name: 'GuardrailMonitoring',
|
||||
component: () => import('@/views/admin/monitoring/Guardrails.vue'),
|
||||
meta: { title: '输出护栏监控' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/conversations',
|
||||
name: 'ConversationTracking',
|
||||
component: () => import('@/views/admin/monitoring/ConversationTracking.vue'),
|
||||
meta: { title: '对话追踪' }
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import type { MetadataPayload } from '@/types/metadata'
|
||||
|
||||
export interface DecompositionTemplate {
|
||||
id: string
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
current_version: number
|
||||
status: DecompositionStatus
|
||||
is_latest_effective: boolean
|
||||
effective_at?: string
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DecompositionTemplateDetail {
|
||||
id: string
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
current_version: number
|
||||
status: DecompositionStatus
|
||||
is_latest_effective: boolean
|
||||
effective_at?: string
|
||||
steps: DecompositionStep[]
|
||||
versions: DecompositionVersion[]
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type DecompositionStatus = 'draft' | 'active' | 'archived'
|
||||
|
||||
export interface DecompositionStep {
|
||||
step_id: string
|
||||
step_no: number
|
||||
instruction: string
|
||||
expected_output?: string
|
||||
dependencies?: string[]
|
||||
}
|
||||
|
||||
export interface DecompositionVersion {
|
||||
version: number
|
||||
status: DecompositionStatus
|
||||
steps: DecompositionStep[]
|
||||
created_at: string
|
||||
effective_at?: string
|
||||
archived_at?: string
|
||||
}
|
||||
|
||||
export interface DecompositionTemplateCreate {
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
steps: DecompositionStep[]
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface DecompositionTemplateUpdate {
|
||||
name?: string
|
||||
scene?: string
|
||||
description?: string
|
||||
steps?: DecompositionStep[]
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface DecompositionTemplateListResponse {
|
||||
data: DecompositionTemplate[]
|
||||
}
|
||||
|
||||
export const DECOMPOSITION_STATUS_OPTIONS = [
|
||||
{ value: 'draft', label: '草稿', color: 'info' },
|
||||
{ value: 'active', label: '生效', color: 'success' },
|
||||
{ value: 'archived', label: '归档', color: 'warning' }
|
||||
]
|
||||
|
||||
export const DECOMPOSITION_SCENE_OPTIONS = [
|
||||
{ value: 'customer_service', label: '客服场景' },
|
||||
{ value: 'sales', label: '销售场景' },
|
||||
{ value: 'support', label: '技术支持' },
|
||||
{ value: 'complaint', label: '投诉处理' },
|
||||
{ value: 'general', label: '通用场景' }
|
||||
]
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
export interface ForbiddenWord {
|
||||
id: string
|
||||
word: string
|
||||
category: 'competitor' | 'sensitive' | 'political' | 'custom'
|
||||
strategy: 'mask' | 'replace' | 'block'
|
||||
replacement?: string
|
||||
fallback_message?: string
|
||||
hit_count: number
|
||||
is_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ForbiddenWordCreate {
|
||||
word: string
|
||||
category: 'competitor' | 'sensitive' | 'political' | 'custom'
|
||||
strategy: 'mask' | 'replace' | 'block'
|
||||
replacement?: string
|
||||
fallback_message?: string
|
||||
is_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface ForbiddenWordUpdate {
|
||||
word?: string
|
||||
category?: 'competitor' | 'sensitive' | 'political' | 'custom'
|
||||
strategy?: 'mask' | 'replace' | 'block'
|
||||
replacement?: string
|
||||
fallback_message?: string
|
||||
is_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface ForbiddenWordListResponse {
|
||||
data: ForbiddenWord[]
|
||||
}
|
||||
|
||||
export interface BehaviorRule {
|
||||
id: string
|
||||
description: string
|
||||
category: 'compliance' | 'tone' | 'boundary' | 'custom'
|
||||
is_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface BehaviorRuleCreate {
|
||||
description: string
|
||||
category: 'compliance' | 'tone' | 'boundary' | 'custom'
|
||||
is_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface BehaviorRuleUpdate {
|
||||
description?: string
|
||||
category?: 'compliance' | 'tone' | 'boundary' | 'custom'
|
||||
is_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface BehaviorRuleListResponse {
|
||||
data: BehaviorRule[]
|
||||
}
|
||||
|
||||
export const WORD_CATEGORY_OPTIONS = [
|
||||
{ value: 'competitor', label: '竞品名称', color: 'danger' },
|
||||
{ value: 'sensitive', label: '敏感词', color: 'warning' },
|
||||
{ value: 'political', label: '政治敏感', color: 'danger' },
|
||||
{ value: 'custom', label: '自定义', color: 'info' }
|
||||
]
|
||||
|
||||
export const WORD_STRATEGY_OPTIONS = [
|
||||
{ value: 'mask', label: '脱敏处理', description: '将敏感词替换为 ***' },
|
||||
{ value: 'replace', label: '替换文本', description: '替换为指定文本' },
|
||||
{ value: 'block', label: '拦截输出', description: '阻止输出并返回兜底话术' }
|
||||
]
|
||||
|
||||
export const BEHAVIOR_CATEGORY_OPTIONS = [
|
||||
{ value: 'compliance', label: '合规要求', color: 'danger' },
|
||||
{ value: 'tone', label: '语气风格', color: 'warning' },
|
||||
{ value: 'boundary', label: '边界限制', color: 'primary' },
|
||||
{ value: 'custom', label: '自定义', color: 'info' }
|
||||
]
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import type { MetadataPayload } from '@/types/metadata'
|
||||
|
||||
export interface IntentRule {
|
||||
id: string
|
||||
name: string
|
||||
keywords: string[]
|
||||
patterns: string[]
|
||||
response_type: 'fixed' | 'rag' | 'flow' | 'transfer'
|
||||
priority: number
|
||||
fixed_reply?: string
|
||||
target_kb_ids?: string[]
|
||||
flow_id?: string
|
||||
transfer_message?: string
|
||||
hit_count: number
|
||||
is_enabled: boolean
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface IntentRuleCreate {
|
||||
name: string
|
||||
keywords?: string[]
|
||||
patterns?: string[]
|
||||
response_type: 'fixed' | 'rag' | 'flow' | 'transfer'
|
||||
priority: number
|
||||
fixed_reply?: string
|
||||
target_kb_ids?: string[]
|
||||
flow_id?: string
|
||||
transfer_message?: string
|
||||
is_enabled?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface IntentRuleUpdate {
|
||||
name?: string
|
||||
keywords?: string[]
|
||||
patterns?: string[]
|
||||
response_type?: 'fixed' | 'rag' | 'flow' | 'transfer'
|
||||
priority?: number
|
||||
fixed_reply?: string
|
||||
target_kb_ids?: string[]
|
||||
flow_id?: string
|
||||
transfer_message?: string
|
||||
is_enabled?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface IntentRuleListResponse {
|
||||
data: IntentRule[]
|
||||
}
|
||||
|
||||
export const RESPONSE_TYPE_OPTIONS = [
|
||||
{ value: 'fixed', label: '固定回复', color: 'primary' },
|
||||
{ value: 'rag', label: '知识库检索', color: 'success' },
|
||||
{ value: 'flow', label: '话术流程', color: 'warning' },
|
||||
{ value: 'transfer', label: '转人工', color: 'danger' }
|
||||
]
|
||||
|
||||
export const RESPONSE_TYPE_MAP: Record<string, { label: string; color: string }> = {
|
||||
fixed: { label: '固定回复', color: 'primary' },
|
||||
rag: { label: '知识库检索', color: 'success' },
|
||||
flow: { label: '话术流程', color: 'warning' },
|
||||
transfer: { label: '转人工', color: 'danger' }
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
export interface KnowledgeBase {
|
||||
id: string
|
||||
name: string
|
||||
kbType: 'product' | 'faq' | 'script' | 'policy' | 'general'
|
||||
description?: string
|
||||
priority: number
|
||||
isEnabled: boolean
|
||||
docCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseCreate {
|
||||
name: string
|
||||
kb_type: 'product' | 'faq' | 'script' | 'policy' | 'general'
|
||||
description?: string
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseUpdate {
|
||||
name?: string
|
||||
kb_type?: 'product' | 'faq' | 'script' | 'policy' | 'general'
|
||||
description?: string
|
||||
priority?: number
|
||||
is_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseListResponse {
|
||||
data: KnowledgeBase[]
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
docId: string
|
||||
kbId: string
|
||||
fileName: string
|
||||
status: string
|
||||
jobId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DocumentListResponse {
|
||||
data: Document[]
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface IndexJob {
|
||||
jobId: string
|
||||
docId: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
progress: number
|
||||
errorMsg?: string
|
||||
}
|
||||
|
||||
export const KB_TYPE_OPTIONS = [
|
||||
{ value: 'product', label: '产品知识', color: 'primary' },
|
||||
{ value: 'faq', label: '常见问题', color: 'success' },
|
||||
{ value: 'script', label: '话术库', color: 'warning' },
|
||||
{ value: 'policy', label: '政策法规', color: 'danger' },
|
||||
{ value: 'general', label: '通用知识', color: 'info' }
|
||||
]
|
||||
|
||||
export const KB_TYPE_MAP: Record<string, { label: string; color: string }> = {
|
||||
product: { label: '产品知识', color: 'primary' },
|
||||
faq: { label: '常见问题', color: 'success' },
|
||||
script: { label: '话术库', color: 'warning' },
|
||||
policy: { label: '政策法规', color: 'danger' },
|
||||
general: { label: '通用知识', color: 'info' }
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
export type MetadataFieldType = 'string' | 'number' | 'boolean' | 'enum' | 'array_enum'
|
||||
export type MetadataFieldStatus = 'draft' | 'active' | 'deprecated'
|
||||
export type MetadataScope = 'kb_document' | 'intent_rule' | 'script_flow' | 'prompt_template'
|
||||
|
||||
export interface MetadataFieldDefinition {
|
||||
id: string
|
||||
field_key: string
|
||||
label: string
|
||||
type: MetadataFieldType
|
||||
description?: string
|
||||
required: boolean
|
||||
options?: string[]
|
||||
default?: string | number | boolean
|
||||
scope: MetadataScope[]
|
||||
is_filterable: boolean
|
||||
is_rank_feature: boolean
|
||||
status: MetadataFieldStatus
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface MetadataFieldCreateRequest {
|
||||
field_key: string
|
||||
label: string
|
||||
type: MetadataFieldType
|
||||
required: boolean
|
||||
options?: string[]
|
||||
default?: string | number | boolean
|
||||
scope: MetadataScope[]
|
||||
is_filterable?: boolean
|
||||
is_rank_feature?: boolean
|
||||
status: MetadataFieldStatus
|
||||
}
|
||||
|
||||
export interface MetadataFieldUpdateRequest {
|
||||
label?: string
|
||||
required?: boolean
|
||||
options?: string[]
|
||||
default?: string | number | boolean
|
||||
scope?: MetadataScope[]
|
||||
is_filterable?: boolean
|
||||
is_rank_feature?: boolean
|
||||
status?: MetadataFieldStatus
|
||||
}
|
||||
|
||||
export interface MetadataFieldListResponse {
|
||||
items: MetadataFieldDefinition[]
|
||||
}
|
||||
|
||||
export interface MetadataPayload {
|
||||
[key: string]: string | number | boolean | string[] | undefined
|
||||
}
|
||||
|
||||
export interface TypeChangeConflict {
|
||||
field_key: string
|
||||
label: string
|
||||
conflict_type: 'removed' | 'type_mismatch'
|
||||
old_value?: string | number | boolean | string[]
|
||||
suggested_action: 'remove' | 'map'
|
||||
map_to?: string
|
||||
}
|
||||
|
||||
export interface TypeChangeResult {
|
||||
preserved: { field_key: string; value: string | number | boolean | string[] | undefined }[]
|
||||
conflicts: TypeChangeConflict[]
|
||||
}
|
||||
|
||||
export const METADATA_STATUS_OPTIONS = [
|
||||
{ value: 'draft', label: '草稿', color: 'info', description: '字段可编辑,不可用于新建对象' },
|
||||
{ value: 'active', label: '生效', color: 'success', description: '可用于新建与编辑对象' },
|
||||
{ value: 'deprecated', label: '废弃', color: 'danger', description: '不可用于新建,历史数据可读' }
|
||||
]
|
||||
|
||||
export const METADATA_SCOPE_OPTIONS = [
|
||||
{ value: 'kb_document', label: '知识库文档' },
|
||||
{ value: 'intent_rule', label: '意图规则' },
|
||||
{ value: 'script_flow', label: '话术流程' },
|
||||
{ value: 'prompt_template', label: 'Prompt模板' }
|
||||
]
|
||||
|
||||
export const METADATA_TYPE_OPTIONS = [
|
||||
{ value: 'string', label: '文本' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'boolean', label: '布尔值' },
|
||||
{ value: 'enum', label: '单选枚举' },
|
||||
{ value: 'array_enum', label: '多选枚举' }
|
||||
]
|
||||
|
||||
export const STATUS_TAG_MAP: Record<MetadataFieldStatus, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
draft: 'info',
|
||||
active: 'success',
|
||||
deprecated: 'danger'
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import type { MetadataPayload } from '@/types/metadata'
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
is_default: boolean
|
||||
metadata?: MetadataPayload
|
||||
published_version?: PromptVersionInfo
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PromptVersionInfo {
|
||||
version: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PromptTemplateDetail {
|
||||
id: string
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
is_default: boolean
|
||||
current_content?: string
|
||||
variables?: PromptVariable[]
|
||||
versions?: PromptVersion[]
|
||||
metadata?: MetadataPayload
|
||||
published_version?: PromptVersionInfo
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PromptVersion {
|
||||
version: number
|
||||
content: string
|
||||
status: 'draft' | 'published' | 'archived'
|
||||
variables?: PromptVariable[]
|
||||
created_at: string
|
||||
published_at?: string
|
||||
}
|
||||
|
||||
export interface PromptVariable {
|
||||
name: string
|
||||
description?: string
|
||||
default_value?: string
|
||||
}
|
||||
|
||||
export interface PromptTemplateCreate {
|
||||
name: string
|
||||
scene: string
|
||||
description?: string
|
||||
system_instruction: string
|
||||
variables?: PromptVariable[]
|
||||
is_default?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface PromptTemplateUpdate {
|
||||
name?: string
|
||||
scene?: string
|
||||
description?: string
|
||||
system_instruction?: string
|
||||
variables?: PromptVariable[]
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface PromptTemplateListResponse {
|
||||
data: PromptTemplate[]
|
||||
}
|
||||
|
||||
export interface PublishRequest {
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface RollbackRequest {
|
||||
version: number
|
||||
}
|
||||
|
||||
export const SCENE_OPTIONS = [
|
||||
{ value: 'chat', label: '对话场景' },
|
||||
{ value: 'qa', label: '问答场景' },
|
||||
{ value: 'summary', label: '摘要场景' },
|
||||
{ value: 'translation', label: '翻译场景' },
|
||||
{ value: 'code', label: '代码场景' },
|
||||
{ value: 'custom', label: '自定义场景' }
|
||||
]
|
||||
|
||||
export const BUILTIN_VARIABLES: PromptVariable[] = [
|
||||
{ name: 'persona_name', description: 'AI 人设名称', default_value: 'AI助手' },
|
||||
{ name: 'persona_personality', description: 'AI 性格特点', default_value: '热情、耐心、专业' },
|
||||
{ name: 'persona_tone', description: 'AI 说话风格', default_value: '亲切自然,使用口语化表达' },
|
||||
{ name: 'brand_name', description: '品牌名称', default_value: '我们公司' },
|
||||
{ name: 'current_time', description: '当前时间' },
|
||||
{ name: 'channel_type', description: '渠道类型(web/wechat/phone/app)' },
|
||||
{ name: 'user_name', description: '用户名称' },
|
||||
{ name: 'context', description: '检索上下文' },
|
||||
{ name: 'query', description: '用户问题' },
|
||||
{ name: 'history', description: '对话历史' }
|
||||
]
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import type { MetadataPayload } from '@/types/metadata'
|
||||
|
||||
export interface ScriptFlow {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
step_count: number
|
||||
is_enabled: boolean
|
||||
linked_rule_count: number
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ScriptFlowDetail {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
steps: FlowStep[]
|
||||
is_enabled: boolean
|
||||
metadata?: MetadataPayload
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ScriptMode = 'fixed' | 'flexible' | 'template'
|
||||
|
||||
export interface FlowStep {
|
||||
step_id: string
|
||||
step_no: number
|
||||
content: string
|
||||
wait_input: boolean
|
||||
timeout_seconds?: number
|
||||
timeout_action?: 'repeat' | 'skip' | 'transfer'
|
||||
next_conditions?: NextCondition[]
|
||||
default_next?: number
|
||||
script_mode?: ScriptMode
|
||||
intent?: string
|
||||
intent_description?: string
|
||||
script_constraints?: string[]
|
||||
expected_variables?: string[]
|
||||
}
|
||||
|
||||
export interface NextCondition {
|
||||
keywords?: string[]
|
||||
pattern?: string
|
||||
goto_step: number
|
||||
}
|
||||
|
||||
export interface ScriptFlowCreate {
|
||||
name: string
|
||||
description?: string
|
||||
steps: FlowStep[]
|
||||
is_enabled?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface ScriptFlowUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
steps?: FlowStep[]
|
||||
is_enabled?: boolean
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
export interface ScriptFlowListResponse {
|
||||
data: ScriptFlow[]
|
||||
}
|
||||
|
||||
export const TIMEOUT_ACTION_OPTIONS = [
|
||||
{ value: 'repeat', label: '重复当前步骤' },
|
||||
{ value: 'skip', label: '跳过进入下一步' },
|
||||
{ value: 'transfer', label: '转人工' }
|
||||
]
|
||||
|
||||
export const SCRIPT_MODE_OPTIONS = [
|
||||
{ value: 'fixed' as const, label: '固定话术', description: '话术内容固定不变' },
|
||||
{ value: 'flexible' as const, label: '灵活话术', description: 'AI根据意图和上下文生成' },
|
||||
{ value: 'template' as const, label: '模板话术', description: 'AI填充模板中的变量' }
|
||||
]
|
||||
|
||||
export const PRESET_CONSTRAINTS = [
|
||||
'必须礼貌',
|
||||
'语气自然',
|
||||
'简洁明了',
|
||||
'不要生硬',
|
||||
'不要重复'
|
||||
]
|
||||
|
|
@ -0,0 +1,676 @@
|
|||
<template>
|
||||
<div class="decomposition-template-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">拆解模板管理</h1>
|
||||
<p class="page-desc">管理复杂问题的拆解模板,支持版本管理与生效标记。[AC-IDSMETA-22]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterStatus" placeholder="按状态筛选" clearable style="width: 130px;">
|
||||
<el-option
|
||||
v-for="opt in DECOMPOSITION_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 140px;">
|
||||
<el-option
|
||||
v-for="opt in DECOMPOSITION_SCENE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建模板
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" class="template-card" v-loading="loading">
|
||||
<el-table :data="templates" stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="模板名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="template-name">
|
||||
<span class="name-text">{{ row.name }}</span>
|
||||
<el-tag v-if="row.is_latest_effective" type="success" size="small" class="effective-tag">
|
||||
最近生效
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scene" label="场景" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ getSceneLabel(row.scene) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_version" label="版本" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="version-badge">v{{ row.current_version }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.status)" size="small">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="effective_at" label="生效时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.effective_at">{{ formatDate(row.effective_at) }}</span>
|
||||
<span v-else class="no-date">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'draft'"
|
||||
type="success"
|
||||
link
|
||||
size="small"
|
||||
@click="handleActivate(row)"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
生效
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'active'"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
@click="handleArchive(row)"
|
||||
>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
归档
|
||||
</el-button>
|
||||
<el-button type="info" link size="small" @click="handleViewDetail(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑模板' : '新建模板'"
|
||||
width="850px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模板名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入模板名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="场景" prop="scene">
|
||||
<el-select v-model="formData.scene" placeholder="请选择场景" style="width: 100%;">
|
||||
<el-option
|
||||
v-for="opt in DECOMPOSITION_SCENE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述(可选)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">拆解步骤</el-divider>
|
||||
|
||||
<div class="steps-editor">
|
||||
<div
|
||||
v-for="(step, index) in formData.steps"
|
||||
:key="step.step_id"
|
||||
class="step-item"
|
||||
>
|
||||
<div class="step-header">
|
||||
<span class="step-order">步骤 {{ index + 1 }}</span>
|
||||
<el-button type="danger" link size="small" @click="removeStep(index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form-item label="指令" required>
|
||||
<el-input
|
||||
v-model="step.instruction"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入该步骤的处理指令"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="期望输出">
|
||||
<el-input
|
||||
v-model="step.expected_output"
|
||||
placeholder="可选:描述该步骤期望的输出格式"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="addStep" style="width: 100%; margin-top: 12px;">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加步骤
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailDrawer" title="模板详情" size="600px" destroy-on-close>
|
||||
<div v-if="currentTemplate" class="detail-content">
|
||||
<div class="detail-header">
|
||||
<h3>{{ currentTemplate.name }}</h3>
|
||||
<div class="detail-tags">
|
||||
<el-tag :type="getStatusTagType(currentTemplate.status)" size="small">
|
||||
{{ getStatusLabel(currentTemplate.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="currentTemplate.is_latest_effective" type="success" size="small">
|
||||
最近生效
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="场景">{{ getSceneLabel(currentTemplate.scene) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前版本">v{{ currentTemplate.current_version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效时间">
|
||||
{{ currentTemplate.effective_at ? formatDate(currentTemplate.effective_at) : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(currentTemplate.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ currentTemplate.description || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider content-position="left">版本历史</el-divider>
|
||||
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="version in currentTemplate.versions"
|
||||
:key="version.version"
|
||||
:type="version.status === 'active' ? 'success' : 'info'"
|
||||
:timestamp="formatDate(version.created_at)"
|
||||
placement="top"
|
||||
>
|
||||
<div class="version-item">
|
||||
<div class="version-header">
|
||||
<span class="version-number">v{{ version.version }}</span>
|
||||
<el-tag :type="getStatusTagType(version.status)" size="small">
|
||||
{{ getStatusLabel(version.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="version-steps">
|
||||
<div v-for="(step, idx) in version.steps" :key="step.step_id" class="version-step">
|
||||
<span class="step-no">{{ idx + 1 }}.</span>
|
||||
<span class="step-instruction">{{ step.instruction }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="version.status === 'archived'"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="handleRollback(version.version)"
|
||||
>
|
||||
回滚到此版本
|
||||
</el-button>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Check, FolderOpened, View } from '@element-plus/icons-vue'
|
||||
import { decompositionTemplateApi } from '@/api/decomposition-template'
|
||||
import { DECOMPOSITION_STATUS_OPTIONS, DECOMPOSITION_SCENE_OPTIONS } from '@/types/decomposition-template'
|
||||
import type {
|
||||
DecompositionTemplate,
|
||||
DecompositionTemplateDetail,
|
||||
DecompositionTemplateCreate,
|
||||
DecompositionTemplateUpdate,
|
||||
DecompositionStatus
|
||||
} from '@/types/decomposition-template'
|
||||
|
||||
const loading = ref(false)
|
||||
const templates = ref<DecompositionTemplate[]>([])
|
||||
const filterStatus = ref('')
|
||||
const filterScene = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const detailDrawer = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const currentTemplate = ref<DecompositionTemplateDetail | null>(null)
|
||||
const currentEditId = ref('')
|
||||
|
||||
const generateStepId = () => `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const formData = ref<DecompositionTemplateCreate>({
|
||||
name: '',
|
||||
scene: '',
|
||||
description: '',
|
||||
steps: [],
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||
scene: [{ required: true, message: '请选择场景', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getSceneLabel = (scene: string) => {
|
||||
const opt = DECOMPOSITION_SCENE_OPTIONS.find(o => o.value === scene)
|
||||
return opt?.label || scene
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: DecompositionStatus) => {
|
||||
const opt = DECOMPOSITION_STATUS_OPTIONS.find(o => o.value === status)
|
||||
return opt?.label || status
|
||||
}
|
||||
|
||||
const getStatusTagType = (status: DecompositionStatus): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const typeMap: Record<DecompositionStatus, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
draft: 'info',
|
||||
active: 'success',
|
||||
archived: 'warning'
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadTemplates = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await decompositionTemplateApi.list({
|
||||
status: filterStatus.value || undefined,
|
||||
scene: filterScene.value || undefined
|
||||
})
|
||||
templates.value = res.data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
currentEditId.value = ''
|
||||
formData.value = {
|
||||
name: '',
|
||||
scene: '',
|
||||
description: '',
|
||||
steps: [],
|
||||
metadata: {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (row: DecompositionTemplate) => {
|
||||
isEdit.value = true
|
||||
currentEditId.value = row.id
|
||||
try {
|
||||
const detail = await decompositionTemplateApi.get(row.id)
|
||||
formData.value = {
|
||||
name: detail.name,
|
||||
scene: detail.scene,
|
||||
description: detail.description || '',
|
||||
steps: detail.steps || [],
|
||||
metadata: detail.metadata || {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: DecompositionTemplate) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该模板吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await decompositionTemplateApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivate = async (row: DecompositionTemplate) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要将该模板设为生效状态吗?', '确认生效', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
})
|
||||
await decompositionTemplateApi.activate(row.id)
|
||||
ElMessage.success('已生效')
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (row: DecompositionTemplate) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要归档该模板吗?', '确认归档', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await decompositionTemplateApi.archive(row.id)
|
||||
ElMessage.success('已归档')
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row: DecompositionTemplate) => {
|
||||
try {
|
||||
currentTemplate.value = await decompositionTemplateApi.get(row.id)
|
||||
detailDrawer.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRollback = async (version: number) => {
|
||||
if (!currentTemplate.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要回滚到版本 v${version} 吗?`, '确认回滚', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await decompositionTemplateApi.rollback(currentTemplate.value.id, version)
|
||||
ElMessage.success('回滚成功')
|
||||
currentTemplate.value = await decompositionTemplateApi.get(currentTemplate.value.id)
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('回滚失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addStep = () => {
|
||||
formData.value.steps.push({
|
||||
step_id: generateStepId(),
|
||||
step_no: formData.value.steps.length + 1,
|
||||
instruction: '',
|
||||
expected_output: '',
|
||||
dependencies: []
|
||||
})
|
||||
}
|
||||
|
||||
const removeStep = (index: number) => {
|
||||
formData.value.steps.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.value.steps.length === 0) {
|
||||
ElMessage.warning('请至少添加一个步骤')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < formData.value.steps.length; i++) {
|
||||
if (!formData.value.steps[i].instruction?.trim()) {
|
||||
ElMessage.warning(`步骤 ${i + 1} 的指令不能为空`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const submitData = {
|
||||
...formData.value,
|
||||
steps: formData.value.steps.map((step, index) => ({
|
||||
...step,
|
||||
step_no: index + 1
|
||||
}))
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
const updateData: DecompositionTemplateUpdate = submitData
|
||||
await decompositionTemplateApi.update(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await decompositionTemplateApi.create(submitData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([filterStatus, filterScene], () => {
|
||||
loadTemplates()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.decomposition-template-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.effective-tag {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-date {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.steps-editor {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-order {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.version-steps {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.version-step {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-no {
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.step-instruction {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
<template>
|
||||
<div class="behavior-rules-tab">
|
||||
<div class="tab-header">
|
||||
<div class="filter-section">
|
||||
<el-select v-model="filterCategory" placeholder="按类别筛选" clearable style="width: 140px;">
|
||||
<el-option v-for="opt in BEHAVIOR_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加规则
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="rules" v-loading="loading" stripe>
|
||||
<el-table-column prop="description" label="规则描述" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="rule-description">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="类别" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getCategoryTagType(row.category)" size="small">
|
||||
{{ getCategoryLabel(row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.is_enabled"
|
||||
@change="handleToggleEnabled(row)"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑规则' : '添加规则'"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="类别" prop="category">
|
||||
<el-select v-model="formData.category" placeholder="请选择类别" style="width: 100%;">
|
||||
<el-option v-for="opt in BEHAVIOR_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="规则描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="请输入规则描述,例如: - 不承诺具体价格或优惠 - 不评价竞品 - 保持专业、友好的语气"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
listBehaviorRules,
|
||||
createBehaviorRule,
|
||||
updateBehaviorRule,
|
||||
deleteBehaviorRule
|
||||
} from '@/api/guardrail'
|
||||
import { BEHAVIOR_CATEGORY_OPTIONS } from '@/types/guardrail'
|
||||
import type { BehaviorRule, BehaviorRuleCreate, BehaviorRuleUpdate } from '@/types/guardrail'
|
||||
|
||||
const loading = ref(false)
|
||||
const rules = ref<BehaviorRule[]>([])
|
||||
const filterCategory = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const currentEditId = ref('')
|
||||
|
||||
const defaultFormData = (): BehaviorRuleCreate => ({
|
||||
description: '',
|
||||
category: 'compliance',
|
||||
is_enabled: true
|
||||
})
|
||||
|
||||
const formData = ref<BehaviorRuleCreate>(defaultFormData())
|
||||
|
||||
const formRules = {
|
||||
category: [{ required: true, message: '请选择类别', trigger: 'change' }],
|
||||
description: [{ required: true, message: '请输入规则描述', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const opt = BEHAVIOR_CATEGORY_OPTIONS.find(o => o.value === category)
|
||||
return opt?.label || category
|
||||
}
|
||||
|
||||
const getCategoryTagType = (category: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const colorMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
compliance: 'danger',
|
||||
tone: 'warning',
|
||||
boundary: '',
|
||||
custom: 'info'
|
||||
}
|
||||
return colorMap[category] || 'info'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadRules = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listBehaviorRules({
|
||||
category: filterCategory.value || undefined
|
||||
})
|
||||
rules.value = res.data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载规则列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
currentEditId.value = ''
|
||||
formData.value = defaultFormData()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: BehaviorRule) => {
|
||||
isEdit.value = true
|
||||
currentEditId.value = row.id
|
||||
formData.value = {
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
is_enabled: row.is_enabled
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: BehaviorRule) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该规则吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await deleteBehaviorRule(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadRules()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (row: BehaviorRule) => {
|
||||
try {
|
||||
await updateBehaviorRule(row.id, { is_enabled: row.is_enabled })
|
||||
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
|
||||
} catch (error) {
|
||||
row.is_enabled = !row.is_enabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const updateData: BehaviorRuleUpdate = {
|
||||
description: formData.value.description,
|
||||
category: formData.value.category,
|
||||
is_enabled: formData.value.is_enabled
|
||||
}
|
||||
await updateBehaviorRule(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await createBehaviorRule(formData.value)
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadRules()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '保存失败' : '添加失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(filterCategory, () => {
|
||||
loadRules()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadRules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.behavior-rules-tab {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rule-description {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
<template>
|
||||
<div class="forbidden-words-tab">
|
||||
<div class="tab-header">
|
||||
<div class="filter-section">
|
||||
<el-select v-model="filterCategory" placeholder="按类别筛选" clearable style="width: 140px;">
|
||||
<el-option v-for="opt in WORD_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索禁词"
|
||||
clearable
|
||||
style="width: 200px;"
|
||||
@keyup.enter="loadWords"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<el-button @click="testDialogVisible = true">
|
||||
<el-icon><Search /></el-icon>
|
||||
测试护栏
|
||||
</el-button>
|
||||
<el-button @click="showBatchImport = true">
|
||||
<el-icon><Upload /></el-icon>
|
||||
批量导入
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加禁词
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="words" v-loading="loading" stripe>
|
||||
<el-table-column prop="word" label="禁词" min-width="150" />
|
||||
<el-table-column prop="category" label="类别" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getCategoryTagType(row.category)" size="small">
|
||||
{{ getCategoryLabel(row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="strategy" label="策略" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">
|
||||
{{ getStrategyLabel(row.strategy) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hit_count" label="命中次数" width="100" sortable />
|
||||
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.is_enabled"
|
||||
@change="handleToggleEnabled(row)"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑禁词' : '添加禁词'"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="禁词" prop="word">
|
||||
<el-input v-model="formData.word" placeholder="请输入禁词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类别" prop="category">
|
||||
<el-select v-model="formData.category" placeholder="请选择类别" style="width: 100%;">
|
||||
<el-option v-for="opt in WORD_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="策略" prop="strategy">
|
||||
<el-radio-group v-model="formData.strategy">
|
||||
<el-radio-button
|
||||
v-for="opt in WORD_STRATEGY_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<transition name="slide-fade" mode="out-in">
|
||||
<el-form-item v-if="formData.strategy === 'replace'" label="替换文本" prop="replacement">
|
||||
<el-input v-model="formData.replacement" placeholder="请输入替换文本" />
|
||||
</el-form-item>
|
||||
<el-form-item v-else-if="formData.strategy === 'block'" label="兜底话术" prop="fallback_message">
|
||||
<el-input v-model="formData.fallback_message" type="textarea" :rows="3" placeholder="请输入兜底话术" />
|
||||
</el-form-item>
|
||||
</transition>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="showBatchImport"
|
||||
title="批量导入禁词"
|
||||
width="500px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="batchForm" label-width="80px">
|
||||
<el-form-item label="默认类别">
|
||||
<el-select v-model="batchForm.category" style="width: 100%;">
|
||||
<el-option v-for="opt in WORD_CATEGORY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认策略">
|
||||
<el-select v-model="batchForm.strategy" style="width: 100%;">
|
||||
<el-option v-for="opt in WORD_STRATEGY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="禁词列表">
|
||||
<el-input
|
||||
v-model="batchForm.words"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="每行一个禁词,例如: 竞品A 竞品B 敏感词1"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showBatchImport = false">取消</el-button>
|
||||
<el-button type="primary" :loading="batchSubmitting" @click="handleBatchImport">
|
||||
导入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<TestDialog v-model:visible="testDialogVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Upload, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
listForbiddenWords,
|
||||
createForbiddenWord,
|
||||
updateForbiddenWord,
|
||||
deleteForbiddenWord
|
||||
} from '@/api/guardrail'
|
||||
import { WORD_CATEGORY_OPTIONS, WORD_STRATEGY_OPTIONS } from '@/types/guardrail'
|
||||
import type { ForbiddenWord, ForbiddenWordCreate, ForbiddenWordUpdate } from '@/types/guardrail'
|
||||
import TestDialog from './TestDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const words = ref<ForbiddenWord[]>([])
|
||||
const filterCategory = ref('')
|
||||
const searchKeyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const showBatchImport = ref(false)
|
||||
const testDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const batchSubmitting = ref(false)
|
||||
const formRef = ref()
|
||||
const currentEditId = ref('')
|
||||
|
||||
const defaultFormData = (): ForbiddenWordCreate => ({
|
||||
word: '',
|
||||
category: 'custom',
|
||||
strategy: 'mask',
|
||||
replacement: '',
|
||||
fallback_message: '',
|
||||
is_enabled: true
|
||||
})
|
||||
|
||||
const formData = ref<ForbiddenWordCreate>(defaultFormData())
|
||||
|
||||
const batchForm = ref({
|
||||
category: 'custom',
|
||||
strategy: 'mask',
|
||||
words: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
word: [{ required: true, message: '请输入禁词', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '请选择类别', trigger: 'change' }],
|
||||
strategy: [{ required: true, message: '请选择策略', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const opt = WORD_CATEGORY_OPTIONS.find(o => o.value === category)
|
||||
return opt?.label || category
|
||||
}
|
||||
|
||||
const getCategoryTagType = (category: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const colorMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
competitor: 'danger',
|
||||
sensitive: 'warning',
|
||||
political: 'danger',
|
||||
custom: 'info'
|
||||
}
|
||||
return colorMap[category] || 'info'
|
||||
}
|
||||
|
||||
const getStrategyLabel = (strategy: string) => {
|
||||
const opt = WORD_STRATEGY_OPTIONS.find(o => o.value === strategy)
|
||||
return opt?.label || strategy
|
||||
}
|
||||
|
||||
const loadWords = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listForbiddenWords({
|
||||
category: filterCategory.value || undefined
|
||||
})
|
||||
words.value = res.data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载禁词列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
currentEditId.value = ''
|
||||
formData.value = defaultFormData()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: ForbiddenWord) => {
|
||||
isEdit.value = true
|
||||
currentEditId.value = row.id
|
||||
formData.value = {
|
||||
word: row.word,
|
||||
category: row.category,
|
||||
strategy: row.strategy,
|
||||
replacement: row.replacement || '',
|
||||
fallback_message: row.fallback_message || '',
|
||||
is_enabled: row.is_enabled
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: ForbiddenWord) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该禁词吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await deleteForbiddenWord(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadWords()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (row: ForbiddenWord) => {
|
||||
try {
|
||||
await updateForbiddenWord(row.id, { is_enabled: row.is_enabled })
|
||||
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
|
||||
} catch (error) {
|
||||
row.is_enabled = !row.is_enabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const updateData: ForbiddenWordUpdate = {
|
||||
word: formData.value.word,
|
||||
category: formData.value.category,
|
||||
strategy: formData.value.strategy,
|
||||
replacement: formData.value.replacement,
|
||||
fallback_message: formData.value.fallback_message,
|
||||
is_enabled: formData.value.is_enabled
|
||||
}
|
||||
await updateForbiddenWord(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await createForbiddenWord(formData.value)
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadWords()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '保存失败' : '添加失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
const wordList = batchForm.value.words
|
||||
.split('\n')
|
||||
.map(w => w.trim())
|
||||
.filter(w => w.length > 0)
|
||||
|
||||
if (wordList.length === 0) {
|
||||
ElMessage.warning('请输入要导入的禁词')
|
||||
return
|
||||
}
|
||||
|
||||
batchSubmitting.value = true
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
try {
|
||||
for (const word of wordList) {
|
||||
try {
|
||||
await createForbiddenWord({
|
||||
word,
|
||||
category: batchForm.value.category as any,
|
||||
strategy: batchForm.value.strategy as any,
|
||||
is_enabled: true
|
||||
})
|
||||
successCount++
|
||||
} catch {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
ElMessage.success(`成功导入 ${successCount} 个禁词${failCount > 0 ? `,${failCount} 个失败` : ''}`)
|
||||
showBatchImport.value = false
|
||||
batchForm.value.words = ''
|
||||
loadWords()
|
||||
} else {
|
||||
ElMessage.error('导入失败')
|
||||
}
|
||||
} finally {
|
||||
batchSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(filterCategory, () => {
|
||||
loadWords()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadWords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.forbidden-words-tab {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
title="护栏测试"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="test-dialog">
|
||||
<div class="input-section">
|
||||
<div class="section-title">测试文本</div>
|
||||
<div class="input-hint">每行一条测试文本,系统将检测是否触发禁词</div>
|
||||
<el-input
|
||||
v-model="testTextsValue"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="请输入测试文本,每行一条 例如: 我们的产品比竞品 A 更好 可以给您赔偿 1000 元 这是正常的回复"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<el-button type="primary" :loading="testing" @click="handleTest">
|
||||
<el-icon><Search /></el-icon>
|
||||
开始测试
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="result-section">
|
||||
<el-divider content-position="left">测试结果</el-divider>
|
||||
|
||||
<div class="result-summary">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">{{ testResult.summary.totalTests }}</div>
|
||||
<div class="summary-label">总测试数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value" :class="{ 'text-warning': testResult.summary.triggeredCount > 0 }">
|
||||
{{ testResult.summary.triggeredCount }}
|
||||
</div>
|
||||
<div class="summary-label">触发数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value" :class="{ 'text-danger': testResult.summary.blockedCount > 0 }">
|
||||
{{ testResult.summary.blockedCount }}
|
||||
</div>
|
||||
<div class="summary-label">拦截数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value" :class="triggerRateClass">
|
||||
{{ (testResult.summary.triggerRate * 100).toFixed(0) }}%
|
||||
</div>
|
||||
<div class="summary-label">触发率</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="results-list">
|
||||
<div class="section-title">详细结果</div>
|
||||
<div
|
||||
v-for="(result, index) in testResult.results"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
:class="{ 'result-triggered': result.triggered, 'result-blocked': result.blocked }"
|
||||
>
|
||||
<div class="result-header">
|
||||
<el-tag :type="result.blocked ? 'danger' : result.triggered ? 'warning' : 'success'" size="small">
|
||||
{{ result.blocked ? '已拦截' : result.triggered ? '已触发' : '正常' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="result-original">
|
||||
<span class="label">原文:</span>
|
||||
<span v-html="highlightWords(result.originalText, result.triggeredWords)"></span>
|
||||
</div>
|
||||
<div v-if="result.triggered" class="result-filtered">
|
||||
<span class="label">处理后:</span>
|
||||
{{ result.filteredText }}
|
||||
</div>
|
||||
<div v-if="result.triggeredWords.length > 0" class="result-words">
|
||||
<span class="label">触发禁词:</span>
|
||||
<el-tag
|
||||
v-for="word in result.triggeredWords"
|
||||
:key="word.word"
|
||||
:type="word.strategy === 'block' ? 'danger' : 'warning'"
|
||||
size="small"
|
||||
style="margin-right: 4px;"
|
||||
>
|
||||
{{ word.word }} ({{ getCategoryLabel(word.category) }} - {{ getStrategyLabel(word.strategy) }})
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false)">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { testGuardrail, type GuardrailTestResponse, type TriggeredWordInfo } from '@/api/guardrail'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}>()
|
||||
|
||||
const testTextsValue = ref('')
|
||||
const testing = ref(false)
|
||||
const testResult = ref<GuardrailTestResponse | null>(null)
|
||||
|
||||
const triggerRateClass = computed(() => {
|
||||
if (!testResult.value) return ''
|
||||
const rate = testResult.value.summary.triggerRate
|
||||
if (rate >= 0.5) return 'text-danger'
|
||||
if (rate >= 0.2) return 'text-warning'
|
||||
return 'text-success'
|
||||
})
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
competitor: '竞品',
|
||||
sensitive: '敏感',
|
||||
political: '政治',
|
||||
custom: '自定义'
|
||||
}
|
||||
return labels[category] || category
|
||||
}
|
||||
|
||||
const getStrategyLabel = (strategy: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
mask: '掩码',
|
||||
replace: '替换',
|
||||
block: '拦截'
|
||||
}
|
||||
return labels[strategy] || strategy
|
||||
}
|
||||
|
||||
const highlightWords = (text: string, triggeredWords: TriggeredWordInfo[]) => {
|
||||
if (!triggeredWords || triggeredWords.length === 0) return text
|
||||
|
||||
let result = text
|
||||
for (const word of triggeredWords) {
|
||||
const regex = new RegExp(word.word, 'gi')
|
||||
result = result.replace(regex, `<span class="highlight-word">${word.word}</span>`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
const texts = testTextsValue.value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
|
||||
if (texts.length === 0) {
|
||||
ElMessage.warning('请输入至少一条测试文本')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
const result = await testGuardrail({ testTexts: texts })
|
||||
testResult.value = result
|
||||
ElMessage.success('测试完成')
|
||||
} catch (error) {
|
||||
ElMessage.error('测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-dialog {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.summary-value.text-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.summary-value.text-warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.summary-value.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.result-item.result-triggered {
|
||||
border-color: var(--el-color-warning-light-3);
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
.result-item.result-blocked {
|
||||
border-color: var(--el-color-danger-light-3);
|
||||
background-color: var(--el-color-danger-light-9);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.result-original,
|
||||
.result-filtered,
|
||||
.result-words {
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.result-original .label,
|
||||
.result-filtered .label,
|
||||
.result-words .label {
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
:deep(.highlight-word) {
|
||||
background-color: var(--el-color-warning-light-5);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<div class="guardrail-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">输出护栏管理</h1>
|
||||
<p class="page-desc">配置禁词和行为规则,确保 AI 输出符合合规要求。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="禁词管理" name="forbidden-words">
|
||||
<forbidden-words-tab />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="行为规则" name="behavior-rules">
|
||||
<behavior-rules-tab />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ForbiddenWordsTab from './components/ForbiddenWordsTab.vue'
|
||||
import BehaviorRulesTab from './components/BehaviorRulesTab.vue'
|
||||
|
||||
const activeTab = ref('forbidden-words')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guardrail-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<div class="keyword-input">
|
||||
<el-tag
|
||||
v-for="(keyword, index) in modelValue"
|
||||
:key="index"
|
||||
closable
|
||||
type="info"
|
||||
size="small"
|
||||
@close="handleRemove(index)"
|
||||
class="keyword-tag"
|
||||
>
|
||||
{{ keyword }}
|
||||
</el-tag>
|
||||
<el-input
|
||||
v-model="inputValue"
|
||||
:placeholder="placeholder"
|
||||
size="small"
|
||||
@keyup.enter="handleAdd"
|
||||
@blur="handleAdd"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}>()
|
||||
|
||||
const inputValue = ref('')
|
||||
|
||||
const handleAdd = () => {
|
||||
const value = inputValue.value.trim()
|
||||
if (value && !props.modelValue.includes(value)) {
|
||||
emit('update:modelValue', [...props.modelValue, value])
|
||||
}
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const newValue = [...props.modelValue]
|
||||
newValue.splice(index, 1)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.keyword-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.keyword-input:focus-within {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.keyword-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-field :deep(.el-input__wrapper) {
|
||||
box-shadow: none;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.input-field :deep(.el-input__wrapper):focus-within {
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<template>
|
||||
<div class="pattern-input">
|
||||
<div
|
||||
v-for="(pattern, index) in modelValue"
|
||||
:key="index"
|
||||
class="pattern-item"
|
||||
>
|
||||
<code class="pattern-code">{{ pattern }}</code>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
@click="handleRemove(index)"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<el-input
|
||||
v-model="inputValue"
|
||||
:placeholder="placeholder"
|
||||
size="small"
|
||||
@keyup.enter="handleAdd"
|
||||
class="input-field"
|
||||
/>
|
||||
<el-button type="primary" size="small" @click="handleAdd">添加</el-button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="errorMessage" class="error-tip">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Close, WarningFilled } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}>()
|
||||
|
||||
const inputValue = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const validateRegex = (pattern: string): boolean => {
|
||||
try {
|
||||
new RegExp(pattern)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
const value = inputValue.value.trim()
|
||||
if (!value) return
|
||||
|
||||
if (!validateRegex(value)) {
|
||||
errorMessage.value = '无效的正则表达式'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
if (!props.modelValue.includes(value)) {
|
||||
emit('update:modelValue', [...props.modelValue, value])
|
||||
}
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const newValue = [...props.modelValue]
|
||||
newValue.splice(index, 1)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pattern-input {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.pattern-input:focus-within {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.pattern-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 6px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pattern-code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
title="测试意图规则"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="test-dialog-content">
|
||||
<div class="rule-info">
|
||||
<el-tag type="info">规则名称:{{ ruleName }}</el-tag>
|
||||
<el-tag type="warning">优先级:{{ rulePriority }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-form @submit.prevent="handleTest">
|
||||
<el-form-item label="测试消息">
|
||||
<el-input
|
||||
v-model="testMessage"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入测试消息,模拟用户输入"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="testing" @click="handleTest">
|
||||
执行测试
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div v-if="testResult" class="test-result">
|
||||
<el-divider>测试结果</el-divider>
|
||||
|
||||
<div class="result-summary">
|
||||
<el-statistic title="匹配状态" :value="testResult.results[0]?.matched ? '匹配成功' : '未匹配'">
|
||||
<template #suffix>
|
||||
<el-icon :class="testResult.results[0]?.matched ? 'success-icon' : 'error-icon'">
|
||||
<component :is="testResult.results[0]?.matched ? 'CircleCheckFilled' : 'CircleCloseFilled'" />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-statistic>
|
||||
<el-statistic title="匹配类型" :value="testResult.results[0]?.matchType || '-'" />
|
||||
<el-statistic title="优先级排名" :value="`第 ${testResult.results[0]?.priorityRank || '-'} 位`" />
|
||||
</div>
|
||||
|
||||
<div v-if="testResult.results[0]?.matched" class="match-details">
|
||||
<div v-if="testResult.results[0]?.matchedKeywords?.length" class="detail-section">
|
||||
<h4>匹配关键词</h4>
|
||||
<div class="tag-list">
|
||||
<el-tag
|
||||
v-for="(kw, idx) in testResult.results[0].matchedKeywords"
|
||||
:key="idx"
|
||||
type="success"
|
||||
size="small"
|
||||
>
|
||||
{{ kw }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult.results[0]?.matchedPatterns?.length" class="detail-section">
|
||||
<h4>匹配正则</h4>
|
||||
<div class="tag-list">
|
||||
<el-tag
|
||||
v-for="(pattern, idx) in testResult.results[0].matchedPatterns"
|
||||
:key="idx"
|
||||
type="warning"
|
||||
size="small"
|
||||
>
|
||||
{{ pattern }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult.results[0]?.conflictRules?.length" class="conflict-section">
|
||||
<el-alert
|
||||
title="检测到优先级冲突"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
<p>以下规则也会匹配此消息,请检查优先级设置:</p>
|
||||
<ul class="conflict-list">
|
||||
<li v-for="conflict in testResult.results[0].conflictRules" :key="conflict.ruleId">
|
||||
<strong>{{ conflict.ruleName }}</strong>(优先级:{{ conflict.priority }})- {{ conflict.reason }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div v-if="!testResult.results[0]?.matched && testResult.results[0]?.reason" class="reason-section">
|
||||
<el-alert
|
||||
title="未匹配原因"
|
||||
type="info"
|
||||
:closable="false"
|
||||
>
|
||||
{{ testResult.results[0].reason }}
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false)">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
import { testIntentRule, type IntentRuleTestResult } from '@/api/monitoring'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
rulePriority: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const testMessage = ref('')
|
||||
const testing = ref(false)
|
||||
const testResult = ref<IntentRuleTestResult | null>(null)
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
testMessage.value = ''
|
||||
testResult.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!testMessage.value.trim()) {
|
||||
ElMessage.warning('请输入测试消息')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
testResult.value = await testIntentRule(props.ruleId, {
|
||||
message: testMessage.value
|
||||
})
|
||||
ElMessage.success('测试完成')
|
||||
} catch (error) {
|
||||
ElMessage.error('测试失败')
|
||||
testResult.value = null
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-dialog-content {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.rule-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: var(--el-color-success);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--el-color-danger);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.match-details {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.conflict-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.conflict-list {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.conflict-list li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.reason-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
<template>
|
||||
<div class="intent-rule-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">意图规则管理</h1>
|
||||
<p class="page-desc">配置意图识别规则,让特定问题走固定回复或话术流程。[AC-IDSMETA-16]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterResponseType" placeholder="按响应类型筛选" clearable style="width: 150px;">
|
||||
<el-option v-for="opt in RESPONSE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建规则
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" class="rule-card" v-loading="loading">
|
||||
<el-table :data="rules" stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="意图名称" min-width="150" />
|
||||
<el-table-column prop="keywords" label="关键词" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="keyword-tags">
|
||||
<el-tag
|
||||
v-for="(kw, idx) in row.keywords?.slice(0, 3)"
|
||||
:key="idx"
|
||||
size="small"
|
||||
type="info"
|
||||
style="margin-right: 4px; margin-bottom: 4px;"
|
||||
>
|
||||
{{ kw }}
|
||||
</el-tag>
|
||||
<span v-if="row.keywords?.length > 3" class="more-tag">
|
||||
+{{ row.keywords.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="response_type" label="响应类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getResponseTagType(row.response_type)" size="small">
|
||||
{{ getResponseLabel(row.response_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="80" sortable />
|
||||
<el-table-column prop="hit_count" label="命中次数" width="100" sortable />
|
||||
<el-table-column label="元数据" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
|
||||
{{ Object.keys(row.metadata).length }} 个字段
|
||||
</el-tag>
|
||||
<span v-else class="no-metadata">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.is_enabled"
|
||||
@change="handleToggleEnabled(row)"
|
||||
active-color="#67C23A"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleTest(row)">
|
||||
<el-icon><Aim /></el-icon>
|
||||
测试
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑规则' : '新建规则'"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="意图名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入意图名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="优先级" prop="priority">
|
||||
<el-input-number v-model="formData.priority" :min="1" :max="100" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="关键词">
|
||||
<keyword-input :model-value="formData.keywords || []" @update:model-value="formData.keywords = $event" placeholder="输入关键词后按回车添加" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="正则表达式">
|
||||
<pattern-input :model-value="formData.patterns || []" @update:model-value="formData.patterns = $event" placeholder="输入正则表达式后按回车添加" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="响应类型" prop="response_type">
|
||||
<el-radio-group v-model="formData.response_type" @change="handleResponseTypeChange">
|
||||
<el-radio-button
|
||||
v-for="opt in RESPONSE_TYPE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<transition name="slide-fade" mode="out-in">
|
||||
<div v-if="formData.response_type === 'fixed'" key="fixed">
|
||||
<el-form-item label="固定回复" prop="fixed_reply">
|
||||
<el-input
|
||||
v-model="formData.fixed_reply"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入固定回复内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div v-else-if="formData.response_type === 'rag'" key="rag">
|
||||
<el-form-item label="知识库" prop="target_kb_ids">
|
||||
<el-select
|
||||
v-model="formData.target_kb_ids"
|
||||
multiple
|
||||
placeholder="请选择知识库"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-option
|
||||
v-for="kb in knowledgeBases"
|
||||
:key="kb.id"
|
||||
:label="kb.name"
|
||||
:value="kb.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div v-else-if="formData.response_type === 'flow'" key="flow">
|
||||
<el-form-item label="话术流程" prop="flow_id">
|
||||
<el-select v-model="formData.flow_id" placeholder="请选择话术流程" style="width: 100%;">
|
||||
<el-option
|
||||
v-for="flow in scriptFlows"
|
||||
:key="flow.id"
|
||||
:label="flow.name"
|
||||
:value="flow.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div v-else-if="formData.response_type === 'transfer'" key="transfer">
|
||||
<el-form-item label="转人工话术" prop="transfer_message">
|
||||
<el-input
|
||||
v-model="formData.transfer_message"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入转人工时的提示话术"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
|
||||
|
||||
<MetadataForm
|
||||
ref="metadataFormRef"
|
||||
scope="intent_rule"
|
||||
v-model="formData.metadata"
|
||||
:is-new-object="!isEdit"
|
||||
:col-span="12"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<TestDialog
|
||||
v-model:visible="testDialogVisible"
|
||||
:rule-id="testRuleId"
|
||||
:rule-name="testRuleName"
|
||||
:rule-priority="testRulePriority"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Aim } from '@element-plus/icons-vue'
|
||||
import {
|
||||
listIntentRules,
|
||||
createIntentRule,
|
||||
updateIntentRule,
|
||||
deleteIntentRule
|
||||
} from '@/api/intent-rule'
|
||||
import { listKnowledgeBases } from '@/api/knowledge-base'
|
||||
import { listScriptFlows } from '@/api/script-flow'
|
||||
import { MetadataForm } from '@/components/metadata'
|
||||
import { RESPONSE_TYPE_OPTIONS, RESPONSE_TYPE_MAP } from '@/types/intent-rule'
|
||||
import type { IntentRule, IntentRuleCreate, IntentRuleUpdate } from '@/types/intent-rule'
|
||||
import type { KnowledgeBase } from '@/types/knowledge-base'
|
||||
import type { ScriptFlow } from '@/types/script-flow'
|
||||
import type { MetadataPayload } from '@/types/metadata'
|
||||
import KeywordInput from './components/KeywordInput.vue'
|
||||
import PatternInput from './components/PatternInput.vue'
|
||||
import TestDialog from './components/TestDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const rules = ref<IntentRule[]>([])
|
||||
const knowledgeBases = ref<KnowledgeBase[]>([])
|
||||
const scriptFlows = ref<ScriptFlow[]>([])
|
||||
const filterResponseType = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const metadataFormRef = ref()
|
||||
const currentEditId = ref('')
|
||||
const testDialogVisible = ref(false)
|
||||
const testRuleId = ref('')
|
||||
const testRuleName = ref('')
|
||||
const testRulePriority = ref(0)
|
||||
|
||||
const defaultFormData = (): IntentRuleCreate => ({
|
||||
name: '',
|
||||
keywords: [],
|
||||
patterns: [],
|
||||
response_type: 'fixed',
|
||||
priority: 50,
|
||||
fixed_reply: '',
|
||||
target_kb_ids: [],
|
||||
flow_id: '',
|
||||
transfer_message: '',
|
||||
is_enabled: true,
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const formData = ref<IntentRuleCreate>(defaultFormData())
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入意图名称', trigger: 'blur' }],
|
||||
response_type: [{ required: true, message: '请选择响应类型', trigger: 'change' }],
|
||||
priority: [{ required: true, message: '请设置优先级', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const getResponseLabel = (type: string) => {
|
||||
return RESPONSE_TYPE_MAP[type]?.label || type
|
||||
}
|
||||
|
||||
const getResponseTagType = (type: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const colorMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
fixed: '',
|
||||
rag: 'success',
|
||||
flow: 'warning',
|
||||
transfer: 'danger'
|
||||
}
|
||||
return colorMap[type] || ''
|
||||
}
|
||||
|
||||
const loadRules = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listIntentRules({
|
||||
response_type: filterResponseType.value || undefined
|
||||
})
|
||||
rules.value = res.data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载规则列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadKnowledgeBases = async () => {
|
||||
try {
|
||||
const res = await listKnowledgeBases({ is_enabled: true })
|
||||
knowledgeBases.value = res.data || []
|
||||
} catch (error) {
|
||||
console.error('加载知识库列表失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadScriptFlows = async () => {
|
||||
try {
|
||||
const res = await listScriptFlows({ is_enabled: true })
|
||||
scriptFlows.value = res.data || []
|
||||
} catch (error) {
|
||||
console.error('加载话术流程列表失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
currentEditId.value = ''
|
||||
formData.value = defaultFormData()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: IntentRule) => {
|
||||
isEdit.value = true
|
||||
currentEditId.value = row.id
|
||||
formData.value = {
|
||||
name: row.name,
|
||||
keywords: row.keywords || [],
|
||||
patterns: row.patterns || [],
|
||||
response_type: row.response_type,
|
||||
priority: row.priority,
|
||||
fixed_reply: row.fixed_reply || '',
|
||||
target_kb_ids: row.target_kb_ids || [],
|
||||
flow_id: row.flow_id || '',
|
||||
transfer_message: row.transfer_message || '',
|
||||
is_enabled: row.is_enabled,
|
||||
metadata: row.metadata || {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: IntentRule) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该规则吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await deleteIntentRule(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadRules()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = (row: IntentRule) => {
|
||||
testRuleId.value = row.id
|
||||
testRuleName.value = row.name
|
||||
testRulePriority.value = row.priority
|
||||
testDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (row: IntentRule) => {
|
||||
try {
|
||||
await updateIntentRule(row.id, { is_enabled: row.is_enabled })
|
||||
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
|
||||
} catch (error) {
|
||||
row.is_enabled = !row.is_enabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResponseTypeChange = () => {
|
||||
formData.value.fixed_reply = ''
|
||||
formData.value.target_kb_ids = []
|
||||
formData.value.flow_id = ''
|
||||
formData.value.transfer_message = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (metadataFormRef.value) {
|
||||
const validation = await metadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const updateData: IntentRuleUpdate = {
|
||||
name: formData.value.name,
|
||||
keywords: formData.value.keywords,
|
||||
patterns: formData.value.patterns,
|
||||
response_type: formData.value.response_type,
|
||||
priority: formData.value.priority,
|
||||
is_enabled: formData.value.is_enabled,
|
||||
metadata: formData.value.metadata
|
||||
}
|
||||
if (formData.value.response_type === 'fixed') {
|
||||
updateData.fixed_reply = formData.value.fixed_reply
|
||||
} else if (formData.value.response_type === 'rag') {
|
||||
updateData.target_kb_ids = formData.value.target_kb_ids
|
||||
} else if (formData.value.response_type === 'flow') {
|
||||
updateData.flow_id = formData.value.flow_id
|
||||
} else if (formData.value.response_type === 'transfer') {
|
||||
updateData.transfer_message = formData.value.transfer_message
|
||||
}
|
||||
await updateIntentRule(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await createIntentRule(formData.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadRules()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(filterResponseType, () => {
|
||||
loadRules()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadRules()
|
||||
loadKnowledgeBases()
|
||||
loadScriptFlows()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intent-rule-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.keyword-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.more-tag {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
<template>
|
||||
<div class="document-list">
|
||||
<div class="list-header">
|
||||
<el-button type="primary" @click="handleUploadClick">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传文档
|
||||
</el-button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".txt,.md,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="documents" v-loading="loading" stripe>
|
||||
<el-table-column prop="fileName" label="文件名" min-width="200" />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="元数据" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="metadata-preview" v-if="row.metadata && Object.keys(row.metadata).length > 0">
|
||||
<el-tag
|
||||
v-for="(value, key) in getPreviewMetadata(row.metadata)"
|
||||
:key="key"
|
||||
size="small"
|
||||
type="info"
|
||||
class="metadata-tag"
|
||||
>
|
||||
{{ key }}: {{ formatMetadataValue(value) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="Object.keys(row.metadata).length > 3" size="small" type="info">
|
||||
+{{ Object.keys(row.metadata).length - 3 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="no-metadata">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="上传时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEditMetadata(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑元数据
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper" v-if="pagination.total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadDocuments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="uploadDialogVisible"
|
||||
title="上传文档"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="uploadForm" :rules="uploadRules" ref="uploadFormRef" label-width="80px">
|
||||
<el-form-item label="文件">
|
||||
<div class="file-info" v-if="selectedFile">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ selectedFile.name }}</span>
|
||||
<el-tag size="small" type="info">{{ formatFileSize(selectedFile.size) }}</el-tag>
|
||||
</div>
|
||||
<el-button v-else @click="handleUploadClick">选择文件</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">元数据配置 [AC-IDSMETA-15]</el-divider>
|
||||
|
||||
<MetadataForm
|
||||
ref="metadataFormRef"
|
||||
scope="kb_document"
|
||||
v-model="uploadForm.metadata"
|
||||
:is-new-object="true"
|
||||
:col-span="12"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="handleUpload">
|
||||
上传
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
title="编辑元数据"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<MetadataForm
|
||||
ref="editMetadataFormRef"
|
||||
scope="kb_document"
|
||||
v-model="editForm.metadata"
|
||||
:is-new-object="false"
|
||||
:show-deprecated="true"
|
||||
:col-span="12"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSaveMetadata">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Upload, Edit, Document } from '@element-plus/icons-vue'
|
||||
import { listDocuments, deleteDocument, getIndexJob } from '@/api/knowledge-base'
|
||||
import { metadataSchemaApi } from '@/api/metadata-schema'
|
||||
import { MetadataForm } from '@/components/metadata'
|
||||
import type { Document as DocType, IndexJob } from '@/types/knowledge-base'
|
||||
import type { MetadataPayload } from '@/types/metadata'
|
||||
import { useTenantStore } from '@/stores/tenant'
|
||||
|
||||
interface DocumentWithMetadata extends DocType {
|
||||
metadata?: MetadataPayload
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
kbId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'upload-success'): void
|
||||
}>()
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const loading = ref(false)
|
||||
const documents = ref<DocumentWithMetadata[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const uploadDialogVisible = ref(false)
|
||||
const editDialogVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const saving = ref(false)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const uploadFormRef = ref()
|
||||
const metadataFormRef = ref()
|
||||
const editMetadataFormRef = ref()
|
||||
const currentEditDoc = ref<DocumentWithMetadata | null>(null)
|
||||
|
||||
const uploadForm = ref({
|
||||
metadata: {} as MetadataPayload
|
||||
})
|
||||
|
||||
const editForm = ref({
|
||||
metadata: {} as MetadataPayload
|
||||
})
|
||||
|
||||
const uploadRules = {
|
||||
file: [{ required: true, message: '请选择文件', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getStatusType = (status: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const typeMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
pending: 'info',
|
||||
processing: 'warning',
|
||||
completed: 'success',
|
||||
failed: 'danger'
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
failed: '失败'
|
||||
}
|
||||
return labelMap[status] || status
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const getPreviewMetadata = (metadata: MetadataPayload) => {
|
||||
const keys = Object.keys(metadata).slice(0, 3)
|
||||
const result: MetadataPayload = {}
|
||||
keys.forEach(key => {
|
||||
result[key] = metadata[key]
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const formatMetadataValue = (value: string | number | boolean | string[] | undefined): string => {
|
||||
if (value === undefined || value === null) return ''
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const loadDocuments = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDocuments({
|
||||
kb_id: props.kbId,
|
||||
page: currentPage.value,
|
||||
page_size: pagination.value.pageSize
|
||||
})
|
||||
documents.value = res.data || []
|
||||
pagination.value = res.pagination
|
||||
} catch (error) {
|
||||
ElMessage.error('加载文档列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const allowedExtensions = ['.txt', '.md', '.pdf', '.doc', '.docx', '.xls', '.xlsx']
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
ElMessage.error('不支持的文件格式')
|
||||
return
|
||||
}
|
||||
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
ElMessage.error('文件大小不能超过 50MB')
|
||||
return
|
||||
}
|
||||
|
||||
selectedFile.value = file
|
||||
uploadForm.value.metadata = {}
|
||||
uploadDialogVisible.value = true
|
||||
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (metadataFormRef.value) {
|
||||
const validation = await metadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
formData.append('kb_id', props.kbId)
|
||||
formData.append('metadata', JSON.stringify(uploadForm.value.metadata))
|
||||
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const response = await fetch(`${baseUrl}/admin/kb/documents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Tenant-Id': tenantStore.currentTenantId
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (result.jobId) {
|
||||
ElMessage.success('文档上传成功,正在处理中...')
|
||||
emit('upload-success')
|
||||
loadDocuments()
|
||||
pollJobStatus(result.jobId)
|
||||
} else {
|
||||
ElMessage.error(result.message || '上传失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败,请重试')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uploadDialogVisible.value = false
|
||||
selectedFile.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditMetadata = (doc: DocumentWithMetadata) => {
|
||||
currentEditDoc.value = doc
|
||||
editForm.value.metadata = doc.metadata || {}
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
if (!currentEditDoc.value) return
|
||||
|
||||
if (editMetadataFormRef.value) {
|
||||
const validation = await editMetadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const response = await fetch(`${baseUrl}/admin/kb/documents/${currentEditDoc.value.docId}/metadata`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': tenantStore.currentTenantId
|
||||
},
|
||||
body: JSON.stringify({ metadata: editForm.value.metadata })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
ElMessage.success('元数据更新成功')
|
||||
loadDocuments()
|
||||
editDialogVisible.value = false
|
||||
} else {
|
||||
const result = await response.json()
|
||||
ElMessage.error(result.message || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('更新失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pollJobStatus = async (jobId: string) => {
|
||||
const maxPolls = 60
|
||||
let pollCount = 0
|
||||
|
||||
const poll = async () => {
|
||||
if (pollCount >= maxPolls) return
|
||||
pollCount++
|
||||
|
||||
try {
|
||||
const job: IndexJob = await getIndexJob(jobId)
|
||||
if (job.status === 'completed') {
|
||||
ElMessage.success('文档处理完成')
|
||||
loadDocuments()
|
||||
} else if (job.status === 'failed') {
|
||||
ElMessage.error(`文档处理失败: ${job.errorMsg || '未知错误'}`)
|
||||
loadDocuments()
|
||||
} else {
|
||||
setTimeout(poll, 3000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询任务状态失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
const handleDelete = async (row: DocumentWithMetadata) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该文档吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await deleteDocument(row.docId)
|
||||
ElMessage.success('删除成功')
|
||||
loadDocuments()
|
||||
emit('upload-success')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.document-list {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.metadata-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metadata-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
<template>
|
||||
<div class="knowledge-base-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">知识库管理</h1>
|
||||
<p class="page-desc">创建和管理多个知识库,按类型分类管理文档。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterType" placeholder="按类型筛选" clearable style="width: 140px;">
|
||||
<el-option v-for="opt in KB_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建知识库
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kb-grid" v-loading="loading">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="kb in knowledgeBases" :key="kb.id">
|
||||
<el-card shadow="hover" class="kb-card" @click="handleViewKb(kb)">
|
||||
<div class="kb-header">
|
||||
<div class="kb-type-badge" :style="{ backgroundColor: getTypeColor(kb.kbType) }">
|
||||
{{ getTypeLabel(kb.kbType) }}
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="kb.isEnabled"
|
||||
@click.stop
|
||||
@change="handleToggleEnabled(kb)"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="kb-name">{{ kb.name }}</h3>
|
||||
<p class="kb-desc">{{ kb.description || '暂无描述' }}</p>
|
||||
<div class="kb-stats">
|
||||
<div class="stat-item">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ kb.docCount }} 文档</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<el-icon><Rank /></el-icon>
|
||||
<span>优先级 {{ kb.priority }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb-actions" @click.stop>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(kb)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(kb)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-empty v-if="!loading && knowledgeBases.length === 0" description="暂无知识库" />
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑知识库' : '新建知识库'"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入知识库名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="kb_type">
|
||||
<el-select v-model="formData.kb_type" placeholder="请选择类型" style="width: 100%;">
|
||||
<el-option v-for="opt in KB_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入描述(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-input-number v-model="formData.priority" :min="1" :max="100" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer
|
||||
v-model="documentDrawer"
|
||||
:title="currentKb?.name || '文档管理'"
|
||||
size="70%"
|
||||
destroy-on-close
|
||||
>
|
||||
<document-list
|
||||
v-if="currentKb"
|
||||
:kb-id="currentKb.id"
|
||||
@upload-success="handleUploadSuccess"
|
||||
/>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Document, Rank } from '@element-plus/icons-vue'
|
||||
import {
|
||||
listKnowledgeBases,
|
||||
createKnowledgeBase,
|
||||
updateKnowledgeBase,
|
||||
deleteKnowledgeBase
|
||||
} from '@/api/knowledge-base'
|
||||
import { KB_TYPE_OPTIONS, KB_TYPE_MAP } from '@/types/knowledge-base'
|
||||
import type { KnowledgeBase, KnowledgeBaseCreate, KnowledgeBaseUpdate } from '@/types/knowledge-base'
|
||||
import DocumentList from './components/DocumentList.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const knowledgeBases = ref<KnowledgeBase[]>([])
|
||||
const filterType = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const documentDrawer = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const currentKb = ref<KnowledgeBase | null>(null)
|
||||
const currentEditId = ref('')
|
||||
|
||||
const defaultFormData = (): KnowledgeBaseCreate => ({
|
||||
name: '',
|
||||
kb_type: 'general',
|
||||
description: '',
|
||||
priority: 50
|
||||
})
|
||||
|
||||
const formData = ref<KnowledgeBaseCreate>(defaultFormData())
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入知识库名称', trigger: 'blur' }],
|
||||
kb_type: [{ required: true, message: '请选择类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
return KB_TYPE_MAP[type]?.label || type
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
product: '#409EFF',
|
||||
faq: '#67C23A',
|
||||
script: '#E6A23C',
|
||||
policy: '#F56C6C',
|
||||
general: '#909399'
|
||||
}
|
||||
return colorMap[type] || '#909399'
|
||||
}
|
||||
|
||||
const loadKnowledgeBases = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listKnowledgeBases({
|
||||
kb_type: filterType.value || undefined
|
||||
})
|
||||
knowledgeBases.value = res.data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载知识库列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
currentEditId.value = ''
|
||||
formData.value = defaultFormData()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (kb: KnowledgeBase) => {
|
||||
isEdit.value = true
|
||||
currentEditId.value = kb.id
|
||||
formData.value = {
|
||||
name: kb.name,
|
||||
kb_type: kb.kbType as any,
|
||||
description: kb.description || '',
|
||||
priority: kb.priority
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (kb: KnowledgeBase) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'删除知识库将同时删除所有关联文档和索引数据,确定要删除吗?',
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
await deleteKnowledgeBase(kb.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadKnowledgeBases()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (kb: KnowledgeBase) => {
|
||||
try {
|
||||
await updateKnowledgeBase(kb.id, { is_enabled: kb.isEnabled })
|
||||
ElMessage.success(kb.isEnabled ? '已启用' : '已禁用')
|
||||
} catch (error) {
|
||||
kb.isEnabled = !kb.isEnabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewKb = (kb: KnowledgeBase) => {
|
||||
currentKb.value = kb
|
||||
documentDrawer.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const updateData: KnowledgeBaseUpdate = {
|
||||
name: formData.value.name,
|
||||
kb_type: formData.value.kb_type,
|
||||
description: formData.value.description,
|
||||
priority: formData.value.priority
|
||||
}
|
||||
await updateKnowledgeBase(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await createKnowledgeBase(formData.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadKnowledgeBases()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
loadKnowledgeBases()
|
||||
}
|
||||
|
||||
watch(filterType, () => {
|
||||
loadKnowledgeBases()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadKnowledgeBases()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-base-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kb-grid {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.kb-card {
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.kb-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.kb-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kb-type-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kb-name {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-desc {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.kb-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.stat-item .el-icon {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.kb-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,587 @@
|
|||
<template>
|
||||
<div class="metadata-schema-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">元数据字段配置</h1>
|
||||
<p class="page-desc">配置知识库、意图规则、话术流程、Prompt模板的动态元数据字段。[AC-IDSMETA-13]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterStatus" placeholder="按状态筛选" clearable style="width: 140px;">
|
||||
<el-option
|
||||
v-for="opt in METADATA_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建字段
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" class="schema-card" v-loading="loading">
|
||||
<el-table :data="fields" stripe style="width: 100%">
|
||||
<el-table-column prop="field_key" label="字段标识" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<code class="field-key">{{ row.field_key }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="label" label="显示名称" width="120" />
|
||||
<el-table-column prop="type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ getTypeLabel(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="required" label="必填" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.required ? 'danger' : 'info'" size="small">
|
||||
{{ row.required ? '必填' : '可选' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scope" label="适用范围" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="scope-tags">
|
||||
<el-tag
|
||||
v-for="s in row.scope"
|
||||
:key="s"
|
||||
size="small"
|
||||
class="scope-tag"
|
||||
>
|
||||
{{ getScopeLabel(s) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="STATUS_TAG_MAP[row.status as MetadataFieldStatus]" size="small">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd: MetadataFieldStatus) => handleStatusChange(row, cmd)">
|
||||
<el-button type="warning" link size="small">
|
||||
<el-icon><Switch /></el-icon>
|
||||
状态
|
||||
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="opt in METADATA_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:command="opt.value"
|
||||
:disabled="opt.value === row.status"
|
||||
>
|
||||
<el-tag :type="STATUS_TAG_MAP[opt.value as MetadataFieldStatus]" size="small">{{ opt.label }}</el-tag>
|
||||
<span class="status-desc">{{ opt.description }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && fields.length === 0" description="暂无元数据字段" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑元数据字段' : '新建元数据字段'"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="字段标识" prop="field_key">
|
||||
<el-input
|
||||
v-model="formData.field_key"
|
||||
placeholder="如:grade, subject"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
<div class="field-hint">仅允许小写字母、数字、下划线</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="显示名称" prop="label">
|
||||
<el-input v-model="formData.label" placeholder="如:年级" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="字段类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%;" @change="onTypeChange">
|
||||
<el-option
|
||||
v-for="opt in METADATA_TYPE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formData.status" style="width: 100%;">
|
||||
<el-option
|
||||
v-for="opt in METADATA_STATUS_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
>
|
||||
<div class="status-option">
|
||||
<el-tag :type="STATUS_TAG_MAP[opt.value as MetadataFieldStatus]" size="small">{{ opt.label }}</el-tag>
|
||||
<span class="status-option-desc">{{ opt.description }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="适用范围" prop="scope">
|
||||
<el-checkbox-group v-model="formData.scope">
|
||||
<el-checkbox
|
||||
v-for="opt in METADATA_SCOPE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="是否必填">
|
||||
<el-switch v-model="formData.required" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="可过滤">
|
||||
<el-switch v-model="formData.is_filterable" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="排序特征">
|
||||
<el-switch v-model="formData.is_rank_feature" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="默认值">
|
||||
<el-input v-model="formData.default_value" placeholder="可选默认值" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formData.type === 'enum' || formData.type === 'array_enum'"
|
||||
label="选项列表"
|
||||
prop="options"
|
||||
>
|
||||
<div class="options-container">
|
||||
<el-tag
|
||||
v-for="(opt, idx) in formData.options"
|
||||
:key="idx"
|
||||
closable
|
||||
@close="removeOption(idx)"
|
||||
class="option-tag"
|
||||
>
|
||||
{{ opt }}
|
||||
</el-tag>
|
||||
<el-input
|
||||
v-model="newOption"
|
||||
placeholder="输入后回车添加"
|
||||
size="small"
|
||||
class="option-input"
|
||||
@keyup.enter="addOption"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left" v-if="isEdit && formData.status === 'deprecated'">
|
||||
<el-tag type="danger">废弃影响范围</el-tag>
|
||||
</el-divider>
|
||||
<div v-if="isEdit && formData.status === 'deprecated'" class="deprecated-impact">
|
||||
<el-alert type="warning" :closable="false" show-icon>
|
||||
<template #title>
|
||||
将此字段设为废弃后,以下影响将生效 [AC-IDSMETA-14]
|
||||
</template>
|
||||
</el-alert>
|
||||
<ul class="impact-list">
|
||||
<li>新建对象时,此字段将不再显示</li>
|
||||
<li>已有对象的历史数据仍可查看,但不可编辑</li>
|
||||
<li>作为过滤条件时,仅对历史数据生效</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Switch, ArrowDown } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { metadataSchemaApi } from '@/api/metadata-schema'
|
||||
import {
|
||||
METADATA_STATUS_OPTIONS,
|
||||
METADATA_SCOPE_OPTIONS,
|
||||
METADATA_TYPE_OPTIONS,
|
||||
STATUS_TAG_MAP,
|
||||
type MetadataFieldDefinition,
|
||||
type MetadataFieldCreateRequest,
|
||||
type MetadataFieldUpdateRequest,
|
||||
type MetadataFieldStatus,
|
||||
type MetadataScope
|
||||
} from '@/types/metadata'
|
||||
|
||||
const loading = ref(false)
|
||||
const fields = ref<MetadataFieldDefinition[]>([])
|
||||
const filterStatus = ref<MetadataFieldStatus | ''>('')
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const newOption = ref('')
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
field_key: '',
|
||||
label: '',
|
||||
type: 'string' as 'string' | 'number' | 'boolean' | 'enum' | 'array_enum',
|
||||
required: false,
|
||||
options: [] as string[],
|
||||
default_value: '' as string | number | boolean,
|
||||
scope: [] as MetadataScope[],
|
||||
is_filterable: true,
|
||||
is_rank_feature: false,
|
||||
status: 'draft' as MetadataFieldStatus
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
field_key: [
|
||||
{ required: true, message: '请输入字段标识', trigger: 'blur' },
|
||||
{ pattern: /^[a-z0-9_]+$/, message: '仅允许小写字母、数字、下划线', trigger: 'blur' }
|
||||
],
|
||||
label: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择字段类型', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
scope: [{ type: 'array', min: 1, message: '请至少选择一个适用范围', trigger: 'change' }],
|
||||
options: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if ((formData.type === 'enum' || formData.type === 'array_enum') && (!value || value.length === 0)) {
|
||||
callback(new Error('枚举类型必须至少有一个选项'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
return METADATA_TYPE_OPTIONS.find(o => o.value === type)?.label || type
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: MetadataFieldStatus) => {
|
||||
return METADATA_STATUS_OPTIONS.find(o => o.value === status)?.label || status
|
||||
}
|
||||
|
||||
const getScopeLabel = (scope: MetadataScope) => {
|
||||
return METADATA_SCOPE_OPTIONS.find(o => o.value === scope)?.label || scope
|
||||
}
|
||||
|
||||
const fetchFields = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await metadataSchemaApi.list(filterStatus.value || undefined)
|
||||
fields.value = res.items || []
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || '获取元数据字段失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
Object.assign(formData, {
|
||||
id: '',
|
||||
field_key: '',
|
||||
label: '',
|
||||
type: 'string',
|
||||
required: false,
|
||||
options: [],
|
||||
default_value: '',
|
||||
scope: [],
|
||||
is_filterable: true,
|
||||
is_rank_feature: false,
|
||||
status: 'draft'
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (field: MetadataFieldDefinition) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: field.id,
|
||||
field_key: field.field_key,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options: field.options || [],
|
||||
default_value: field.default ?? '',
|
||||
scope: [...field.scope],
|
||||
is_filterable: field.is_filterable,
|
||||
is_rank_feature: field.is_rank_feature,
|
||||
status: field.status
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (field: MetadataFieldDefinition) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除字段「${field.label}(${field.field_key})」吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await metadataSchemaApi.delete(field.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchFields()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (field: MetadataFieldDefinition, newStatus: MetadataFieldStatus) => {
|
||||
if (newStatus === field.status) return
|
||||
|
||||
const statusDesc = METADATA_STATUS_OPTIONS.find(o => o.value === newStatus)?.description
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要将字段「${field.label}」状态改为「${getStatusLabel(newStatus)}」吗?\n\n${statusDesc}`,
|
||||
'状态变更确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await metadataSchemaApi.update(field.id, { status: newStatus })
|
||||
ElMessage.success('状态更新成功')
|
||||
fetchFields()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.response?.data?.message || '状态更新失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onTypeChange = () => {
|
||||
if (formData.type !== 'enum' && formData.type !== 'array_enum') {
|
||||
formData.options = []
|
||||
}
|
||||
}
|
||||
|
||||
const addOption = () => {
|
||||
const value = newOption.value.trim()
|
||||
if (value && !formData.options.includes(value)) {
|
||||
formData.options.push(value)
|
||||
newOption.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
formData.options.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: MetadataFieldCreateRequest | MetadataFieldUpdateRequest = {
|
||||
field_key: formData.field_key,
|
||||
label: formData.label,
|
||||
type: formData.type,
|
||||
required: formData.required,
|
||||
scope: formData.scope,
|
||||
is_filterable: formData.is_filterable,
|
||||
is_rank_feature: formData.is_rank_feature,
|
||||
status: formData.status
|
||||
}
|
||||
|
||||
if (formData.type === 'enum' || formData.type === 'array_enum') {
|
||||
data.options = formData.options
|
||||
}
|
||||
|
||||
if (formData.default_value !== '') {
|
||||
data.default = formData.default_value
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await metadataSchemaApi.update(formData.id, data as MetadataFieldUpdateRequest)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await metadataSchemaApi.create(data as MetadataFieldCreateRequest)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchFields()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(filterStatus, () => {
|
||||
fetchFields()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchFields()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.metadata-schema-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.schema-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.field-key {
|
||||
padding: 2px 6px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.scope-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.status-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-option-desc {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.option-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option-input {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.deprecated-impact {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-color-warning-light-9);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.impact-list {
|
||||
margin: 12px 0 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,632 @@
|
|||
<template>
|
||||
<div class="conversation-tracking-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">对话追踪</h1>
|
||||
<p class="page-desc">查看对话记录,追踪执行链路,导出对话数据</p>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" class="filter-card">
|
||||
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||
<el-form-item label="会话 ID">
|
||||
<el-input v-model="queryParams.session_id" placeholder="输入会话 ID" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="话术流程">
|
||||
<el-select v-model="queryParams.has_flow" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="有" :value="true" />
|
||||
<el-option label="无" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="护栏触发">
|
||||
<el-select v-model="queryParams.has_guardrail" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="已触发" :value="true" />
|
||||
<el-option label="未触发" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
<el-button type="success" @click="handleExport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover" class="table-card" v-loading="loading">
|
||||
<el-table :data="tableData" style="width: 100%" @row-click="handleRowClick">
|
||||
<el-table-column prop="id" label="ID" width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="sessionId" label="会话 ID" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="userMessage" label="用户消息" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="aiReply" label="AI 回复" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.aiReply">{{ scope.row.aiReply }}</span>
|
||||
<span v-else class="text-muted">无回复</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流程" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.hasFlow" type="success" size="small">有</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="护栏" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.hasGuardrail" type="danger" size="small">触发</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="getList"
|
||||
@current-change="getList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
title="对话详情"
|
||||
size="60%"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="detailLoading" class="detail-container">
|
||||
<el-empty v-if="!conversationDetail && !detailLoading" description="暂无详情数据" />
|
||||
<div v-else-if="conversationDetail">
|
||||
<el-descriptions :column="2" border class="info-descriptions">
|
||||
<el-descriptions-item label="对话 ID">{{ conversationDetail.conversationId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会话 ID">{{ conversationDetail.sessionId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执行耗时">{{ conversationDetail.executionTimeMs ? `${conversationDetail.executionTimeMs}ms` : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="置信度">{{ conversationDetail.confidence ? `${(conversationDetail.confidence * 100).toFixed(1)}%` : '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider content-position="left">用户消息</el-divider>
|
||||
<div class="message-box user-message">
|
||||
{{ conversationDetail.userMessage }}
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">AI 回复</el-divider>
|
||||
<div class="message-box ai-message">
|
||||
{{ conversationDetail.aiReply || '无回复' }}
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left" v-if="conversationDetail.triggeredRules?.length">触发的规则</el-divider>
|
||||
<div v-if="conversationDetail.triggeredRules?.length" class="rules-list">
|
||||
<el-tag v-for="rule in conversationDetail.triggeredRules" :key="rule.id" class="rule-tag">
|
||||
{{ rule.name }} ({{ rule.responseType }})
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left" v-if="conversationDetail.usedTemplate">使用的模板</el-divider>
|
||||
<div v-if="conversationDetail.usedTemplate" class="template-info">
|
||||
<el-tag type="success">{{ conversationDetail.usedTemplate.name }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left" v-if="conversationDetail.usedFlow">话术流程</el-divider>
|
||||
<div v-if="conversationDetail.usedFlow" class="flow-info">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="状态">{{ conversationDetail.usedFlow.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前步骤">{{ conversationDetail.usedFlow.currentStep }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left" v-if="conversationDetail.guardrailTriggered">护栏触发</el-divider>
|
||||
<div v-if="conversationDetail.guardrailTriggered" class="guardrail-info">
|
||||
<el-alert type="warning" :closable="false">
|
||||
<template #title>
|
||||
触发词汇: {{ conversationDetail.guardrailWords?.join(', ') || '未知' }}
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left" v-if="conversationDetail.executionSteps?.length">执行链路</el-divider>
|
||||
<div v-if="conversationDetail.executionSteps?.length" class="execution-steps">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="step in conversationDetail.executionSteps"
|
||||
:key="step.step"
|
||||
:type="getStepStatusType(step.status)"
|
||||
:hollow="step.status === 'skipped'"
|
||||
size="large"
|
||||
>
|
||||
<el-card shadow="never" class="step-card" @click="toggleStepDetail(step.step)">
|
||||
<div class="step-header">
|
||||
<div class="step-info">
|
||||
<span class="step-number">Step {{ step.step }}</span>
|
||||
<span class="step-name">{{ getStepName(step.name) }}</span>
|
||||
</div>
|
||||
<div class="step-meta">
|
||||
<el-tag :type="getStepStatusType(step.status)" size="small" effect="plain">
|
||||
{{ step.status }}
|
||||
</el-tag>
|
||||
<span class="step-duration">{{ step.duration_ms }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expandedSteps.includes(step.step)" class="step-detail">
|
||||
<el-divider content-position="left">输入</el-divider>
|
||||
<pre class="code-block"><code>{{ JSON.stringify(step.input, null, 2) }}</code></pre>
|
||||
<el-divider content-position="left">输出</el-divider>
|
||||
<pre class="code-block"><code>{{ JSON.stringify(step.output, null, 2) }}</code></pre>
|
||||
<template v-if="step.error">
|
||||
<el-divider content-position="left">错误</el-divider>
|
||||
<el-alert type="error" :closable="false">{{ step.error }}</el-alert>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="exportDialogVisible" title="导出对话记录" width="500px">
|
||||
<el-form :model="exportForm" label-width="100px">
|
||||
<el-form-item label="导出格式">
|
||||
<el-radio-group v-model="exportForm.format">
|
||||
<el-radio value="json">JSON</el-radio>
|
||||
<el-radio value="csv">CSV</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="exportForm.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="exportDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmExport" :loading="exportLoading">确认导出</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="exportStatusDialogVisible" title="导出状态" width="500px">
|
||||
<div v-if="exportTask" class="export-status">
|
||||
<el-progress
|
||||
v-if="exportTask.status === 'processing'"
|
||||
:percentage="50"
|
||||
:indeterminate="true"
|
||||
status="warning"
|
||||
/>
|
||||
<el-result
|
||||
v-else-if="exportTask.status === 'completed'"
|
||||
icon="success"
|
||||
title="导出完成"
|
||||
:sub-title="`共导出 ${exportTask.totalRows} 条记录,文件大小 ${formatFileSize(exportTask.fileSize || 0)}`"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="downloadExport">下载文件</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
<el-result
|
||||
v-else-if="exportTask.status === 'failed'"
|
||||
icon="error"
|
||||
title="导出失败"
|
||||
:sub-title="exportTask.errorMessage"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import {
|
||||
listConversations,
|
||||
getConversationDetail,
|
||||
createExportTask,
|
||||
getExportStatus,
|
||||
getExportDownloadUrl,
|
||||
type ConversationItem,
|
||||
type ConversationDetail,
|
||||
type ExportTaskResponse
|
||||
} from '@/api/monitoring'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<ConversationItem[]>([])
|
||||
const total = ref(0)
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
session_id: '',
|
||||
has_flow: undefined as boolean | undefined,
|
||||
has_guardrail: undefined as boolean | undefined
|
||||
})
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const conversationDetail = ref<ConversationDetail | null>(null)
|
||||
const expandedSteps = ref<number[]>([])
|
||||
|
||||
const exportDialogVisible = ref(false)
|
||||
const exportStatusDialogVisible = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const exportTask = ref<ExportTaskResponse | null>(null)
|
||||
const exportForm = reactive({
|
||||
format: 'json' as 'json' | 'csv',
|
||||
dateRange: null as [string, string] | null
|
||||
})
|
||||
|
||||
const stepNameMap: Record<string, string> = {
|
||||
'InputScanner': '输入扫描',
|
||||
'FlowEngine': '流程引擎',
|
||||
'IntentRouter': '意图路由',
|
||||
'QueryRewriter': '查询重写',
|
||||
'MultiKBRetrieval': '多知识库检索',
|
||||
'ResultRanker': '结果排序',
|
||||
'PromptBuilder': 'Prompt 构建',
|
||||
'LLMGenerate': 'LLM 生成',
|
||||
'OutputFilter': '输出过滤',
|
||||
'Confidence': '置信度计算',
|
||||
'Memory': '记忆存储',
|
||||
'Response': '响应返回'
|
||||
}
|
||||
|
||||
const getStepName = (name: string) => {
|
||||
return stepNameMap[name] || name
|
||||
}
|
||||
|
||||
const getStepStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return 'success'
|
||||
case 'failed': return 'danger'
|
||||
case 'skipped': return 'info'
|
||||
default: return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStepDetail = (step: number) => {
|
||||
const index = expandedSteps.value.indexOf(step)
|
||||
if (index > -1) {
|
||||
expandedSteps.value.splice(index, 1)
|
||||
} else {
|
||||
expandedSteps.value.push(step)
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
|
||||
}
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: queryParams.page,
|
||||
pageSize: queryParams.pageSize
|
||||
}
|
||||
if (queryParams.session_id) {
|
||||
params.session_id = queryParams.session_id
|
||||
}
|
||||
if (queryParams.has_flow !== undefined) {
|
||||
params.has_flow = queryParams.has_flow
|
||||
}
|
||||
if (queryParams.has_guardrail !== undefined) {
|
||||
params.has_guardrail = queryParams.has_guardrail
|
||||
}
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
|
||||
const res = await listConversations(params)
|
||||
tableData.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch conversations:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
queryParams.page = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
const resetQuery = () => {
|
||||
queryParams.session_id = ''
|
||||
queryParams.has_flow = undefined
|
||||
queryParams.has_guardrail = undefined
|
||||
dateRange.value = null
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
const handleRowClick = async (row: ConversationItem) => {
|
||||
drawerVisible.value = true
|
||||
detailLoading.value = true
|
||||
expandedSteps.value = []
|
||||
|
||||
try {
|
||||
conversationDetail.value = await getConversationDetail(row.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch conversation detail:', error)
|
||||
ElMessage.error('获取对话详情失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
exportForm.format = 'json'
|
||||
exportForm.dateRange = dateRange.value
|
||||
exportDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmExport = async () => {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const data: any = {
|
||||
format: exportForm.format
|
||||
}
|
||||
if (exportForm.dateRange && exportForm.dateRange.length === 2) {
|
||||
data.start_date = exportForm.dateRange[0]
|
||||
data.end_date = exportForm.dateRange[1]
|
||||
}
|
||||
|
||||
const task = await createExportTask(data)
|
||||
exportTask.value = task
|
||||
exportDialogVisible.value = false
|
||||
exportStatusDialogVisible.value = true
|
||||
|
||||
if (task.status === 'processing') {
|
||||
pollExportStatus(task.taskId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create export task:', error)
|
||||
ElMessage.error('创建导出任务失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pollExportStatus = async (taskId: string) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getExportStatus(taskId)
|
||||
exportTask.value = status
|
||||
|
||||
if (status.status === 'processing') {
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll export status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
const downloadExport = () => {
|
||||
if (exportTask.value) {
|
||||
const url = getExportDownloadUrl(exportTask.value.taskId)
|
||||
const link = document.createElement('a')
|
||||
const baseURL = import.meta.env.VITE_APP_BASE_API || '/api'
|
||||
link.href = baseURL + url
|
||||
link.download = exportTask.value.fileName || 'export.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conversation-tracking-page {
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.info-descriptions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message-box {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
background-color: var(--primary-lighter);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ai-message {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-tag {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.execution-steps {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.step-card:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.step-detail {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.export-status {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.conversation-tracking-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
<template>
|
||||
<div class="guardrail-monitoring-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">输出护栏监控</h1>
|
||||
<p class="page-desc">查看护栏拦截统计和禁词触发记录。</p>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<el-select v-model="filterCategory" placeholder="按类别筛选" clearable @change="loadStats">
|
||||
<el-option label="竞品" value="competitor" />
|
||||
<el-option label="敏感" value="sensitive" />
|
||||
<el-option label="政治" value="political" />
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="summary-cards">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon" style="background-color: var(--el-color-warning-light-9);">
|
||||
<el-icon :size="24" color="var(--el-color-warning)"><Warning /></el-icon>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-value">{{ stats.totalTriggers }}</div>
|
||||
<div class="summary-label">总触发次数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon" style="background-color: var(--el-color-danger-light-9);">
|
||||
<el-icon :size="24" color="var(--el-color-danger)"><CircleClose /></el-icon>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-value">{{ stats.totalBlocks }}</div>
|
||||
<div class="summary-label">总拦截次数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon" style="background-color: var(--el-color-info-light-9);">
|
||||
<el-icon :size="24" color="var(--el-color-info)"><DataAnalysis /></el-icon>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-value">{{ (stats.blockRate * 100).toFixed(2) }}%</div>
|
||||
<div class="summary-label">拦截率</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover" class="word-table-card" v-loading="loading">
|
||||
<template #header>
|
||||
<span>禁词统计列表</span>
|
||||
</template>
|
||||
<el-table :data="stats.words" stripe style="width: 100%">
|
||||
<el-table-column prop="word" label="禁词" min-width="120" />
|
||||
<el-table-column prop="category" label="类别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getCategoryType(row.category)" size="small">
|
||||
{{ getCategoryLabel(row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="strategy" label="策略" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ getStrategyLabel(row.strategy) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hitCount" label="命中次数" width="100" sortable />
|
||||
<el-table-column prop="blockCount" label="拦截次数" width="100" sortable />
|
||||
<el-table-column prop="lastHitAt" label="最近命中" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.lastHitAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.strategy === 'block'"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="showBlocks(row)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="category-card">
|
||||
<template #header>
|
||||
<span>类别分布</span>
|
||||
</template>
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="(count, category) in stats.categoryBreakdown"
|
||||
:key="category"
|
||||
class="category-item"
|
||||
>
|
||||
<div class="category-label">
|
||||
<el-tag :type="getCategoryType(category)" size="small">
|
||||
{{ getCategoryLabel(category) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="category-bar">
|
||||
<el-progress
|
||||
:percentage="getCategoryPercentage(category)"
|
||||
:color="getCategoryColor(category)"
|
||||
:stroke-width="12"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="category-count">{{ count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-dialog
|
||||
v-model="blocksDialogVisible"
|
||||
:title="`拦截记录 - ${currentWord}`"
|
||||
width="700px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-table :data="blocks" v-loading="blocksLoading" stripe>
|
||||
<el-table-column prop="sessionId" label="会话ID" min-width="180" />
|
||||
<el-table-column prop="originalText" label="原始文本" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-text truncated>{{ row.originalText }}</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="filteredText" label="处理后" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-text truncated>{{ row.filteredText }}</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="blockedAt" label="拦截时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.blockedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="blocksPage"
|
||||
:page-size="20"
|
||||
:total="blocksTotal"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadBlocks"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Warning, CircleClose, DataAnalysis } from '@element-plus/icons-vue'
|
||||
import { getGuardrailStats, getGuardrailBlocks, type GuardrailStatsResponse, type GuardrailBlockRecord } from '@/api/monitoring'
|
||||
|
||||
const loading = ref(false)
|
||||
const filterCategory = ref('')
|
||||
const stats = ref<GuardrailStatsResponse>({
|
||||
totalBlocks: 0,
|
||||
totalTriggers: 0,
|
||||
blockRate: 0,
|
||||
words: [],
|
||||
categoryBreakdown: {
|
||||
competitor: 0,
|
||||
sensitive: 0,
|
||||
political: 0,
|
||||
custom: 0
|
||||
}
|
||||
})
|
||||
|
||||
const blocksDialogVisible = ref(false)
|
||||
const blocksLoading = ref(false)
|
||||
const currentWordId = ref('')
|
||||
const currentWord = ref('')
|
||||
const blocks = ref<GuardrailBlockRecord[]>([])
|
||||
const blocksPage = ref(1)
|
||||
const blocksTotal = ref(0)
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
competitor: '竞品',
|
||||
sensitive: '敏感',
|
||||
political: '政治',
|
||||
custom: '自定义'
|
||||
}
|
||||
return labels[category] || category
|
||||
}
|
||||
|
||||
const getCategoryType = (category: string) => {
|
||||
const types: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
competitor: 'danger',
|
||||
sensitive: 'warning',
|
||||
political: 'danger',
|
||||
custom: 'info'
|
||||
}
|
||||
return types[category] || 'info'
|
||||
}
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
competitor: '#F56C6C',
|
||||
sensitive: '#E6A23C',
|
||||
political: '#F56C6C',
|
||||
custom: '#909399'
|
||||
}
|
||||
return colors[category] || '#909399'
|
||||
}
|
||||
|
||||
const getStrategyLabel = (strategy: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
mask: '掩码',
|
||||
replace: '替换',
|
||||
block: '拦截'
|
||||
}
|
||||
return labels[strategy] || strategy
|
||||
}
|
||||
|
||||
const getCategoryPercentage = computed(() => {
|
||||
return (category: string) => {
|
||||
const total = stats.value.totalTriggers
|
||||
if (total === 0) return 0
|
||||
return (stats.value.categoryBreakdown[category] / total) * 100
|
||||
}
|
||||
})
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (filterCategory.value) {
|
||||
params.category = filterCategory.value
|
||||
}
|
||||
stats.value = await getGuardrailStats(params)
|
||||
} catch (error) {
|
||||
ElMessage.error('加载统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showBlocks = (word: GuardrailStatsResponse['words'][0]) => {
|
||||
currentWordId.value = word.wordId
|
||||
currentWord.value = word.word
|
||||
blocksPage.value = 1
|
||||
blocksDialogVisible.value = true
|
||||
loadBlocks()
|
||||
}
|
||||
|
||||
const loadBlocks = async () => {
|
||||
blocksLoading.value = true
|
||||
try {
|
||||
const res = await getGuardrailBlocks(currentWordId.value, {
|
||||
page: blocksPage.value,
|
||||
pageSize: 20
|
||||
})
|
||||
blocks.value = res.data
|
||||
blocksTotal.value = res.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载拦截记录失败')
|
||||
} finally {
|
||||
blocksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guardrail-monitoring-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.word-table-card,
|
||||
.category-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.category-bar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
<template>
|
||||
<div class="monitoring-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">意图规则监控</h1>
|
||||
<p class="page-desc">查看意图规则的命中统计和详细记录</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 280px;"
|
||||
@change="loadStats"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterResponseType"
|
||||
placeholder="响应类型"
|
||||
clearable
|
||||
style="width: 140px;"
|
||||
@change="loadStats"
|
||||
>
|
||||
<el-option label="固定回复" value="fixed" />
|
||||
<el-option label="知识库检索" value="rag" />
|
||||
<el-option label="话术流程" value="flow" />
|
||||
<el-option label="转人工" value="transfer" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="stats-cards">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="总命中次数" :value="stats.totalHits">
|
||||
<template #suffix>
|
||||
<span class="stat-suffix">次</span>
|
||||
</template>
|
||||
</el-statistic>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="总对话数" :value="stats.totalConversations">
|
||||
<template #suffix>
|
||||
<span class="stat-suffix">条</span>
|
||||
</template>
|
||||
</el-statistic>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="命中率" :value="(stats.hitRate * 100).toFixed(1)">
|
||||
<template #suffix>
|
||||
<span class="stat-suffix">%</span>
|
||||
</template>
|
||||
</el-statistic>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="hover" class="rule-stats-card" v-loading="loading">
|
||||
<template #header>
|
||||
<span>规则命中统计</span>
|
||||
</template>
|
||||
<el-table :data="stats.rules" stripe style="width: 100%" @row-click="handleRowClick">
|
||||
<el-table-column prop="ruleName" label="规则名称" min-width="150" />
|
||||
<el-table-column prop="hitCount" label="命中次数" width="100" sortable>
|
||||
<template #default="{ row }">
|
||||
<el-tag type="primary">{{ row.hitCount }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hitRate" label="命中率" width="100" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ (row.hitRate * 100).toFixed(1) }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avgResponseTime" label="平均响应时间" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.avgResponseTime.toFixed(0) }} ms
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="responseType" label="响应类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getResponseTagType(row.responseType)" size="small">
|
||||
{{ getResponseLabel(row.responseType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastHitTime" label="最后命中时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ row.lastHitTime ? formatTime(row.lastHitTime) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
:title="`${selectedRuleName} - 命中记录`"
|
||||
size="50%"
|
||||
direction="rtl"
|
||||
>
|
||||
<div class="hit-records" v-loading="hitsLoading">
|
||||
<el-table :data="hitRecords" stripe style="width: 100%">
|
||||
<el-table-column prop="userMessage" label="用户消息" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="matchedKeywords" label="匹配关键词" width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="keyword-tags">
|
||||
<el-tag
|
||||
v-for="(kw, idx) in row.matchedKeywords?.slice(0, 2)"
|
||||
:key="idx"
|
||||
size="small"
|
||||
type="success"
|
||||
>
|
||||
{{ kw }}
|
||||
</el-tag>
|
||||
<span v-if="row.matchedKeywords?.length > 2" class="more">
|
||||
+{{ row.matchedKeywords.length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="responseType" label="响应类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getResponseTagType(row.responseType)" size="small">
|
||||
{{ getResponseLabel(row.responseType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="executionResult" label="执行结果" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.executionResult === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.executionResult === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hitTime" label="命中时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.hitTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="totalHits"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadHitRecords"
|
||||
@current-change="loadHitRecords"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getIntentRuleStats,
|
||||
getIntentRuleHits,
|
||||
type IntentRuleStatsResponse,
|
||||
type IntentRuleHitRecord
|
||||
} from '@/api/monitoring'
|
||||
|
||||
const loading = ref(false)
|
||||
const hitsLoading = ref(false)
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const filterResponseType = ref('')
|
||||
|
||||
const stats = ref<IntentRuleStatsResponse>({
|
||||
totalHits: 0,
|
||||
totalConversations: 0,
|
||||
hitRate: 0,
|
||||
rules: []
|
||||
})
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const selectedRuleId = ref('')
|
||||
const selectedRuleName = ref('')
|
||||
const hitRecords = ref<IntentRuleHitRecord[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalHits = ref(0)
|
||||
|
||||
const getResponseLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
fixed: '固定回复',
|
||||
rag: '知识库检索',
|
||||
flow: '话术流程',
|
||||
transfer: '转人工'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const getResponseTagType = (type: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const colorMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
fixed: '',
|
||||
rag: 'success',
|
||||
flow: 'warning',
|
||||
transfer: 'danger'
|
||||
}
|
||||
return colorMap[type] || 'info'
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return '-'
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, string | undefined> = {}
|
||||
if (dateRange.value) {
|
||||
params.startDate = dateRange.value[0]
|
||||
params.endDate = dateRange.value[1]
|
||||
}
|
||||
if (filterResponseType.value) {
|
||||
params.responseType = filterResponseType.value
|
||||
}
|
||||
stats.value = await getIntentRuleStats(params)
|
||||
} catch (error) {
|
||||
ElMessage.error('加载统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowClick = (row: { ruleId: string; ruleName: string }) => {
|
||||
selectedRuleId.value = row.ruleId
|
||||
selectedRuleName.value = row.ruleName
|
||||
currentPage.value = 1
|
||||
loadHitRecords()
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
const loadHitRecords = async () => {
|
||||
hitsLoading.value = true
|
||||
try {
|
||||
const res = await getIntentRuleHits(selectedRuleId.value, {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value
|
||||
})
|
||||
hitRecords.value = res.records
|
||||
totalHits.value = res.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载命中记录失败')
|
||||
} finally {
|
||||
hitsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitoring-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.stat-suffix {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rule-stats-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hit-records {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.keyword-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
<template>
|
||||
<div class="monitoring-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">Prompt 模板监控</h1>
|
||||
<p class="page-desc">查看 Prompt 模板的使用统计和 Token 消耗</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 280px;"
|
||||
@change="loadStats"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterScene"
|
||||
placeholder="场景筛选"
|
||||
clearable
|
||||
style="width: 140px;"
|
||||
@change="loadStats"
|
||||
>
|
||||
<el-option label="对话场景" value="chat" />
|
||||
<el-option label="问答场景" value="qa" />
|
||||
<el-option label="摘要场景" value="summary" />
|
||||
<el-option label="翻译场景" value="translation" />
|
||||
<el-option label="代码场景" value="code" />
|
||||
<el-option label="自定义场景" value="custom" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" class="stats-cards">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="总使用次数" :value="stats.totalUsage">
|
||||
<template #suffix>
|
||||
<span class="stat-suffix">次</span>
|
||||
</template>
|
||||
</el-statistic>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="模板数量" :value="stats.templates.length">
|
||||
<template #suffix>
|
||||
<span class="stat-suffix">个</span>
|
||||
</template>
|
||||
</el-statistic>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-statistic title="场景数量" :value="Object.keys(stats.sceneBreakdown).length">
|
||||
<template #suffix>
|
||||
<span class="stat-suffix">个</span>
|
||||
</template>
|
||||
</el-statistic>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover" class="template-stats-card" v-loading="loading">
|
||||
<template #header>
|
||||
<span>模板使用统计</span>
|
||||
</template>
|
||||
<el-table :data="stats.templates" stripe style="width: 100%">
|
||||
<el-table-column prop="templateName" label="模板名称" min-width="150" />
|
||||
<el-table-column prop="scene" label="场景" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getSceneTagType(row.scene)" size="small">
|
||||
{{ getSceneLabel(row.scene) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="usageCount" label="使用次数" width="100" sortable>
|
||||
<template #default="{ row }">
|
||||
<el-tag type="primary">{{ row.usageCount }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avgTokens" label="平均 Token" width="100" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ row.avgTokens.toFixed(0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avgPromptTokens" label="平均 Prompt Token" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.avgPromptTokens.toFixed(0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avgCompletionTokens" label="平均 Completion Token" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.avgCompletionTokens.toFixed(0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastUsedTime" label="最后使用时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ row.lastUsedTime ? formatTime(row.lastUsedTime) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="scene-breakdown-card">
|
||||
<template #header>
|
||||
<span>场景分布</span>
|
||||
</template>
|
||||
<div class="scene-list">
|
||||
<div
|
||||
v-for="(count, scene) in stats.sceneBreakdown"
|
||||
:key="scene"
|
||||
class="scene-item"
|
||||
>
|
||||
<div class="scene-info">
|
||||
<el-tag :type="getSceneTagType(scene)" size="small">
|
||||
{{ getSceneLabel(scene) }}
|
||||
</el-tag>
|
||||
<span class="scene-count">{{ count }} 次</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="getPercentage(count)"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-if="Object.keys(stats.sceneBreakdown).length === 0" description="暂无数据" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getPromptTemplateStats,
|
||||
type PromptTemplateStatsResponse
|
||||
} from '@/api/monitoring'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const filterScene = ref('')
|
||||
|
||||
const stats = ref<PromptTemplateStatsResponse>({
|
||||
totalUsage: 0,
|
||||
templates: [],
|
||||
sceneBreakdown: {}
|
||||
})
|
||||
|
||||
const getSceneLabel = (scene: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
chat: '对话场景',
|
||||
qa: '问答场景',
|
||||
summary: '摘要场景',
|
||||
translation: '翻译场景',
|
||||
code: '代码场景',
|
||||
custom: '自定义场景'
|
||||
}
|
||||
return labels[scene] || scene
|
||||
}
|
||||
|
||||
const getSceneTagType = (scene: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const typeMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
chat: '',
|
||||
qa: 'success',
|
||||
summary: 'warning',
|
||||
translation: 'danger',
|
||||
code: 'info',
|
||||
custom: 'info'
|
||||
}
|
||||
return typeMap[scene] || 'info'
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return '-'
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const getPercentage = (count: number) => {
|
||||
if (stats.value.totalUsage === 0) return 0
|
||||
return Math.round((count / stats.value.totalUsage) * 100)
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, string | undefined> = {}
|
||||
if (dateRange.value) {
|
||||
params.startDate = dateRange.value[0]
|
||||
params.endDate = dateRange.value[1]
|
||||
}
|
||||
if (filterScene.value) {
|
||||
params.scene = filterScene.value
|
||||
}
|
||||
stats.value = await getPromptTemplateStats(params)
|
||||
} catch (error) {
|
||||
ElMessage.error('加载统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitoring-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.stat-suffix {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.template-stats-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.scene-breakdown-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.scene-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.scene-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scene-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scene-count {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
<template>
|
||||
<div class="flow-monitoring-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">话术流程监控</h1>
|
||||
<p class="page-desc">查看话术流程的激活统计、完成率和流失点分析。</p>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="loadStats"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="summary-cards">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon" style="background-color: var(--el-color-primary-light-9);">
|
||||
<el-icon :size="24" color="var(--el-color-primary)"><DataLine /></el-icon>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-value">{{ stats.totalActivations }}</div>
|
||||
<div class="summary-label">总激活次数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon" style="background-color: var(--el-color-success-light-9);">
|
||||
<el-icon :size="24" color="var(--el-color-success)"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-value">{{ stats.totalCompletions }}</div>
|
||||
<div class="summary-label">总完成次数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover">
|
||||
<div class="summary-card">
|
||||
<div class="summary-icon" style="background-color: var(--el-color-warning-light-9);">
|
||||
<el-icon :size="24" color="var(--el-color-warning)"><TrendCharts /></el-icon>
|
||||
</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-value">{{ (stats.completionRate * 100).toFixed(1) }}%</div>
|
||||
<div class="summary-label">整体完成率</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="hover" class="flow-table-card" v-loading="loading">
|
||||
<template #header>
|
||||
<span>流程统计列表</span>
|
||||
</template>
|
||||
<el-table :data="stats.flows" stripe style="width: 100%">
|
||||
<el-table-column prop="flowName" label="流程名称" min-width="150" />
|
||||
<el-table-column prop="activationCount" label="激活次数" width="100" sortable />
|
||||
<el-table-column prop="completionCount" label="完成次数" width="100" sortable />
|
||||
<el-table-column label="完成率" width="120" sortable :sort-method="(a: FlowStatItem, b: FlowStatItem) => a.completionRate - b.completionRate">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="row.completionRate * 100"
|
||||
:color="getProgressColor(row.completionRate)"
|
||||
:stroke-width="10"
|
||||
:show-text="false"
|
||||
/>
|
||||
<span class="progress-text">{{ (row.completionRate * 100).toFixed(1) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avgDuration" label="平均耗时(秒)" width="120" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ row.avgDuration.toFixed(1) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avgStepsCompleted" label="平均完成步骤" width="120" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ row.avgStepsCompleted.toFixed(1) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流失点" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.dropOffPoints && row.dropOffPoints.length > 0" class="dropoff-tags">
|
||||
<el-tag
|
||||
v-for="point in row.dropOffPoints.slice(0, 3)"
|
||||
:key="point.stepNo"
|
||||
type="warning"
|
||||
size="small"
|
||||
style="margin-right: 4px;"
|
||||
>
|
||||
步骤{{ point.stepNo }}: {{ point.dropOffCount }}次
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastActivatedAt" label="最近激活" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.lastActivatedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="showExecutions(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="executionsDialogVisible"
|
||||
:title="`${currentFlowName} - 执行记录`"
|
||||
width="800px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-table :data="executions" v-loading="executionsLoading" stripe>
|
||||
<el-table-column prop="sessionId" label="会话ID" min-width="200" />
|
||||
<el-table-column label="进度" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.currentStep }} / {{ row.totalSteps }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startedAt" label="开始时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.startedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completedAt" label="完成时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.completedAt) || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="executionsPage"
|
||||
:page-size="20"
|
||||
:total="executionsTotal"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadExecutions"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataLine, CircleCheck, TrendCharts } from '@element-plus/icons-vue'
|
||||
import { getFlowStats, getFlowExecutions, type FlowStatsResponse, type FlowExecutionRecord, type FlowStatItem } from '@/api/monitoring'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const stats = ref<FlowStatsResponse>({
|
||||
totalActivations: 0,
|
||||
totalCompletions: 0,
|
||||
completionRate: 0,
|
||||
flows: []
|
||||
})
|
||||
|
||||
const executionsDialogVisible = ref(false)
|
||||
const executionsLoading = ref(false)
|
||||
const currentFlowId = ref('')
|
||||
const currentFlowName = ref('')
|
||||
const executions = ref<FlowExecutionRecord[]>([])
|
||||
const executionsPage = ref(1)
|
||||
const executionsTotal = ref(0)
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getProgressColor = (rate: number) => {
|
||||
if (rate >= 0.8) return '#67C23A'
|
||||
if (rate >= 0.5) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'success'
|
||||
case 'active':
|
||||
return 'primary'
|
||||
case 'timeout':
|
||||
return 'warning'
|
||||
case 'cancelled':
|
||||
return 'info'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
completed: '已完成',
|
||||
active: '进行中',
|
||||
timeout: '超时',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (dateRange.value) {
|
||||
params.startDate = dateRange.value[0]
|
||||
params.endDate = dateRange.value[1]
|
||||
}
|
||||
stats.value = await getFlowStats(params)
|
||||
} catch (error) {
|
||||
ElMessage.error('加载统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showExecutions = (flow: FlowStatsResponse['flows'][0]) => {
|
||||
currentFlowId.value = flow.flowId
|
||||
currentFlowName.value = flow.flowName
|
||||
executionsPage.value = 1
|
||||
executionsDialogVisible.value = true
|
||||
loadExecutions()
|
||||
}
|
||||
|
||||
const loadExecutions = async () => {
|
||||
executionsLoading.value = true
|
||||
try {
|
||||
const res = await getFlowExecutions(currentFlowId.value, {
|
||||
page: executionsPage.value,
|
||||
pageSize: 20
|
||||
})
|
||||
executions.value = res.data
|
||||
executionsTotal.value = res.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载执行记录失败')
|
||||
} finally {
|
||||
executionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-monitoring-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.flow-table-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dropoff-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
title="预览 Prompt 模板"
|
||||
width="900px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="preview-dialog-content">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="10">
|
||||
<div class="variable-section">
|
||||
<h4>变量设置</h4>
|
||||
<el-form label-width="100px" size="small">
|
||||
<el-form-item
|
||||
v-for="variable in allVariables"
|
||||
:key="variable.name"
|
||||
:label="variable.name"
|
||||
>
|
||||
<el-input
|
||||
v-model="variableValues[variable.name]"
|
||||
:placeholder="variable.description || variable.default_value"
|
||||
@input="handleVariableChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider>测试数据</el-divider>
|
||||
|
||||
<el-form label-width="100px" size="small">
|
||||
<el-form-item label="示例消息">
|
||||
<el-input
|
||||
v-model="sampleMessage"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="输入示例用户消息"
|
||||
@input="handleVariableChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-button type="primary" :loading="previewing" @click="handlePreview" style="margin-top: 12px;">
|
||||
生成预览
|
||||
</el-button>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="14">
|
||||
<div class="preview-section">
|
||||
<div class="preview-header">
|
||||
<h4>渲染结果</h4>
|
||||
<div class="token-info">
|
||||
<el-tag type="info" size="small">
|
||||
预估 Token: {{ previewResult?.estimatedTokens || 0 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewResult?.warnings?.length" class="warnings">
|
||||
<el-alert
|
||||
v-for="(warning, idx) in previewResult.warnings"
|
||||
:key="idx"
|
||||
:title="warning"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 8px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="token-breakdown">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="系统指令">
|
||||
{{ previewResult?.tokenCount?.systemPrompt || 0 }} tokens
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="历史消息">
|
||||
{{ previewResult?.tokenCount?.history || 0 }} tokens
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前消息">
|
||||
{{ previewResult?.tokenCount?.currentMessage || 0 }} tokens
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="总计">
|
||||
<strong>{{ previewResult?.tokenCount?.total || 0 }} tokens</strong>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="rendered-content">
|
||||
<pre>{{ previewResult?.renderedContent || '点击"生成预览"查看渲染结果' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false)">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { previewPromptTemplate, type PromptPreviewResponse } from '@/api/monitoring'
|
||||
import type { PromptVariable } from '@/types/prompt-template'
|
||||
import { BUILTIN_VARIABLES } from '@/types/prompt-template'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
templateId: string
|
||||
templateVariables?: PromptVariable[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const allVariables = computed(() => {
|
||||
const builtin = BUILTIN_VARIABLES.map(v => ({
|
||||
name: v.name,
|
||||
description: v.description,
|
||||
default_value: v.default_value || ''
|
||||
}))
|
||||
const custom = (props.templateVariables || []).map(v => ({
|
||||
name: v.name,
|
||||
description: v.description,
|
||||
default_value: v.default_value || ''
|
||||
}))
|
||||
return [...builtin, ...custom]
|
||||
})
|
||||
|
||||
const variables = computed(() => props.templateVariables || [])
|
||||
const variableValues = ref<Record<string, string>>({})
|
||||
const sampleMessage = ref('')
|
||||
const previewing = ref(false)
|
||||
const previewResult = ref<PromptPreviewResponse | null>(null)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
variableValues.value = {}
|
||||
sampleMessage.value = ''
|
||||
previewResult.value = null
|
||||
|
||||
allVariables.value.forEach(v => {
|
||||
variableValues.value[v.name] = v.default_value || ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleVariableChange = () => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
handlePreview()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handlePreview = async () => {
|
||||
previewing.value = true
|
||||
try {
|
||||
previewResult.value = await previewPromptTemplate(props.templateId, {
|
||||
variables: variableValues.value,
|
||||
sampleMessage: sampleMessage.value || undefined
|
||||
})
|
||||
} catch (error) {
|
||||
previewResult.value = null
|
||||
} finally {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-dialog-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.variable-section {
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.variable-section h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.token-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.warnings {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-breakdown {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rendered-content {
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rendered-content pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
<template>
|
||||
<div class="template-detail">
|
||||
<div class="detail-section">
|
||||
<div class="section-header">
|
||||
<h3>基本信息</h3>
|
||||
</div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="模板名称">{{ template.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="场景">
|
||||
<el-tag size="small">{{ getSceneLabel(template.scene) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{{ template.description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDate(template.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDate(template.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="template.current_content">
|
||||
<div class="section-header">
|
||||
<h3>当前内容</h3>
|
||||
<el-button type="primary" size="small" @click="handlePublishCurrent" v-if="template.published_version">
|
||||
发布此版本
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="content-preview">
|
||||
<pre>{{ template.current_content }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="template.variables && template.variables.length > 0">
|
||||
<div class="section-header">
|
||||
<h3>变量定义</h3>
|
||||
</div>
|
||||
<el-table :data="template.variables" size="small" border>
|
||||
<el-table-column prop="name" label="变量名" width="180">
|
||||
<template #default="{ row }">
|
||||
<code class="var-code">{{ getVarSyntax(row.name) }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" />
|
||||
<el-table-column prop="default_value" label="默认值" width="150" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="template.versions && template.versions.length > 0">
|
||||
<div class="section-header">
|
||||
<h3>版本历史</h3>
|
||||
</div>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="version in template.versions"
|
||||
:key="version.version"
|
||||
:type="getVersionTimelineType(version.status)"
|
||||
:timestamp="formatDate(version.created_at)"
|
||||
placement="top"
|
||||
>
|
||||
<div class="version-card">
|
||||
<div class="version-header">
|
||||
<span class="version-number">v{{ version.version }}</span>
|
||||
<el-tag :type="getVersionStatusType(version.status)" size="small">
|
||||
{{ getVersionStatusLabel(version.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="version-content" v-if="version.content">
|
||||
<pre>{{ truncateContent(version.content) }}</pre>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
@click="handlePublish(version.version)"
|
||||
v-if="version.status !== 'published'"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
size="small"
|
||||
link
|
||||
@click="handleRollback(version.version)"
|
||||
v-if="version.status === 'archived'"
|
||||
>
|
||||
回滚到此版本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SCENE_OPTIONS } from '@/types/prompt-template'
|
||||
import type { PromptTemplateDetail, PromptVersion } from '@/types/prompt-template'
|
||||
|
||||
const props = defineProps<{
|
||||
template: PromptTemplateDetail
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'publish', version: number): void
|
||||
(e: 'rollback', version: number): void
|
||||
}>()
|
||||
|
||||
const getSceneLabel = (scene: string) => {
|
||||
const opt = SCENE_OPTIONS.find(o => o.value === scene)
|
||||
return opt?.label || scene
|
||||
}
|
||||
|
||||
const getVarSyntax = (name: string) => {
|
||||
return `{{${name}}}`
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getVersionTimelineType = (status: string): 'primary' | 'success' | 'info' | 'warning' | 'danger' => {
|
||||
const typeMap: Record<string, 'primary' | 'success' | 'info' | 'warning' | 'danger'> = {
|
||||
draft: 'info',
|
||||
published: 'success',
|
||||
archived: 'warning'
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const getVersionStatusType = (status: string): '' | 'success' | 'warning' | 'info' | 'danger' => {
|
||||
const typeMap: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
draft: 'info',
|
||||
published: 'success',
|
||||
archived: 'warning'
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const getVersionStatusLabel = (status: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '历史版本'
|
||||
}
|
||||
return labelMap[status] || status
|
||||
}
|
||||
|
||||
const truncateContent = (content: string, maxLength = 200) => {
|
||||
if (content.length <= maxLength) return content
|
||||
return content.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
const handlePublishCurrent = () => {
|
||||
if (props.template.published_version) {
|
||||
emit('publish', props.template.published_version.version)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = (version: number) => {
|
||||
emit('publish', version)
|
||||
}
|
||||
|
||||
const handleRollback = (version: number) => {
|
||||
emit('rollback', version)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-detail {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.content-preview pre {
|
||||
margin: 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.var-code {
|
||||
background-color: var(--el-fill-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.version-card {
|
||||
background-color: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.version-content {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.version-content pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
<template>
|
||||
<div class="variable-manager">
|
||||
<div class="manager-header">
|
||||
<span class="header-title">自定义变量</span>
|
||||
<el-button type="primary" size="small" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加变量
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="variable-list" v-if="localVariables.length > 0">
|
||||
<div
|
||||
v-for="(variable, index) in localVariables"
|
||||
:key="index"
|
||||
class="variable-item"
|
||||
>
|
||||
<div class="variable-content">
|
||||
<div class="variable-name">
|
||||
<code>{{ getVarSyntax(variable.name) }}</code>
|
||||
</div>
|
||||
<div class="variable-info">
|
||||
<span class="info-label">默认值:</span>
|
||||
<span class="info-value">{{ variable.default_value || '-' }}</span>
|
||||
</div>
|
||||
<div class="variable-info" v-if="variable.description">
|
||||
<span class="info-label">描述:</span>
|
||||
<span class="info-value">{{ variable.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="variable-actions">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(index)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="暂无自定义变量" :image-size="80" />
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑变量' : '添加变量'"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="变量名" prop="name">
|
||||
<el-input
|
||||
v-model="formData.name"
|
||||
placeholder="例如: company_name"
|
||||
:disabled="isEdit"
|
||||
>
|
||||
<template #prepend><span v-text="'{{'"></span></template>
|
||||
<template #append><span v-text="'}}'"></span></template>
|
||||
</el-input>
|
||||
<div class="form-tip">只能包含字母、数字和下划线</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认值" prop="default_value">
|
||||
<el-input
|
||||
v-model="formData.default_value"
|
||||
placeholder="请输入默认值"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
placeholder="请输入变量描述(可选)"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import type { PromptVariable } from '@/types/prompt-template'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: PromptVariable[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: PromptVariable[]): void
|
||||
(e: 'insert', varName: string): void
|
||||
}>()
|
||||
|
||||
const localVariables = ref<PromptVariable[]>([...props.modelValue])
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentIndex = ref(-1)
|
||||
const formRef = ref()
|
||||
|
||||
const formData = ref<PromptVariable>({
|
||||
name: '',
|
||||
default_value: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const validateVarName = (rule: any, value: string, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入变量名'))
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
|
||||
callback(new Error('变量名只能包含字母、数字和下划线,且不能以数字开头'))
|
||||
return
|
||||
}
|
||||
const exists = localVariables.value.some(
|
||||
(v, idx) => v.name === value && idx !== currentIndex.value
|
||||
)
|
||||
if (exists) {
|
||||
callback(new Error('变量名已存在'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, validator: validateVarName, trigger: 'blur' }],
|
||||
default_value: [{ required: true, message: '请输入默认值', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const getVarSyntax = (name: string) => {
|
||||
return `{{${name}}}`
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
currentIndex.value = -1
|
||||
formData.value = {
|
||||
name: '',
|
||||
default_value: '',
|
||||
description: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (index: number) => {
|
||||
isEdit.value = true
|
||||
currentIndex.value = index
|
||||
formData.value = { ...localVariables.value[index] }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
localVariables.value.splice(index, 1)
|
||||
emit('update:modelValue', localVariables.value)
|
||||
ElMessage.success('删除成功')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
localVariables.value[currentIndex.value] = { ...formData.value }
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
localVariables.value.push({ ...formData.value })
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
|
||||
emit('update:modelValue', localVariables.value)
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
localVariables.value = [...newVal]
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.variable-manager {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manager-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.variable-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.variable-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.variable-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.variable-item:hover {
|
||||
background-color: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.variable-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.variable-name code {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.variable-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.variable-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,623 @@
|
|||
<template>
|
||||
<div class="prompt-template-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">Prompt 模板管理</h1>
|
||||
<p class="page-desc">管理不同场景的 Prompt 模板,支持版本管理和一键回滚。[AC-IDSMETA-16]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="filterScene" placeholder="按场景筛选" clearable style="width: 150px;">
|
||||
<el-option v-for="opt in SCENE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建模板
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" class="template-card" v-loading="loading">
|
||||
<el-table :data="templates" stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="模板名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="template-name">
|
||||
<span class="name-text">{{ row.name }}</span>
|
||||
<el-tag v-if="row.is_default" type="success" size="small" style="margin-left: 8px;">默认</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="scene" label="场景" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="getSceneTagType(row.scene)">
|
||||
{{ getSceneLabel(row.scene) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="published_version" label="发布版本" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.published_version" class="version-badge">
|
||||
v{{ row.published_version.version }}
|
||||
</span>
|
||||
<span v-else class="no-version">未发布</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="元数据" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
|
||||
{{ Object.keys(row.metadata).length }} 个字段
|
||||
</el-tag>
|
||||
<span v-else class="no-metadata">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="info" link size="small" @click="handlePreview(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="success" link size="small" @click="handlePublish(row)" v-if="row.published_version">
|
||||
<el-icon><Upload /></el-icon>
|
||||
发布
|
||||
</el-button>
|
||||
<el-button type="warning" link size="small" @click="handleViewDetail(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑模板' : '新建模板'"
|
||||
width="950px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="100px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模板名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入模板名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="场景" prop="scene">
|
||||
<el-select v-model="formData.scene" placeholder="请选择场景" style="width: 100%;">
|
||||
<el-option v-for="opt in SCENE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入模板描述(可选)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
|
||||
|
||||
<MetadataForm
|
||||
ref="metadataFormRef"
|
||||
scope="prompt_template"
|
||||
v-model="formData.metadata"
|
||||
:is-new-object="!isEdit"
|
||||
:col-span="8"
|
||||
/>
|
||||
|
||||
<el-divider content-position="left">系统指令</el-divider>
|
||||
|
||||
<el-form-item label="系统指令" prop="system_instruction">
|
||||
<div class="content-editor">
|
||||
<div class="editor-main">
|
||||
<el-input
|
||||
v-model="formData.system_instruction"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="请输入系统指令内容,支持 {{variable}} 变量语法"
|
||||
class="content-textarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="variables-panel">
|
||||
<div class="panel-title">内置变量</div>
|
||||
<div class="variable-list">
|
||||
<div
|
||||
v-for="v in BUILTIN_VARIABLES"
|
||||
:key="v.name"
|
||||
class="variable-item"
|
||||
@click="insertVariable(v.name)"
|
||||
>
|
||||
<span class="var-name">{{ getVarSyntax(v.name) }}</span>
|
||||
<span class="var-desc">{{ v.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-divider"></div>
|
||||
<div class="panel-title">自定义变量</div>
|
||||
<div class="variable-list" v-if="formData.variables && formData.variables.length > 0">
|
||||
<div
|
||||
v-for="v in formData.variables"
|
||||
:key="v.name"
|
||||
class="variable-item"
|
||||
@click="insertVariable(v.name)"
|
||||
>
|
||||
<span class="var-name">{{ getVarSyntax(v.name) }}</span>
|
||||
<span class="var-desc">{{ v.description || v.default_value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint">暂无自定义变量</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="自定义变量">
|
||||
<variable-manager :model-value="formData.variables || []" @update:model-value="formData.variables = $event" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailDrawer" title="模板详情" size="600px" destroy-on-close>
|
||||
<template-detail
|
||||
v-if="currentTemplate"
|
||||
:template="currentTemplate"
|
||||
@publish="handlePublishVersion"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
</el-drawer>
|
||||
|
||||
<PreviewDialog
|
||||
v-model:visible="previewDialogVisible"
|
||||
:template-id="previewTemplateId"
|
||||
:template-variables="previewVariables"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Upload, View } from '@element-plus/icons-vue'
|
||||
import {
|
||||
listPromptTemplates,
|
||||
createPromptTemplate,
|
||||
updatePromptTemplate,
|
||||
deletePromptTemplate,
|
||||
getPromptTemplate,
|
||||
publishPromptTemplate,
|
||||
rollbackPromptTemplate
|
||||
} from '@/api/prompt-template'
|
||||
import { MetadataForm } from '@/components/metadata'
|
||||
import { SCENE_OPTIONS, BUILTIN_VARIABLES } from '@/types/prompt-template'
|
||||
import type { PromptTemplate, PromptTemplateDetail, PromptTemplateCreate, PromptTemplateUpdate, PromptVariable } from '@/types/prompt-template'
|
||||
import TemplateDetail from './components/TemplateDetail.vue'
|
||||
import VariableManager from './components/VariableManager.vue'
|
||||
import PreviewDialog from './components/PreviewDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const templates = ref<PromptTemplate[]>([])
|
||||
const filterScene = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const detailDrawer = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const metadataFormRef = ref()
|
||||
const currentTemplate = ref<PromptTemplateDetail | null>(null)
|
||||
const currentEditId = ref('')
|
||||
const previewDialogVisible = ref(false)
|
||||
const previewTemplateId = ref('')
|
||||
const previewVariables = ref<PromptVariable[]>([])
|
||||
|
||||
const formData = ref<PromptTemplateCreate>({
|
||||
name: '',
|
||||
scene: '',
|
||||
description: '',
|
||||
system_instruction: '',
|
||||
variables: [],
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||
scene: [{ required: true, message: '请选择场景', trigger: 'change' }],
|
||||
system_instruction: [{ required: true, message: '请输入系统指令内容', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const getSceneLabel = (scene: string) => {
|
||||
const opt = SCENE_OPTIONS.find(o => o.value === scene)
|
||||
return opt?.label || scene
|
||||
}
|
||||
|
||||
const getVarSyntax = (name: string) => {
|
||||
return `{{${name}}}`
|
||||
}
|
||||
|
||||
const getSceneTagType = (scene: string): '' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const typeMap: Record<string, '' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
chat: '',
|
||||
qa: 'success',
|
||||
summary: 'warning',
|
||||
translation: 'danger',
|
||||
code: 'info',
|
||||
custom: ''
|
||||
}
|
||||
return typeMap[scene] || ''
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadTemplates = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listPromptTemplates({ scene: filterScene.value || undefined })
|
||||
templates.value = res.data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
currentEditId.value = ''
|
||||
formData.value = {
|
||||
name: '',
|
||||
scene: '',
|
||||
description: '',
|
||||
system_instruction: '',
|
||||
variables: [],
|
||||
metadata: {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (row: PromptTemplate) => {
|
||||
isEdit.value = true
|
||||
currentEditId.value = row.id
|
||||
try {
|
||||
const detail = await getPromptTemplate(row.id)
|
||||
formData.value = {
|
||||
name: detail.name,
|
||||
scene: detail.scene,
|
||||
description: detail.description || '',
|
||||
system_instruction: detail.current_content || '',
|
||||
variables: detail.variables || [],
|
||||
metadata: detail.metadata || {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: PromptTemplate) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该模板吗?删除后无法恢复。', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await deletePromptTemplate(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async (row: PromptTemplate) => {
|
||||
if (!row.published_version) {
|
||||
ElMessage.warning('该模板暂无可发布版本')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要发布版本 v${row.published_version.version} 吗?`, '确认发布', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
})
|
||||
await publishPromptTemplate(row.id, { version: row.published_version.version })
|
||||
ElMessage.success('发布成功')
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('发布失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row: PromptTemplate) => {
|
||||
try {
|
||||
currentTemplate.value = await getPromptTemplate(row.id)
|
||||
detailDrawer.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = async (row: PromptTemplate) => {
|
||||
try {
|
||||
const detail = await getPromptTemplate(row.id)
|
||||
previewTemplateId.value = row.id
|
||||
previewVariables.value = detail.variables || []
|
||||
previewDialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模板详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (metadataFormRef.value) {
|
||||
const validation = await metadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const updateData: PromptTemplateUpdate = {
|
||||
name: formData.value.name,
|
||||
scene: formData.value.scene,
|
||||
description: formData.value.description,
|
||||
system_instruction: formData.value.system_instruction,
|
||||
variables: formData.value.variables,
|
||||
metadata: formData.value.metadata
|
||||
}
|
||||
await updatePromptTemplate(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await createPromptTemplate(formData.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const insertVariable = (varName: string) => {
|
||||
const textarea = document.querySelector('.content-textarea textarea') as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const text = formData.value.system_instruction || ''
|
||||
const insertText = `{{${varName}}}`
|
||||
formData.value.system_instruction = text.substring(0, start) + insertText + text.substring(end)
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(start + insertText.length, start + insertText.length)
|
||||
}, 0)
|
||||
} else {
|
||||
formData.value.system_instruction = (formData.value.system_instruction || '') + `{{${varName}}}`
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublishVersion = async (version: number) => {
|
||||
if (!currentTemplate.value) return
|
||||
try {
|
||||
await publishPromptTemplate(currentTemplate.value.id, { version })
|
||||
ElMessage.success('发布成功')
|
||||
currentTemplate.value = await getPromptTemplate(currentTemplate.value.id)
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
ElMessage.error('发布失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRollback = async (version: number) => {
|
||||
if (!currentTemplate.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要回滚到版本 v${version} 吗?`, '确认回滚', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await rollbackPromptTemplate(currentTemplate.value.id, { version })
|
||||
ElMessage.success('回滚成功')
|
||||
currentTemplate.value = await getPromptTemplate(currentTemplate.value.id)
|
||||
loadTemplates()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('回滚失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(filterScene, () => {
|
||||
loadTemplates()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prompt-template-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-version {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.content-editor {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.variables-panel {
|
||||
width: 200px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
padding: 10px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-secondary);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.variable-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.variable-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.variable-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.variable-item:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.var-name {
|
||||
display: block;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.var-desc {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
height: 1px;
|
||||
background-color: var(--el-border-color-light);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
<template>
|
||||
<div class="constraint-manager">
|
||||
<div class="constraint-tags" v-if="modelValue && modelValue.length > 0">
|
||||
<el-tag
|
||||
v-for="(constraint, index) in modelValue"
|
||||
:key="index"
|
||||
closable
|
||||
type="info"
|
||||
@close="removeConstraint(index)"
|
||||
class="constraint-tag"
|
||||
:title="constraint"
|
||||
>
|
||||
<span class="constraint-text">{{ constraint }}</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="newConstraint"
|
||||
placeholder="输入约束条件后按回车添加"
|
||||
@keyup.enter="addConstraint"
|
||||
class="constraint-input"
|
||||
size="small"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="addConstraint" :disabled="!newConstraint.trim()">
|
||||
添加
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<div class="constraint-presets">
|
||||
<span class="preset-label">常用约束:</span>
|
||||
<el-button
|
||||
v-for="preset in PRESET_CONSTRAINTS"
|
||||
:key="preset"
|
||||
size="small"
|
||||
round
|
||||
@click="addPreset(preset)"
|
||||
:disabled="modelValue?.includes(preset)"
|
||||
>
|
||||
{{ preset }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { PRESET_CONSTRAINTS } from '@/types/script-flow'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}>()
|
||||
|
||||
const newConstraint = ref('')
|
||||
|
||||
const addConstraint = () => {
|
||||
const value = newConstraint.value.trim()
|
||||
if (!value) return
|
||||
|
||||
const currentConstraints = props.modelValue || []
|
||||
if (currentConstraints.includes(value)) {
|
||||
newConstraint.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', [...currentConstraints, value])
|
||||
newConstraint.value = ''
|
||||
}
|
||||
|
||||
const removeConstraint = (index: number) => {
|
||||
const currentConstraints = props.modelValue || []
|
||||
const newConstraints = [...currentConstraints]
|
||||
newConstraints.splice(index, 1)
|
||||
emit('update:modelValue', newConstraints)
|
||||
}
|
||||
|
||||
const addPreset = (preset: string) => {
|
||||
const currentConstraints = props.modelValue || []
|
||||
if (currentConstraints.includes(preset)) return
|
||||
emit('update:modelValue', [...currentConstraints, preset])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.constraint-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.constraint-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.constraint-tag {
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.constraint-tag :deep(.el-tag__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.constraint-tag :deep(.el-tag__close) {
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.constraint-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.constraint-input {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.constraint-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
<template>
|
||||
<div class="flow-preview">
|
||||
<div class="flow-info">
|
||||
<h3>{{ flow.name }}</h3>
|
||||
<p v-if="flow.description">{{ flow.description }}</p>
|
||||
</div>
|
||||
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(step, index) in flow.steps"
|
||||
:key="step.step_id"
|
||||
:type="getStepType(index)"
|
||||
:size="index === currentStep ? 'large' : 'normal'"
|
||||
:hollow="index !== currentStep"
|
||||
>
|
||||
<div class="step-card" :class="{ active: index === currentStep }">
|
||||
<div class="step-header">
|
||||
<span class="step-number">步骤 {{ index + 1 }}</span>
|
||||
<el-tag v-if="step.wait_input" size="small" type="info">等待输入</el-tag>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<pre>{{ step.content }}</pre>
|
||||
</div>
|
||||
<div class="step-config" v-if="step.wait_input">
|
||||
<div class="config-item">
|
||||
<span class="label">超时时间:</span>
|
||||
<span class="value">{{ step.timeout_seconds }}秒</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">超时动作:</span>
|
||||
<span class="value">{{ getTimeoutActionLabel(step.timeout_action) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
|
||||
<div class="preview-controls" v-if="flow.steps.length > 0">
|
||||
<el-button :disabled="currentStep <= 0" @click="prevStep">上一步</el-button>
|
||||
<span class="step-indicator">{{ currentStep + 1 }} / {{ flow.steps.length }}</span>
|
||||
<el-button :disabled="currentStep >= flow.steps.length - 1" @click="nextStep">下一步</el-button>
|
||||
<el-button type="primary" @click="resetPreview">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
|
||||
import type { ScriptFlowDetail } from '@/types/script-flow'
|
||||
|
||||
const props = defineProps<{
|
||||
flow: ScriptFlowDetail
|
||||
}>()
|
||||
|
||||
const currentStep = ref(0)
|
||||
|
||||
const getStepType = (index: number): 'primary' | 'success' | 'warning' | 'danger' | 'info' => {
|
||||
if (index === currentStep.value) return 'primary'
|
||||
if (index < currentStep.value) return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const getTimeoutActionLabel = (action?: string) => {
|
||||
if (!action) return '-'
|
||||
const opt = TIMEOUT_ACTION_OPTIONS.find(o => o.value === action)
|
||||
return opt?.label || action
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value < props.flow.steps.length - 1) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
const resetPreview = () => {
|
||||
currentStep.value = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flow-preview {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.flow-info {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.flow-info h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.flow-info p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.step-card {
|
||||
background-color: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.step-card.active {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.step-content pre {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.step-config {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.config-item {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.config-item .label {
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.config-item .value {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
title="流程模拟执行"
|
||||
width="900px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="simulate-dialog">
|
||||
<div class="input-section">
|
||||
<div class="section-title">用户输入</div>
|
||||
<div class="input-hint">每行一条用户输入,按顺序模拟流程执行</div>
|
||||
<el-input
|
||||
v-model="userInputsText"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="请输入用户回复,每行一条 例如: 12345678901234 质量问题 是的"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<el-button type="primary" :loading="simulating" @click="handleSimulate">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
开始模拟
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="simulationResult" class="result-section">
|
||||
<el-divider content-position="left">模拟结果</el-divider>
|
||||
|
||||
<div class="result-summary">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">{{ simulationResult.result.totalSteps }}</div>
|
||||
<div class="summary-label">总步骤数</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">{{ simulationResult.coverage.coveredSteps }}</div>
|
||||
<div class="summary-label">已覆盖步骤</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value" :class="coverageClass">
|
||||
{{ (simulationResult.coverage.coverageRate * 100).toFixed(0) }}%
|
||||
</div>
|
||||
<div class="summary-label">覆盖率</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value">{{ simulationResult.result.totalDurationMs }}ms</div>
|
||||
<div class="summary-label">总耗时</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div v-if="simulationResult.issues.length > 0" class="issues-section">
|
||||
<el-alert
|
||||
v-for="(issue, index) in simulationResult.issues"
|
||||
:key="index"
|
||||
:title="issue"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 8px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="timeline-section">
|
||||
<div class="section-title">执行时间线</div>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(step, index) in simulationResult.simulation"
|
||||
:key="index"
|
||||
:type="step.matchedCondition ? 'success' : 'info'"
|
||||
:hollow="true"
|
||||
>
|
||||
<div class="timeline-step">
|
||||
<div class="step-header">
|
||||
<el-tag size="small" type="primary">步骤 {{ step.stepNo }}</el-tag>
|
||||
<span class="step-duration">{{ step.durationMs }}ms</span>
|
||||
</div>
|
||||
<div class="step-bot-message">
|
||||
<span class="label">机器人:</span>
|
||||
{{ step.botMessage }}
|
||||
</div>
|
||||
<div class="step-user-input">
|
||||
<span class="label">用户:</span>
|
||||
{{ step.userInput }}
|
||||
</div>
|
||||
<div v-if="step.matchedCondition" class="step-condition">
|
||||
<span class="label">匹配条件:</span>
|
||||
<el-tag size="small" :type="getConditionType(step.matchedCondition.type)">
|
||||
{{ step.matchedCondition.type }}
|
||||
</el-tag>
|
||||
<span v-if="step.matchedCondition.keywords" class="condition-detail">
|
||||
关键词: {{ step.matchedCondition.keywords.join(', ') }}
|
||||
</span>
|
||||
<span v-if="step.matchedCondition.pattern" class="condition-detail">
|
||||
正则: {{ step.matchedCondition.pattern }}
|
||||
</span>
|
||||
<span class="condition-goto">→ 步骤 {{ step.matchedCondition.gotoStep }}</span>
|
||||
</div>
|
||||
<div v-else class="step-condition">
|
||||
<span class="label">匹配条件:</span>
|
||||
<el-tag size="small" type="info">无匹配</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
|
||||
<div v-if="simulationResult.coverage.uncoveredSteps.length > 0" class="uncovered-section">
|
||||
<div class="section-title">未覆盖步骤</div>
|
||||
<div class="uncovered-steps">
|
||||
<el-tag
|
||||
v-for="stepNo in simulationResult.coverage.uncoveredSteps"
|
||||
:key="stepNo"
|
||||
type="warning"
|
||||
style="margin-right: 8px;"
|
||||
>
|
||||
步骤 {{ stepNo }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false)">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { VideoPlay } from '@element-plus/icons-vue'
|
||||
import { simulateScriptFlow, type FlowSimulateResponse } from '@/api/script-flow'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
flowId: string
|
||||
flowName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}>()
|
||||
|
||||
const userInputsText = ref('')
|
||||
const simulating = ref(false)
|
||||
const simulationResult = ref<FlowSimulateResponse | null>(null)
|
||||
|
||||
const coverageClass = computed(() => {
|
||||
if (!simulationResult.value) return ''
|
||||
const rate = simulationResult.value.coverage.coverageRate
|
||||
if (rate >= 0.8) return 'coverage-good'
|
||||
if (rate >= 0.5) return 'coverage-medium'
|
||||
return 'coverage-low'
|
||||
})
|
||||
|
||||
const getConditionType = (type: string) => {
|
||||
switch (type) {
|
||||
case 'keyword':
|
||||
return 'success'
|
||||
case 'pattern':
|
||||
return 'warning'
|
||||
case 'default':
|
||||
return 'info'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSimulate = async () => {
|
||||
const inputs = userInputsText.value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
|
||||
if (inputs.length === 0) {
|
||||
ElMessage.warning('请输入至少一条用户回复')
|
||||
return
|
||||
}
|
||||
|
||||
simulating.value = true
|
||||
try {
|
||||
const result = await simulateScriptFlow(props.flowId, { userInputs: inputs })
|
||||
simulationResult.value = result
|
||||
ElMessage.success('模拟执行完成')
|
||||
} catch (error) {
|
||||
ElMessage.error('模拟执行失败')
|
||||
} finally {
|
||||
simulating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simulate-dialog {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.summary-value.coverage-good {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.summary-value.coverage-medium {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.summary-value.coverage-low {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.issues-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.timeline-step {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.step-duration {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.step-bot-message,
|
||||
.step-user-input {
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-bot-message .label,
|
||||
.step-user-input .label {
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.step-condition {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.step-condition .label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.condition-detail {
|
||||
margin-left: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.condition-goto {
|
||||
margin-left: 8px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.uncovered-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.uncovered-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,742 @@
|
|||
<template>
|
||||
<div class="script-flow-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">话术流程管理</h1>
|
||||
<p class="page-desc">编排多步骤的话术流程,引导用户按固定步骤完成信息收集。[AC-IDSMETA-16]</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建流程
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" class="flow-card" v-loading="loading">
|
||||
<el-table :data="flows" stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="流程名称" min-width="180" />
|
||||
<el-table-column prop="description" label="描述" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ row.description || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="step_count" label="步骤数" width="100" />
|
||||
<el-table-column prop="linked_rule_count" label="关联规则" width="100" />
|
||||
<el-table-column label="元数据" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.metadata && Object.keys(row.metadata).length > 0" size="small" type="info">
|
||||
{{ Object.keys(row.metadata).length }} 个字段
|
||||
</el-tag>
|
||||
<span v-else class="no-metadata">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.is_enabled"
|
||||
@change="handleToggleEnabled(row)"
|
||||
active-color="#67C23A"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="success" link size="small" @click="handleSimulate(row)">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
模拟
|
||||
</el-button>
|
||||
<el-button type="info" link size="small" @click="handlePreview(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑流程' : '新建流程'"
|
||||
width="950px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-width="80px">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="流程名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入流程名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.is_enabled" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="2" placeholder="请输入描述(可选)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">元数据配置 [AC-IDSMETA-16]</el-divider>
|
||||
|
||||
<MetadataForm
|
||||
ref="metadataFormRef"
|
||||
scope="script_flow"
|
||||
v-model="formData.metadata"
|
||||
:is-new-object="!isEdit"
|
||||
:col-span="8"
|
||||
/>
|
||||
|
||||
<el-divider content-position="left">步骤配置</el-divider>
|
||||
|
||||
<div class="steps-editor">
|
||||
<draggable
|
||||
v-model="formData.steps"
|
||||
item-key="step_id"
|
||||
handle=".drag-handle"
|
||||
animation="200"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="step-item">
|
||||
<div class="step-header">
|
||||
<div class="drag-handle">
|
||||
<el-icon><Rank /></el-icon>
|
||||
</div>
|
||||
<span class="step-order">步骤 {{ index + 1 }}</span>
|
||||
<el-button type="danger" link size="small" @click="removeStep(index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<el-form-item label="话术模式">
|
||||
<el-radio-group v-model="element.script_mode" size="small">
|
||||
<el-radio-button
|
||||
v-for="opt in SCRIPT_MODE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
<el-tooltip :content="opt.description" placement="top">
|
||||
<span>{{ opt.label }} <el-icon class="mode-help-icon"><QuestionFilled /></el-icon></span>
|
||||
</el-tooltip>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="element.script_mode === 'fixed'">
|
||||
<el-form-item label="话术内容" required>
|
||||
<el-input
|
||||
v-model="element.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入固定话术内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="element.script_mode === 'flexible'">
|
||||
<el-form-item label="步骤意图" required>
|
||||
<el-input
|
||||
v-model="element.intent"
|
||||
placeholder="例如:获取用户姓名"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="意图说明">
|
||||
<el-input
|
||||
v-model="element.intent_description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="详细描述这一步的目的和期望效果"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="话术约束">
|
||||
<ConstraintManager v-model="element.script_constraints" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Fallback话术" required>
|
||||
<el-input
|
||||
v-model="element.content"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="AI生成失败时使用的备用话术"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="期望变量">
|
||||
<el-select
|
||||
v-model="element.expected_variables"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入变量名后回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="element.script_mode === 'template'">
|
||||
<el-form-item label="话术模板" required>
|
||||
<el-input
|
||||
v-model="element.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="使用 {变量名} 标记可变部分,例如:您好{user_name},请问您{inquiry_style}?"
|
||||
/>
|
||||
<div class="template-hint">
|
||||
提示:使用 {变量名} 标记需要AI填充的部分
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="步骤意图">
|
||||
<el-input
|
||||
v-model="element.intent"
|
||||
placeholder="可选:描述模板的使用场景"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="期望变量">
|
||||
<el-select
|
||||
v-model="element.expected_variables"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入变量名后回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="等待输入">
|
||||
<el-switch v-model="element.wait_input" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8" v-if="element.wait_input">
|
||||
<el-form-item label="超时(秒)">
|
||||
<el-input-number v-model="element.timeout_seconds" :min="5" :max="300" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8" v-if="element.wait_input">
|
||||
<el-form-item label="超时动作">
|
||||
<el-select v-model="element.timeout_action" style="width: 100%;">
|
||||
<el-option
|
||||
v-for="opt in TIMEOUT_ACTION_OPTIONS"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider content-position="left" v-if="element.wait_input">分支跳转</el-divider>
|
||||
|
||||
<div v-if="element.wait_input" class="branch-editor">
|
||||
<div
|
||||
v-for="(cond, ci) in (element.next_conditions || [])"
|
||||
:key="ci"
|
||||
class="branch-item"
|
||||
>
|
||||
<el-row :gutter="8" align="middle">
|
||||
<el-col :span="10">
|
||||
<el-form-item label="关键词" label-width="60px" style="margin-bottom: 0;">
|
||||
<el-select
|
||||
v-model="cond.keywords"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入关键词回车"
|
||||
style="width: 100%"
|
||||
size="small"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="正则" label-width="40px" style="margin-bottom: 0;">
|
||||
<el-input
|
||||
v-model="cond.pattern"
|
||||
placeholder="可选"
|
||||
size="small"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-form-item label="跳转" label-width="40px" style="margin-bottom: 0;">
|
||||
<el-select v-model="cond.goto_step" placeholder="步骤" size="small" style="width: 100%">
|
||||
<el-option
|
||||
v-for="(s, si) in formData.steps"
|
||||
:key="si"
|
||||
:label="'步骤 ' + (si + 1)"
|
||||
:value="si + 1"
|
||||
:disabled="si === index"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="3">
|
||||
<el-button type="danger" link size="small" @click="removeBranch(element, ci)">删除</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<el-row :gutter="8" align="middle" style="margin-top: 8px;">
|
||||
<el-col :span="16">
|
||||
<el-button type="primary" link size="small" @click="addBranch(element)">
|
||||
<el-icon><Plus /></el-icon> 添加分支条件
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="默认跳转" label-width="70px" style="margin-bottom: 0;">
|
||||
<el-select v-model="element.default_next" placeholder="顺序" size="small" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="(s, si) in formData.steps"
|
||||
:key="si"
|
||||
:label="'步骤 ' + (si + 1)"
|
||||
:value="si + 1"
|
||||
:disabled="si === index"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<el-button type="primary" plain @click="addStep" style="width: 100%; margin-top: 12px;">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加步骤
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="previewDrawer" title="流程预览" size="500px" destroy-on-close>
|
||||
<flow-preview v-if="currentFlow" :flow="currentFlow" />
|
||||
</el-drawer>
|
||||
|
||||
<SimulateDialog
|
||||
v-model:visible="simulateDialogVisible"
|
||||
:flow-id="currentSimulateFlowId"
|
||||
:flow-name="currentSimulateFlowName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, View, Rank, VideoPlay, QuestionFilled } from '@element-plus/icons-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import {
|
||||
listScriptFlows,
|
||||
createScriptFlow,
|
||||
updateScriptFlow,
|
||||
deleteScriptFlow,
|
||||
getScriptFlow
|
||||
} from '@/api/script-flow'
|
||||
import { MetadataForm } from '@/components/metadata'
|
||||
import { TIMEOUT_ACTION_OPTIONS, SCRIPT_MODE_OPTIONS } from '@/types/script-flow'
|
||||
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep, ScriptMode } from '@/types/script-flow'
|
||||
import FlowPreview from './components/FlowPreview.vue'
|
||||
import SimulateDialog from './components/SimulateDialog.vue'
|
||||
import ConstraintManager from './components/ConstraintManager.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const flows = ref<ScriptFlow[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const previewDrawer = ref(false)
|
||||
const simulateDialogVisible = ref(false)
|
||||
const currentSimulateFlowId = ref('')
|
||||
const currentSimulateFlowName = ref('')
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const metadataFormRef = ref()
|
||||
const currentFlow = ref<ScriptFlowDetail | null>(null)
|
||||
const currentEditId = ref('')
|
||||
|
||||
const generateStepId = () => `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const defaultFormData = (): ScriptFlowCreate => ({
|
||||
name: '',
|
||||
description: '',
|
||||
steps: [],
|
||||
is_enabled: true,
|
||||
metadata: {}
|
||||
})
|
||||
|
||||
const formData = ref<ScriptFlowCreate>(defaultFormData())
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadFlows = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listScriptFlows()
|
||||
flows.value = res.data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载流程列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
isEdit.value = false
|
||||
currentEditId.value = ''
|
||||
formData.value = defaultFormData()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (row: ScriptFlow) => {
|
||||
isEdit.value = true
|
||||
currentEditId.value = row.id
|
||||
try {
|
||||
const detail = await getScriptFlow(row.id)
|
||||
formData.value = {
|
||||
name: detail.name,
|
||||
description: detail.description || '',
|
||||
steps: (detail.steps || []).map(step => ({
|
||||
...step,
|
||||
script_mode: step.script_mode || 'fixed',
|
||||
script_constraints: step.script_constraints || [],
|
||||
expected_variables: step.expected_variables || []
|
||||
})),
|
||||
is_enabled: detail.is_enabled,
|
||||
metadata: detail.metadata || {}
|
||||
}
|
||||
dialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载流程详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: ScriptFlow) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该流程吗?', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await deleteScriptFlow(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadFlows()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (row: ScriptFlow) => {
|
||||
try {
|
||||
await updateScriptFlow(row.id, { is_enabled: row.is_enabled })
|
||||
ElMessage.success(row.is_enabled ? '已启用' : '已禁用')
|
||||
} catch (error) {
|
||||
row.is_enabled = !row.is_enabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreview = async (row: ScriptFlow) => {
|
||||
try {
|
||||
currentFlow.value = await getScriptFlow(row.id)
|
||||
previewDrawer.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('加载流程详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSimulate = (row: ScriptFlow) => {
|
||||
currentSimulateFlowId.value = row.id
|
||||
currentSimulateFlowName.value = row.name
|
||||
simulateDialogVisible.value = true
|
||||
}
|
||||
|
||||
const addStep = () => {
|
||||
formData.value.steps.push({
|
||||
step_id: generateStepId(),
|
||||
step_no: formData.value.steps.length + 1,
|
||||
content: '',
|
||||
wait_input: true,
|
||||
timeout_seconds: 30,
|
||||
timeout_action: 'repeat',
|
||||
next_conditions: [],
|
||||
script_mode: 'fixed',
|
||||
script_constraints: [],
|
||||
expected_variables: []
|
||||
})
|
||||
}
|
||||
|
||||
const removeStep = (index: number) => {
|
||||
const removedStepNo = index + 1
|
||||
formData.value.steps.splice(index, 1)
|
||||
formData.value.steps.forEach((step, i) => {
|
||||
step.step_no = i + 1
|
||||
if (step.next_conditions) {
|
||||
step.next_conditions = step.next_conditions
|
||||
.filter(c => c.goto_step !== removedStepNo)
|
||||
.map(c => ({
|
||||
...c,
|
||||
goto_step: c.goto_step > removedStepNo ? c.goto_step - 1 : c.goto_step
|
||||
}))
|
||||
}
|
||||
if (step.default_next !== undefined && step.default_next !== null) {
|
||||
if (step.default_next === removedStepNo) {
|
||||
step.default_next = undefined
|
||||
} else if (step.default_next > removedStepNo) {
|
||||
step.default_next = step.default_next - 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addBranch = (step: FlowStep) => {
|
||||
if (!step.next_conditions) {
|
||||
step.next_conditions = []
|
||||
}
|
||||
step.next_conditions.push({ keywords: [], goto_step: 0 })
|
||||
}
|
||||
|
||||
const removeBranch = (step: FlowStep, index: number) => {
|
||||
step.next_conditions?.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (metadataFormRef.value) {
|
||||
const validation = await metadataFormRef.value.validate()
|
||||
if (!validation.valid) {
|
||||
ElMessage.warning('请完善必填的元数据字段')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.value.steps.length === 0) {
|
||||
ElMessage.warning('请至少添加一个步骤')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < formData.value.steps.length; i++) {
|
||||
const step = formData.value.steps[i]
|
||||
const stepLabel = `步骤 ${i + 1}`
|
||||
|
||||
if (step.script_mode === 'fixed' && !step.content?.trim()) {
|
||||
ElMessage.warning(`${stepLabel}:固定模式需要填写话术内容`)
|
||||
return
|
||||
}
|
||||
|
||||
if (step.script_mode === 'flexible') {
|
||||
if (!step.intent?.trim()) {
|
||||
ElMessage.warning(`${stepLabel}:灵活模式需要填写步骤意图`)
|
||||
return
|
||||
}
|
||||
if (!step.content?.trim()) {
|
||||
ElMessage.warning(`${stepLabel}:灵活模式需要填写Fallback话术`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (step.script_mode === 'template' && !step.content?.trim()) {
|
||||
ElMessage.warning(`${stepLabel}:模板模式需要填写话术模板`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const submitData = {
|
||||
...formData.value,
|
||||
steps: formData.value.steps.map((step, index) => ({
|
||||
...step,
|
||||
step_no: index + 1
|
||||
}))
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
const updateData: ScriptFlowUpdate = submitData
|
||||
await updateScriptFlow(currentEditId.value, updateData)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await createScriptFlow(submitData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadFlows()
|
||||
} catch (error) {
|
||||
ElMessage.error(isEdit.value ? '保存失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFlows()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.script-flow-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.no-metadata {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.steps-editor {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.step-order {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.template-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.mode-help-icon {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.branch-editor {
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.branch-item {
|
||||
padding: 8px;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,13 +1,27 @@
|
|||
<template>
|
||||
<div class="dashboard-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">控制台</h1>
|
||||
<p class="page-desc">系统概览与数据统计</p>
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">控制台</h1>
|
||||
<p class="page-desc">系统概览与数据统计</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:shortcuts="dateShortcuts"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="20" v-loading="loading">
|
||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-card shadow="hover" class="stat-card" @click="navigateTo('/admin/knowledge-bases')">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon primary">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
|
|
@ -20,7 +34,7 @@
|
|||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-card shadow="hover" class="stat-card" @click="navigateTo('/kb')">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon success">
|
||||
<el-icon><Document /></el-icon>
|
||||
|
|
@ -33,7 +47,7 @@
|
|||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<el-card shadow="hover" class="stat-card" @click="navigateTo('/monitoring')">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon warning">
|
||||
<el-icon><ChatDotSquare /></el-icon>
|
||||
|
|
@ -60,6 +74,109 @@
|
|||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :xs="24" :sm="12" :md="6" :lg="6">
|
||||
<el-card shadow="hover" class="enhanced-stat-card" @click="navigateTo('/admin/monitoring/intent-rules')">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<div class="icon-wrapper primary">
|
||||
<el-icon><Aim /></el-icon>
|
||||
</div>
|
||||
<span class="header-title">意图规则命中</span>
|
||||
</div>
|
||||
<el-tag type="primary" size="small" effect="plain">{{ (stats.intentRuleHitRate * 100).toFixed(1) }}%</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="enhanced-content">
|
||||
<div class="enhanced-value">{{ stats.intentRuleHitCount }}</div>
|
||||
<div class="enhanced-label">命中次数</div>
|
||||
<div class="top-list" v-if="stats.topIntentRules && stats.topIntentRules.length > 0">
|
||||
<div class="top-item" v-for="rule in stats.topIntentRules.slice(0, 3)" :key="rule.ruleId">
|
||||
<span class="top-name">{{ rule.ruleName }}</span>
|
||||
<span class="top-count">{{ rule.hitCount }}次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6" :lg="6">
|
||||
<el-card shadow="hover" class="enhanced-stat-card" @click="navigateTo('/admin/monitoring/prompt-templates')">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<div class="icon-wrapper success">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</div>
|
||||
<span class="header-title">Prompt 模板</span>
|
||||
</div>
|
||||
<el-tag type="success" size="small" effect="plain">使用中</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="enhanced-content">
|
||||
<div class="enhanced-value">{{ stats.promptTemplateUsageCount }}</div>
|
||||
<div class="enhanced-label">使用次数</div>
|
||||
<div class="top-list" v-if="stats.topPromptTemplates && stats.topPromptTemplates.length > 0">
|
||||
<div class="top-item" v-for="tpl in stats.topPromptTemplates.slice(0, 3)" :key="tpl.templateId">
|
||||
<span class="top-name">{{ tpl.templateName }}</span>
|
||||
<span class="top-count">{{ tpl.usageCount }}次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6" :lg="6">
|
||||
<el-card shadow="hover" class="enhanced-stat-card" @click="navigateTo('/admin/monitoring/script-flows')">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<div class="icon-wrapper warning">
|
||||
<el-icon><Share /></el-icon>
|
||||
</div>
|
||||
<span class="header-title">话术流程</span>
|
||||
</div>
|
||||
<el-tag type="warning" size="small" effect="plain">{{ (stats.scriptFlowCompletionRate * 100).toFixed(1) }}%</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="enhanced-content">
|
||||
<div class="enhanced-value">{{ stats.scriptFlowActivationCount }}</div>
|
||||
<div class="enhanced-label">激活次数</div>
|
||||
<div class="top-list" v-if="stats.topScriptFlows && stats.topScriptFlows.length > 0">
|
||||
<div class="top-item" v-for="flow in stats.topScriptFlows.slice(0, 3)" :key="flow.flowId">
|
||||
<span class="top-name">{{ flow.flowName }}</span>
|
||||
<span class="top-count">{{ flow.activationCount }}次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6" :lg="6">
|
||||
<el-card shadow="hover" class="enhanced-stat-card" @click="navigateTo('/admin/monitoring/guardrails')">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<div class="icon-wrapper danger">
|
||||
<el-icon><Warning /></el-icon>
|
||||
</div>
|
||||
<span class="header-title">护栏拦截</span>
|
||||
</div>
|
||||
<el-tag type="danger" size="small" effect="plain">{{ (stats.guardrailBlockRate * 100).toFixed(1) }}%</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="enhanced-content">
|
||||
<div class="enhanced-value">{{ stats.guardrailBlockCount }}</div>
|
||||
<div class="enhanced-label">拦截次数</div>
|
||||
<div class="top-list" v-if="stats.topGuardrailWords && stats.topGuardrailWords.length > 0">
|
||||
<div class="top-item" v-for="word in stats.topGuardrailWords.slice(0, 3)" :key="word.word">
|
||||
<span class="top-name">{{ word.word }}</span>
|
||||
<span class="top-count">{{ word.hitCount }}次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :xs="24" :sm="24" :md="8" :lg="8">
|
||||
<el-card shadow="hover" class="metric-card">
|
||||
|
|
@ -203,7 +320,7 @@
|
|||
</div>
|
||||
</template>
|
||||
<div class="help-content">
|
||||
<div class="help-item">
|
||||
<div class="help-item" @click="navigateTo('/admin/knowledge-bases')">
|
||||
<div class="help-icon primary">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</div>
|
||||
|
|
@ -212,7 +329,7 @@
|
|||
<p>上传文档并建立向量索引,支持 PDF、Word、TXT 等格式。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<div class="help-item" @click="navigateTo('/rag-lab')">
|
||||
<div class="help-icon success">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
</div>
|
||||
|
|
@ -221,24 +338,78 @@
|
|||
<p>测试检索增强生成效果,查看检索结果和 AI 响应。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<div class="help-item" @click="navigateTo('/monitoring')">
|
||||
<div class="help-icon warning">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>会话监控</h4>
|
||||
<p>实时监控用户会话,查看对话记录和系统响应。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item" @click="navigateTo('/admin/embedding')">
|
||||
<div class="help-icon info">
|
||||
<el-icon><Connection /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>嵌入模型配置</h4>
|
||||
<h4>嵌入模型</h4>
|
||||
<p>配置文本嵌入模型,用于文档向量化。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<div class="help-icon info">
|
||||
<div class="help-item" @click="navigateTo('/admin/llm')">
|
||||
<div class="help-icon primary">
|
||||
<el-icon><ChatDotSquare /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>LLM 模型配置</h4>
|
||||
<h4>LLM 配置</h4>
|
||||
<p>配置大语言模型,支持 OpenAI、DeepSeek、Ollama 等。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item" @click="navigateTo('/admin/prompt-templates')">
|
||||
<div class="help-icon success">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>Prompt 模板</h4>
|
||||
<p>管理和配置提示词模板,优化 AI 响应效果。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item" @click="navigateTo('/admin/intent-rules')">
|
||||
<div class="help-icon warning">
|
||||
<el-icon><Aim /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>意图规则</h4>
|
||||
<p>配置意图识别规则,实现智能对话路由。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item" @click="navigateTo('/admin/script-flows')">
|
||||
<div class="help-icon info">
|
||||
<el-icon><Share /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>话术流程</h4>
|
||||
<p>设计和管理对话流程,实现多轮对话控制。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item" @click="navigateTo('/admin/guardrails')">
|
||||
<div class="help-icon danger">
|
||||
<el-icon><Warning /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>输出护栏</h4>
|
||||
<p>配置敏感词过滤和内容审核规则,保障输出安全。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-item" @click="navigateTo('/admin/metadata-schemas')">
|
||||
<div class="help-icon primary">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</div>
|
||||
<div class="help-text">
|
||||
<h4>元数据配置</h4>
|
||||
<p>配置知识库、意图规则等的动态元数据字段定义。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
|
@ -248,11 +419,64 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { FolderOpened, Document, ChatDotSquare, Monitor, Cpu, InfoFilled, Connection, Timer, DataLine } from '@element-plus/icons-vue'
|
||||
import { getDashboardStats } from '@/api/dashboard'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { FolderOpened, Document, ChatDotSquare, Monitor, Cpu, InfoFilled, Connection, Timer, DataLine, Aim, DocumentCopy, Share, Warning, Setting } from '@element-plus/icons-vue'
|
||||
import { getDashboardStats, type DashboardStats } from '@/api/dashboard'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = reactive({
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const dateShortcuts = [
|
||||
{
|
||||
text: '今日',
|
||||
value: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return [today, new Date()]
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '本周',
|
||||
value: () => {
|
||||
const today = new Date()
|
||||
const day = today.getDay() || 7
|
||||
const monday = new Date(today)
|
||||
monday.setDate(today.getDate() - day + 1)
|
||||
monday.setHours(0, 0, 0, 0)
|
||||
return [monday, new Date()]
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '本月',
|
||||
value: () => {
|
||||
const today = new Date()
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
return [firstDay, new Date()]
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 7)
|
||||
return [start, end]
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '最近30天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 30)
|
||||
return [start, end]
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const stats = reactive<DashboardStats>({
|
||||
knowledgeBases: 0,
|
||||
totalDocuments: 0,
|
||||
totalMessages: 0,
|
||||
|
|
@ -262,14 +486,26 @@ const stats = reactive({
|
|||
completionTokens: 0,
|
||||
aiRequestsCount: 0,
|
||||
avgLatencyMs: 0,
|
||||
lastLatencyMs: 0,
|
||||
lastLatencyMs: null,
|
||||
lastRequestTime: null,
|
||||
slowRequestsCount: 0,
|
||||
errorRequestsCount: 0,
|
||||
p95LatencyMs: 0,
|
||||
p99LatencyMs: 0,
|
||||
minLatencyMs: 0,
|
||||
maxLatencyMs: 0,
|
||||
latencyThresholdMs: 5000
|
||||
latencyThresholdMs: 5000,
|
||||
intentRuleHitRate: 0,
|
||||
intentRuleHitCount: 0,
|
||||
topIntentRules: [],
|
||||
promptTemplateUsageCount: 0,
|
||||
topPromptTemplates: [],
|
||||
scriptFlowActivationCount: 0,
|
||||
scriptFlowCompletionRate: 0,
|
||||
topScriptFlows: [],
|
||||
guardrailBlockCount: 0,
|
||||
guardrailBlockRate: 0,
|
||||
topGuardrailWords: [],
|
||||
})
|
||||
|
||||
const errorRate = computed(() => {
|
||||
|
|
@ -299,27 +535,24 @@ const formatLatency = (ms: number | null | undefined) => {
|
|||
return ms.toFixed(0) + 'ms'
|
||||
}
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const handleDateChange = () => {
|
||||
fetchStats()
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getDashboardStats()
|
||||
stats.knowledgeBases = res.knowledgeBases || 0
|
||||
stats.totalDocuments = res.totalDocuments || 0
|
||||
stats.totalMessages = res.totalMessages || 0
|
||||
stats.totalSessions = res.totalSessions || 0
|
||||
stats.totalTokens = res.totalTokens || 0
|
||||
stats.promptTokens = res.promptTokens || 0
|
||||
stats.completionTokens = res.completionTokens || 0
|
||||
stats.aiRequestsCount = res.aiRequestsCount || 0
|
||||
stats.avgLatencyMs = res.avgLatencyMs || 0
|
||||
stats.lastLatencyMs = res.lastLatencyMs || 0
|
||||
stats.slowRequestsCount = res.slowRequestsCount || 0
|
||||
stats.errorRequestsCount = res.errorRequestsCount || 0
|
||||
stats.p95LatencyMs = res.p95LatencyMs || 0
|
||||
stats.p99LatencyMs = res.p99LatencyMs || 0
|
||||
stats.minLatencyMs = res.minLatencyMs || 0
|
||||
stats.maxLatencyMs = res.maxLatencyMs || 0
|
||||
stats.latencyThresholdMs = res.latencyThresholdMs || 5000
|
||||
const params: any = {}
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
const res = await getDashboardStats(params)
|
||||
Object.assign(stats, res)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard stats:', error)
|
||||
} finally {
|
||||
|
|
@ -340,6 +573,19 @@ onMounted(() => {
|
|||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
|
|
@ -359,6 +605,13 @@ onMounted(() => {
|
|||
|
||||
.stat-card {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card:nth-child(1) { animation-delay: 0s; }
|
||||
|
|
@ -431,8 +684,15 @@ onMounted(() => {
|
|||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
.enhanced-stat-card {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.enhanced-stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
|
|
@ -473,6 +733,11 @@ onMounted(() => {
|
|||
color: #D97706;
|
||||
}
|
||||
|
||||
.icon-wrapper.danger {
|
||||
background-color: #FEE2E2;
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
.icon-wrapper.info {
|
||||
background-color: #E0E7FF;
|
||||
color: #4F46E5;
|
||||
|
|
@ -484,6 +749,57 @@ onMounted(() => {
|
|||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.enhanced-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.enhanced-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.enhanced-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.top-list {
|
||||
border-top: 1px solid var(--border-light);
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.top-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.top-name {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.top-count {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.metric-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -584,11 +900,6 @@ onMounted(() => {
|
|||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.requests-grid {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
|
|
@ -634,7 +945,7 @@ onMounted(() => {
|
|||
|
||||
.help-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
|
|
@ -645,6 +956,7 @@ onMounted(() => {
|
|||
background-color: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-item:hover {
|
||||
|
|
@ -682,6 +994,11 @@ onMounted(() => {
|
|||
color: #4F46E5;
|
||||
}
|
||||
|
||||
.help-icon.danger {
|
||||
background-color: #FEE2E2;
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
.help-text h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 14px;
|
||||
|
|
@ -696,11 +1013,21 @@ onMounted(() => {
|
|||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.help-content {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
|
@ -716,5 +1043,9 @@ onMounted(() => {
|
|||
.metric-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.enhanced-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@
|
|||
</div>
|
||||
<span class="header-title">调试输入</span>
|
||||
</div>
|
||||
<el-switch
|
||||
v-model="flowTestMode"
|
||||
active-text="完整流程测试"
|
||||
inactive-text="RAG 测试"
|
||||
@change="handleModeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<el-form label-position="top">
|
||||
|
|
@ -27,7 +33,7 @@
|
|||
placeholder="输入测试问题..."
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="知识库范围">
|
||||
<el-form-item label="知识库范围" v-if="!flowTestMode">
|
||||
<el-select
|
||||
v-model="kbIds"
|
||||
multiple
|
||||
|
|
@ -45,7 +51,7 @@
|
|||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="LLM 模型">
|
||||
<el-form-item label="LLM 模型" v-if="!flowTestMode">
|
||||
<LLMSelector
|
||||
v-model="llmProvider"
|
||||
:providers="llmProviders"
|
||||
|
|
@ -56,37 +62,67 @@
|
|||
@change="handleLLMChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="参数配置">
|
||||
<div class="param-item">
|
||||
<span class="label">Top-K</span>
|
||||
<el-input-number v-model="topK" :min="1" :max="10" />
|
||||
|
||||
<template v-if="flowTestMode">
|
||||
<el-divider content-position="left">流程配置</el-divider>
|
||||
<div class="flow-config">
|
||||
<div class="config-item">
|
||||
<span class="config-label">意图识别</span>
|
||||
<el-switch v-model="flowConfig.enable_intent" />
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">话术流程</span>
|
||||
<el-switch v-model="flowConfig.enable_flow" />
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">RAG 检索</span>
|
||||
<el-switch v-model="flowConfig.enable_rag" />
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">输出护栏</span>
|
||||
<el-switch v-model="flowConfig.enable_guardrail" />
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">上下文记忆</span>
|
||||
<el-switch v-model="flowConfig.enable_memory" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="label">Score Threshold</span>
|
||||
<el-slider
|
||||
v-model="scoreThreshold"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
show-input
|
||||
/>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="label">生成 AI 回复</span>
|
||||
<el-switch v-model="generateResponse" />
|
||||
</div>
|
||||
<div class="param-item" v-if="generateResponse">
|
||||
<span class="label">流式输出</span>
|
||||
<el-switch v-model="streamOutput" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="!flowTestMode">
|
||||
<el-form-item label="参数配置">
|
||||
<div class="param-item">
|
||||
<span class="label">Top-K</span>
|
||||
<el-input-number v-model="topK" :min="1" :max="10" />
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="label">Score Threshold</span>
|
||||
<el-slider
|
||||
v-model="scoreThreshold"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.1"
|
||||
show-input
|
||||
/>
|
||||
</div>
|
||||
<div class="param-item">
|
||||
<span class="label">生成 AI 回复</span>
|
||||
<el-switch v-model="generateResponse" />
|
||||
</div>
|
||||
<div class="param-item" v-if="generateResponse">
|
||||
<span class="label">流式输出</span>
|
||||
<el-switch v-model="streamOutput" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
block
|
||||
@click="handleRun"
|
||||
:loading="loading || streaming"
|
||||
>
|
||||
{{ streaming ? '生成中...' : '运行实验' }}
|
||||
{{ flowTestMode ? '执行流程测试' : (streaming ? '生成中...' : '运行实验') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="streaming"
|
||||
|
|
@ -102,67 +138,145 @@
|
|||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="24" :md="14" :lg="14">
|
||||
<el-tabs v-model="activeTab" type="border-card" class="result-tabs">
|
||||
<el-tab-pane label="召回片段" name="retrieval">
|
||||
<div v-if="retrievalResults.length === 0" class="placeholder-text">
|
||||
暂无实验数据
|
||||
</div>
|
||||
<div v-else class="result-list">
|
||||
<el-card
|
||||
v-for="(item, index) in retrievalResults"
|
||||
:key="index"
|
||||
class="result-card"
|
||||
shadow="never"
|
||||
>
|
||||
<div class="result-header">
|
||||
<el-tag size="small" type="primary">Score: {{ item.score.toFixed(4) }}</el-tag>
|
||||
<span class="source">来源: {{ item.source }}</span>
|
||||
<template v-if="flowTestMode">
|
||||
<el-card shadow="hover" class="result-card" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<div class="icon-wrapper success">
|
||||
<el-icon><Share /></el-icon>
|
||||
</div>
|
||||
<span class="header-title">执行流程 (12步)</span>
|
||||
</div>
|
||||
<div class="result-content">{{ item.content }}</div>
|
||||
</el-card>
|
||||
<div class="header-right" v-if="flowTestResult">
|
||||
<el-tag :type="getStatusType(flowTestResult.status)" size="small">
|
||||
{{ flowTestResult.status }}
|
||||
</el-tag>
|
||||
<span class="duration">{{ flowTestResult.total_duration_ms }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!flowTestResult" class="placeholder-text">
|
||||
切换到"完整流程测试"模式,输入测试消息后点击执行
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="最终 Prompt" name="prompt">
|
||||
<div v-if="!finalPrompt" class="placeholder-text">
|
||||
等待实验运行...
|
||||
|
||||
<div v-else class="flow-result">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="step in flowTestResult.steps"
|
||||
:key="step.step"
|
||||
:type="getStepStatusType(step.status)"
|
||||
:hollow="step.status === 'skipped'"
|
||||
size="large"
|
||||
>
|
||||
<el-card shadow="never" class="step-card" @click="toggleStepDetail(step.step)">
|
||||
<div class="step-header">
|
||||
<div class="step-info">
|
||||
<span class="step-number">Step {{ step.step }}</span>
|
||||
<span class="step-name">{{ getStepName(step.name) }}</span>
|
||||
</div>
|
||||
<div class="step-meta">
|
||||
<el-tag :type="getStepStatusType(step.status)" size="small" effect="plain">
|
||||
{{ step.status }}
|
||||
</el-tag>
|
||||
<span class="step-duration">{{ step.duration_ms }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expandedSteps.includes(step.step)" class="step-detail">
|
||||
<el-divider content-position="left">输入</el-divider>
|
||||
<pre class="code-block"><code>{{ JSON.stringify(step.input, null, 2) }}</code></pre>
|
||||
<el-divider content-position="left">输出</el-divider>
|
||||
<pre class="code-block"><code>{{ JSON.stringify(step.output, null, 2) }}</code></pre>
|
||||
<template v-if="step.error">
|
||||
<el-divider content-position="left">错误</el-divider>
|
||||
<el-alert type="error" :closable="false">{{ step.error }}</el-alert>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
|
||||
<el-divider content-position="left" v-if="flowTestResult.final_response">最终响应</el-divider>
|
||||
<div v-if="flowTestResult.final_response" class="final-response">
|
||||
<div class="response-content">{{ flowTestResult.final_response.reply }}</div>
|
||||
<div class="response-meta">
|
||||
<span v-if="flowTestResult.final_response.confidence">
|
||||
置信度: {{ (flowTestResult.final_response.confidence * 100).toFixed(1) }}%
|
||||
</span>
|
||||
<el-tag v-if="flowTestResult.final_response.should_transfer" type="warning" size="small">
|
||||
需转人工
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="prompt-view">
|
||||
<pre><code>{{ finalPrompt }}</code></pre>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="AI 回复" name="ai-response" v-if="generateResponse">
|
||||
<StreamOutput
|
||||
v-if="streamOutput"
|
||||
:content="streamContent"
|
||||
:is-streaming="streaming"
|
||||
:error="streamError"
|
||||
/>
|
||||
<AIResponseViewer
|
||||
v-else
|
||||
:response="aiResponse"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="诊断信息" name="diagnostics">
|
||||
<div v-if="!diagnostics" class="placeholder-text">
|
||||
等待实验运行...
|
||||
</div>
|
||||
<div v-else class="diagnostics-view">
|
||||
<pre><code>{{ JSON.stringify(diagnostics, null, 2) }}</code></pre>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-tabs v-model="activeTab" type="border-card" class="result-tabs">
|
||||
<el-tab-pane label="召回片段" name="retrieval">
|
||||
<div v-if="retrievalResults.length === 0" class="placeholder-text">
|
||||
暂无实验数据
|
||||
</div>
|
||||
<div v-else class="result-list">
|
||||
<el-card
|
||||
v-for="(item, index) in retrievalResults"
|
||||
:key="index"
|
||||
class="result-card"
|
||||
shadow="never"
|
||||
>
|
||||
<div class="result-header">
|
||||
<el-tag size="small" type="primary">Score: {{ item.score.toFixed(4) }}</el-tag>
|
||||
<span class="source">来源: {{ item.source }}</span>
|
||||
</div>
|
||||
<div class="result-content">{{ item.content }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="最终 Prompt" name="prompt">
|
||||
<div v-if="!finalPrompt" class="placeholder-text">
|
||||
等待实验运行...
|
||||
</div>
|
||||
<div v-else class="prompt-view">
|
||||
<pre><code>{{ finalPrompt }}</code></pre>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="AI 回复" name="ai-response" v-if="generateResponse">
|
||||
<StreamOutput
|
||||
v-if="streamOutput"
|
||||
:content="streamContent"
|
||||
:is-streaming="streaming"
|
||||
:error="streamError"
|
||||
/>
|
||||
<AIResponseViewer
|
||||
v-else
|
||||
:response="aiResponse"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="诊断信息" name="diagnostics">
|
||||
<div v-if="!diagnostics" class="placeholder-text">
|
||||
等待实验运行...
|
||||
</div>
|
||||
<div v-else class="diagnostics-view">
|
||||
<pre><code>{{ JSON.stringify(diagnostics, null, 2) }}</code></pre>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Edit } from '@element-plus/icons-vue'
|
||||
import { Edit, Share } from '@element-plus/icons-vue'
|
||||
import { runRagExperiment, createSSEConnection, type AIResponse, type RetrievalResult } from '@/api/rag'
|
||||
import { getLLMProviders, getLLMConfig, type LLMProviderInfo } from '@/api/llm'
|
||||
import { listKnowledgeBases } from '@/api/kb'
|
||||
import { executeFlowTest, type FlowExecutionResponse, type FlowExecutionStep } from '@/api/flow-test'
|
||||
import { useRagLabStore } from '@/stores/ragLab'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import AIResponseViewer from '@/components/rag/AIResponseViewer.vue'
|
||||
|
|
@ -204,8 +318,72 @@ const streamError = ref<string | null>(null)
|
|||
|
||||
const totalLatencyMs = ref(0)
|
||||
|
||||
const flowTestMode = ref(false)
|
||||
const flowTestResult = ref<FlowExecutionResponse | null>(null)
|
||||
const expandedSteps = ref<number[]>([])
|
||||
|
||||
const flowConfig = reactive({
|
||||
enable_intent: true,
|
||||
enable_flow: true,
|
||||
enable_rag: true,
|
||||
enable_guardrail: true,
|
||||
enable_memory: true
|
||||
})
|
||||
|
||||
let abortStream: (() => void) | null = null
|
||||
|
||||
const stepNameMap: Record<string, string> = {
|
||||
'InputScanner': '输入扫描',
|
||||
'FlowEngine': '流程引擎',
|
||||
'IntentRouter': '意图路由',
|
||||
'QueryRewriter': '查询重写',
|
||||
'MultiKBRetrieval': '多知识库检索',
|
||||
'ResultRanker': '结果排序',
|
||||
'PromptBuilder': 'Prompt 构建',
|
||||
'LLMGenerate': 'LLM 生成',
|
||||
'OutputFilter': '输出过滤',
|
||||
'Confidence': '置信度计算',
|
||||
'Memory': '记忆存储',
|
||||
'Response': '响应返回'
|
||||
}
|
||||
|
||||
const getStepName = (name: string) => {
|
||||
return stepNameMap[name] || name
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return 'success'
|
||||
case 'failed': return 'danger'
|
||||
case 'partial': return 'warning'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const getStepStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return 'success'
|
||||
case 'failed': return 'danger'
|
||||
case 'skipped': return 'info'
|
||||
default: return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStepDetail = (step: number) => {
|
||||
const index = expandedSteps.value.indexOf(step)
|
||||
if (index > -1) {
|
||||
expandedSteps.value.splice(index, 1)
|
||||
} else {
|
||||
expandedSteps.value.push(step)
|
||||
}
|
||||
}
|
||||
|
||||
const handleModeChange = () => {
|
||||
flowTestResult.value = null
|
||||
expandedSteps.value = []
|
||||
clearResults()
|
||||
}
|
||||
|
||||
const fetchKnowledgeBases = async () => {
|
||||
kbLoading.value = true
|
||||
try {
|
||||
|
|
@ -244,12 +422,40 @@ const handleRun = async () => {
|
|||
return
|
||||
}
|
||||
|
||||
clearResults()
|
||||
|
||||
if (streamOutput.value && generateResponse.value) {
|
||||
await runStreamExperiment()
|
||||
if (flowTestMode.value) {
|
||||
await runFlowTest()
|
||||
} else {
|
||||
await runNormalExperiment()
|
||||
clearResults()
|
||||
if (streamOutput.value && generateResponse.value) {
|
||||
await runStreamExperiment()
|
||||
} else {
|
||||
await runNormalExperiment()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runFlowTest = async () => {
|
||||
loading.value = true
|
||||
flowTestResult.value = null
|
||||
expandedSteps.value = []
|
||||
|
||||
try {
|
||||
const result = await executeFlowTest({
|
||||
message: query.value,
|
||||
enable_flow: flowConfig.enable_flow,
|
||||
enable_intent: flowConfig.enable_intent,
|
||||
enable_rag: flowConfig.enable_rag,
|
||||
enable_guardrail: flowConfig.enable_guardrail,
|
||||
enable_memory: flowConfig.enable_memory
|
||||
})
|
||||
|
||||
flowTestResult.value = result
|
||||
ElMessage.success('流程测试完成')
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
ElMessage.error(err?.message || '流程测试失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -434,12 +640,49 @@ onMounted(() => {
|
|||
font-size: 18px;
|
||||
}
|
||||
|
||||
.icon-wrapper.success {
|
||||
background-color: #D1FAE5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.flow-config {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -523,6 +766,98 @@ onMounted(() => {
|
|||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.flow-result {
|
||||
max-height: 700px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.step-card:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.step-detail {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.final-response {
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.response-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rag-lab-page {
|
||||
padding: 16px;
|
||||
|
|
@ -541,5 +876,9 @@ onMounted(() => {
|
|||
.param-item .label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flow-config {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,38 @@ Admin API routes for AI Service management.
|
|||
|
||||
from app.api.admin.api_key import router as api_key_router
|
||||
from app.api.admin.dashboard import router as dashboard_router
|
||||
from app.api.admin.decomposition_template import router as decomposition_template_router
|
||||
from app.api.admin.embedding import router as embedding_router
|
||||
from app.api.admin.flow_test import router as flow_test_router
|
||||
from app.api.admin.guardrails import router as guardrails_router
|
||||
from app.api.admin.intent_rules import router as intent_rules_router
|
||||
from app.api.admin.kb import router as kb_router
|
||||
from app.api.admin.llm import router as llm_router
|
||||
from app.api.admin.metadata_field_definition import router as metadata_field_definition_router
|
||||
from app.api.admin.metadata_schema import router as metadata_schema_router
|
||||
from app.api.admin.monitoring import router as monitoring_router
|
||||
from app.api.admin.prompt_templates import router as prompt_templates_router
|
||||
from app.api.admin.rag import router as rag_router
|
||||
from app.api.admin.script_flows import router as script_flows_router
|
||||
from app.api.admin.sessions import router as sessions_router
|
||||
from app.api.admin.tenants import router as tenants_router
|
||||
|
||||
__all__ = ["api_key_router", "dashboard_router", "embedding_router", "kb_router", "llm_router", "rag_router", "sessions_router", "tenants_router"]
|
||||
__all__ = [
|
||||
"api_key_router",
|
||||
"dashboard_router",
|
||||
"decomposition_template_router",
|
||||
"embedding_router",
|
||||
"flow_test_router",
|
||||
"guardrails_router",
|
||||
"intent_rules_router",
|
||||
"kb_router",
|
||||
"llm_router",
|
||||
"metadata_field_definition_router",
|
||||
"metadata_schema_router",
|
||||
"monitoring_router",
|
||||
"prompt_templates_router",
|
||||
"rag_router",
|
||||
"script_flows_router",
|
||||
"sessions_router",
|
||||
"tenants_router",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ async def list_api_keys(
|
|||
"""
|
||||
service = get_api_key_service()
|
||||
keys = await service.list_keys(session)
|
||||
|
||||
|
||||
return ApiKeyListResponse(
|
||||
keys=[api_key_to_response(k) for k in keys],
|
||||
total=len(keys),
|
||||
|
|
@ -87,18 +87,18 @@ async def create_api_key(
|
|||
[AC-AISVC-50] Create a new API key.
|
||||
"""
|
||||
service = get_api_key_service()
|
||||
|
||||
|
||||
key_value = request.key or service.generate_key()
|
||||
|
||||
|
||||
key_create = ApiKeyCreate(
|
||||
key=key_value,
|
||||
name=request.name,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
api_key = await service.create_key(session, key_create)
|
||||
logger.info(f"[AC-AISVC-50] Created API key: {api_key.name}")
|
||||
|
||||
|
||||
return api_key_to_response(api_key)
|
||||
|
||||
|
||||
|
|
@ -111,9 +111,9 @@ async def delete_api_key(
|
|||
[AC-AISVC-50] Delete an API key.
|
||||
"""
|
||||
service = get_api_key_service()
|
||||
|
||||
|
||||
deleted = await service.delete_key(session, key_id)
|
||||
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
|
@ -131,15 +131,15 @@ async def toggle_api_key(
|
|||
[AC-AISVC-50] Toggle API key active status.
|
||||
"""
|
||||
service = get_api_key_service()
|
||||
|
||||
|
||||
api_key = await service.toggle_key(session, key_id, request.is_active)
|
||||
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found",
|
||||
)
|
||||
|
||||
|
||||
return api_key_to_response(api_key)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
"""
|
||||
Dashboard statistics endpoints.
|
||||
Provides overview statistics for the admin dashboard.
|
||||
[AC-AISVC-91, AC-AISVC-92] Provides overview statistics for the admin dashboard.
|
||||
Enhanced with monitoring metrics for intent rules, templates, flows, and guardrails.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
|
|
@ -16,6 +18,8 @@ from app.core.exceptions import MissingTenantIdException
|
|||
from app.core.tenant import get_tenant_id
|
||||
from app.models import ErrorResponse
|
||||
from app.models.entities import ChatMessage, ChatSession, Document, KnowledgeBase
|
||||
from app.services.monitoring.cache import get_monitoring_cache
|
||||
from app.services.monitoring.dashboard_service import DashboardService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -32,11 +36,21 @@ def get_current_tenant_id() -> str:
|
|||
return tenant_id
|
||||
|
||||
|
||||
def parse_date_param(date_str: str | None) -> datetime | None:
|
||||
"""Parse ISO 8601 date string to datetime."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
operation_id="getDashboardStats",
|
||||
summary="Get dashboard statistics",
|
||||
description="Get overview statistics for the admin dashboard.",
|
||||
description="[AC-AISVC-91, AC-AISVC-92] Get overview statistics for the admin dashboard with enhanced monitoring metrics.",
|
||||
responses={
|
||||
200: {"description": "Dashboard statistics"},
|
||||
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||
|
|
@ -47,11 +61,21 @@ async def get_dashboard_stats(
|
|||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
latency_threshold: int = Query(default=LATENCY_THRESHOLD_MS, description="Latency threshold in ms"),
|
||||
start_date: str | None = Query(default=None, description="Start date filter (ISO 8601)"),
|
||||
end_date: str | None = Query(default=None, description="End date filter (ISO 8601)"),
|
||||
include_enhanced: bool = Query(default=True, description="Include enhanced monitoring stats"),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Get dashboard statistics including knowledge bases, messages, and activity.
|
||||
[AC-AISVC-91, AC-AISVC-92] Get dashboard statistics including:
|
||||
- Basic stats: knowledge bases, messages, documents, sessions
|
||||
- Token statistics
|
||||
- Latency statistics
|
||||
- Enhanced stats (v0.7.0): intent rules, templates, flows, guardrails
|
||||
"""
|
||||
logger.info(f"Getting dashboard stats: tenant={tenant_id}")
|
||||
logger.info(f"Getting dashboard stats: tenant={tenant_id}, start={start_date}, end={end_date}")
|
||||
|
||||
start_dt = parse_date_param(start_date)
|
||||
end_dt = parse_date_param(end_date)
|
||||
|
||||
kb_count_stmt = select(func.count()).select_from(KnowledgeBase).where(
|
||||
KnowledgeBase.tenant_id == tenant_id
|
||||
|
|
@ -62,6 +86,10 @@ async def get_dashboard_stats(
|
|||
msg_count_stmt = select(func.count()).select_from(ChatMessage).where(
|
||||
ChatMessage.tenant_id == tenant_id
|
||||
)
|
||||
if start_dt:
|
||||
msg_count_stmt = msg_count_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
msg_count_stmt = msg_count_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
msg_result = await session.execute(msg_count_stmt)
|
||||
msg_count = msg_result.scalar() or 0
|
||||
|
||||
|
|
@ -74,24 +102,40 @@ async def get_dashboard_stats(
|
|||
session_count_stmt = select(func.count()).select_from(ChatSession).where(
|
||||
ChatSession.tenant_id == tenant_id
|
||||
)
|
||||
if start_dt:
|
||||
session_count_stmt = session_count_stmt.where(ChatSession.created_at >= start_dt)
|
||||
if end_dt:
|
||||
session_count_stmt = session_count_stmt.where(ChatSession.created_at <= end_dt)
|
||||
session_result = await session.execute(session_count_stmt)
|
||||
session_count = session_result.scalar() or 0
|
||||
|
||||
total_tokens_stmt = select(func.coalesce(func.sum(ChatMessage.total_tokens), 0)).select_from(
|
||||
ChatMessage
|
||||
).where(ChatMessage.tenant_id == tenant_id)
|
||||
if start_dt:
|
||||
total_tokens_stmt = total_tokens_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
total_tokens_stmt = total_tokens_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
total_tokens_result = await session.execute(total_tokens_stmt)
|
||||
total_tokens = total_tokens_result.scalar() or 0
|
||||
|
||||
prompt_tokens_stmt = select(func.coalesce(func.sum(ChatMessage.prompt_tokens), 0)).select_from(
|
||||
ChatMessage
|
||||
).where(ChatMessage.tenant_id == tenant_id)
|
||||
if start_dt:
|
||||
prompt_tokens_stmt = prompt_tokens_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
prompt_tokens_stmt = prompt_tokens_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
prompt_tokens_result = await session.execute(prompt_tokens_stmt)
|
||||
prompt_tokens = prompt_tokens_result.scalar() or 0
|
||||
|
||||
completion_tokens_stmt = select(func.coalesce(func.sum(ChatMessage.completion_tokens), 0)).select_from(
|
||||
ChatMessage
|
||||
).where(ChatMessage.tenant_id == tenant_id)
|
||||
if start_dt:
|
||||
completion_tokens_stmt = completion_tokens_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
completion_tokens_stmt = completion_tokens_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
completion_tokens_result = await session.execute(completion_tokens_stmt)
|
||||
completion_tokens = completion_tokens_result.scalar() or 0
|
||||
|
||||
|
|
@ -99,6 +143,10 @@ async def get_dashboard_stats(
|
|||
ChatMessage.tenant_id == tenant_id,
|
||||
ChatMessage.role == "assistant"
|
||||
)
|
||||
if start_dt:
|
||||
ai_requests_stmt = ai_requests_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
ai_requests_stmt = ai_requests_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
ai_requests_result = await session.execute(ai_requests_stmt)
|
||||
ai_requests_count = ai_requests_result.scalar() or 0
|
||||
|
||||
|
|
@ -109,6 +157,10 @@ async def get_dashboard_stats(
|
|||
ChatMessage.role == "assistant",
|
||||
ChatMessage.latency_ms.isnot(None)
|
||||
)
|
||||
if start_dt:
|
||||
avg_latency_stmt = avg_latency_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
avg_latency_stmt = avg_latency_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
avg_latency_result = await session.execute(avg_latency_stmt)
|
||||
avg_latency_ms = float(avg_latency_result.scalar() or 0)
|
||||
|
||||
|
|
@ -127,6 +179,10 @@ async def get_dashboard_stats(
|
|||
ChatMessage.latency_ms.isnot(None),
|
||||
ChatMessage.latency_ms >= latency_threshold
|
||||
)
|
||||
if start_dt:
|
||||
slow_requests_stmt = slow_requests_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
slow_requests_stmt = slow_requests_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
slow_requests_result = await session.execute(slow_requests_stmt)
|
||||
slow_requests_count = slow_requests_result.scalar() or 0
|
||||
|
||||
|
|
@ -135,6 +191,10 @@ async def get_dashboard_stats(
|
|||
ChatMessage.role == "assistant",
|
||||
ChatMessage.is_error == True
|
||||
)
|
||||
if start_dt:
|
||||
error_requests_stmt = error_requests_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
error_requests_stmt = error_requests_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
error_requests_result = await session.execute(error_requests_stmt)
|
||||
error_requests_count = error_requests_result.scalar() or 0
|
||||
|
||||
|
|
@ -145,6 +205,10 @@ async def get_dashboard_stats(
|
|||
ChatMessage.role == "assistant",
|
||||
ChatMessage.latency_ms.isnot(None)
|
||||
)
|
||||
if start_dt:
|
||||
p95_latency_stmt = p95_latency_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
p95_latency_stmt = p95_latency_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
p95_latency_result = await session.execute(p95_latency_stmt)
|
||||
p95_latency_ms = float(p95_latency_result.scalar() or 0)
|
||||
|
||||
|
|
@ -155,6 +219,10 @@ async def get_dashboard_stats(
|
|||
ChatMessage.role == "assistant",
|
||||
ChatMessage.latency_ms.isnot(None)
|
||||
)
|
||||
if start_dt:
|
||||
p99_latency_stmt = p99_latency_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
p99_latency_stmt = p99_latency_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
p99_latency_result = await session.execute(p99_latency_stmt)
|
||||
p99_latency_ms = float(p99_latency_result.scalar() or 0)
|
||||
|
||||
|
|
@ -165,6 +233,10 @@ async def get_dashboard_stats(
|
|||
ChatMessage.role == "assistant",
|
||||
ChatMessage.latency_ms.isnot(None)
|
||||
)
|
||||
if start_dt:
|
||||
min_latency_stmt = min_latency_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
min_latency_stmt = min_latency_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
min_latency_result = await session.execute(min_latency_stmt)
|
||||
min_latency_ms = float(min_latency_result.scalar() or 0)
|
||||
|
||||
|
|
@ -175,28 +247,58 @@ async def get_dashboard_stats(
|
|||
ChatMessage.role == "assistant",
|
||||
ChatMessage.latency_ms.isnot(None)
|
||||
)
|
||||
if start_dt:
|
||||
max_latency_stmt = max_latency_stmt.where(ChatMessage.created_at >= start_dt)
|
||||
if end_dt:
|
||||
max_latency_stmt = max_latency_stmt.where(ChatMessage.created_at <= end_dt)
|
||||
max_latency_result = await session.execute(max_latency_stmt)
|
||||
max_latency_ms = float(max_latency_result.scalar() or 0)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"knowledgeBases": kb_count,
|
||||
"totalMessages": msg_count,
|
||||
"totalDocuments": doc_count,
|
||||
"totalSessions": session_count,
|
||||
"totalTokens": total_tokens,
|
||||
"promptTokens": prompt_tokens,
|
||||
"completionTokens": completion_tokens,
|
||||
"aiRequestsCount": ai_requests_count,
|
||||
"avgLatencyMs": round(avg_latency_ms, 2),
|
||||
"lastLatencyMs": last_latency_ms,
|
||||
"lastRequestTime": last_request_time,
|
||||
"slowRequestsCount": slow_requests_count,
|
||||
"errorRequestsCount": error_requests_count,
|
||||
"p95LatencyMs": round(p95_latency_ms, 2),
|
||||
"p99LatencyMs": round(p99_latency_ms, 2),
|
||||
"minLatencyMs": round(min_latency_ms, 2),
|
||||
"maxLatencyMs": round(max_latency_ms, 2),
|
||||
"latencyThresholdMs": latency_threshold,
|
||||
}
|
||||
)
|
||||
response_data: dict[str, Any] = {
|
||||
"knowledgeBases": kb_count,
|
||||
"totalMessages": msg_count,
|
||||
"totalDocuments": doc_count,
|
||||
"totalSessions": session_count,
|
||||
"totalTokens": total_tokens,
|
||||
"promptTokens": prompt_tokens,
|
||||
"completionTokens": completion_tokens,
|
||||
"aiRequestsCount": ai_requests_count,
|
||||
"avgLatencyMs": round(avg_latency_ms, 2),
|
||||
"lastLatencyMs": last_latency_ms,
|
||||
"lastRequestTime": last_request_time,
|
||||
"slowRequestsCount": slow_requests_count,
|
||||
"errorRequestsCount": error_requests_count,
|
||||
"p95LatencyMs": round(p95_latency_ms, 2),
|
||||
"p99LatencyMs": round(p99_latency_ms, 2),
|
||||
"minLatencyMs": round(min_latency_ms, 2),
|
||||
"maxLatencyMs": round(max_latency_ms, 2),
|
||||
"latencyThresholdMs": latency_threshold,
|
||||
}
|
||||
|
||||
if include_enhanced:
|
||||
try:
|
||||
cache = get_monitoring_cache()
|
||||
dashboard_service = DashboardService(session, cache)
|
||||
enhanced_stats = await dashboard_service.get_enhanced_stats(
|
||||
tenant_id=tenant_id,
|
||||
start_date=start_dt,
|
||||
end_date=end_dt,
|
||||
)
|
||||
response_data.update(enhanced_stats.to_dict())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get enhanced stats: {e}")
|
||||
response_data.update({
|
||||
"intentRuleHitRate": 0.0,
|
||||
"intentRuleHitCount": 0,
|
||||
"topIntentRules": [],
|
||||
"promptTemplateUsageCount": 0,
|
||||
"topPromptTemplates": [],
|
||||
"scriptFlowActivationCount": 0,
|
||||
"scriptFlowCompletionRate": 0.0,
|
||||
"topScriptFlows": [],
|
||||
"guardrailBlockCount": 0,
|
||||
"guardrailBlockRate": 0.0,
|
||||
"topGuardrailWords": [],
|
||||
})
|
||||
|
||||
return JSONResponse(content=response_data)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
"""
|
||||
Decomposition Template API.
|
||||
[AC-IDSMETA-21, AC-IDSMETA-22] 拆解模板管理接口,支持文本拆解为结构化数据。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.core.exceptions import MissingTenantIdException
|
||||
from app.core.tenant import get_tenant_id
|
||||
from app.models.entities import (
|
||||
DecompositionRequest,
|
||||
DecompositionTemplateCreate,
|
||||
DecompositionTemplateStatus,
|
||||
DecompositionTemplateUpdate,
|
||||
)
|
||||
from app.services.decomposition_template_service import DecompositionTemplateService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/decomposition-templates", tags=["DecompositionTemplates"])
|
||||
|
||||
|
||||
def get_current_tenant_id() -> str:
|
||||
"""Get current tenant ID from context."""
|
||||
tenant_id = get_tenant_id()
|
||||
if not tenant_id:
|
||||
raise MissingTenantIdException()
|
||||
return tenant_id
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
operation_id="listDecompositionTemplates",
|
||||
summary="List decomposition templates",
|
||||
description="[AC-IDSMETA-22] 获取拆解模板列表,支持按状态过滤",
|
||||
)
|
||||
async def list_templates(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
status: Annotated[str | None, Query(
|
||||
description="按状态过滤: draft/published/archived"
|
||||
)] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-22] 列出拆解模板
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-22] Listing decomposition templates: "
|
||||
f"tenant={tenant_id}, status={status}"
|
||||
)
|
||||
|
||||
if status and status not in [s.value for s in DecompositionTemplateStatus]:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_STATUS",
|
||||
"message": f"Invalid status: {status}",
|
||||
"details": {
|
||||
"valid_values": [s.value for s in DecompositionTemplateStatus]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
service = DecompositionTemplateService(session)
|
||||
templates = await service.list_templates(tenant_id, status)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [
|
||||
{
|
||||
"id": str(t.id),
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"version": t.version,
|
||||
"status": t.status,
|
||||
"template_schema": t.template_schema,
|
||||
"extraction_hints": t.extraction_hints,
|
||||
"example_input": t.example_input,
|
||||
"example_output": t.example_output,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
"updated_at": t.updated_at.isoformat(),
|
||||
}
|
||||
for t in templates
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createDecompositionTemplate",
|
||||
summary="Create decomposition template",
|
||||
description="[AC-IDSMETA-22] 创建新的拆解模板",
|
||||
status_code=201,
|
||||
)
|
||||
async def create_template(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
template_create: DecompositionTemplateCreate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-22] 创建拆解模板
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-22] Creating decomposition template: "
|
||||
f"tenant={tenant_id}, name={template_create.name}"
|
||||
)
|
||||
|
||||
service = DecompositionTemplateService(session)
|
||||
template = await service.create_template(tenant_id, template_create)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"version": template.version,
|
||||
"status": template.status,
|
||||
"template_schema": template.template_schema,
|
||||
"extraction_hints": template.extraction_hints,
|
||||
"example_input": template.example_input,
|
||||
"example_output": template.example_output,
|
||||
"created_at": template.created_at.isoformat(),
|
||||
"updated_at": template.updated_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/latest",
|
||||
operation_id="getLatestPublishedTemplate",
|
||||
summary="Get latest published template",
|
||||
description="[AC-IDSMETA-22] 获取最近生效的发布版本模板",
|
||||
)
|
||||
async def get_latest_template(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-22] 获取最近生效的发布版本模板
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-22] Getting latest published template: tenant={tenant_id}"
|
||||
)
|
||||
|
||||
service = DecompositionTemplateService(session)
|
||||
template = await service.get_latest_published_template(tenant_id)
|
||||
|
||||
if not template:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"code": "NOT_FOUND",
|
||||
"message": "No published template found",
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"version": template.version,
|
||||
"status": template.status,
|
||||
"template_schema": template.template_schema,
|
||||
"extraction_hints": template.extraction_hints,
|
||||
"example_input": template.example_input,
|
||||
"example_output": template.example_output,
|
||||
"created_at": template.created_at.isoformat(),
|
||||
"updated_at": template.updated_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{id}",
|
||||
operation_id="updateDecompositionTemplate",
|
||||
summary="Update decomposition template",
|
||||
description="[AC-IDSMETA-22] 更新拆解模板,支持状态切换",
|
||||
)
|
||||
async def update_template(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
id: str,
|
||||
template_update: DecompositionTemplateUpdate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-22] 更新拆解模板
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-22] Updating decomposition template: "
|
||||
f"tenant={tenant_id}, id={id}"
|
||||
)
|
||||
|
||||
if template_update.status and template_update.status not in [s.value for s in DecompositionTemplateStatus]:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_STATUS",
|
||||
"message": f"Invalid status: {template_update.status}",
|
||||
"details": {
|
||||
"valid_values": [s.value for s in DecompositionTemplateStatus]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
service = DecompositionTemplateService(session)
|
||||
template = await service.update_template(tenant_id, id, template_update)
|
||||
|
||||
if not template:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"Template {id} not found",
|
||||
}
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"version": template.version,
|
||||
"status": template.status,
|
||||
"template_schema": template.template_schema,
|
||||
"extraction_hints": template.extraction_hints,
|
||||
"example_input": template.example_input,
|
||||
"example_output": template.example_output,
|
||||
"created_at": template.created_at.isoformat(),
|
||||
"updated_at": template.updated_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/decompose",
|
||||
operation_id="decomposeText",
|
||||
summary="Decompose text to structured data",
|
||||
description="[AC-IDSMETA-21] 将待录入文本拆解为固定模板输出",
|
||||
)
|
||||
async def decompose_text(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
request: DecompositionRequest,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-21] 将待录入文本拆解为固定模板输出
|
||||
|
||||
如果不指定 template_id,则使用最近生效的发布版本模板
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-21] Decomposing text: tenant={tenant_id}, "
|
||||
f"template_id={request.template_id}, text_length={len(request.text)}"
|
||||
)
|
||||
|
||||
from app.services.llm import get_llm_client
|
||||
llm_client = get_llm_client()
|
||||
|
||||
service = DecompositionTemplateService(session, llm_client)
|
||||
result = await service.decompose_text(tenant_id, request)
|
||||
|
||||
if not result.success:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "DECOMPOSITION_FAILED",
|
||||
"message": result.error,
|
||||
"details": {
|
||||
"template_id": result.template_id,
|
||||
"template_version": result.template_version,
|
||||
"latency_ms": result.latency_ms,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": result.success,
|
||||
"data": result.data,
|
||||
"template_id": result.template_id,
|
||||
"template_version": result.template_version,
|
||||
"confidence": result.confidence,
|
||||
"latency_ms": result.latency_ms,
|
||||
}
|
||||
)
|
||||
|
|
@ -39,7 +39,7 @@ async def list_embedding_providers(
|
|||
for name in EmbeddingProviderFactory.get_available_providers():
|
||||
info = EmbeddingProviderFactory.get_provider_info(name)
|
||||
providers.append(info)
|
||||
|
||||
|
||||
return {"providers": providers}
|
||||
|
||||
|
||||
|
|
@ -66,32 +66,32 @@ async def update_embedding_config(
|
|||
"""
|
||||
provider = request.get("provider")
|
||||
config = request.get("config", {})
|
||||
|
||||
|
||||
if not provider:
|
||||
raise InvalidRequestException("provider is required")
|
||||
|
||||
|
||||
if provider not in EmbeddingProviderFactory.get_available_providers():
|
||||
raise InvalidRequestException(
|
||||
f"Unknown provider: {provider}. "
|
||||
f"Available: {EmbeddingProviderFactory.get_available_providers()}"
|
||||
)
|
||||
|
||||
|
||||
manager = get_embedding_config_manager()
|
||||
|
||||
|
||||
old_config = manager.get_full_config()
|
||||
old_provider = old_config.get("provider")
|
||||
old_model = old_config.get("config", {}).get("model", "")
|
||||
|
||||
|
||||
new_model = config.get("model", "")
|
||||
|
||||
|
||||
try:
|
||||
await manager.update_config(provider, config)
|
||||
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"message": f"Configuration updated to use {provider}",
|
||||
}
|
||||
|
||||
|
||||
if old_provider != provider or old_model != new_model:
|
||||
response["warning"] = (
|
||||
"嵌入模型已更改。由于不同模型生成的向量不兼容,"
|
||||
|
|
@ -102,7 +102,7 @@ async def update_embedding_config(
|
|||
f"[EMBEDDING] Model changed from {old_provider}/{old_model} to {provider}/{new_model}. "
|
||||
f"Documents need to be re-uploaded."
|
||||
)
|
||||
|
||||
|
||||
return response
|
||||
except EmbeddingException as e:
|
||||
raise InvalidRequestException(str(e))
|
||||
|
|
@ -121,15 +121,15 @@ async def test_embedding(
|
|||
test_text = request.get("test_text", "这是一个测试文本")
|
||||
config = request.get("config")
|
||||
provider = request.get("provider")
|
||||
|
||||
|
||||
manager = get_embedding_config_manager()
|
||||
|
||||
|
||||
result = await manager.test_connection(
|
||||
test_text=test_text,
|
||||
provider=provider,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -141,11 +141,11 @@ async def get_supported_document_formats(
|
|||
Get supported document formats for embedding.
|
||||
Returns list of supported file extensions.
|
||||
"""
|
||||
from app.services.document import get_supported_document_formats, DocumentParserFactory
|
||||
|
||||
from app.services.document import DocumentParserFactory, get_supported_document_formats
|
||||
|
||||
formats = get_supported_document_formats()
|
||||
parser_info = DocumentParserFactory.get_parser_info()
|
||||
|
||||
|
||||
return {
|
||||
"formats": formats,
|
||||
"parsers": parser_info,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,402 @@
|
|||
"""
|
||||
Flow test API for AI Service Admin.
|
||||
[AC-AISVC-93~AC-AISVC-95] Complete 12-step flow execution testing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import FlowTestRecord, FlowTestRecordStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/test", tags=["Flow Test"])
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
|
||||
"""Extract tenant ID from header."""
|
||||
if not x_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
|
||||
return x_tenant_id
|
||||
|
||||
|
||||
class FlowExecutionRequest(BaseModel):
|
||||
"""Request for flow execution test."""
|
||||
|
||||
message: str
|
||||
session_id: str | None = None
|
||||
user_id: str | None = None
|
||||
enable_flow: bool = True
|
||||
enable_intent: bool = True
|
||||
enable_rag: bool = True
|
||||
enable_guardrail: bool = True
|
||||
enable_memory: bool = True
|
||||
compare_mode: bool = False
|
||||
|
||||
|
||||
class FlowExecutionResponse(BaseModel):
|
||||
"""Response for flow execution test."""
|
||||
|
||||
test_id: str
|
||||
session_id: str
|
||||
status: str
|
||||
steps: list[dict[str, Any]]
|
||||
final_response: dict[str, Any] | None
|
||||
total_duration_ms: int
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/flow-execution",
|
||||
operation_id="executeFlowTest",
|
||||
summary="Execute complete 12-step flow",
|
||||
description="[AC-AISVC-93] Execute complete 12-step generation flow with detailed step logging.",
|
||||
)
|
||||
async def execute_flow_test(
|
||||
request: FlowExecutionRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> FlowExecutionResponse:
|
||||
"""
|
||||
[AC-AISVC-93] Execute complete 12-step flow for testing.
|
||||
|
||||
Steps:
|
||||
1. InputScanner - Scan input for forbidden words
|
||||
2. FlowEngine - Check if flow is active
|
||||
3. IntentRouter - Match intent rules
|
||||
4. QueryRewriter - Rewrite query for better retrieval
|
||||
5. MultiKBRetrieval - Retrieve from multiple knowledge bases
|
||||
6. ResultRanker - Rank and filter results
|
||||
7. PromptBuilder - Build prompt from template
|
||||
8. LLMGenerate - Generate response via LLM
|
||||
9. OutputFilter - Filter output for forbidden words
|
||||
10. Confidence - Calculate confidence score
|
||||
11. Memory - Store conversation in memory
|
||||
12. Response - Return final response
|
||||
"""
|
||||
import time
|
||||
|
||||
from app.models import ChatRequest, ChannelType
|
||||
from app.services.llm.factory import get_llm_config_manager
|
||||
from app.services.memory import MemoryService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
from app.services.retrieval.optimized_retriever import get_optimized_retriever
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-93] Executing flow test for tenant={tenant_id}, "
|
||||
f"message={request.message[:50]}..."
|
||||
)
|
||||
|
||||
test_session_id = request.session_id or f"test_{uuid.uuid4().hex[:8]}"
|
||||
start_time = time.time()
|
||||
|
||||
memory_service = MemoryService(session)
|
||||
llm_config_manager = get_llm_config_manager()
|
||||
llm_client = llm_config_manager.get_client()
|
||||
retriever = await get_optimized_retriever()
|
||||
|
||||
orchestrator = OrchestratorService(
|
||||
llm_client=llm_client,
|
||||
memory_service=memory_service,
|
||||
retriever=retriever,
|
||||
)
|
||||
|
||||
try:
|
||||
chat_request = ChatRequest(
|
||||
session_id=test_session_id,
|
||||
current_message=request.message,
|
||||
channel_type=ChannelType.WECHAT,
|
||||
history=[],
|
||||
)
|
||||
|
||||
result = await orchestrator.generate(
|
||||
tenant_id=tenant_id,
|
||||
request=chat_request,
|
||||
)
|
||||
|
||||
steps = result.metadata.get("execution_steps", []) if result.metadata else []
|
||||
total_duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
has_failure = any(s.get("status") == "failed" for s in steps)
|
||||
has_partial = any(s.get("status") == "skipped" for s in steps)
|
||||
|
||||
if has_failure:
|
||||
status = FlowTestRecordStatus.FAILED.value
|
||||
elif has_partial:
|
||||
status = FlowTestRecordStatus.PARTIAL.value
|
||||
else:
|
||||
status = FlowTestRecordStatus.SUCCESS.value
|
||||
|
||||
test_record = FlowTestRecord(
|
||||
tenant_id=tenant_id,
|
||||
session_id=test_session_id,
|
||||
status=status,
|
||||
steps=steps,
|
||||
final_response={
|
||||
"reply": result.reply,
|
||||
"confidence": result.confidence,
|
||||
"should_transfer": result.should_transfer,
|
||||
},
|
||||
total_duration_ms=total_duration_ms,
|
||||
)
|
||||
|
||||
try:
|
||||
session.add(test_record)
|
||||
await session.commit()
|
||||
await session.refresh(test_record)
|
||||
except Exception as db_error:
|
||||
logger.warning(f"Failed to save test record: {db_error}")
|
||||
await session.rollback()
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-93] Flow test completed: id={test_record.id}, "
|
||||
f"status={status}, duration={total_duration_ms}ms"
|
||||
)
|
||||
|
||||
return FlowExecutionResponse(
|
||||
test_id=str(test_record.id),
|
||||
session_id=test_session_id,
|
||||
status=status,
|
||||
steps=steps,
|
||||
final_response=test_record.final_response,
|
||||
total_duration_ms=total_duration_ms,
|
||||
created_at=test_record.created_at.isoformat(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AC-AISVC-93] Flow test failed: {e}")
|
||||
|
||||
total_duration_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
await session.rollback()
|
||||
|
||||
test_record = FlowTestRecord(
|
||||
tenant_id=tenant_id,
|
||||
session_id=test_session_id,
|
||||
status=FlowTestRecordStatus.FAILED.value,
|
||||
steps=[{
|
||||
"step": 0,
|
||||
"name": "Error",
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
}],
|
||||
final_response=None,
|
||||
total_duration_ms=total_duration_ms,
|
||||
)
|
||||
session.add(test_record)
|
||||
await session.commit()
|
||||
await session.refresh(test_record)
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/flow-execution/{test_id}",
|
||||
operation_id="getFlowTestResult",
|
||||
summary="Get flow test result",
|
||||
description="[AC-AISVC-94] Get detailed result of a flow execution test.",
|
||||
)
|
||||
async def get_flow_test_result(
|
||||
test_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-94] Get detailed result of a flow execution test.
|
||||
Returns step-by-step execution details for debugging.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-94] Getting flow test result for tenant={tenant_id}, "
|
||||
f"test_id={test_id}"
|
||||
)
|
||||
|
||||
stmt = select(FlowTestRecord).where(
|
||||
FlowTestRecord.id == test_id,
|
||||
FlowTestRecord.tenant_id == tenant_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Test record not found")
|
||||
|
||||
return {
|
||||
"testId": str(record.id),
|
||||
"sessionId": record.session_id,
|
||||
"status": record.status,
|
||||
"steps": record.steps,
|
||||
"finalResponse": record.final_response,
|
||||
"totalDurationMs": record.total_duration_ms,
|
||||
"createdAt": record.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/flow-executions",
|
||||
operation_id="listFlowTests",
|
||||
summary="List flow test records",
|
||||
description="[AC-AISVC-95] List flow test execution records.",
|
||||
)
|
||||
async def list_flow_tests(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session_id: str | None = Query(None, description="Filter by session ID"),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-95] List flow test execution records.
|
||||
Records are retained for 7 days.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-95] Listing flow tests for tenant={tenant_id}, "
|
||||
f"session={session_id}, page={page}"
|
||||
)
|
||||
|
||||
stmt = select(FlowTestRecord).where(
|
||||
FlowTestRecord.tenant_id == tenant_id,
|
||||
)
|
||||
|
||||
if session_id:
|
||||
stmt = stmt.where(FlowTestRecord.session_id == session_id)
|
||||
if status:
|
||||
stmt = stmt.where(FlowTestRecord.status == status)
|
||||
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await session.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
stmt = stmt.order_by(desc(FlowTestRecord.created_at))
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"testId": str(r.id),
|
||||
"sessionId": r.session_id,
|
||||
"status": r.status,
|
||||
"stepCount": len(r.steps),
|
||||
"totalDurationMs": r.total_duration_ms,
|
||||
"createdAt": r.created_at.isoformat(),
|
||||
}
|
||||
for r in records
|
||||
],
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
class CompareRequest(BaseModel):
|
||||
"""Request for comparison test."""
|
||||
|
||||
message: str
|
||||
baseline_config: dict[str, Any] | None = None
|
||||
test_config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/compare",
|
||||
operation_id="compareFlowTest",
|
||||
summary="Compare two flow executions",
|
||||
description="[AC-AISVC-95] Compare baseline and test configurations.",
|
||||
)
|
||||
async def compare_flow_test(
|
||||
request: CompareRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-95] Compare two flow executions with different configurations.
|
||||
|
||||
Useful for:
|
||||
- A/B testing prompt templates
|
||||
- Comparing RAG retrieval strategies
|
||||
- Testing guardrail effectiveness
|
||||
"""
|
||||
import time
|
||||
|
||||
from app.models import ChatRequest, ChannelType
|
||||
from app.services.llm.factory import get_llm_config_manager
|
||||
from app.services.memory import MemoryService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
from app.services.retrieval.optimized_retriever import get_optimized_retriever
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-95] Running comparison test for tenant={tenant_id}"
|
||||
)
|
||||
|
||||
baseline_session_id = f"compare_baseline_{uuid.uuid4().hex[:8]}"
|
||||
test_session_id = f"compare_test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
memory_service = MemoryService(session)
|
||||
llm_config_manager = get_llm_config_manager()
|
||||
llm_client = llm_config_manager.get_client()
|
||||
retriever = await get_optimized_retriever()
|
||||
|
||||
orchestrator = OrchestratorService(
|
||||
llm_client=llm_client,
|
||||
memory_service=memory_service,
|
||||
retriever=retriever,
|
||||
)
|
||||
|
||||
baseline_chat_request = ChatRequest(
|
||||
session_id=baseline_session_id,
|
||||
current_message=request.message,
|
||||
channel_type=ChannelType.WECHAT,
|
||||
history=[],
|
||||
)
|
||||
|
||||
baseline_start = time.time()
|
||||
baseline_result = await orchestrator.generate(
|
||||
tenant_id=tenant_id,
|
||||
request=baseline_chat_request,
|
||||
)
|
||||
baseline_duration = int((time.time() - baseline_start) * 1000)
|
||||
|
||||
test_chat_request = ChatRequest(
|
||||
session_id=test_session_id,
|
||||
current_message=request.message,
|
||||
channel_type=ChannelType.WECHAT,
|
||||
history=[],
|
||||
)
|
||||
|
||||
test_start = time.time()
|
||||
test_result = await orchestrator.generate(
|
||||
tenant_id=tenant_id,
|
||||
request=test_chat_request,
|
||||
)
|
||||
test_duration = int((time.time() - test_start) * 1000)
|
||||
|
||||
return {
|
||||
"baseline": {
|
||||
"sessionId": baseline_session_id,
|
||||
"reply": baseline_result.reply,
|
||||
"confidence": baseline_result.confidence,
|
||||
"durationMs": baseline_duration,
|
||||
"steps": baseline_result.metadata.get("execution_steps", []) if baseline_result.metadata else [],
|
||||
},
|
||||
"test": {
|
||||
"sessionId": test_session_id,
|
||||
"reply": test_result.reply,
|
||||
"confidence": test_result.confidence,
|
||||
"durationMs": test_duration,
|
||||
"steps": test_result.metadata.get("execution_steps", []) if test_result.metadata else [],
|
||||
},
|
||||
"comparison": {
|
||||
"durationDiffMs": test_duration - baseline_duration,
|
||||
"confidenceDiff": (test_result.confidence or 0) - (baseline_result.confidence or 0),
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
"""
|
||||
Guardrail Management API.
|
||||
[AC-AISVC-78~AC-AISVC-85] Forbidden words and behavior rules CRUD endpoints.
|
||||
[AC-AISVC-105] Guardrail testing endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import (
|
||||
BehaviorRuleCreate,
|
||||
BehaviorRuleUpdate,
|
||||
ForbiddenWordCreate,
|
||||
ForbiddenWordUpdate,
|
||||
)
|
||||
from app.services.guardrail.behavior_service import BehaviorRuleService
|
||||
from app.services.guardrail.tester import GuardrailTester
|
||||
from app.services.guardrail.word_service import ForbiddenWordService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/guardrails", tags=["Guardrails"])
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
|
||||
"""Extract tenant ID from header."""
|
||||
if not x_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
|
||||
return x_tenant_id
|
||||
|
||||
|
||||
@router.get("/forbidden-words")
|
||||
async def list_forbidden_words(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
category: str | None = None,
|
||||
is_enabled: bool | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-79] List all forbidden words for a tenant.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-79] Listing forbidden words for tenant={tenant_id}, "
|
||||
f"category={category}, is_enabled={is_enabled}"
|
||||
)
|
||||
|
||||
service = ForbiddenWordService(session)
|
||||
words = await service.list_words(tenant_id, category, is_enabled)
|
||||
|
||||
data = []
|
||||
for word in words:
|
||||
data.append(await service.word_to_info_dict(word))
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.post("/forbidden-words", status_code=201)
|
||||
async def create_forbidden_word(
|
||||
body: ForbiddenWordCreate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-78] Create a new forbidden word.
|
||||
"""
|
||||
valid_categories = ["competitor", "sensitive", "political", "custom"]
|
||||
if body.category not in valid_categories:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid category. Must be one of: {valid_categories}"
|
||||
)
|
||||
|
||||
valid_strategies = ["mask", "replace", "block"]
|
||||
if body.strategy not in valid_strategies:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid strategy. Must be one of: {valid_strategies}"
|
||||
)
|
||||
|
||||
if body.strategy == "replace" and not body.replacement:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="replacement is required when strategy is 'replace'"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-78] Creating forbidden word for tenant={tenant_id}, word={body.word}"
|
||||
)
|
||||
|
||||
service = ForbiddenWordService(session)
|
||||
try:
|
||||
word = await service.create_word(tenant_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return await service.word_to_info_dict(word)
|
||||
|
||||
|
||||
@router.get("/forbidden-words/{word_id}")
|
||||
async def get_forbidden_word(
|
||||
word_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-79] Get forbidden word detail.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-79] Getting forbidden word for tenant={tenant_id}, id={word_id}")
|
||||
|
||||
service = ForbiddenWordService(session)
|
||||
word = await service.get_word(tenant_id, word_id)
|
||||
|
||||
if not word:
|
||||
raise HTTPException(status_code=404, detail="Forbidden word not found")
|
||||
|
||||
return await service.word_to_info_dict(word)
|
||||
|
||||
|
||||
@router.put("/forbidden-words/{word_id}")
|
||||
async def update_forbidden_word(
|
||||
word_id: uuid.UUID,
|
||||
body: ForbiddenWordUpdate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-80] Update a forbidden word.
|
||||
"""
|
||||
valid_categories = ["competitor", "sensitive", "political", "custom"]
|
||||
if body.category is not None and body.category not in valid_categories:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid category. Must be one of: {valid_categories}"
|
||||
)
|
||||
|
||||
valid_strategies = ["mask", "replace", "block"]
|
||||
if body.strategy is not None and body.strategy not in valid_strategies:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid strategy. Must be one of: {valid_strategies}"
|
||||
)
|
||||
|
||||
logger.info(f"[AC-AISVC-80] Updating forbidden word for tenant={tenant_id}, id={word_id}")
|
||||
|
||||
service = ForbiddenWordService(session)
|
||||
try:
|
||||
word = await service.update_word(tenant_id, word_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not word:
|
||||
raise HTTPException(status_code=404, detail="Forbidden word not found")
|
||||
|
||||
return await service.word_to_info_dict(word)
|
||||
|
||||
|
||||
@router.delete("/forbidden-words/{word_id}", status_code=204)
|
||||
async def delete_forbidden_word(
|
||||
word_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""
|
||||
[AC-AISVC-81] Delete a forbidden word.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-81] Deleting forbidden word for tenant={tenant_id}, id={word_id}")
|
||||
|
||||
service = ForbiddenWordService(session)
|
||||
success = await service.delete_word(tenant_id, word_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Forbidden word not found")
|
||||
|
||||
|
||||
@router.get("/behavior-rules")
|
||||
async def list_behavior_rules(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
category: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-85] List all behavior rules for a tenant.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-85] Listing behavior rules for tenant={tenant_id}, category={category}"
|
||||
)
|
||||
|
||||
service = BehaviorRuleService(session)
|
||||
rules = await service.list_rules(tenant_id, category)
|
||||
|
||||
data = []
|
||||
for rule in rules:
|
||||
data.append(await service.rule_to_info_dict(rule))
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.post("/behavior-rules", status_code=201)
|
||||
async def create_behavior_rule(
|
||||
body: BehaviorRuleCreate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-84] Create a new behavior rule.
|
||||
"""
|
||||
valid_categories = ["compliance", "tone", "boundary", "custom"]
|
||||
if body.category not in valid_categories:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid category. Must be one of: {valid_categories}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-84] Creating behavior rule for tenant={tenant_id}, category={body.category}"
|
||||
)
|
||||
|
||||
service = BehaviorRuleService(session)
|
||||
try:
|
||||
rule = await service.create_rule(tenant_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return await service.rule_to_info_dict(rule)
|
||||
|
||||
|
||||
@router.get("/behavior-rules/{rule_id}")
|
||||
async def get_behavior_rule(
|
||||
rule_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-85] Get behavior rule detail.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-85] Getting behavior rule for tenant={tenant_id}, id={rule_id}")
|
||||
|
||||
service = BehaviorRuleService(session)
|
||||
rule = await service.get_rule(tenant_id, rule_id)
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Behavior rule not found")
|
||||
|
||||
return await service.rule_to_info_dict(rule)
|
||||
|
||||
|
||||
@router.put("/behavior-rules/{rule_id}")
|
||||
async def update_behavior_rule(
|
||||
rule_id: uuid.UUID,
|
||||
body: BehaviorRuleUpdate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-85] Update a behavior rule.
|
||||
"""
|
||||
valid_categories = ["compliance", "tone", "boundary", "custom"]
|
||||
if body.category is not None and body.category not in valid_categories:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid category. Must be one of: {valid_categories}"
|
||||
)
|
||||
|
||||
logger.info(f"[AC-AISVC-85] Updating behavior rule for tenant={tenant_id}, id={rule_id}")
|
||||
|
||||
service = BehaviorRuleService(session)
|
||||
try:
|
||||
rule = await service.update_rule(tenant_id, rule_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Behavior rule not found")
|
||||
|
||||
return await service.rule_to_info_dict(rule)
|
||||
|
||||
|
||||
@router.delete("/behavior-rules/{rule_id}", status_code=204)
|
||||
async def delete_behavior_rule(
|
||||
rule_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""
|
||||
[AC-AISVC-85] Delete a behavior rule.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-85] Deleting behavior rule for tenant={tenant_id}, id={rule_id}")
|
||||
|
||||
service = BehaviorRuleService(session)
|
||||
success = await service.delete_rule(tenant_id, rule_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Behavior rule not found")
|
||||
|
||||
|
||||
class GuardrailTestRequest(BaseModel):
|
||||
"""Request body for guardrail testing."""
|
||||
|
||||
testTexts: list[str]
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_guardrail(
|
||||
body: GuardrailTestRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-105] Test forbidden word detection and filtering.
|
||||
|
||||
This endpoint tests texts against the tenant's forbidden words
|
||||
without modifying any database state. It returns:
|
||||
- Detection results for each text
|
||||
- Filtered text (with mask/replace applied)
|
||||
- Summary statistics
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-105] Testing guardrail for tenant={tenant_id}, "
|
||||
f"texts_count={len(body.testTexts)}"
|
||||
)
|
||||
|
||||
tester = GuardrailTester(session)
|
||||
result = await tester.test_guardrail(tenant_id, body.testTexts)
|
||||
|
||||
return result.to_dict()
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
"""
|
||||
Intent Rule Management API.
|
||||
[AC-AISVC-65~AC-AISVC-68] Intent rule CRUD endpoints.
|
||||
[AC-AISVC-96] Intent rule testing endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import IntentRuleCreate, IntentRuleUpdate
|
||||
from app.services.intent.rule_service import IntentRuleService
|
||||
from app.services.intent.tester import IntentRuleTester
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/intent-rules", tags=["Intent Rules"])
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
|
||||
"""Extract tenant ID from header."""
|
||||
if not x_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
|
||||
return x_tenant_id
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_rules(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
response_type: str | None = None,
|
||||
is_enabled: bool | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-66] List all intent rules for a tenant.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-66] Listing intent rules for tenant={tenant_id}, "
|
||||
f"response_type={response_type}, is_enabled={is_enabled}"
|
||||
)
|
||||
|
||||
service = IntentRuleService(session)
|
||||
rules = await service.list_rules(tenant_id, response_type, is_enabled)
|
||||
|
||||
data = []
|
||||
for rule in rules:
|
||||
data.append(await service.rule_to_info_dict(rule))
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_rule(
|
||||
body: IntentRuleCreate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-65] Create a new intent rule.
|
||||
"""
|
||||
valid_response_types = ["fixed", "rag", "flow", "transfer"]
|
||||
if body.response_type not in valid_response_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid response_type. Must be one of: {valid_response_types}"
|
||||
)
|
||||
|
||||
if body.response_type == "rag" and not body.target_kb_ids:
|
||||
logger.warning(
|
||||
f"[AC-AISVC-65] Creating rag rule without target_kb_ids: tenant={tenant_id}"
|
||||
)
|
||||
|
||||
if body.response_type == "flow" and not body.flow_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="flow_id is required when response_type is 'flow'"
|
||||
)
|
||||
|
||||
if body.response_type == "fixed" and not body.fixed_reply:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="fixed_reply is required when response_type is 'fixed'"
|
||||
)
|
||||
|
||||
if body.response_type == "transfer" and not body.transfer_message:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="transfer_message is required when response_type is 'transfer'"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-65] Creating intent rule for tenant={tenant_id}, name={body.name}"
|
||||
)
|
||||
|
||||
service = IntentRuleService(session)
|
||||
rule = await service.create_rule(tenant_id, body)
|
||||
|
||||
return await service.rule_to_info_dict(rule)
|
||||
|
||||
|
||||
@router.get("/{rule_id}")
|
||||
async def get_rule(
|
||||
rule_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-66] Get intent rule detail.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-66] Getting intent rule for tenant={tenant_id}, id={rule_id}")
|
||||
|
||||
service = IntentRuleService(session)
|
||||
rule = await service.get_rule(tenant_id, rule_id)
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Intent rule not found")
|
||||
|
||||
return await service.rule_to_info_dict(rule)
|
||||
|
||||
|
||||
@router.put("/{rule_id}")
|
||||
async def update_rule(
|
||||
rule_id: uuid.UUID,
|
||||
body: IntentRuleUpdate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-67] Update an intent rule.
|
||||
"""
|
||||
valid_response_types = ["fixed", "rag", "flow", "transfer"]
|
||||
if body.response_type is not None and body.response_type not in valid_response_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid response_type. Must be one of: {valid_response_types}"
|
||||
)
|
||||
|
||||
logger.info(f"[AC-AISVC-67] Updating intent rule for tenant={tenant_id}, id={rule_id}")
|
||||
|
||||
service = IntentRuleService(session)
|
||||
rule = await service.update_rule(tenant_id, rule_id, body)
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Intent rule not found")
|
||||
|
||||
return await service.rule_to_info_dict(rule)
|
||||
|
||||
|
||||
@router.delete("/{rule_id}", status_code=204)
|
||||
async def delete_rule(
|
||||
rule_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""
|
||||
[AC-AISVC-68] Delete an intent rule.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-68] Deleting intent rule for tenant={tenant_id}, id={rule_id}")
|
||||
|
||||
service = IntentRuleService(session)
|
||||
success = await service.delete_rule(tenant_id, rule_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Intent rule not found")
|
||||
|
||||
|
||||
class IntentRuleTestRequest(BaseModel):
|
||||
"""Request body for testing an intent rule."""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/{rule_id}/test")
|
||||
async def test_rule(
|
||||
rule_id: uuid.UUID,
|
||||
body: IntentRuleTestRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-96] Test an intent rule against a message.
|
||||
|
||||
Returns match result with conflict detection.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-96] Testing intent rule for tenant={tenant_id}, "
|
||||
f"rule_id={rule_id}, message='{body.message[:50]}...'"
|
||||
)
|
||||
|
||||
service = IntentRuleService(session)
|
||||
rule = await service.get_rule(tenant_id, rule_id)
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Intent rule not found")
|
||||
|
||||
all_rules = await service.get_enabled_rules_for_matching(tenant_id)
|
||||
|
||||
tester = IntentRuleTester()
|
||||
result = await tester.test_rule(rule, [body.message], all_rules)
|
||||
|
||||
return result.to_dict()
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
"""
|
||||
Knowledge Base management endpoints.
|
||||
[AC-ASA-01, AC-ASA-02, AC-ASA-08] Document upload, list, and index job status.
|
||||
[AC-AISVC-59~AC-AISVC-64] Multi-knowledge-base management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Optional
|
||||
from typing import Annotated, Any, Optional
|
||||
|
||||
import tiktoken
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query, UploadFile, File, Form
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -19,8 +19,15 @@ from app.core.database import get_session
|
|||
from app.core.exceptions import MissingTenantIdException
|
||||
from app.core.tenant import get_tenant_id
|
||||
from app.models import ErrorResponse
|
||||
from app.models.entities import DocumentStatus, IndexJob, IndexJobStatus
|
||||
from app.models.entities import (
|
||||
IndexJob,
|
||||
IndexJobStatus,
|
||||
KBType,
|
||||
KnowledgeBaseCreate,
|
||||
KnowledgeBaseUpdate,
|
||||
)
|
||||
from app.services.kb import KBService
|
||||
from app.services.knowledge_base_service import KnowledgeBaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -44,24 +51,24 @@ def chunk_text_by_lines(
|
|||
) -> list[TextChunk]:
|
||||
"""
|
||||
按行分块,每行作为一个独立的检索单元。
|
||||
|
||||
|
||||
Args:
|
||||
text: 要分块的文本
|
||||
min_line_length: 最小行长度,低于此长度的行会被跳过
|
||||
source: 来源文件路径(可选)
|
||||
|
||||
|
||||
Returns:
|
||||
分块列表,每个块对应一行文本
|
||||
"""
|
||||
lines = text.split('\n')
|
||||
chunks: list[TextChunk] = []
|
||||
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line = line.strip()
|
||||
|
||||
|
||||
if len(line) < min_line_length:
|
||||
continue
|
||||
|
||||
|
||||
chunks.append(TextChunk(
|
||||
text=line,
|
||||
start_token=i,
|
||||
|
|
@ -69,7 +76,7 @@ def chunk_text_by_lines(
|
|||
page=None,
|
||||
source=source,
|
||||
))
|
||||
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
|
|
@ -82,14 +89,14 @@ def chunk_text_with_tiktoken(
|
|||
) -> list[TextChunk]:
|
||||
"""
|
||||
使用 tiktoken 按 token 数分块,支持重叠分块。
|
||||
|
||||
|
||||
Args:
|
||||
text: 要分块的文本
|
||||
chunk_size: 每个块的最大 token 数
|
||||
overlap: 块之间的重叠 token 数
|
||||
page: 页码(可选)
|
||||
source: 来源文件路径(可选)
|
||||
|
||||
|
||||
Returns:
|
||||
分块列表,每个块包含文本及起始/结束位置
|
||||
"""
|
||||
|
|
@ -97,7 +104,7 @@ def chunk_text_with_tiktoken(
|
|||
tokens = encoding.encode(text)
|
||||
chunks: list[TextChunk] = []
|
||||
start = 0
|
||||
|
||||
|
||||
while start < len(tokens):
|
||||
end = min(start + chunk_size, len(tokens))
|
||||
chunk_tokens = tokens[start:end]
|
||||
|
|
@ -112,7 +119,7 @@ def chunk_text_with_tiktoken(
|
|||
if end == len(tokens):
|
||||
break
|
||||
start += chunk_size - overlap
|
||||
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
|
|
@ -128,7 +135,7 @@ def get_current_tenant_id() -> str:
|
|||
"/knowledge-bases",
|
||||
operation_id="listKnowledgeBases",
|
||||
summary="Query knowledge base list",
|
||||
description="Get list of knowledge bases for the current tenant.",
|
||||
description="[AC-AISVC-60] Get list of knowledge bases for the current tenant with type and status filters.",
|
||||
responses={
|
||||
200: {"description": "Knowledge base list"},
|
||||
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||
|
|
@ -138,41 +145,264 @@ def get_current_tenant_id() -> str:
|
|||
async def list_knowledge_bases(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
kb_type: Annotated[Optional[str], Query()] = None,
|
||||
is_enabled: Annotated[Optional[bool], Query()] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
List all knowledge bases for the current tenant.
|
||||
[AC-AISVC-60] List all knowledge bases for the current tenant.
|
||||
Supports filtering by kb_type and is_enabled status.
|
||||
"""
|
||||
logger.info(f"Listing knowledge bases: tenant={tenant_id}")
|
||||
try:
|
||||
logger.info(f"[AC-AISVC-60] Listing knowledge bases: tenant={tenant_id}, kb_type={kb_type}, is_enabled={is_enabled}")
|
||||
|
||||
kb_service = KBService(session)
|
||||
knowledge_bases = await kb_service.list_knowledge_bases(tenant_id)
|
||||
|
||||
kb_ids = [str(kb.id) for kb in knowledge_bases]
|
||||
|
||||
doc_counts = {}
|
||||
if kb_ids:
|
||||
from sqlalchemy import func
|
||||
from app.models.entities import Document
|
||||
count_stmt = (
|
||||
select(Document.kb_id, func.count(Document.id).label("count"))
|
||||
.where(Document.tenant_id == tenant_id, Document.kb_id.in_(kb_ids))
|
||||
.group_by(Document.kb_id)
|
||||
kb_service = KnowledgeBaseService(session)
|
||||
logger.info(f"[AC-AISVC-60] KnowledgeBaseService created, calling list_knowledge_bases...")
|
||||
knowledge_bases = await kb_service.list_knowledge_bases(
|
||||
tenant_id=tenant_id,
|
||||
kb_type=kb_type,
|
||||
is_enabled=is_enabled,
|
||||
)
|
||||
count_result = await session.execute(count_stmt)
|
||||
for row in count_result:
|
||||
doc_counts[row.kb_id] = row.count
|
||||
logger.info(f"[AC-AISVC-60] Found {len(knowledge_bases)} knowledge bases")
|
||||
|
||||
data = []
|
||||
for kb in knowledge_bases:
|
||||
kb_id_str = str(kb.id)
|
||||
data.append({
|
||||
"id": kb_id_str,
|
||||
data = []
|
||||
for kb in knowledge_bases:
|
||||
data.append({
|
||||
"id": str(kb.id),
|
||||
"name": kb.name,
|
||||
"kbType": kb.kb_type,
|
||||
"description": kb.description,
|
||||
"priority": kb.priority,
|
||||
"isEnabled": kb.is_enabled,
|
||||
"docCount": kb.doc_count,
|
||||
"createdAt": kb.created_at.isoformat() + "Z",
|
||||
"updatedAt": kb.updated_at.isoformat() + "Z",
|
||||
})
|
||||
|
||||
logger.info(f"[AC-AISVC-60] Returning {len(data)} knowledge bases")
|
||||
return JSONResponse(content={"data": data})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"[AC-AISVC-60] Error listing knowledge bases: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/knowledge-bases",
|
||||
operation_id="createKnowledgeBase",
|
||||
summary="Create knowledge base",
|
||||
description="[AC-AISVC-59] Create a new knowledge base with specified type and priority.",
|
||||
responses={
|
||||
201: {"description": "Knowledge base created"},
|
||||
400: {"description": "Bad Request - invalid kb_type"},
|
||||
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||
403: {"description": "Forbidden", "model": ErrorResponse},
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
async def create_knowledge_base(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
kb_create: KnowledgeBaseCreate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-AISVC-59] Create a new knowledge base.
|
||||
Initializes corresponding Qdrant Collection.
|
||||
"""
|
||||
valid_types = [t.value for t in KBType]
|
||||
if kb_create.kb_type not in valid_types:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_KB_TYPE",
|
||||
"message": f"Invalid kb_type: {kb_create.kb_type}",
|
||||
"details": {"valid_types": valid_types},
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-59] Creating knowledge base: tenant={tenant_id}, "
|
||||
f"name={kb_create.name}, type={kb_create.kb_type}"
|
||||
)
|
||||
|
||||
kb_service = KnowledgeBaseService(session)
|
||||
kb = await kb_service.create_knowledge_base(tenant_id, kb_create)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"id": str(kb.id),
|
||||
"name": kb.name,
|
||||
"documentCount": doc_counts.get(kb_id_str, 0),
|
||||
"kbType": kb.kb_type,
|
||||
"description": kb.description,
|
||||
"priority": kb.priority,
|
||||
"isEnabled": kb.is_enabled,
|
||||
"docCount": kb.doc_count,
|
||||
"createdAt": kb.created_at.isoformat() + "Z",
|
||||
})
|
||||
"updatedAt": kb.updated_at.isoformat() + "Z",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(content={"data": data})
|
||||
|
||||
@router.get(
|
||||
"/knowledge-bases/{kb_id}",
|
||||
operation_id="getKnowledgeBase",
|
||||
summary="Get knowledge base details",
|
||||
description="Get detailed information about a specific knowledge base.",
|
||||
responses={
|
||||
200: {"description": "Knowledge base details"},
|
||||
404: {"description": "Knowledge base not found"},
|
||||
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||
403: {"description": "Forbidden", "model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
async def get_knowledge_base(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
kb_id: str,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Get a specific knowledge base by ID.
|
||||
"""
|
||||
logger.info(f"Getting knowledge base: tenant={tenant_id}, kb_id={kb_id}")
|
||||
|
||||
kb_service = KnowledgeBaseService(session)
|
||||
kb = await kb_service.get_knowledge_base(tenant_id, kb_id)
|
||||
|
||||
if not kb:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"code": "KB_NOT_FOUND",
|
||||
"message": f"Knowledge base {kb_id} not found",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"id": str(kb.id),
|
||||
"name": kb.name,
|
||||
"kbType": kb.kb_type,
|
||||
"description": kb.description,
|
||||
"priority": kb.priority,
|
||||
"isEnabled": kb.is_enabled,
|
||||
"docCount": kb.doc_count,
|
||||
"createdAt": kb.created_at.isoformat() + "Z",
|
||||
"updatedAt": kb.updated_at.isoformat() + "Z",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/knowledge-bases/{kb_id}",
|
||||
operation_id="updateKnowledgeBase",
|
||||
summary="Update knowledge base",
|
||||
description="[AC-AISVC-61] Update knowledge base name, type, description, priority, or enabled status.",
|
||||
responses={
|
||||
200: {"description": "Knowledge base updated"},
|
||||
400: {"description": "Bad Request - invalid kb_type"},
|
||||
404: {"description": "Knowledge base not found"},
|
||||
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||
403: {"description": "Forbidden", "model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
async def update_knowledge_base(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
kb_id: str,
|
||||
kb_update: KnowledgeBaseUpdate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-AISVC-61] Update a knowledge base.
|
||||
"""
|
||||
if kb_update.kb_type is not None:
|
||||
valid_types = [t.value for t in KBType]
|
||||
if kb_update.kb_type not in valid_types:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_KB_TYPE",
|
||||
"message": f"Invalid kb_type: {kb_update.kb_type}",
|
||||
"details": {"valid_types": valid_types},
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-61] Updating knowledge base: tenant={tenant_id}, kb_id={kb_id}"
|
||||
)
|
||||
|
||||
kb_service = KnowledgeBaseService(session)
|
||||
kb = await kb_service.update_knowledge_base(tenant_id, kb_id, kb_update)
|
||||
|
||||
if not kb:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"code": "KB_NOT_FOUND",
|
||||
"message": f"Knowledge base {kb_id} not found",
|
||||
},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"id": str(kb.id),
|
||||
"name": kb.name,
|
||||
"kbType": kb.kb_type,
|
||||
"description": kb.description,
|
||||
"priority": kb.priority,
|
||||
"isEnabled": kb.is_enabled,
|
||||
"docCount": kb.doc_count,
|
||||
"createdAt": kb.created_at.isoformat() + "Z",
|
||||
"updatedAt": kb.updated_at.isoformat() + "Z",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/knowledge-bases/{kb_id}",
|
||||
operation_id="deleteKnowledgeBase",
|
||||
summary="Delete knowledge base",
|
||||
description="[AC-AISVC-62] Delete a knowledge base and its associated documents and Qdrant Collection.",
|
||||
responses={
|
||||
204: {"description": "Knowledge base deleted"},
|
||||
404: {"description": "Knowledge base not found"},
|
||||
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||
403: {"description": "Forbidden", "model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
async def delete_knowledge_base(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
kb_id: str,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-AISVC-62] Delete a knowledge base.
|
||||
Also deletes associated documents and Qdrant Collection.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-62] Deleting knowledge base: tenant={tenant_id}, kb_id={kb_id}"
|
||||
)
|
||||
|
||||
kb_service = KnowledgeBaseService(session)
|
||||
deleted = await kb_service.delete_knowledge_base(tenant_id, kb_id)
|
||||
|
||||
if not deleted:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"code": "KB_NOT_FOUND",
|
||||
"message": f"Knowledge base {kb_id} not found",
|
||||
},
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
status_code=204,
|
||||
content=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -221,7 +451,7 @@ async def list_documents(
|
|||
).order_by(IndexJob.created_at.desc())
|
||||
job_result = await session.execute(job_stmt)
|
||||
latest_job = job_result.scalar_one_or_none()
|
||||
|
||||
|
||||
data.append({
|
||||
"docId": str(doc.id),
|
||||
"kbId": doc.kb_id,
|
||||
|
|
@ -249,10 +479,10 @@ async def list_documents(
|
|||
"/documents",
|
||||
operation_id="uploadDocument",
|
||||
summary="Upload/import document",
|
||||
description="[AC-ASA-01] Upload document and trigger indexing job.",
|
||||
description="[AC-ASA-01, AC-AISVC-63, AC-IDSMETA-15] Upload document to specified knowledge base and trigger indexing job.",
|
||||
responses={
|
||||
202: {"description": "Accepted - async indexing job started"},
|
||||
400: {"description": "Bad Request - unsupported format"},
|
||||
400: {"description": "Bad Request - unsupported format or invalid kb_id"},
|
||||
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||
403: {"description": "Forbidden", "model": ErrorResponse},
|
||||
},
|
||||
|
|
@ -263,22 +493,34 @@ async def upload_document(
|
|||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(...),
|
||||
kb_id: str = Form(...),
|
||||
metadata: str = Form(default="{}", description="元数据 JSON 字符串,根据元数据模式配置动态字段"),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-ASA-01] Upload document and create indexing job.
|
||||
[AC-AISVC-33, AC-AISVC-34, AC-AISVC-35, AC-AISVC-37] Support multiple document formats.
|
||||
"""
|
||||
from app.services.document import get_supported_document_formats, UnsupportedFormatError
|
||||
from pathlib import Path
|
||||
[AC-ASA-01, AC-AISVC-63, AC-IDSMETA-15] Upload document to specified knowledge base.
|
||||
Creates KB if not exists, indexes to corresponding Qdrant Collection.
|
||||
|
||||
[AC-IDSMETA-15] 支持动态元数据校验:
|
||||
- metadata: JSON 格式的元数据,字段根据元数据模式配置
|
||||
- 根据 scope=kb_document 的字段定义进行 required/type/enum 校验
|
||||
|
||||
示例 metadata:
|
||||
- 教育行业: {"grade": "初一", "subject": "语文", "type": "痛点"}
|
||||
- 医疗行业: {"department": "内科", "disease_type": "慢性病", "content_type": "科普"}
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.document import get_supported_document_formats
|
||||
from app.services.metadata_field_definition_service import MetadataFieldDefinitionService
|
||||
|
||||
logger.info(
|
||||
f"[AC-ASA-01] Uploading document: tenant={tenant_id}, "
|
||||
f"[AC-IDSMETA-15] Uploading document: tenant={tenant_id}, "
|
||||
f"kb_id={kb_id}, filename={file.filename}"
|
||||
)
|
||||
|
||||
file_ext = Path(file.filename or "").suffix.lower()
|
||||
supported_formats = get_supported_document_formats()
|
||||
|
||||
|
||||
if file_ext and file_ext not in supported_formats:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
|
|
@ -291,23 +533,65 @@ async def upload_document(
|
|||
},
|
||||
)
|
||||
|
||||
kb_service = KBService(session)
|
||||
try:
|
||||
metadata_dict = json.loads(metadata) if metadata else {}
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_METADATA",
|
||||
"message": "Invalid JSON format for metadata",
|
||||
},
|
||||
)
|
||||
|
||||
kb = await kb_service.get_or_create_kb(tenant_id, kb_id)
|
||||
field_def_service = MetadataFieldDefinitionService(session)
|
||||
|
||||
is_valid, validation_errors = await field_def_service.validate_metadata_for_create(
|
||||
tenant_id, metadata_dict, "kb_document"
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"[AC-IDSMETA-15] Metadata validation failed: {validation_errors}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "METADATA_VALIDATION_ERROR",
|
||||
"message": "Metadata validation failed",
|
||||
"details": {
|
||||
"errors": validation_errors,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
kb_service = KnowledgeBaseService(session)
|
||||
|
||||
try:
|
||||
kb = await kb_service.get_knowledge_base(tenant_id, kb_id)
|
||||
if not kb:
|
||||
kb = await kb_service.get_or_create_default_kb(tenant_id)
|
||||
kb_id = str(kb.id)
|
||||
logger.info(f"[AC-IDSMETA-15] KB not found, using default: {kb_id}")
|
||||
else:
|
||||
kb_id = str(kb.id)
|
||||
except Exception:
|
||||
kb = await kb_service.get_or_create_default_kb(tenant_id)
|
||||
kb_id = str(kb.id)
|
||||
|
||||
doc_kb_service = KBService(session)
|
||||
file_content = await file.read()
|
||||
document, job = await kb_service.upload_document(
|
||||
document, job = await doc_kb_service.upload_document(
|
||||
tenant_id=tenant_id,
|
||||
kb_id=str(kb.id),
|
||||
kb_id=kb_id,
|
||||
file_name=file.filename or "unknown",
|
||||
file_content=file_content,
|
||||
file_type=file.content_type,
|
||||
)
|
||||
|
||||
|
||||
await kb_service.update_doc_count(tenant_id, kb_id, delta=1)
|
||||
await session.commit()
|
||||
|
||||
background_tasks.add_task(
|
||||
_index_document, tenant_id, str(job.id), str(document.id), file_content, file.filename
|
||||
_index_document, tenant_id, kb_id, str(job.id), str(document.id), file_content, file.filename, metadata_dict
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
|
|
@ -315,27 +599,43 @@ async def upload_document(
|
|||
content={
|
||||
"jobId": str(job.id),
|
||||
"docId": str(document.id),
|
||||
"kbId": kb_id,
|
||||
"status": job.status,
|
||||
"metadata": metadata_dict,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: bytes, filename: str | None = None):
|
||||
async def _index_document(
|
||||
tenant_id: str,
|
||||
kb_id: str,
|
||||
job_id: str,
|
||||
doc_id: str,
|
||||
content: bytes,
|
||||
filename: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
):
|
||||
"""
|
||||
Background indexing task.
|
||||
[AC-AISVC-33, AC-AISVC-34, AC-AISVC-35] Uses document parsing and pluggable embedding.
|
||||
[AC-AISVC-33, AC-AISVC-34, AC-AISVC-35, AC-AISVC-63] Uses document parsing and pluggable embedding.
|
||||
Indexes to the specified knowledge base's Qdrant Collection.
|
||||
|
||||
Args:
|
||||
metadata: 动态元数据,字段根据元数据模式配置
|
||||
"""
|
||||
from app.core.database import async_session_maker
|
||||
from app.services.kb import KBService
|
||||
from app.core.qdrant_client import get_qdrant_client
|
||||
from app.services.embedding import get_embedding_provider
|
||||
from app.services.document import parse_document, UnsupportedFormatError, DocumentParseException, PageText
|
||||
from qdrant_client.models import PointStruct
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logger.info(f"[INDEX] Starting indexing: tenant={tenant_id}, job_id={job_id}, doc_id={doc_id}, filename={filename}")
|
||||
from qdrant_client.models import PointStruct
|
||||
|
||||
from app.core.database import async_session_maker
|
||||
from app.core.qdrant_client import get_qdrant_client
|
||||
from app.services.document import DocumentParseException, UnsupportedFormatError, parse_document
|
||||
from app.services.embedding import get_embedding_provider
|
||||
from app.services.kb import KBService
|
||||
|
||||
logger.info(f"[INDEX] Starting indexing: tenant={tenant_id}, kb_id={kb_id}, job_id={job_id}, doc_id={doc_id}, filename={filename}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
|
|
@ -350,11 +650,11 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
text = None
|
||||
file_ext = Path(filename or "").suffix.lower()
|
||||
logger.info(f"[INDEX] File extension: {file_ext}, content size: {len(content)} bytes")
|
||||
|
||||
|
||||
text_extensions = {".txt", ".md", ".markdown", ".rst", ".log", ".json", ".xml", ".yaml", ".yml"}
|
||||
|
||||
|
||||
if file_ext in text_extensions or not file_ext:
|
||||
logger.info(f"[INDEX] Treating as text file, trying multiple encodings")
|
||||
logger.info("[INDEX] Treating as text file, trying multiple encodings")
|
||||
text = None
|
||||
for encoding in ["utf-8", "gbk", "gb2312", "gb18030", "big5", "utf-16", "latin-1"]:
|
||||
try:
|
||||
|
|
@ -363,23 +663,23 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
break
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
|
||||
|
||||
if text is None:
|
||||
text = content.decode("utf-8", errors="replace")
|
||||
logger.warning(f"[INDEX] Failed to decode with known encodings, using utf-8 with replacement")
|
||||
logger.warning("[INDEX] Failed to decode with known encodings, using utf-8 with replacement")
|
||||
else:
|
||||
logger.info(f"[INDEX] Binary file detected, will parse with document parser")
|
||||
logger.info("[INDEX] Binary file detected, will parse with document parser")
|
||||
await kb_service.update_job_status(
|
||||
tenant_id, job_id, IndexJobStatus.PROCESSING.value, progress=15
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as tmp_file:
|
||||
tmp_file.write(content)
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
|
||||
logger.info(f"[INDEX] Temp file created: {tmp_path}")
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"[INDEX] Starting document parsing for {file_ext}...")
|
||||
parse_result = parse_document(tmp_path)
|
||||
|
|
@ -403,23 +703,23 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
text = content.decode("utf-8", errors="ignore")
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
logger.info(f"[INDEX] Temp file cleaned up")
|
||||
|
||||
logger.info("[INDEX] Temp file cleaned up")
|
||||
|
||||
logger.info(f"[INDEX] Final text length: {len(text)} chars")
|
||||
if len(text) < 50:
|
||||
logger.warning(f"[INDEX] Text too short, preview: {repr(text[:200])}")
|
||||
|
||||
|
||||
await kb_service.update_job_status(
|
||||
tenant_id, job_id, IndexJobStatus.PROCESSING.value, progress=20
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[INDEX] Getting embedding provider...")
|
||||
logger.info("[INDEX] Getting embedding provider...")
|
||||
embedding_provider = await get_embedding_provider()
|
||||
logger.info(f"[INDEX] Embedding provider: {type(embedding_provider).__name__}")
|
||||
|
||||
|
||||
all_chunks: list[TextChunk] = []
|
||||
|
||||
|
||||
if parse_result and parse_result.pages:
|
||||
logger.info(f"[INDEX] PDF with {len(parse_result.pages)} pages, using line-based chunking with page metadata")
|
||||
for page in parse_result.pages:
|
||||
|
|
@ -433,7 +733,7 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
all_chunks.extend(page_chunks)
|
||||
logger.info(f"[INDEX] Total chunks from PDF: {len(all_chunks)}")
|
||||
else:
|
||||
logger.info(f"[INDEX] Using line-based chunking")
|
||||
logger.info("[INDEX] Using line-based chunking")
|
||||
all_chunks = chunk_text_by_lines(
|
||||
text,
|
||||
min_line_length=10,
|
||||
|
|
@ -442,7 +742,7 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
logger.info(f"[INDEX] Total chunks: {len(all_chunks)}")
|
||||
|
||||
qdrant = await get_qdrant_client()
|
||||
await qdrant.ensure_collection_exists(tenant_id, use_multi_vector=True)
|
||||
await qdrant.ensure_kb_collection_exists(tenant_id, kb_id, use_multi_vector=True)
|
||||
|
||||
from app.services.embedding.nomic_provider import NomicEmbeddingProvider
|
||||
use_multi_vector = isinstance(embedding_provider, NomicEmbeddingProvider)
|
||||
|
|
@ -450,19 +750,25 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
|
||||
points = []
|
||||
total_chunks = len(all_chunks)
|
||||
|
||||
doc_metadata = metadata or {}
|
||||
logger.info(f"[INDEX] Document metadata: {doc_metadata}")
|
||||
|
||||
for i, chunk in enumerate(all_chunks):
|
||||
payload = {
|
||||
"text": chunk.text,
|
||||
"source": doc_id,
|
||||
"kb_id": kb_id,
|
||||
"chunk_index": i,
|
||||
"start_token": chunk.start_token,
|
||||
"end_token": chunk.end_token,
|
||||
"metadata": doc_metadata,
|
||||
}
|
||||
if chunk.page is not None:
|
||||
payload["page"] = chunk.page
|
||||
if chunk.source:
|
||||
payload["filename"] = chunk.source
|
||||
|
||||
|
||||
if use_multi_vector:
|
||||
embedding_result = await embedding_provider.embed_document(chunk.text)
|
||||
points.append({
|
||||
|
|
@ -483,7 +789,7 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
payload=payload,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
progress = 20 + int((i + 1) / total_chunks * 70)
|
||||
if i % 10 == 0 or i == total_chunks - 1:
|
||||
await kb_service.update_job_status(
|
||||
|
|
@ -492,11 +798,11 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
await session.commit()
|
||||
|
||||
if points:
|
||||
logger.info(f"[INDEX] Upserting {len(points)} vectors to Qdrant...")
|
||||
logger.info(f"[INDEX] Upserting {len(points)} vectors to Qdrant for kb_id={kb_id}...")
|
||||
if use_multi_vector:
|
||||
await qdrant.upsert_multi_vector(tenant_id, points)
|
||||
await qdrant.upsert_multi_vector(tenant_id, points, kb_id=kb_id)
|
||||
else:
|
||||
await qdrant.upsert_vectors(tenant_id, points)
|
||||
await qdrant.upsert_vectors(tenant_id, points, kb_id=kb_id)
|
||||
|
||||
await kb_service.update_job_status(
|
||||
tenant_id, job_id, IndexJobStatus.COMPLETED.value, progress=100
|
||||
|
|
@ -504,7 +810,7 @@ async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: byt
|
|||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[INDEX] COMPLETED: tenant={tenant_id}, "
|
||||
f"[INDEX] COMPLETED: tenant={tenant_id}, kb_id={kb_id}, "
|
||||
f"job_id={job_id}, chunks={len(all_chunks)}, text_len={len(text)}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ Reference: rag-optimization/spec.md Section 4.2
|
|||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
|
@ -16,9 +15,6 @@ from app.core.database import get_session
|
|||
from app.services.retrieval import (
|
||||
ChunkMetadata,
|
||||
ChunkMetadataModel,
|
||||
IndexingProgress,
|
||||
IndexingResult,
|
||||
KnowledgeIndexer,
|
||||
MetadataFilter,
|
||||
RetrievalStrategy,
|
||||
get_knowledge_indexer,
|
||||
|
|
@ -100,7 +96,7 @@ async def index_document(
|
|||
):
|
||||
"""
|
||||
Index a document with optimized embedding.
|
||||
|
||||
|
||||
Features:
|
||||
- Task prefixes (search_document:) for document embedding
|
||||
- Multi-dimensional vectors (256/512/768)
|
||||
|
|
@ -108,7 +104,7 @@ async def index_document(
|
|||
"""
|
||||
try:
|
||||
index = get_knowledge_indexer()
|
||||
|
||||
|
||||
chunk_metadata = None
|
||||
if request.metadata:
|
||||
chunk_metadata = ChunkMetadata(
|
||||
|
|
@ -121,14 +117,14 @@ async def index_document(
|
|||
priority=request.metadata.priority,
|
||||
keywords=request.metadata.keywords,
|
||||
)
|
||||
|
||||
|
||||
result = await index.index_document(
|
||||
tenant_id=request.tenant_id,
|
||||
document_id=request.document_id,
|
||||
text=request.text,
|
||||
metadata=chunk_metadata,
|
||||
)
|
||||
|
||||
|
||||
return IndexDocumentResponse(
|
||||
success=result.success,
|
||||
total_chunks=result.total_chunks,
|
||||
|
|
@ -137,7 +133,7 @@ async def index_document(
|
|||
elapsed_seconds=result.elapsed_seconds,
|
||||
error_message=result.error_message,
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KB-API] Failed to index document: {e}")
|
||||
raise HTTPException(
|
||||
|
|
@ -152,10 +148,10 @@ async def get_indexing_progress():
|
|||
try:
|
||||
index = get_knowledge_indexer()
|
||||
progress = index.get_progress()
|
||||
|
||||
|
||||
if progress is None:
|
||||
return None
|
||||
|
||||
|
||||
return IndexingProgressResponse(
|
||||
total_chunks=progress.total_chunks,
|
||||
processed_chunks=progress.processed_chunks,
|
||||
|
|
@ -164,7 +160,7 @@ async def get_indexing_progress():
|
|||
elapsed_seconds=progress.elapsed_seconds,
|
||||
current_document=progress.current_document,
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KB-API] Failed to get progress: {e}")
|
||||
raise HTTPException(
|
||||
|
|
@ -177,7 +173,7 @@ async def get_indexing_progress():
|
|||
async def retrieve_knowledge(request: RetrieveRequest):
|
||||
"""
|
||||
Retrieve knowledge using optimized RAG.
|
||||
|
||||
|
||||
Strategies:
|
||||
- vector: Simple vector search
|
||||
- bm25: BM25 keyword search
|
||||
|
|
@ -185,26 +181,26 @@ async def retrieve_knowledge(request: RetrieveRequest):
|
|||
- two_stage: Two-stage retrieval with Matryoshka dimensions
|
||||
"""
|
||||
try:
|
||||
from app.services.retrieval.optimized_retriever import get_optimized_retriever
|
||||
from app.services.retrieval.base import RetrievalContext
|
||||
|
||||
from app.services.retrieval.optimized_retriever import get_optimized_retriever
|
||||
|
||||
retriever = await get_optimized_retriever()
|
||||
|
||||
|
||||
metadata_filter = None
|
||||
if request.filters:
|
||||
filter_dict = request.filters.model_dump(exclude_none=True)
|
||||
metadata_filter = MetadataFilter(**filter_dict)
|
||||
|
||||
|
||||
ctx = RetrievalContext(
|
||||
tenant_id=request.tenant_id,
|
||||
query=request.query,
|
||||
)
|
||||
|
||||
|
||||
if metadata_filter:
|
||||
ctx.metadata = {"filter": metadata_filter.to_qdrant_filter()}
|
||||
|
||||
|
||||
result = await retriever.retrieve(ctx)
|
||||
|
||||
|
||||
return RetrieveResponse(
|
||||
hits=[
|
||||
{
|
||||
|
|
@ -220,7 +216,7 @@ async def retrieve_knowledge(request: RetrieveRequest):
|
|||
is_insufficient=result.diagnostics.get("is_insufficient", False),
|
||||
diagnostics=result.diagnostics or {},
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KB-API] Failed to retrieve: {e}")
|
||||
raise HTTPException(
|
||||
|
|
@ -266,7 +262,7 @@ async def get_metadata_options():
|
|||
],
|
||||
priorities=list(range(1, 11)),
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KB-API] Failed to get metadata options: {e}")
|
||||
raise HTTPException(
|
||||
|
|
@ -286,42 +282,42 @@ async def reindex_all(
|
|||
"""
|
||||
try:
|
||||
from app.models.entities import Document, DocumentStatus
|
||||
|
||||
|
||||
stmt = select(Document).where(
|
||||
Document.tenant_id == tenant_id,
|
||||
Document.status == DocumentStatus.COMPLETED.value,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
documents = result.scalars().all()
|
||||
|
||||
|
||||
index = get_knowledge_indexer()
|
||||
|
||||
|
||||
total_indexed = 0
|
||||
total_failed = 0
|
||||
|
||||
|
||||
for doc in documents:
|
||||
if doc.file_path:
|
||||
import os
|
||||
if os.path.exists(doc.file_path):
|
||||
with open(doc.file_path, 'r', encoding='utf-8') as f:
|
||||
with open(doc.file_path, encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
|
||||
|
||||
result = await index.index_document(
|
||||
tenant_id=tenant_id,
|
||||
document_id=str(doc.id),
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
total_indexed += result.indexed_chunks
|
||||
total_failed += result.failed_chunks
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_documents": len(documents),
|
||||
"total_indexed": total_indexed,
|
||||
"total_failed": total_failed,
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KB-API] Failed to reindex: {e}")
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from typing import Any
|
|||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
|
||||
from app.services.llm.factory import (
|
||||
LLMConfigManager,
|
||||
LLMProviderFactory,
|
||||
get_llm_config_manager,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,414 @@
|
|||
"""
|
||||
Metadata Field Definition API.
|
||||
[AC-IDSMETA-13, AC-IDSMETA-14] 元数据字段定义管理接口,支持字段级状态治理。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.core.exceptions import MissingTenantIdException
|
||||
from app.core.tenant import get_tenant_id
|
||||
from app.models.entities import (
|
||||
MetadataFieldDefinition,
|
||||
MetadataFieldDefinitionCreate,
|
||||
MetadataFieldDefinitionUpdate,
|
||||
MetadataFieldStatus,
|
||||
)
|
||||
from app.services.metadata_field_definition_service import MetadataFieldDefinitionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/metadata-schemas", tags=["MetadataSchemas"])
|
||||
|
||||
|
||||
def get_current_tenant_id() -> str:
|
||||
"""Get current tenant ID from context."""
|
||||
tenant_id = get_tenant_id()
|
||||
if not tenant_id:
|
||||
raise MissingTenantIdException()
|
||||
return tenant_id
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
operation_id="listMetadataSchemas",
|
||||
summary="List metadata schemas",
|
||||
description="[AC-IDSMETA-13] 获取元数据字段定义列表,支持按状态和范围过滤",
|
||||
)
|
||||
async def list_schemas(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
status: Annotated[str | None, Query(
|
||||
description="按状态过滤: draft/active/deprecated"
|
||||
)] = None,
|
||||
scope: Annotated[str | None, Query(
|
||||
description="按适用范围过滤: kb_document/intent_rule/script_flow/prompt_template"
|
||||
)] = None,
|
||||
include_deprecated: Annotated[bool, Query(
|
||||
description="是否包含已废弃的字段"
|
||||
)] = False,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-13] 列出元数据字段定义
|
||||
|
||||
Args:
|
||||
status: 按状态过滤
|
||||
scope: 按适用范围过滤
|
||||
include_deprecated: 是否包含已废弃的字段(当 status 未指定时生效)
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-13] Listing metadata field definitions: "
|
||||
f"tenant={tenant_id}, status={status}, scope={scope}, include_deprecated={include_deprecated}"
|
||||
)
|
||||
|
||||
if status and status not in [s.value for s in MetadataFieldStatus]:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_STATUS",
|
||||
"message": f"Invalid status: {status}",
|
||||
"details": {
|
||||
"valid_values": [s.value for s in MetadataFieldStatus]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
|
||||
if include_deprecated and not status:
|
||||
fields = await service.get_field_definitions_for_read(tenant_id, scope)
|
||||
else:
|
||||
fields = await service.list_field_definitions(tenant_id, status, scope)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"field_key": f.field_key,
|
||||
"label": f.label,
|
||||
"type": f.type,
|
||||
"required": f.required,
|
||||
"options": f.options,
|
||||
"default": f.default_value,
|
||||
"scope": f.scope,
|
||||
"is_filterable": f.is_filterable,
|
||||
"is_rank_feature": f.is_rank_feature,
|
||||
"status": f.status,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in fields
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createMetadataSchema",
|
||||
summary="Create metadata schema",
|
||||
description="[AC-IDSMETA-13] 创建新的元数据字段定义",
|
||||
status_code=201,
|
||||
)
|
||||
async def create_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
field_create: MetadataFieldDefinitionCreate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-13] 创建元数据字段定义
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-13] Creating metadata field definition: "
|
||||
f"tenant={tenant_id}, field_key={field_create.field_key}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
|
||||
try:
|
||||
field = await service.create_field_definition(tenant_id, field_create)
|
||||
await session.commit()
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"id": str(field.id),
|
||||
"field_key": field.field_key,
|
||||
"label": field.label,
|
||||
"type": field.type,
|
||||
"required": field.required,
|
||||
"options": field.options,
|
||||
"default": field.default_value,
|
||||
"scope": field.scope,
|
||||
"is_filterable": field.is_filterable,
|
||||
"is_rank_feature": field.is_rank_feature,
|
||||
"status": field.status,
|
||||
"created_at": field.created_at.isoformat() if field.created_at else None,
|
||||
"updated_at": field.updated_at.isoformat() if field.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{id}",
|
||||
operation_id="updateMetadataSchema",
|
||||
summary="Update metadata schema",
|
||||
description="[AC-IDSMETA-14] 更新元数据字段定义,支持状态切换",
|
||||
)
|
||||
async def update_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
id: str,
|
||||
field_update: MetadataFieldDefinitionUpdate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-14] 更新元数据字段定义
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Updating metadata field definition: "
|
||||
f"tenant={tenant_id}, id={id}"
|
||||
)
|
||||
|
||||
if field_update.status and field_update.status not in [s.value for s in MetadataFieldStatus]:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "INVALID_STATUS",
|
||||
"message": f"Invalid status: {field_update.status}",
|
||||
"details": {
|
||||
"valid_values": [s.value for s in MetadataFieldStatus]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
|
||||
try:
|
||||
field = await service.update_field_definition(tenant_id, id, field_update)
|
||||
except ValueError as e:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
if not field:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"Field definition {id} not found",
|
||||
}
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"id": str(field.id),
|
||||
"field_key": field.field_key,
|
||||
"label": field.label,
|
||||
"type": field.type,
|
||||
"required": field.required,
|
||||
"options": field.options,
|
||||
"default": field.default_value,
|
||||
"scope": field.scope,
|
||||
"is_filterable": field.is_filterable,
|
||||
"is_rank_feature": field.is_rank_feature,
|
||||
"status": field.status,
|
||||
"created_at": field.created_at.isoformat() if field.created_at else None,
|
||||
"updated_at": field.updated_at.isoformat() if field.updated_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/active",
|
||||
operation_id="getActiveMetadataSchemas",
|
||||
summary="Get active metadata schemas",
|
||||
description="[AC-IDSMETA-14] 获取活跃状态的字段定义,用于新建对象时选择",
|
||||
)
|
||||
async def get_active_schemas(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
scope: Annotated[str | None, Query(
|
||||
description="按适用范围过滤: kb_document/intent_rule/script_flow/prompt_template"
|
||||
)] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-14] 获取活跃状态的字段定义
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Getting active metadata field definitions: "
|
||||
f"tenant={tenant_id}, scope={scope}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
fields = await service.get_active_field_definitions(tenant_id, scope)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"field_key": f.field_key,
|
||||
"label": f.label,
|
||||
"type": f.type,
|
||||
"required": f.required,
|
||||
"options": f.options,
|
||||
"default": f.default_value,
|
||||
"scope": f.scope,
|
||||
"is_filterable": f.is_filterable,
|
||||
"is_rank_feature": f.is_rank_feature,
|
||||
"status": f.status,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in fields
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/readable",
|
||||
operation_id="getReadableMetadataSchemas",
|
||||
summary="Get readable metadata schemas",
|
||||
description="[AC-IDSMETA-14] 获取可读取的字段定义(active + deprecated),用于历史数据展示",
|
||||
)
|
||||
async def get_readable_schemas(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
scope: Annotated[str | None, Query(
|
||||
description="按适用范围过滤: kb_document/intent_rule/script_flow/prompt_template"
|
||||
)] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-14] 获取可读取的字段定义(active + deprecated)
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Getting readable metadata field definitions: "
|
||||
f"tenant={tenant_id}, scope={scope}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
fields = await service.get_field_definitions_for_read(tenant_id, scope)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"items": [
|
||||
{
|
||||
"id": str(f.id),
|
||||
"field_key": f.field_key,
|
||||
"label": f.label,
|
||||
"type": f.type,
|
||||
"required": f.required,
|
||||
"options": f.options,
|
||||
"default": f.default_value,
|
||||
"scope": f.scope,
|
||||
"is_filterable": f.is_filterable,
|
||||
"is_rank_feature": f.is_rank_feature,
|
||||
"status": f.status,
|
||||
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in fields
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/validate",
|
||||
operation_id="validateMetadataForCreate",
|
||||
summary="Validate metadata for create",
|
||||
description="[AC-IDSMETA-14, AC-IDSMETA-15] 验证元数据是否可用于新建对象",
|
||||
)
|
||||
async def validate_metadata_for_create(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
body: dict[str, Any],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-14, AC-IDSMETA-15] 验证元数据是否可用于新建对象
|
||||
|
||||
Request body:
|
||||
{
|
||||
"metadata": {"grade": "初一", "subject": "语文"},
|
||||
"scope": "kb_document"
|
||||
}
|
||||
"""
|
||||
metadata = body.get("metadata", {})
|
||||
scope = body.get("scope", "kb_document")
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-14] Validating metadata for create: "
|
||||
f"tenant={tenant_id}, scope={scope}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
is_valid, errors = await service.validate_metadata_for_create(
|
||||
tenant_id, metadata, scope
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"isValid": is_valid,
|
||||
"errors": errors,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{field_id}",
|
||||
operation_id="deleteMetadataSchema",
|
||||
summary="Delete metadata schema",
|
||||
description="[AC-IDSMETA-13] 删除元数据字段定义",
|
||||
)
|
||||
async def delete_schema(
|
||||
field_id: str,
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
[AC-IDSMETA-13] 删除元数据字段定义
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-13] Deleting metadata field definition: "
|
||||
f"tenant={tenant_id}, field_id={field_id}"
|
||||
)
|
||||
|
||||
service = MetadataFieldDefinitionService(session)
|
||||
success = await service.delete_field_definition(tenant_id, field_id)
|
||||
|
||||
if not success:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"code": "NOT_FOUND",
|
||||
"message": f"Field definition not found: {field_id}",
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Field definition deleted successfully",
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
"""
|
||||
Metadata Schema API.
|
||||
动态元数据模式管理接口。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import (
|
||||
MetadataField,
|
||||
MetadataSchema,
|
||||
MetadataSchemaCreate,
|
||||
MetadataSchemaUpdate,
|
||||
)
|
||||
from app.services.metadata_schema_service import MetadataSchemaService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/metadata-schemas", tags=["Metadata Schemas"])
|
||||
|
||||
|
||||
def get_current_tenant_id() -> str:
|
||||
"""Get current tenant ID from context."""
|
||||
from app.core.tenant import get_tenant_id
|
||||
tenant_id = get_tenant_id()
|
||||
if not tenant_id:
|
||||
from app.core.exceptions import MissingTenantIdException
|
||||
raise MissingTenantIdException()
|
||||
return tenant_id
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
operation_id="listMetadataSchemas",
|
||||
summary="List metadata schemas",
|
||||
description="获取租户所有元数据模式配置",
|
||||
)
|
||||
async def list_schemas(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
include_disabled: bool = False,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
列出租户所有元数据模式
|
||||
"""
|
||||
service = MetadataSchemaService(session)
|
||||
schemas = await service.list_schemas(tenant_id, include_disabled)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"schemas": [
|
||||
{
|
||||
"id": str(s.id),
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"fields": s.fields,
|
||||
"isDefault": s.is_default,
|
||||
"isEnabled": s.is_enabled,
|
||||
"createdAt": s.created_at.isoformat(),
|
||||
"updatedAt": s.updated_at.isoformat(),
|
||||
}
|
||||
for s in schemas
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/default",
|
||||
operation_id="getDefaultMetadataSchema",
|
||||
summary="Get default metadata schema",
|
||||
description="获取租户默认的元数据模式配置",
|
||||
)
|
||||
async def get_default_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
获取租户默认的元数据模式
|
||||
"""
|
||||
service = MetadataSchemaService(session)
|
||||
schema = await service.get_schema(tenant_id)
|
||||
|
||||
if not schema:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"schema": None,
|
||||
"message": "No default schema configured",
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"schema": {
|
||||
"id": str(schema.id),
|
||||
"name": schema.name,
|
||||
"description": schema.description,
|
||||
"fields": schema.fields,
|
||||
"isDefault": schema.is_default,
|
||||
"isEnabled": schema.is_enabled,
|
||||
"createdAt": schema.created_at.isoformat(),
|
||||
"updatedAt": schema.updated_at.isoformat(),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{schema_id}",
|
||||
operation_id="getMetadataSchema",
|
||||
summary="Get metadata schema by ID",
|
||||
description="根据 ID 获取元数据模式配置",
|
||||
)
|
||||
async def get_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
schema_id: str,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
根据 ID 获取元数据模式
|
||||
"""
|
||||
service = MetadataSchemaService(session)
|
||||
schema = await service.get_schema(tenant_id, schema_id)
|
||||
|
||||
if not schema:
|
||||
raise HTTPException(status_code=404, detail="Schema not found")
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"schema": {
|
||||
"id": str(schema.id),
|
||||
"name": schema.name,
|
||||
"description": schema.description,
|
||||
"fields": schema.fields,
|
||||
"isDefault": schema.is_default,
|
||||
"isEnabled": schema.is_enabled,
|
||||
"createdAt": schema.created_at.isoformat(),
|
||||
"updatedAt": schema.updated_at.isoformat(),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
operation_id="createMetadataSchema",
|
||||
summary="Create metadata schema",
|
||||
description="创建新的元数据模式配置",
|
||||
)
|
||||
async def create_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
schema_create: MetadataSchemaCreate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
创建元数据模式
|
||||
"""
|
||||
service = MetadataSchemaService(session)
|
||||
|
||||
for field in schema_create.fields:
|
||||
if isinstance(field, MetadataField):
|
||||
field_dict = field.model_dump()
|
||||
else:
|
||||
field_dict = field
|
||||
|
||||
field_type = field_dict.get("field_type", "string")
|
||||
if field_type in ["select", "multi_select"]:
|
||||
if not field_dict.get("options"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Field '{field_dict.get('name')}' is {field_type} type but has no options"
|
||||
)
|
||||
|
||||
schema = await service.create_schema(tenant_id, schema_create)
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"id": str(schema.id),
|
||||
"name": schema.name,
|
||||
"description": schema.description,
|
||||
"fields": schema.fields,
|
||||
"isDefault": schema.is_default,
|
||||
"isEnabled": schema.is_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{schema_id}",
|
||||
operation_id="updateMetadataSchema",
|
||||
summary="Update metadata schema",
|
||||
description="更新元数据模式配置",
|
||||
)
|
||||
async def update_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
schema_id: str,
|
||||
schema_update: MetadataSchemaUpdate,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
更新元数据模式
|
||||
"""
|
||||
service = MetadataSchemaService(session)
|
||||
schema = await service.update_schema(tenant_id, schema_id, schema_update)
|
||||
|
||||
if not schema:
|
||||
raise HTTPException(status_code=404, detail="Schema not found")
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"id": str(schema.id),
|
||||
"name": schema.name,
|
||||
"description": schema.description,
|
||||
"fields": schema.fields,
|
||||
"isDefault": schema.is_default,
|
||||
"isEnabled": schema.is_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{schema_id}",
|
||||
operation_id="deleteMetadataSchema",
|
||||
summary="Delete metadata schema",
|
||||
description="删除元数据模式配置",
|
||||
)
|
||||
async def delete_schema(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
schema_id: str,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
删除元数据模式
|
||||
"""
|
||||
service = MetadataSchemaService(session)
|
||||
success = await service.delete_schema(tenant_id, schema_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete schema (not found or is default)"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Schema deleted"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/default/field-definitions",
|
||||
operation_id="getFieldDefinitions",
|
||||
summary="Get field definitions",
|
||||
description="获取字段定义映射,用于前端动态渲染表单",
|
||||
)
|
||||
async def get_field_definitions(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
schema_id: str | None = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
获取字段定义映射
|
||||
"""
|
||||
service = MetadataSchemaService(session)
|
||||
field_defs = await service.get_field_definitions(tenant_id, schema_id)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"fieldDefinitions": field_defs
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/default/validate",
|
||||
operation_id="validateMetadata",
|
||||
summary="Validate metadata",
|
||||
description="验证元数据是否符合模式定义",
|
||||
)
|
||||
async def validate_metadata(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
metadata: dict[str, Any],
|
||||
schema_id: str | None = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
验证元数据
|
||||
"""
|
||||
service = MetadataSchemaService(session)
|
||||
is_valid, errors = await service.validate_metadata(tenant_id, metadata, schema_id)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"isValid": is_valid,
|
||||
"errors": errors
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,666 @@
|
|||
"""
|
||||
Monitoring API for AI Service Admin.
|
||||
[AC-AISVC-97~AC-AISVC-100] Intent rule and prompt template monitoring.
|
||||
[AC-AISVC-103, AC-AISVC-104] Flow monitoring.
|
||||
[AC-AISVC-106, AC-AISVC-107] Guardrail monitoring.
|
||||
[AC-AISVC-108~AC-AISVC-110] Conversation tracking and export.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import (
|
||||
ChatMessage,
|
||||
ExportTask,
|
||||
ExportTaskStatus,
|
||||
FlowInstance,
|
||||
FlowTestRecord,
|
||||
IntentRule,
|
||||
PromptTemplate,
|
||||
)
|
||||
from app.services.monitoring.flow_monitor import FlowMonitor
|
||||
from app.services.monitoring.guardrail_monitor import GuardrailMonitor
|
||||
from app.services.monitoring.intent_monitor import IntentMonitor
|
||||
from app.services.monitoring.prompt_monitor import PromptMonitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/monitoring", tags=["Monitoring"])
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
|
||||
"""Extract tenant ID from header."""
|
||||
if not x_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
|
||||
return x_tenant_id
|
||||
|
||||
|
||||
@router.get("/intent-rules")
|
||||
async def get_intent_rule_stats(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
start_date: datetime | None = Query(None, description="Start date filter"),
|
||||
end_date: datetime | None = Query(None, description="End date filter"),
|
||||
response_type: str | None = Query(None, description="Response type filter"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-97] Get aggregated statistics for all intent rules.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-97] Getting intent rule stats for tenant={tenant_id}, "
|
||||
f"start={start_date}, end={end_date}"
|
||||
)
|
||||
|
||||
monitor = IntentMonitor(session)
|
||||
result = await monitor.get_rule_stats(tenant_id, start_date, end_date, response_type)
|
||||
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/intent-rules/{rule_id}/hits")
|
||||
async def get_intent_rule_hits(
|
||||
rule_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-98] Get hit records for a specific intent rule.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-98] Getting intent rule hits for tenant={tenant_id}, "
|
||||
f"rule_id={rule_id}, page={page}"
|
||||
)
|
||||
|
||||
monitor = IntentMonitor(session)
|
||||
records, total = await monitor.get_rule_hits(tenant_id, rule_id, page, page_size)
|
||||
|
||||
return {
|
||||
"data": [r.to_dict() for r in records],
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/script-flows")
|
||||
async def get_flow_stats(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
start_date: datetime | None = Query(None, description="Start date filter"),
|
||||
end_date: datetime | None = Query(None, description="End date filter"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-103] Get aggregated statistics for all script flows.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-103] Getting flow stats for tenant={tenant_id}, "
|
||||
f"start={start_date}, end={end_date}"
|
||||
)
|
||||
|
||||
monitor = FlowMonitor(session)
|
||||
result = await monitor.get_flow_stats(tenant_id, start_date, end_date)
|
||||
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/script-flows/{flow_id}/executions")
|
||||
async def get_flow_executions(
|
||||
flow_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-104] Get execution records for a specific flow.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-104] Getting flow executions for tenant={tenant_id}, "
|
||||
f"flow_id={flow_id}, page={page}"
|
||||
)
|
||||
|
||||
monitor = FlowMonitor(session)
|
||||
records, total = await monitor.get_flow_executions(tenant_id, flow_id, page, page_size)
|
||||
|
||||
return {
|
||||
"data": [r.to_dict() for r in records],
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/guardrails")
|
||||
async def get_guardrail_stats(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
category: str | None = Query(None, description="Category filter"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-106] Get aggregated statistics for all guardrails.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-106] Getting guardrail stats for tenant={tenant_id}, "
|
||||
f"category={category}"
|
||||
)
|
||||
|
||||
monitor = GuardrailMonitor(session)
|
||||
result = await monitor.get_guardrail_stats(tenant_id, category)
|
||||
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/guardrails/{word_id}/blocks")
|
||||
async def get_guardrail_blocks(
|
||||
word_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-107] Get block records for a specific forbidden word.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-107] Getting guardrail blocks for tenant={tenant_id}, "
|
||||
f"word_id={word_id}, page={page}"
|
||||
)
|
||||
|
||||
monitor = GuardrailMonitor(session)
|
||||
records, total = await monitor.get_word_blocks(tenant_id, word_id, page, page_size)
|
||||
|
||||
return {
|
||||
"data": [r.to_dict() for r in records],
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/prompt-templates")
|
||||
async def get_prompt_template_stats(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
start_date: datetime | None = Query(None, description="Start date filter"),
|
||||
end_date: datetime | None = Query(None, description="End date filter"),
|
||||
scene: str | None = Query(None, description="Scene filter"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-100] Get aggregated statistics for all prompt templates.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-100] Getting prompt template stats for tenant={tenant_id}, "
|
||||
f"start={start_date}, end={end_date}, scene={scene}"
|
||||
)
|
||||
|
||||
monitor = PromptMonitor(session)
|
||||
result = await monitor.get_template_stats(tenant_id, scene, start_date, end_date)
|
||||
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session_id: str | None = Query(None, description="Filter by session ID"),
|
||||
start_date: datetime | None = Query(None, description="Start date filter"),
|
||||
end_date: datetime | None = Query(None, description="End date filter"),
|
||||
has_flow: bool | None = Query(None, description="Filter by flow involvement"),
|
||||
has_guardrail: bool | None = Query(None, description="Filter by guardrail trigger"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Page size"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-108] List conversations with filters.
|
||||
Returns paginated list of conversations with basic info.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-108] Listing conversations for tenant={tenant_id}, "
|
||||
f"session={session_id}, page={page}"
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(ChatMessage)
|
||||
.where(
|
||||
ChatMessage.tenant_id == tenant_id,
|
||||
ChatMessage.role == "user",
|
||||
)
|
||||
)
|
||||
|
||||
if session_id:
|
||||
stmt = stmt.where(ChatMessage.session_id == session_id)
|
||||
if start_date:
|
||||
stmt = stmt.where(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
stmt = stmt.where(ChatMessage.created_at <= end_date)
|
||||
if has_flow is not None:
|
||||
if has_flow:
|
||||
stmt = stmt.where(ChatMessage.flow_instance_id.is_not(None))
|
||||
else:
|
||||
stmt = stmt.where(ChatMessage.flow_instance_id.is_(None))
|
||||
if has_guardrail is not None:
|
||||
if has_guardrail:
|
||||
stmt = stmt.where(ChatMessage.guardrail_triggered.is_(True))
|
||||
else:
|
||||
stmt = stmt.where(ChatMessage.guardrail_triggered.is_(False))
|
||||
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await session.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
stmt = stmt.order_by(desc(ChatMessage.created_at))
|
||||
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
messages = result.scalars().all()
|
||||
|
||||
conversations = []
|
||||
for msg in messages:
|
||||
assistant_stmt = select(ChatMessage).where(
|
||||
ChatMessage.tenant_id == tenant_id,
|
||||
ChatMessage.session_id == msg.session_id,
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.created_at > msg.created_at,
|
||||
).order_by(ChatMessage.created_at).limit(1)
|
||||
assistant_result = await session.execute(assistant_stmt)
|
||||
assistant_msg = assistant_result.scalar_one_or_none()
|
||||
|
||||
user_msg_display = (
|
||||
msg.content[:200] + "..." if len(msg.content) > 200 else msg.content
|
||||
)
|
||||
ai_reply_display = None
|
||||
if assistant_msg:
|
||||
ai_reply_display = (
|
||||
assistant_msg.content[:200] + "..."
|
||||
if len(assistant_msg.content) > 200
|
||||
else assistant_msg.content
|
||||
)
|
||||
|
||||
conversations.append({
|
||||
"id": str(msg.id),
|
||||
"sessionId": msg.session_id,
|
||||
"userMessage": user_msg_display,
|
||||
"aiReply": ai_reply_display,
|
||||
"hasFlow": msg.flow_instance_id is not None,
|
||||
"hasGuardrail": msg.guardrail_triggered,
|
||||
"createdAt": msg.created_at.isoformat(),
|
||||
})
|
||||
|
||||
return {
|
||||
"data": conversations,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/conversations/{message_id}")
|
||||
async def get_conversation_detail(
|
||||
message_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-109] Get conversation detail with execution chain.
|
||||
Returns detailed execution steps for debugging.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-109] Getting conversation detail for tenant={tenant_id}, "
|
||||
f"message_id={message_id}"
|
||||
)
|
||||
|
||||
user_msg_stmt = select(ChatMessage).where(
|
||||
ChatMessage.id == message_id,
|
||||
ChatMessage.tenant_id == tenant_id,
|
||||
ChatMessage.role == "user",
|
||||
)
|
||||
result = await session.execute(user_msg_stmt)
|
||||
user_msg = result.scalar_one_or_none()
|
||||
|
||||
if not user_msg:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
assistant_stmt = select(ChatMessage).where(
|
||||
ChatMessage.tenant_id == tenant_id,
|
||||
ChatMessage.session_id == user_msg.session_id,
|
||||
ChatMessage.role == "assistant",
|
||||
ChatMessage.created_at > user_msg.created_at,
|
||||
).order_by(ChatMessage.created_at).limit(1)
|
||||
assistant_result = await session.execute(assistant_stmt)
|
||||
assistant_msg = assistant_result.scalar_one_or_none()
|
||||
|
||||
triggered_rules = []
|
||||
if user_msg.intent_rule_id:
|
||||
rule_stmt = select(IntentRule).where(IntentRule.id == user_msg.intent_rule_id)
|
||||
rule_result = await session.execute(rule_stmt)
|
||||
rule = rule_result.scalar_one_or_none()
|
||||
if rule:
|
||||
triggered_rules.append({
|
||||
"id": str(rule.id),
|
||||
"name": rule.name,
|
||||
"responseType": rule.response_type,
|
||||
})
|
||||
|
||||
used_template = None
|
||||
if assistant_msg and assistant_msg.prompt_template_id:
|
||||
template_stmt = select(PromptTemplate).where(
|
||||
PromptTemplate.id == assistant_msg.prompt_template_id
|
||||
)
|
||||
template_result = await session.execute(template_stmt)
|
||||
template = template_result.scalar_one_or_none()
|
||||
if template:
|
||||
used_template = {
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
}
|
||||
|
||||
used_flow = None
|
||||
if user_msg.flow_instance_id:
|
||||
flow_stmt = select(FlowInstance).where(
|
||||
FlowInstance.id == user_msg.flow_instance_id
|
||||
)
|
||||
flow_result = await session.execute(flow_stmt)
|
||||
flow_instance = flow_result.scalar_one_or_none()
|
||||
if flow_instance:
|
||||
used_flow = {
|
||||
"id": str(flow_instance.id),
|
||||
"flowId": str(flow_instance.flow_id),
|
||||
"status": flow_instance.status,
|
||||
"currentStep": flow_instance.current_step,
|
||||
}
|
||||
|
||||
execution_steps = None
|
||||
test_record_stmt = select(FlowTestRecord).where(
|
||||
FlowTestRecord.session_id == user_msg.session_id,
|
||||
).order_by(desc(FlowTestRecord.created_at)).limit(1)
|
||||
test_result = await session.execute(test_record_stmt)
|
||||
test_record = test_result.scalar_one_or_none()
|
||||
if test_record:
|
||||
execution_steps = test_record.steps
|
||||
|
||||
return {
|
||||
"conversationId": str(user_msg.id),
|
||||
"sessionId": user_msg.session_id,
|
||||
"userMessage": user_msg.content,
|
||||
"aiReply": assistant_msg.content if assistant_msg else None,
|
||||
"triggeredRules": triggered_rules,
|
||||
"usedTemplate": used_template,
|
||||
"usedFlow": used_flow,
|
||||
"executionTimeMs": assistant_msg.latency_ms if assistant_msg else None,
|
||||
"confidence": None,
|
||||
"shouldTransfer": False,
|
||||
"guardrailTriggered": user_msg.guardrail_triggered,
|
||||
"guardrailWords": user_msg.guardrail_words,
|
||||
"executionSteps": execution_steps,
|
||||
"createdAt": user_msg.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
"""Export request schema."""
|
||||
|
||||
format: str = "json"
|
||||
session_id: str | None = None
|
||||
start_date: datetime | None = None
|
||||
end_date: datetime | None = None
|
||||
|
||||
|
||||
@router.post("/conversations/export")
|
||||
async def export_conversations(
|
||||
request: ExportRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-110] Export conversations to file.
|
||||
Supports JSON and CSV formats.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-110] Exporting conversations for tenant={tenant_id}, "
|
||||
f"format={request.format}"
|
||||
)
|
||||
|
||||
export_task = ExportTask(
|
||||
tenant_id=tenant_id,
|
||||
status=ExportTaskStatus.PROCESSING.value,
|
||||
format=request.format,
|
||||
filters={
|
||||
"session_id": request.session_id,
|
||||
"start_date": request.start_date.isoformat() if request.start_date else None,
|
||||
"end_date": request.end_date.isoformat() if request.end_date else None,
|
||||
},
|
||||
expires_at=datetime.utcnow() + timedelta(hours=24),
|
||||
)
|
||||
session.add(export_task)
|
||||
await session.commit()
|
||||
await session.refresh(export_task)
|
||||
|
||||
asyncio.create_task(
|
||||
_process_export(
|
||||
export_task.id,
|
||||
tenant_id,
|
||||
request.format,
|
||||
request.session_id,
|
||||
request.start_date,
|
||||
request.end_date,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"taskId": str(export_task.id),
|
||||
"status": export_task.status,
|
||||
"format": export_task.format,
|
||||
"createdAt": export_task.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/conversations/export/{task_id}")
|
||||
async def get_export_status(
|
||||
task_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-110] Get export task status.
|
||||
"""
|
||||
stmt = select(ExportTask).where(
|
||||
ExportTask.id == task_id,
|
||||
ExportTask.tenant_id == tenant_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Export task not found")
|
||||
|
||||
response = {
|
||||
"taskId": str(task.id),
|
||||
"status": task.status,
|
||||
"format": task.format,
|
||||
"createdAt": task.created_at.isoformat(),
|
||||
}
|
||||
|
||||
if task.status == ExportTaskStatus.COMPLETED.value:
|
||||
response["fileName"] = task.file_name
|
||||
response["fileSize"] = task.file_size
|
||||
response["totalRows"] = task.total_rows
|
||||
response["completedAt"] = task.completed_at.isoformat() if task.completed_at else None
|
||||
elif task.status == ExportTaskStatus.FAILED.value:
|
||||
response["errorMessage"] = task.error_message
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/conversations/export/{task_id}/download")
|
||||
async def download_export(
|
||||
task_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
[AC-AISVC-110] Download exported file.
|
||||
"""
|
||||
stmt = select(ExportTask).where(
|
||||
ExportTask.id == task_id,
|
||||
ExportTask.tenant_id == tenant_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Export task not found")
|
||||
|
||||
if task.status != ExportTaskStatus.COMPLETED.value:
|
||||
raise HTTPException(status_code=400, detail="Export not completed")
|
||||
|
||||
if not task.file_path:
|
||||
raise HTTPException(status_code=404, detail="Export file not found")
|
||||
|
||||
try:
|
||||
with open(task.file_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
media_type = "application/json" if task.format == "json" else "text/csv"
|
||||
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{task.file_name}"',
|
||||
},
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Export file expired or not found")
|
||||
|
||||
|
||||
async def _process_export(
|
||||
task_id: uuid.UUID,
|
||||
tenant_id: str,
|
||||
format: str,
|
||||
session_id: str | None,
|
||||
start_date: datetime | None,
|
||||
end_date: datetime | None,
|
||||
) -> None:
|
||||
"""Background task to process export."""
|
||||
from app.core.database import async_session_maker
|
||||
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
stmt = select(ExportTask).where(ExportTask.id == task_id)
|
||||
result = await session.execute(stmt)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
return
|
||||
|
||||
msg_stmt = (
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.tenant_id == tenant_id)
|
||||
.order_by(ChatMessage.created_at)
|
||||
)
|
||||
|
||||
if session_id:
|
||||
msg_stmt = msg_stmt.where(ChatMessage.session_id == session_id)
|
||||
if start_date:
|
||||
msg_stmt = msg_stmt.where(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
msg_stmt = msg_stmt.where(ChatMessage.created_at <= end_date)
|
||||
|
||||
result = await session.execute(msg_stmt)
|
||||
messages = result.scalars().all()
|
||||
|
||||
conversations = []
|
||||
current_conv = None
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "user":
|
||||
if current_conv:
|
||||
conversations.append(current_conv)
|
||||
current_conv = {
|
||||
"session_id": msg.session_id,
|
||||
"user_message": msg.content,
|
||||
"ai_reply": None,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
"intent_rule_id": str(msg.intent_rule_id) if msg.intent_rule_id else None,
|
||||
"flow_instance_id": str(msg.flow_instance_id) if msg.flow_instance_id else None,
|
||||
"guardrail_triggered": msg.guardrail_triggered,
|
||||
}
|
||||
elif msg.role == "assistant" and current_conv:
|
||||
current_conv["ai_reply"] = msg.content
|
||||
current_conv["latency_ms"] = msg.latency_ms
|
||||
current_conv["prompt_template_id"] = str(msg.prompt_template_id) if msg.prompt_template_id else None
|
||||
|
||||
if current_conv:
|
||||
conversations.append(current_conv)
|
||||
|
||||
import os
|
||||
export_dir = "exports"
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
|
||||
file_name = f"conversations_{tenant_id}_{task_id}.{format}"
|
||||
file_path = os.path.join(export_dir, file_name)
|
||||
|
||||
if format == "json":
|
||||
content = json.dumps(conversations, indent=2, ensure_ascii=False)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
else:
|
||||
with open(file_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"session_id", "user_message", "ai_reply", "created_at",
|
||||
"intent_rule_id", "flow_instance_id", "guardrail_triggered",
|
||||
"latency_ms", "prompt_template_id"
|
||||
])
|
||||
for conv in conversations:
|
||||
writer.writerow([
|
||||
conv.get("session_id"),
|
||||
conv.get("user_message"),
|
||||
conv.get("ai_reply"),
|
||||
conv.get("created_at"),
|
||||
conv.get("intent_rule_id"),
|
||||
conv.get("flow_instance_id"),
|
||||
conv.get("guardrail_triggered"),
|
||||
conv.get("latency_ms"),
|
||||
conv.get("prompt_template_id"),
|
||||
])
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
task.status = ExportTaskStatus.COMPLETED.value
|
||||
task.file_path = file_path
|
||||
task.file_name = file_name
|
||||
task.file_size = file_size
|
||||
task.total_rows = len(conversations)
|
||||
task.completed_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-110] Export completed: task_id={task_id}, "
|
||||
f"rows={len(conversations)}, size={file_size}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AC-AISVC-110] Export failed: task_id={task_id}, error={e}")
|
||||
|
||||
task = await session.get(ExportTask, task_id)
|
||||
if task:
|
||||
task.status = ExportTaskStatus.FAILED.value
|
||||
task.error_message = str(e)
|
||||
await session.commit()
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
"""
|
||||
Prompt Template Management API.
|
||||
[AC-AISVC-52, AC-AISVC-57, AC-AISVC-58, AC-AISVC-54, AC-AISVC-55] Prompt template CRUD and publish/rollback endpoints.
|
||||
[AC-AISVC-99] Prompt template preview endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import PromptTemplateCreate, PromptTemplateUpdate
|
||||
from app.services.prompt.template_service import PromptTemplateService
|
||||
from app.services.monitoring.prompt_monitor import PromptMonitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/prompt-templates", tags=["Prompt Management"])
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
|
||||
"""Extract tenant ID from header."""
|
||||
if not x_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
|
||||
return x_tenant_id
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_templates(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
scene: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-57] List all prompt templates for a tenant.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-57] Listing prompt templates for tenant={tenant_id}, scene={scene}")
|
||||
|
||||
service = PromptTemplateService(session)
|
||||
templates = await service.list_templates(tenant_id, scene)
|
||||
|
||||
data = []
|
||||
for t in templates:
|
||||
published_version = await service.get_published_version_info(tenant_id, t.id)
|
||||
data.append({
|
||||
"id": str(t.id),
|
||||
"name": t.name,
|
||||
"scene": t.scene,
|
||||
"description": t.description,
|
||||
"is_default": t.is_default,
|
||||
"published_version": published_version,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
"updated_at": t.updated_at.isoformat(),
|
||||
})
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_template(
|
||||
body: PromptTemplateCreate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-52] Create a new prompt template.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-52] Creating prompt template for tenant={tenant_id}, name={body.name}")
|
||||
|
||||
service = PromptTemplateService(session)
|
||||
template = await service.create_template(tenant_id, body)
|
||||
|
||||
return {
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
"scene": template.scene,
|
||||
"description": template.description,
|
||||
"is_default": template.is_default,
|
||||
"created_at": template.created_at.isoformat(),
|
||||
"updated_at": template.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{tpl_id}")
|
||||
async def get_template_detail(
|
||||
tpl_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-58] Get prompt template detail with version history.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-58] Getting template detail for tenant={tenant_id}, id={tpl_id}")
|
||||
|
||||
service = PromptTemplateService(session)
|
||||
detail = await service.get_template_detail(tenant_id, tpl_id)
|
||||
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
return detail
|
||||
|
||||
|
||||
@router.put("/{tpl_id}")
|
||||
async def update_template(
|
||||
tpl_id: uuid.UUID,
|
||||
body: PromptTemplateUpdate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-53] Update prompt template (creates a new version).
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-53] Updating template for tenant={tenant_id}, id={tpl_id}")
|
||||
|
||||
service = PromptTemplateService(session)
|
||||
template = await service.update_template(tenant_id, tpl_id, body)
|
||||
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
published_version = await service.get_published_version_info(tenant_id, template.id)
|
||||
|
||||
return {
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
"scene": template.scene,
|
||||
"description": template.description,
|
||||
"is_default": template.is_default,
|
||||
"published_version": published_version,
|
||||
"created_at": template.created_at.isoformat(),
|
||||
"updated_at": template.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{tpl_id}/publish")
|
||||
async def publish_template(
|
||||
tpl_id: uuid.UUID,
|
||||
body: dict[str, int],
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-54] Publish a specific version of the template.
|
||||
"""
|
||||
version = body.get("version")
|
||||
if version is None:
|
||||
raise HTTPException(status_code=400, detail="version is required")
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-54] Publishing template version for tenant={tenant_id}, "
|
||||
f"id={tpl_id}, version={version}"
|
||||
)
|
||||
|
||||
service = PromptTemplateService(session)
|
||||
success = await service.publish_version(tenant_id, tpl_id, version)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Template or version not found")
|
||||
|
||||
return {"success": True, "message": f"Version {version} published successfully"}
|
||||
|
||||
|
||||
@router.post("/{tpl_id}/rollback")
|
||||
async def rollback_template(
|
||||
tpl_id: uuid.UUID,
|
||||
body: dict[str, int],
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-55] Rollback to a specific historical version.
|
||||
"""
|
||||
version = body.get("version")
|
||||
if version is None:
|
||||
raise HTTPException(status_code=400, detail="version is required")
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-55] Rolling back template for tenant={tenant_id}, "
|
||||
f"id={tpl_id}, version={version}"
|
||||
)
|
||||
|
||||
service = PromptTemplateService(session)
|
||||
success = await service.rollback_version(tenant_id, tpl_id, version)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Template or version not found")
|
||||
|
||||
return {"success": True, "message": f"Rolled back to version {version} successfully"}
|
||||
|
||||
|
||||
@router.delete("/{tpl_id}", status_code=204)
|
||||
async def delete_template(
|
||||
tpl_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""
|
||||
Delete a prompt template and all its versions.
|
||||
"""
|
||||
logger.info(f"Deleting template for tenant={tenant_id}, id={tpl_id}")
|
||||
|
||||
service = PromptTemplateService(session)
|
||||
success = await service.delete_template(tenant_id, tpl_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
|
||||
class PromptPreviewRequest(BaseModel):
|
||||
"""Request body for previewing a prompt template."""
|
||||
|
||||
variables: dict[str, str] | None = None
|
||||
sample_history: list[dict[str, str]] | None = None
|
||||
sample_message: str | None = None
|
||||
|
||||
|
||||
@router.post("/{tpl_id}/preview")
|
||||
async def preview_template(
|
||||
tpl_id: uuid.UUID,
|
||||
body: PromptPreviewRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-99] Preview a prompt template with variable substitution.
|
||||
|
||||
Returns rendered content and token count estimation.
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-99] Previewing template for tenant={tenant_id}, id={tpl_id}"
|
||||
)
|
||||
|
||||
monitor = PromptMonitor(session)
|
||||
result = await monitor.preview_template(
|
||||
tenant_id=tenant_id,
|
||||
template_id=tpl_id,
|
||||
variables=body.variables,
|
||||
sample_history=body.sample_history,
|
||||
sample_message=body.sample_message,
|
||||
)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
return result.to_dict()
|
||||
|
|
@ -6,21 +6,20 @@ RAG Lab endpoints for debugging and experimentation.
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Annotated, Any, List
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import MissingTenantIdException
|
||||
from app.core.prompts import format_evidence_for_prompt, build_user_prompt_with_evidence
|
||||
from app.core.prompts import build_user_prompt_with_evidence, format_evidence_for_prompt
|
||||
from app.core.tenant import get_tenant_id
|
||||
from app.models import ErrorResponse
|
||||
from app.services.retrieval.vector_retriever import get_vector_retriever
|
||||
from app.services.retrieval.optimized_retriever import get_optimized_retriever
|
||||
from app.services.retrieval.base import RetrievalContext
|
||||
from app.services.llm.factory import get_llm_config_manager
|
||||
from app.services.retrieval.base import RetrievalContext
|
||||
from app.services.retrieval.optimized_retriever import get_optimized_retriever
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -37,7 +36,7 @@ def get_current_tenant_id() -> str:
|
|||
|
||||
class RAGExperimentRequest(BaseModel):
|
||||
query: str = Field(..., description="Query text for retrieval")
|
||||
kb_ids: List[str] | None = Field(default=None, description="Knowledge base IDs to search")
|
||||
kb_ids: list[str] | None = Field(default=None, description="Knowledge base IDs to search")
|
||||
top_k: int = Field(default=5, description="Number of results to retrieve")
|
||||
score_threshold: float = Field(default=0.5, description="Minimum similarity score")
|
||||
generate_response: bool = Field(default=True, description="Whether to generate AI response")
|
||||
|
|
@ -55,7 +54,7 @@ class AIResponse(BaseModel):
|
|||
|
||||
class RAGExperimentResult(BaseModel):
|
||||
query: str
|
||||
retrieval_results: List[dict] = []
|
||||
retrieval_results: list[dict] = []
|
||||
final_prompt: str = ""
|
||||
ai_response: AIResponse | None = None
|
||||
total_latency_ms: float = 0
|
||||
|
|
@ -227,10 +226,10 @@ async def run_rag_experiment_stream(
|
|||
|
||||
final_prompt = _build_final_prompt(request.query, retrieval_results)
|
||||
|
||||
logger.info(f"[AC-ASA-20] ========== RAG LAB STREAM FULL PROMPT ==========")
|
||||
logger.info("[AC-ASA-20] ========== RAG LAB STREAM FULL PROMPT ==========")
|
||||
logger.info(f"[AC-ASA-20] Prompt length: {len(final_prompt)}")
|
||||
logger.info(f"[AC-ASA-20] Prompt content:\n{final_prompt}")
|
||||
logger.info(f"[AC-ASA-20] ==============================================")
|
||||
logger.info("[AC-ASA-20] ==============================================")
|
||||
|
||||
yield f"event: retrieval\ndata: {json.dumps({'results': retrieval_results, 'count': len(retrieval_results)})}\n\n"
|
||||
|
||||
|
|
@ -276,10 +275,10 @@ async def _generate_ai_response(
|
|||
"""
|
||||
import time
|
||||
|
||||
logger.info(f"[AC-ASA-19] ========== RAG LAB FULL PROMPT ==========")
|
||||
logger.info("[AC-ASA-19] ========== RAG LAB FULL PROMPT ==========")
|
||||
logger.info(f"[AC-ASA-19] Prompt length: {len(prompt)}")
|
||||
logger.info(f"[AC-ASA-19] Prompt content:\n{prompt}")
|
||||
logger.info(f"[AC-ASA-19] ==========================================")
|
||||
logger.info("[AC-ASA-19] ==========================================")
|
||||
|
||||
try:
|
||||
manager = get_llm_config_manager()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
"""
|
||||
Script Flow Management API.
|
||||
[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73] Script flow CRUD endpoints.
|
||||
[AC-AISVC-101, AC-AISVC-102] Flow simulation endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import ScriptFlowCreate, ScriptFlowUpdate
|
||||
from app.services.flow.flow_service import ScriptFlowService
|
||||
from app.services.flow.tester import ScriptFlowTester
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/script-flows", tags=["Script Flows"])
|
||||
|
||||
|
||||
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
|
||||
"""Extract tenant ID from header."""
|
||||
if not x_tenant_id:
|
||||
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
|
||||
return x_tenant_id
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_flows(
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
is_enabled: bool | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-72] List all script flows for a tenant.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-72] Listing script flows for tenant={tenant_id}, is_enabled={is_enabled}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
flows = await service.list_flows(tenant_id, is_enabled)
|
||||
|
||||
data = []
|
||||
for f in flows:
|
||||
linked_rule_count = await service._get_linked_rule_count(tenant_id, f.id)
|
||||
data.append({
|
||||
"id": str(f.id),
|
||||
"name": f.name,
|
||||
"description": f.description,
|
||||
"step_count": len(f.steps),
|
||||
"is_enabled": f.is_enabled,
|
||||
"linked_rule_count": linked_rule_count,
|
||||
"created_at": f.created_at.isoformat(),
|
||||
"updated_at": f.updated_at.isoformat(),
|
||||
})
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_flow(
|
||||
body: ScriptFlowCreate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-71] Create a new script flow.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-71] Creating script flow for tenant={tenant_id}, name={body.name}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
|
||||
try:
|
||||
flow = await service.create_flow(tenant_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return {
|
||||
"id": str(flow.id),
|
||||
"name": flow.name,
|
||||
"description": flow.description,
|
||||
"step_count": len(flow.steps),
|
||||
"is_enabled": flow.is_enabled,
|
||||
"created_at": flow.created_at.isoformat(),
|
||||
"updated_at": flow.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{flow_id}")
|
||||
async def get_flow_detail(
|
||||
flow_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-73] Get script flow detail with complete step definitions.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-73] Getting flow detail for tenant={tenant_id}, id={flow_id}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
detail = await service.get_flow_detail(tenant_id, flow_id)
|
||||
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
return detail
|
||||
|
||||
|
||||
@router.put("/{flow_id}")
|
||||
async def update_flow(
|
||||
flow_id: uuid.UUID,
|
||||
body: ScriptFlowUpdate,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-73] Update script flow definition.
|
||||
"""
|
||||
logger.info(f"[AC-AISVC-73] Updating flow for tenant={tenant_id}, id={flow_id}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
|
||||
try:
|
||||
flow = await service.update_flow(tenant_id, flow_id, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
return {
|
||||
"id": str(flow.id),
|
||||
"name": flow.name,
|
||||
"description": flow.description,
|
||||
"step_count": len(flow.steps),
|
||||
"is_enabled": flow.is_enabled,
|
||||
"created_at": flow.created_at.isoformat(),
|
||||
"updated_at": flow.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{flow_id}", status_code=204)
|
||||
async def delete_flow(
|
||||
flow_id: uuid.UUID,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""
|
||||
Delete a script flow.
|
||||
"""
|
||||
logger.info(f"Deleting flow for tenant={tenant_id}, id={flow_id}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
success = await service.delete_flow(tenant_id, flow_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
|
||||
class FlowSimulateRequest(BaseModel):
|
||||
"""Request body for flow simulation."""
|
||||
|
||||
userInputs: list[str]
|
||||
|
||||
|
||||
@router.post("/{flow_id}/simulate")
|
||||
async def simulate_flow(
|
||||
flow_id: uuid.UUID,
|
||||
body: FlowSimulateRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-101, AC-AISVC-102] Simulate flow execution and analyze coverage.
|
||||
|
||||
This endpoint simulates the flow execution with provided user inputs
|
||||
without modifying any database state. It returns:
|
||||
- Step-by-step simulation results
|
||||
- Coverage analysis (covered steps, coverage rate)
|
||||
- Detected issues (dead loops, low coverage, etc.)
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-101] Simulating flow for tenant={tenant_id}, "
|
||||
f"flow_id={flow_id}, inputs_count={len(body.userInputs)}"
|
||||
)
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
flow = await service.get_flow(tenant_id, flow_id)
|
||||
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
if not flow.steps:
|
||||
raise HTTPException(status_code=400, detail="Flow has no steps")
|
||||
|
||||
tester = ScriptFlowTester()
|
||||
result = tester.simulate_flow(flow, body.userInputs)
|
||||
|
||||
return tester.to_dict(result)
|
||||
|
|
@ -4,12 +4,13 @@ Session monitoring and management endpoints.
|
|||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Optional, Sequence
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ from app.core.database import get_session
|
|||
from app.core.exceptions import MissingTenantIdException
|
||||
from app.core.tenant import get_tenant_id
|
||||
from app.models import ErrorResponse
|
||||
from app.models.entities import ChatSession, ChatMessage, SessionStatus
|
||||
from app.models.entities import ChatMessage, ChatSession, SessionStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ from typing import Annotated, Any
|
|||
|
||||
from fastapi import APIRouter, Depends, Header, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.core.middleware import get_response_mode, is_sse_request
|
||||
from app.core.sse import SSEStateMachine, create_error_event
|
||||
from app.core.tenant import get_tenant_id
|
||||
from app.models import ChatRequest, ChatResponse, ErrorResponse
|
||||
from app.models import ChatRequest, ErrorResponse
|
||||
from app.services.memory import MemoryService
|
||||
from app.services.orchestrator import OrchestratorService
|
||||
|
||||
|
|
@ -33,12 +33,12 @@ async def get_orchestrator_service_with_memory(
|
|||
"""
|
||||
from app.services.llm.factory import get_llm_config_manager
|
||||
from app.services.retrieval.optimized_retriever import get_optimized_retriever
|
||||
|
||||
|
||||
memory_service = MemoryService(session)
|
||||
llm_config_manager = get_llm_config_manager()
|
||||
llm_client = llm_config_manager.get_client()
|
||||
retriever = await get_optimized_retriever()
|
||||
|
||||
|
||||
return OrchestratorService(
|
||||
llm_client=llm_client,
|
||||
memory_service=memory_service,
|
||||
|
|
@ -52,7 +52,7 @@ async def get_orchestrator_service_with_memory(
|
|||
summary="Generate AI reply",
|
||||
description="""
|
||||
[AC-AISVC-01, AC-AISVC-02, AC-AISVC-06] Generate AI reply based on user message.
|
||||
|
||||
|
||||
Response mode is determined by Accept header:
|
||||
- Accept: text/event-stream -> SSE streaming response
|
||||
- Other -> JSON response
|
||||
|
|
@ -78,7 +78,7 @@ async def generate_reply(
|
|||
) -> Any:
|
||||
"""
|
||||
[AC-AISVC-06] Generate AI reply with automatic response mode switching.
|
||||
|
||||
|
||||
Based on Accept header:
|
||||
- text/event-stream: Returns SSE stream with message/final/error events
|
||||
- Other: Returns JSON ChatResponse
|
||||
|
|
@ -134,11 +134,11 @@ async def _handle_streaming_request(
|
|||
) -> EventSourceResponse:
|
||||
"""
|
||||
[AC-AISVC-06, AC-AISVC-07, AC-AISVC-08, AC-AISVC-09] Handle SSE streaming request.
|
||||
|
||||
|
||||
SSE Event Sequence (per design.md Section 6.2):
|
||||
- message* (0 or more) -> final (exactly 1) -> close
|
||||
- OR message* (0 or more) -> error (exactly 1) -> close
|
||||
|
||||
|
||||
State machine ensures:
|
||||
- No events after final/error
|
||||
- Only one final OR one error event
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Core module - Configuration, dependencies, and utilities.
|
|||
"""
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.database import async_session_maker, get_session, init_db, close_db
|
||||
from app.core.database import async_session_maker, close_db, get_session, init_db
|
||||
from app.core.qdrant_client import QdrantClient, get_qdrant_client
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class Settings(BaseSettings):
|
|||
rag_score_threshold: float = 0.01
|
||||
rag_min_hits: int = 1
|
||||
rag_max_evidence_tokens: int = 2000
|
||||
|
||||
|
||||
rag_two_stage_enabled: bool = True
|
||||
rag_two_stage_expand_factor: int = 10
|
||||
rag_hybrid_enabled: bool = True
|
||||
|
|
@ -60,6 +60,11 @@ class Settings(BaseSettings):
|
|||
confidence_insufficient_penalty: float = 0.3
|
||||
max_history_tokens: int = 4000
|
||||
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
redis_enabled: bool = True
|
||||
dashboard_cache_ttl: int = 60
|
||||
stats_counter_ttl: int = 7776000
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ Database client for AI Service.
|
|||
"""
|
||||
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models import entities # noqa: F401 - Import to register all SQLModel tables
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
|
|||
|
||||
|
||||
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Unhandled exception: {type(exc).__name__}: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=ErrorResponse(
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ Middleware for AI Service.
|
|||
|
||||
import logging
|
||||
import re
|
||||
from typing import Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.core.exceptions import ErrorCode, ErrorResponse, MissingTenantIdException
|
||||
from app.core.exceptions import ErrorCode, ErrorResponse
|
||||
from app.core.tenant import clear_tenant_context, set_tenant_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -52,7 +52,7 @@ def parse_tenant_id(tenant_id: str) -> tuple[str, str]:
|
|||
class ApiKeyMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
[AC-AISVC-50] Middleware to validate API Key for all requests.
|
||||
|
||||
|
||||
Features:
|
||||
- Validates X-API-Key header against in-memory cache
|
||||
- Skips validation for health/docs endpoints
|
||||
|
|
@ -80,6 +80,16 @@ class ApiKeyMiddleware(BaseHTTPMiddleware):
|
|||
from app.services.api_key import get_api_key_service
|
||||
service = get_api_key_service()
|
||||
|
||||
if not service._initialized:
|
||||
logger.warning("[AC-AISVC-50] API key service not initialized, attempting lazy initialization...")
|
||||
try:
|
||||
from app.core.database import async_session_maker
|
||||
async with async_session_maker() as session:
|
||||
await service.initialize(session)
|
||||
logger.info(f"[AC-AISVC-50] API key service lazy initialized with {len(service._keys_cache)} keys")
|
||||
except Exception as e:
|
||||
logger.error(f"[AC-AISVC-50] Failed to initialize API key service: {e}")
|
||||
|
||||
if not service.validate_key(api_key):
|
||||
logger.warning(f"[AC-AISVC-50] Invalid API key for {request.url.path}")
|
||||
return JSONResponse(
|
||||
|
|
@ -148,10 +158,16 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
|||
set_tenant_context(tenant_id)
|
||||
request.state.tenant_id = tenant_id
|
||||
|
||||
logger.info(f"[AC-AISVC-10] Tenant context set: tenant_id={tenant_id}")
|
||||
logger.info(f"[AC-AISVC-10] Tenant context set: tenant_id={tenant_id}, path={request.url.path}")
|
||||
|
||||
try:
|
||||
logger.info(f"[MIDDLEWARE] Calling next handler for path={request.url.path}")
|
||||
response = await call_next(request)
|
||||
logger.info(f"[MIDDLEWARE] Response received for path={request.url.path}, status={response.status_code}")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"[MIDDLEWARE] Exception in call_next for path={request.url.path}: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
||||
raise
|
||||
finally:
|
||||
clear_tenant_context()
|
||||
|
||||
|
|
@ -162,7 +178,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
|||
[AC-AISVC-10] Ensure tenant exists in database, create if not.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import async_session_maker
|
||||
from app.models.entities import Tenant
|
||||
|
|
|
|||
|
|
@ -15,26 +15,26 @@ SYSTEM_PROMPT = """你是一名经验丰富的客服专员,名字叫"小N"。
|
|||
|
||||
|
||||
def format_evidence_for_prompt(
|
||||
retrieval_results: list,
|
||||
max_results: int = 5,
|
||||
retrieval_results: list,
|
||||
max_results: int = 5,
|
||||
max_content_length: int = 500
|
||||
) -> str:
|
||||
"""
|
||||
Format retrieval results as evidence text for prompts.
|
||||
|
||||
|
||||
Args:
|
||||
retrieval_results: List of retrieval hits. Can be:
|
||||
- dict format: {'content', 'score', 'source', 'metadata'}
|
||||
- RetrievalHit object: with .text, .score, .source, .metadata attributes
|
||||
max_results: Maximum number of results to include
|
||||
max_content_length: Maximum length of each content snippet
|
||||
|
||||
|
||||
Returns:
|
||||
Formatted evidence text
|
||||
"""
|
||||
if not retrieval_results:
|
||||
return ""
|
||||
|
||||
|
||||
evidence_parts = []
|
||||
for i, hit in enumerate(retrieval_results[:max_results]):
|
||||
if hasattr(hit, 'text'):
|
||||
|
|
@ -47,15 +47,15 @@ def format_evidence_for_prompt(
|
|||
score = hit.get('score', 0)
|
||||
source = hit.get('source', '知识库')
|
||||
metadata = hit.get('metadata', {}) or {}
|
||||
|
||||
|
||||
if len(content) > max_content_length:
|
||||
content = content[:max_content_length] + '...'
|
||||
|
||||
|
||||
nested_meta = metadata.get('metadata', {})
|
||||
source_doc = nested_meta.get('source_doc', source) if nested_meta else source
|
||||
category = nested_meta.get('category', '') if nested_meta else ''
|
||||
department = nested_meta.get('department', '') if nested_meta else ''
|
||||
|
||||
|
||||
header = f"[文档{i+1}]"
|
||||
if source_doc and source_doc != "知识库":
|
||||
header += f" 来源:{source_doc}"
|
||||
|
|
@ -63,25 +63,25 @@ def format_evidence_for_prompt(
|
|||
header += f" | 类别:{category}"
|
||||
if department:
|
||||
header += f" | 部门:{department}"
|
||||
|
||||
|
||||
evidence_parts.append(f"{header}\n相关度:{score:.2f}\n内容:{content}")
|
||||
|
||||
|
||||
return "\n\n".join(evidence_parts)
|
||||
|
||||
|
||||
def build_system_prompt_with_evidence(evidence_text: str) -> str:
|
||||
"""
|
||||
Build system prompt with knowledge base evidence.
|
||||
|
||||
|
||||
Args:
|
||||
evidence_text: Formatted evidence from retrieval results
|
||||
|
||||
|
||||
Returns:
|
||||
Complete system prompt
|
||||
"""
|
||||
if not evidence_text:
|
||||
return SYSTEM_PROMPT
|
||||
|
||||
|
||||
return f"""{SYSTEM_PROMPT}
|
||||
|
||||
知识库参考内容:
|
||||
|
|
@ -91,11 +91,11 @@ def build_system_prompt_with_evidence(evidence_text: str) -> str:
|
|||
def build_user_prompt_with_evidence(query: str, evidence_text: str) -> str:
|
||||
"""
|
||||
Build user prompt with knowledge base evidence (for single-message format).
|
||||
|
||||
|
||||
Args:
|
||||
query: User's question
|
||||
evidence_text: Formatted evidence from retrieval results
|
||||
|
||||
|
||||
Returns:
|
||||
Complete user prompt
|
||||
"""
|
||||
|
|
@ -103,7 +103,7 @@ def build_user_prompt_with_evidence(query: str, evidence_text: str) -> str:
|
|||
return f"""用户问题:{query}
|
||||
|
||||
未找到相关检索结果,请基于通用知识回答用户问题。"""
|
||||
|
||||
|
||||
return f"""【系统指令】
|
||||
{SYSTEM_PROMPT}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import logging
|
|||
from typing import Any
|
||||
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.models import Distance, PointStruct, VectorParams, QueryRequest
|
||||
from qdrant_client.models import Distance, PointStruct, VectorParams
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
|
@ -19,8 +19,12 @@ settings = get_settings()
|
|||
|
||||
class QdrantClient:
|
||||
"""
|
||||
[AC-AISVC-10] Qdrant client with tenant-isolated collection management.
|
||||
Collection naming: kb_{tenantId} for tenant isolation.
|
||||
[AC-AISVC-10, AC-AISVC-59] Qdrant client with tenant-isolated collection management.
|
||||
|
||||
Collection naming conventions:
|
||||
- Legacy (single KB): kb_{tenantId}
|
||||
- Multi-KB: kb_{tenantId}_{kbId}
|
||||
|
||||
Supports multi-dimensional vectors (256/512/768) for Matryoshka retrieval.
|
||||
"""
|
||||
|
||||
|
|
@ -45,16 +49,42 @@ class QdrantClient:
|
|||
|
||||
def get_collection_name(self, tenant_id: str) -> str:
|
||||
"""
|
||||
[AC-AISVC-10] Get collection name for a tenant.
|
||||
[AC-AISVC-10] Get legacy collection name for a tenant.
|
||||
Naming convention: kb_{tenantId}
|
||||
Replaces @ with _ to ensure valid collection names.
|
||||
|
||||
Note: This is kept for backward compatibility.
|
||||
For multi-KB, use get_kb_collection_name() instead.
|
||||
"""
|
||||
safe_tenant_id = tenant_id.replace('@', '_')
|
||||
return f"{self._collection_prefix}{safe_tenant_id}"
|
||||
|
||||
def get_kb_collection_name(self, tenant_id: str, kb_id: str | None = None) -> str:
|
||||
"""
|
||||
[AC-AISVC-59, AC-AISVC-63] Get collection name for a specific knowledge base.
|
||||
|
||||
Naming convention:
|
||||
- If kb_id is None or "default": kb_{tenantId} (legacy format for backward compatibility)
|
||||
- Otherwise: kb_{tenantId}_{kbId}
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
kb_id: Knowledge base ID (optional, defaults to legacy naming)
|
||||
|
||||
Returns:
|
||||
Collection name for the knowledge base
|
||||
"""
|
||||
safe_tenant_id = tenant_id.replace('@', '_')
|
||||
|
||||
if kb_id is None or kb_id == "default" or kb_id == "":
|
||||
return f"{self._collection_prefix}{safe_tenant_id}"
|
||||
|
||||
safe_kb_id = kb_id.replace('-', '_')[:8]
|
||||
return f"{self._collection_prefix}{safe_tenant_id}_{safe_kb_id}"
|
||||
|
||||
async def ensure_collection_exists(self, tenant_id: str, use_multi_vector: bool = True) -> bool:
|
||||
"""
|
||||
[AC-AISVC-10] Ensure collection exists for tenant.
|
||||
[AC-AISVC-10] Ensure collection exists for tenant (legacy single-KB mode).
|
||||
Supports multi-dimensional vectors for Matryoshka retrieval.
|
||||
"""
|
||||
client = await self.get_client()
|
||||
|
|
@ -84,7 +114,7 @@ class QdrantClient:
|
|||
size=self._vector_size,
|
||||
distance=Distance.COSINE,
|
||||
)
|
||||
|
||||
|
||||
await client.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=vectors_config,
|
||||
|
|
@ -98,16 +128,80 @@ class QdrantClient:
|
|||
logger.error(f"[AC-AISVC-10] Error ensuring collection: {e}")
|
||||
return False
|
||||
|
||||
async def ensure_kb_collection_exists(
|
||||
self,
|
||||
tenant_id: str,
|
||||
kb_id: str | None = None,
|
||||
use_multi_vector: bool = True,
|
||||
) -> bool:
|
||||
"""
|
||||
[AC-AISVC-59] Ensure collection exists for a specific knowledge base.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
kb_id: Knowledge base ID (optional, defaults to legacy naming)
|
||||
use_multi_vector: Whether to use multi-dimensional vectors
|
||||
|
||||
Returns:
|
||||
True if collection exists or was created successfully
|
||||
"""
|
||||
client = await self.get_client()
|
||||
collection_name = self.get_kb_collection_name(tenant_id, kb_id)
|
||||
|
||||
try:
|
||||
exists = await client.collection_exists(collection_name)
|
||||
|
||||
if not exists:
|
||||
if use_multi_vector:
|
||||
vectors_config = {
|
||||
"full": VectorParams(
|
||||
size=768,
|
||||
distance=Distance.COSINE,
|
||||
),
|
||||
"dim_256": VectorParams(
|
||||
size=256,
|
||||
distance=Distance.COSINE,
|
||||
),
|
||||
"dim_512": VectorParams(
|
||||
size=512,
|
||||
distance=Distance.COSINE,
|
||||
),
|
||||
}
|
||||
else:
|
||||
vectors_config = VectorParams(
|
||||
size=self._vector_size,
|
||||
distance=Distance.COSINE,
|
||||
)
|
||||
|
||||
await client.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=vectors_config,
|
||||
)
|
||||
logger.info(
|
||||
f"[AC-AISVC-59] Created KB collection: {collection_name} for tenant={tenant_id}, kb_id={kb_id} "
|
||||
f"with multi_vector={use_multi_vector}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[AC-AISVC-59] Error ensuring KB collection: {e}")
|
||||
return False
|
||||
|
||||
async def upsert_vectors(
|
||||
self,
|
||||
tenant_id: str,
|
||||
points: list[PointStruct],
|
||||
kb_id: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
[AC-AISVC-10] Upsert vectors into tenant's collection.
|
||||
[AC-AISVC-10, AC-AISVC-63] Upsert vectors into tenant's collection.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
points: List of PointStruct to upsert
|
||||
kb_id: Knowledge base ID (optional, uses legacy naming if not provided)
|
||||
"""
|
||||
client = await self.get_client()
|
||||
collection_name = self.get_collection_name(tenant_id)
|
||||
collection_name = self.get_kb_collection_name(tenant_id, kb_id)
|
||||
|
||||
try:
|
||||
await client.upsert(
|
||||
|
|
@ -115,7 +209,7 @@ class QdrantClient:
|
|||
points=points,
|
||||
)
|
||||
logger.info(
|
||||
f"[AC-AISVC-10] Upserted {len(points)} vectors for tenant={tenant_id}"
|
||||
f"[AC-AISVC-10] Upserted {len(points)} vectors for tenant={tenant_id}, kb_id={kb_id}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
|
|
@ -126,10 +220,11 @@ class QdrantClient:
|
|||
self,
|
||||
tenant_id: str,
|
||||
points: list[dict[str, Any]],
|
||||
kb_id: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Upsert points with multi-dimensional vectors.
|
||||
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
points: List of points with format:
|
||||
|
|
@ -142,9 +237,10 @@ class QdrantClient:
|
|||
},
|
||||
"payload": dict
|
||||
}
|
||||
kb_id: Knowledge base ID (optional, uses legacy naming if not provided)
|
||||
"""
|
||||
client = await self.get_client()
|
||||
collection_name = self.get_collection_name(tenant_id)
|
||||
collection_name = self.get_kb_collection_name(tenant_id, kb_id)
|
||||
|
||||
try:
|
||||
qdrant_points = []
|
||||
|
|
@ -155,13 +251,13 @@ class QdrantClient:
|
|||
payload=p.get("payload", {}),
|
||||
)
|
||||
qdrant_points.append(point)
|
||||
|
||||
|
||||
await client.upsert(
|
||||
collection_name=collection_name,
|
||||
points=qdrant_points,
|
||||
)
|
||||
logger.info(
|
||||
f"[RAG-OPT] Upserted {len(points)} multi-vector points for tenant={tenant_id}"
|
||||
f"[RAG-OPT] Upserted {len(points)} multi-vector points for tenant={tenant_id}, kb_id={kb_id}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
|
|
@ -181,7 +277,7 @@ class QdrantClient:
|
|||
[AC-AISVC-10] Search vectors in tenant's collection.
|
||||
Returns results with score >= score_threshold if specified.
|
||||
Searches both old format (with @) and new format (with _) for backward compatibility.
|
||||
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
query_vector: Query vector for similarity search
|
||||
|
|
@ -192,31 +288,31 @@ class QdrantClient:
|
|||
with_vectors: Whether to return vectors in results (for two-stage reranking)
|
||||
"""
|
||||
client = await self.get_client()
|
||||
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-10] Starting search: tenant_id={tenant_id}, "
|
||||
f"limit={limit}, score_threshold={score_threshold}, vector_dim={len(query_vector)}, vector_name={vector_name}"
|
||||
)
|
||||
|
||||
|
||||
collection_names = [self.get_collection_name(tenant_id)]
|
||||
if '@' in tenant_id:
|
||||
old_format = f"{self._collection_prefix}{tenant_id}"
|
||||
new_format = f"{self._collection_prefix}{tenant_id.replace('@', '_')}"
|
||||
collection_names = [new_format, old_format]
|
||||
|
||||
|
||||
logger.info(f"[AC-AISVC-10] Will search in collections: {collection_names}")
|
||||
|
||||
|
||||
all_hits = []
|
||||
|
||||
|
||||
for collection_name in collection_names:
|
||||
try:
|
||||
logger.info(f"[AC-AISVC-10] Searching in collection: {collection_name}")
|
||||
|
||||
|
||||
exists = await client.collection_exists(collection_name)
|
||||
if not exists:
|
||||
logger.warning(f"[AC-AISVC-10] Collection {collection_name} does not exist")
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
results = await client.query_points(
|
||||
collection_name=collection_name,
|
||||
|
|
@ -241,7 +337,7 @@ class QdrantClient:
|
|||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-10] Collection {collection_name} returned {len(results.points)} raw results"
|
||||
)
|
||||
|
|
@ -257,7 +353,7 @@ class QdrantClient:
|
|||
hit["vector"] = result.vector
|
||||
hits.append(hit)
|
||||
all_hits.extend(hits)
|
||||
|
||||
|
||||
if hits:
|
||||
logger.info(
|
||||
f"[AC-AISVC-10] Search in collection {collection_name}: {len(hits)} results for tenant={tenant_id}"
|
||||
|
|
@ -277,17 +373,17 @@ class QdrantClient:
|
|||
continue
|
||||
|
||||
all_hits = sorted(all_hits, key=lambda x: x["score"], reverse=True)[:limit]
|
||||
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-10] Search returned {len(all_hits)} total results for tenant={tenant_id}"
|
||||
)
|
||||
|
||||
|
||||
if len(all_hits) == 0:
|
||||
logger.warning(
|
||||
f"[AC-AISVC-10] No results found! tenant={tenant_id}, "
|
||||
f"collections_tried={collection_names}, limit={limit}"
|
||||
)
|
||||
|
||||
|
||||
return all_hits
|
||||
|
||||
async def delete_collection(self, tenant_id: str) -> bool:
|
||||
|
|
@ -306,6 +402,132 @@ class QdrantClient:
|
|||
logger.error(f"[AC-AISVC-10] Error deleting collection: {e}")
|
||||
return False
|
||||
|
||||
async def delete_kb_collection(self, tenant_id: str, kb_id: str) -> bool:
|
||||
"""
|
||||
[AC-AISVC-62] Delete a specific knowledge base's collection.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
kb_id: Knowledge base ID
|
||||
|
||||
Returns:
|
||||
True if collection was deleted successfully
|
||||
"""
|
||||
client = await self.get_client()
|
||||
collection_name = self.get_kb_collection_name(tenant_id, kb_id)
|
||||
|
||||
try:
|
||||
exists = await client.collection_exists(collection_name)
|
||||
if exists:
|
||||
await client.delete_collection(collection_name=collection_name)
|
||||
logger.info(f"[AC-AISVC-62] Deleted KB collection: {collection_name} for kb_id={kb_id}")
|
||||
else:
|
||||
logger.info(f"[AC-AISVC-62] KB collection {collection_name} does not exist, nothing to delete")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[AC-AISVC-62] Error deleting KB collection: {e}")
|
||||
return False
|
||||
|
||||
async def search_kb(
|
||||
self,
|
||||
tenant_id: str,
|
||||
query_vector: list[float],
|
||||
kb_ids: list[str] | None = None,
|
||||
limit: int = 5,
|
||||
score_threshold: float | None = None,
|
||||
vector_name: str = "full",
|
||||
with_vectors: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
[AC-AISVC-64] Search vectors across multiple knowledge base collections.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
query_vector: Query vector for similarity search
|
||||
kb_ids: List of knowledge base IDs to search. If None, searches legacy collection.
|
||||
limit: Maximum number of results per collection
|
||||
score_threshold: Minimum score threshold for results
|
||||
vector_name: Name of the vector to search
|
||||
with_vectors: Whether to return vectors in results
|
||||
|
||||
Returns:
|
||||
Combined and sorted results from all collections
|
||||
"""
|
||||
client = await self.get_client()
|
||||
|
||||
if kb_ids is None or len(kb_ids) == 0:
|
||||
return await self.search(
|
||||
tenant_id=tenant_id,
|
||||
query_vector=query_vector,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
vector_name=vector_name,
|
||||
with_vectors=with_vectors,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-64] Starting multi-KB search: tenant_id={tenant_id}, "
|
||||
f"kb_ids={kb_ids}, limit={limit}, score_threshold={score_threshold}"
|
||||
)
|
||||
|
||||
all_hits = []
|
||||
|
||||
for kb_id in kb_ids:
|
||||
collection_name = self.get_kb_collection_name(tenant_id, kb_id)
|
||||
|
||||
try:
|
||||
exists = await client.collection_exists(collection_name)
|
||||
if not exists:
|
||||
logger.warning(f"[AC-AISVC-64] Collection {collection_name} does not exist")
|
||||
continue
|
||||
|
||||
try:
|
||||
results = await client.query_points(
|
||||
collection_name=collection_name,
|
||||
query=query_vector,
|
||||
using=vector_name,
|
||||
limit=limit,
|
||||
with_vectors=with_vectors,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
except Exception as e:
|
||||
if "vector name" in str(e).lower() or "Not existing vector" in str(e) or "using" in str(e).lower():
|
||||
results = await client.query_points(
|
||||
collection_name=collection_name,
|
||||
query=query_vector,
|
||||
limit=limit,
|
||||
with_vectors=with_vectors,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
for result in results.points:
|
||||
hit = {
|
||||
"id": str(result.id),
|
||||
"score": result.score,
|
||||
"payload": result.payload or {},
|
||||
"kb_id": kb_id,
|
||||
}
|
||||
if with_vectors and result.vector:
|
||||
hit["vector"] = result.vector
|
||||
all_hits.append(hit)
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-64] Collection {collection_name} returned {len(results.points)} results"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AC-AISVC-64] Error searching collection {collection_name}: {e}")
|
||||
continue
|
||||
|
||||
all_hits = sorted(all_hits, key=lambda x: x["score"], reverse=True)[:limit]
|
||||
|
||||
logger.info(
|
||||
f"[AC-AISVC-64] Multi-KB search returned {len(all_hits)} total results"
|
||||
)
|
||||
|
||||
return all_hits
|
||||
|
||||
|
||||
_qdrant_client: QdrantClient | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ SSE utilities for AI Service.
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from enum import Enum
|
||||
from typing import Any, AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ class SSEStateMachine:
|
|||
async with self._lock:
|
||||
if self._state == SSEState.INIT:
|
||||
self._state = SSEState.STREAMING
|
||||
logger.debug(f"[AC-AISVC-07] SSE state transition: INIT -> STREAMING")
|
||||
logger.debug("[AC-AISVC-07] SSE state transition: INIT -> STREAMING")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -51,7 +52,7 @@ class SSEStateMachine:
|
|||
async with self._lock:
|
||||
if self._state == SSEState.STREAMING:
|
||||
self._state = SSEState.FINAL_SENT
|
||||
logger.debug(f"[AC-AISVC-08] SSE state transition: STREAMING -> FINAL_SENT")
|
||||
logger.debug("[AC-AISVC-08] SSE state transition: STREAMING -> FINAL_SENT")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,25 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api import chat_router, health_router
|
||||
from app.api.admin import api_key_router, dashboard_router, embedding_router, kb_router, llm_router, rag_router, sessions_router, tenants_router
|
||||
from app.api.admin import (
|
||||
api_key_router,
|
||||
dashboard_router,
|
||||
decomposition_template_router,
|
||||
embedding_router,
|
||||
flow_test_router,
|
||||
guardrails_router,
|
||||
intent_rules_router,
|
||||
kb_router,
|
||||
llm_router,
|
||||
metadata_field_definition_router,
|
||||
metadata_schema_router,
|
||||
monitoring_router,
|
||||
prompt_templates_router,
|
||||
rag_router,
|
||||
script_flows_router,
|
||||
sessions_router,
|
||||
tenants_router,
|
||||
)
|
||||
from app.api.admin.kb_optimized import router as kb_optimized_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import close_db, init_db
|
||||
|
|
@ -55,14 +73,17 @@ async def lifespan(app: FastAPI):
|
|||
from app.core.database import async_session_maker
|
||||
from app.services.api_key import get_api_key_service
|
||||
|
||||
logger.info("[AC-AISVC-50] Starting API key initialization...")
|
||||
async with async_session_maker() as session:
|
||||
api_key_service = get_api_key_service()
|
||||
logger.info(f"[AC-AISVC-50] Got API key service instance, initializing...")
|
||||
await api_key_service.initialize(session)
|
||||
logger.info(f"[AC-AISVC-50] API key service initialized, cache size: {len(api_key_service._keys_cache)}")
|
||||
default_key = await api_key_service.create_default_key(session)
|
||||
if default_key:
|
||||
logger.info(f"[AC-AISVC-50] Default API key created: {default_key.key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[AC-AISVC-50] API key initialization skipped: {e}")
|
||||
logger.error(f"[AC-AISVC-50] API key initialization FAILED: {e}", exc_info=True)
|
||||
|
||||
yield
|
||||
|
||||
|
|
@ -76,12 +97,12 @@ app = FastAPI(
|
|||
version=settings.app_version,
|
||||
description="""
|
||||
Python AI Service for intelligent chat with RAG support.
|
||||
|
||||
|
||||
## Features
|
||||
- Multi-tenant isolation via X-Tenant-Id header
|
||||
- SSE streaming support via Accept: text/event-stream
|
||||
- RAG-powered responses with confidence scoring
|
||||
|
||||
|
||||
## Response Modes
|
||||
- **JSON**: Default response mode (Accept: application/json or no Accept header)
|
||||
- **SSE Streaming**: Set Accept: text/event-stream for streaming responses
|
||||
|
|
@ -129,11 +150,20 @@ app.include_router(chat_router)
|
|||
|
||||
app.include_router(api_key_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(decomposition_template_router)
|
||||
app.include_router(embedding_router)
|
||||
app.include_router(flow_test_router)
|
||||
app.include_router(guardrails_router)
|
||||
app.include_router(intent_rules_router)
|
||||
app.include_router(kb_router)
|
||||
app.include_router(kb_optimized_router)
|
||||
app.include_router(llm_router)
|
||||
app.include_router(metadata_field_definition_router)
|
||||
app.include_router(metadata_schema_router)
|
||||
app.include_router(monitoring_router)
|
||||
app.include_router(prompt_templates_router)
|
||||
app.include_router(rag_router)
|
||||
app.include_router(script_flows_router)
|
||||
app.include_router(sessions_router)
|
||||
app.include_router(tenants_router)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||
class ApiKeyService:
|
||||
"""
|
||||
[AC-AISVC-50] API Key management service.
|
||||
|
||||
|
||||
Features:
|
||||
- In-memory cache for fast validation
|
||||
- Database persistence
|
||||
|
|
@ -39,49 +39,49 @@ class ApiKeyService:
|
|||
select(ApiKey).where(ApiKey.is_active == True)
|
||||
)
|
||||
keys = result.scalars().all()
|
||||
|
||||
|
||||
self._keys_cache = {key.key for key in keys}
|
||||
self._initialized = True
|
||||
|
||||
|
||||
logger.info(f"[AC-AISVC-50] Loaded {len(self._keys_cache)} API keys into memory")
|
||||
|
||||
def validate_key(self, key: str) -> bool:
|
||||
"""
|
||||
Validate an API key against the in-memory cache.
|
||||
|
||||
|
||||
Args:
|
||||
key: The API key to validate
|
||||
|
||||
|
||||
Returns:
|
||||
True if the key is valid, False otherwise
|
||||
"""
|
||||
if not self._initialized:
|
||||
logger.warning("[AC-AISVC-50] API key service not initialized")
|
||||
return False
|
||||
|
||||
|
||||
return key in self._keys_cache
|
||||
|
||||
def generate_key(self) -> str:
|
||||
"""
|
||||
Generate a new secure API key.
|
||||
|
||||
|
||||
Returns:
|
||||
A URL-safe random string
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
async def create_key(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
self,
|
||||
session: AsyncSession,
|
||||
key_create: ApiKeyCreate
|
||||
) -> ApiKey:
|
||||
"""
|
||||
Create a new API key.
|
||||
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
key_create: Key creation data
|
||||
|
||||
|
||||
Returns:
|
||||
The created ApiKey entity
|
||||
"""
|
||||
|
|
@ -90,139 +90,139 @@ class ApiKeyService:
|
|||
name=key_create.name,
|
||||
is_active=key_create.is_active,
|
||||
)
|
||||
|
||||
|
||||
session.add(api_key)
|
||||
await session.commit()
|
||||
await session.refresh(api_key)
|
||||
|
||||
|
||||
if api_key.is_active:
|
||||
self._keys_cache.add(api_key.key)
|
||||
|
||||
|
||||
logger.info(f"[AC-AISVC-50] Created API key: {api_key.name}")
|
||||
return api_key
|
||||
|
||||
async def create_default_key(self, session: AsyncSession) -> Optional[ApiKey]:
|
||||
"""
|
||||
Create a default API key if none exists.
|
||||
|
||||
|
||||
Returns:
|
||||
The created ApiKey or None if keys already exist
|
||||
"""
|
||||
result = await session.execute(select(ApiKey).limit(1))
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if existing:
|
||||
return None
|
||||
|
||||
|
||||
default_key = secrets.token_urlsafe(32)
|
||||
api_key = ApiKey(
|
||||
key=default_key,
|
||||
name="Default API Key",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
session.add(api_key)
|
||||
await session.commit()
|
||||
await session.refresh(api_key)
|
||||
|
||||
|
||||
self._keys_cache.add(api_key.key)
|
||||
|
||||
|
||||
logger.info(f"[AC-AISVC-50] Created default API key: {api_key.key}")
|
||||
return api_key
|
||||
|
||||
async def delete_key(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
self,
|
||||
session: AsyncSession,
|
||||
key_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Delete an API key.
|
||||
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
key_id: The key ID to delete
|
||||
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
import uuid
|
||||
|
||||
|
||||
try:
|
||||
key_uuid = uuid.UUID(key_id)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(ApiKey.id == key_uuid)
|
||||
)
|
||||
api_key = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not api_key:
|
||||
return False
|
||||
|
||||
|
||||
key_value = api_key.key
|
||||
await session.delete(api_key)
|
||||
await session.commit()
|
||||
|
||||
|
||||
self._keys_cache.discard(key_value)
|
||||
|
||||
|
||||
logger.info(f"[AC-AISVC-50] Deleted API key: {api_key.name}")
|
||||
return True
|
||||
|
||||
async def toggle_key(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
self,
|
||||
session: AsyncSession,
|
||||
key_id: str,
|
||||
is_active: bool
|
||||
) -> Optional[ApiKey]:
|
||||
"""
|
||||
Toggle API key active status.
|
||||
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
key_id: The key ID to toggle
|
||||
is_active: New active status
|
||||
|
||||
|
||||
Returns:
|
||||
The updated ApiKey or None if not found
|
||||
"""
|
||||
import uuid
|
||||
|
||||
|
||||
try:
|
||||
key_uuid = uuid.UUID(key_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(ApiKey.id == key_uuid)
|
||||
)
|
||||
api_key = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
|
||||
api_key.is_active = is_active
|
||||
api_key.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
session.add(api_key)
|
||||
await session.commit()
|
||||
await session.refresh(api_key)
|
||||
|
||||
|
||||
if is_active:
|
||||
self._keys_cache.add(api_key.key)
|
||||
else:
|
||||
self._keys_cache.discard(api_key.key)
|
||||
|
||||
|
||||
logger.info(f"[AC-AISVC-50] Toggled API key {api_key.name}: active={is_active}")
|
||||
return api_key
|
||||
|
||||
async def list_keys(self, session: AsyncSession) -> list[ApiKey]:
|
||||
"""
|
||||
List all API keys.
|
||||
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
|
||||
|
||||
Returns:
|
||||
List of all ApiKey entities
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Cache services for AI Service.
|
||||
"""
|
||||
|
||||
from app.services.cache.flow_cache import FlowCache, get_flow_cache
|
||||
|
||||
__all__ = ["FlowCache", "get_flow_cache"]
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
"""
|
||||
Flow Instance Cache Layer.
|
||||
Provides Redis-based caching for FlowInstance to reduce database load.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.entities import FlowInstance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FlowCache:
|
||||
"""
|
||||
Redis cache layer for FlowInstance state management.
|
||||
|
||||
Features:
|
||||
- L1: In-memory cache (process-level, 5 min TTL)
|
||||
- L2: Redis cache (shared, 1 hour TTL)
|
||||
- Automatic fallback on cache miss
|
||||
- Cache invalidation on flow completion/cancellation
|
||||
|
||||
Key format: flow:{tenant_id}:{session_id}
|
||||
TTL: 3600 seconds (1 hour)
|
||||
"""
|
||||
|
||||
# L1 cache: in-memory (process-level)
|
||||
_local_cache: dict[str, tuple[FlowInstance, float]] = {}
|
||||
_local_cache_ttl = 300 # 5 minutes
|
||||
|
||||
def __init__(self, redis_client: redis.Redis | None = None):
|
||||
self._redis = redis_client
|
||||
self._settings = get_settings()
|
||||
self._enabled = self._settings.redis_enabled
|
||||
self._cache_ttl = 3600 # 1 hour
|
||||
|
||||
async def _get_client(self) -> redis.Redis | None:
|
||||
"""Get or create Redis client."""
|
||||
if not self._enabled:
|
||||
return None
|
||||
if self._redis is None:
|
||||
try:
|
||||
self._redis = redis.from_url(
|
||||
self._settings.redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[FlowCache] Failed to connect to Redis: {e}")
|
||||
self._enabled = False
|
||||
return None
|
||||
return self._redis
|
||||
|
||||
def _make_key(self, tenant_id: str, session_id: str) -> str:
|
||||
"""Generate cache key."""
|
||||
return f"flow:{tenant_id}:{session_id}"
|
||||
|
||||
def _make_local_key(self, tenant_id: str, session_id: str) -> str:
|
||||
"""Generate local cache key."""
|
||||
return f"{tenant_id}:{session_id}"
|
||||
|
||||
async def get(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
) -> FlowInstance | None:
|
||||
"""
|
||||
Get FlowInstance from cache (L1 -> L2).
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID for isolation
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
Cached FlowInstance or None if not found
|
||||
"""
|
||||
# L1: Check local cache
|
||||
local_key = self._make_local_key(tenant_id, session_id)
|
||||
if local_key in self._local_cache:
|
||||
instance, timestamp = self._local_cache[local_key]
|
||||
import time
|
||||
if time.time() - timestamp < self._local_cache_ttl:
|
||||
logger.debug(f"[FlowCache] L1 hit: {local_key}")
|
||||
return instance
|
||||
else:
|
||||
# Expired, remove from L1
|
||||
del self._local_cache[local_key]
|
||||
|
||||
# L2: Check Redis cache
|
||||
client = await self._get_client()
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
key = self._make_key(tenant_id, session_id)
|
||||
|
||||
try:
|
||||
data = await client.get(key)
|
||||
if data:
|
||||
logger.debug(f"[FlowCache] L2 hit: {key}")
|
||||
instance_dict = json.loads(data)
|
||||
instance = self._deserialize_instance(instance_dict)
|
||||
|
||||
# Populate L1 cache
|
||||
import time
|
||||
self._local_cache[local_key] = (instance, time.time())
|
||||
|
||||
return instance
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"[FlowCache] Failed to get from cache: {e}")
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
instance: FlowInstance,
|
||||
) -> bool:
|
||||
"""
|
||||
Set FlowInstance to cache (L1 + L2).
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID for isolation
|
||||
session_id: Session ID
|
||||
instance: FlowInstance to cache
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
# L1: Update local cache
|
||||
local_key = self._make_local_key(tenant_id, session_id)
|
||||
import time
|
||||
self._local_cache[local_key] = (instance, time.time())
|
||||
|
||||
# L2: Update Redis cache
|
||||
client = await self._get_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
key = self._make_key(tenant_id, session_id)
|
||||
|
||||
try:
|
||||
instance_dict = self._serialize_instance(instance)
|
||||
await client.setex(
|
||||
key,
|
||||
self._cache_ttl,
|
||||
json.dumps(instance_dict, default=str),
|
||||
)
|
||||
logger.debug(f"[FlowCache] Set cache: {key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[FlowCache] Failed to set cache: {e}")
|
||||
return False
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete FlowInstance from cache (L1 + L2).
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID for isolation
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
# L1: Remove from local cache
|
||||
local_key = self._make_local_key(tenant_id, session_id)
|
||||
if local_key in self._local_cache:
|
||||
del self._local_cache[local_key]
|
||||
|
||||
# L2: Remove from Redis
|
||||
client = await self._get_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
key = self._make_key(tenant_id, session_id)
|
||||
|
||||
try:
|
||||
await client.delete(key)
|
||||
logger.debug(f"[FlowCache] Deleted cache: {key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[FlowCache] Failed to delete cache: {e}")
|
||||
return False
|
||||
|
||||
def _serialize_instance(self, instance: FlowInstance) -> dict[str, Any]:
|
||||
"""Serialize FlowInstance to dict."""
|
||||
return {
|
||||
"id": str(instance.id),
|
||||
"tenant_id": instance.tenant_id,
|
||||
"session_id": instance.session_id,
|
||||
"flow_id": str(instance.flow_id),
|
||||
"current_step": instance.current_step,
|
||||
"status": instance.status,
|
||||
"context": instance.context,
|
||||
"started_at": instance.started_at.isoformat() if instance.started_at else None,
|
||||
"completed_at": instance.completed_at.isoformat() if instance.completed_at else None,
|
||||
"updated_at": instance.updated_at.isoformat() if instance.updated_at else None,
|
||||
}
|
||||
|
||||
def _deserialize_instance(self, data: dict[str, Any]) -> FlowInstance:
|
||||
"""Deserialize dict to FlowInstance."""
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
return FlowInstance(
|
||||
id=UUID(data["id"]),
|
||||
tenant_id=data["tenant_id"],
|
||||
session_id=data["session_id"],
|
||||
flow_id=UUID(data["flow_id"]),
|
||||
current_step=data["current_step"],
|
||||
status=data["status"],
|
||||
context=data.get("context"),
|
||||
started_at=datetime.fromisoformat(data["started_at"]) if data.get("started_at") else None,
|
||||
completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else None,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close Redis connection."""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
|
||||
|
||||
_flow_cache: FlowCache | None = None
|
||||
|
||||
|
||||
def get_flow_cache() -> FlowCache:
|
||||
"""Get singleton FlowCache instance."""
|
||||
global _flow_cache
|
||||
if _flow_cache is None:
|
||||
_flow_cache = FlowCache()
|
||||
return _flow_cache
|
||||
|
|
@ -17,7 +17,7 @@ from typing import Any
|
|||
import tiktoken
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models import ChatMessage, Role
|
||||
from app.models import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,416 @@
|
|||
"""
|
||||
Decomposition Template Service.
|
||||
[AC-IDSMETA-21, AC-IDSMETA-22] 拆解模板服务,支持文本拆解为结构化数据。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col
|
||||
|
||||
from app.models.entities import (
|
||||
DecompositionTemplate,
|
||||
DecompositionTemplateCreate,
|
||||
DecompositionTemplateStatus,
|
||||
DecompositionTemplateUpdate,
|
||||
DecompositionRequest,
|
||||
DecompositionResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DecompositionTemplateService:
|
||||
"""
|
||||
[AC-IDSMETA-22] 拆解模板服务
|
||||
管理拆解模板,支持版本控制和最近生效版本查询
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession, llm_client=None):
|
||||
self._session = session
|
||||
self._llm_client = llm_client
|
||||
|
||||
async def list_templates(
|
||||
self,
|
||||
tenant_id: str,
|
||||
status: str | None = None,
|
||||
) -> list[DecompositionTemplate]:
|
||||
"""
|
||||
[AC-IDSMETA-22] 列出租户所有拆解模板
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
status: 按状态过滤(draft/published/archived)
|
||||
|
||||
Returns:
|
||||
DecompositionTemplate 列表
|
||||
"""
|
||||
stmt = select(DecompositionTemplate).where(
|
||||
DecompositionTemplate.tenant_id == tenant_id,
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(DecompositionTemplate.status == status)
|
||||
|
||||
stmt = stmt.order_by(col(DecompositionTemplate.created_at).desc())
|
||||
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_template(
|
||||
self,
|
||||
tenant_id: str,
|
||||
template_id: str,
|
||||
) -> DecompositionTemplate | None:
|
||||
"""
|
||||
获取单个模板
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
template_id: 模板 ID
|
||||
|
||||
Returns:
|
||||
DecompositionTemplate 或 None
|
||||
"""
|
||||
stmt = select(DecompositionTemplate).where(
|
||||
DecompositionTemplate.tenant_id == tenant_id,
|
||||
DecompositionTemplate.id == uuid.UUID(template_id),
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_latest_published_template(
|
||||
self,
|
||||
tenant_id: str,
|
||||
) -> DecompositionTemplate | None:
|
||||
"""
|
||||
[AC-IDSMETA-22] 获取最近生效的发布版本模板
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
|
||||
Returns:
|
||||
状态为 published 的最新模板
|
||||
"""
|
||||
stmt = select(DecompositionTemplate).where(
|
||||
DecompositionTemplate.tenant_id == tenant_id,
|
||||
DecompositionTemplate.status == DecompositionTemplateStatus.PUBLISHED.value,
|
||||
).order_by(col(DecompositionTemplate.updated_at).desc()).limit(1)
|
||||
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_template(
|
||||
self,
|
||||
tenant_id: str,
|
||||
template_create: DecompositionTemplateCreate,
|
||||
) -> DecompositionTemplate:
|
||||
"""
|
||||
[AC-IDSMETA-22] 创建拆解模板
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
template_create: 创建数据
|
||||
|
||||
Returns:
|
||||
创建的 DecompositionTemplate
|
||||
"""
|
||||
template = DecompositionTemplate(
|
||||
tenant_id=tenant_id,
|
||||
name=template_create.name,
|
||||
description=template_create.description,
|
||||
template_schema=template_create.template_schema,
|
||||
extraction_hints=template_create.extraction_hints,
|
||||
example_input=template_create.example_input,
|
||||
example_output=template_create.example_output,
|
||||
version=1,
|
||||
status=DecompositionTemplateStatus.DRAFT.value,
|
||||
)
|
||||
|
||||
self._session.add(template)
|
||||
await self._session.flush()
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-22] Created decomposition template: tenant={tenant_id}, "
|
||||
f"id={template.id}, name={template.name}"
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
async def update_template(
|
||||
self,
|
||||
tenant_id: str,
|
||||
template_id: str,
|
||||
template_update: DecompositionTemplateUpdate,
|
||||
) -> DecompositionTemplate | None:
|
||||
"""
|
||||
[AC-IDSMETA-22] 更新拆解模板
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
template_id: 模板 ID
|
||||
template_update: 更新数据
|
||||
|
||||
Returns:
|
||||
更新后的 DecompositionTemplate 或 None
|
||||
"""
|
||||
template = await self.get_template(tenant_id, template_id)
|
||||
if not template:
|
||||
return None
|
||||
|
||||
if template_update.name is not None:
|
||||
template.name = template_update.name
|
||||
if template_update.description is not None:
|
||||
template.description = template_update.description
|
||||
if template_update.template_schema is not None:
|
||||
template.template_schema = template_update.template_schema
|
||||
if template_update.extraction_hints is not None:
|
||||
template.extraction_hints = template_update.extraction_hints
|
||||
if template_update.example_input is not None:
|
||||
template.example_input = template_update.example_input
|
||||
if template_update.example_output is not None:
|
||||
template.example_output = template_update.example_output
|
||||
if template_update.status is not None:
|
||||
old_status = template.status
|
||||
template.status = template_update.status
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-22] Template status changed: tenant={tenant_id}, "
|
||||
f"id={template_id}, {old_status} -> {template.status}"
|
||||
)
|
||||
|
||||
template.version += 1
|
||||
template.updated_at = datetime.utcnow()
|
||||
await self._session.flush()
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-22] Updated decomposition template: tenant={tenant_id}, "
|
||||
f"id={template_id}, version={template.version}"
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
async def decompose_text(
|
||||
self,
|
||||
tenant_id: str,
|
||||
request: DecompositionRequest,
|
||||
) -> DecompositionResult:
|
||||
"""
|
||||
[AC-IDSMETA-21] 将待录入文本拆解为固定模板输出
|
||||
|
||||
Args:
|
||||
tenant_id: 租户 ID
|
||||
request: 拆解请求
|
||||
|
||||
Returns:
|
||||
DecompositionResult 包含拆解后的结构化数据
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-21] Starting text decomposition: tenant={tenant_id}, "
|
||||
f"template_id={request.template_id}, text_length={len(request.text)}"
|
||||
)
|
||||
|
||||
# Get template
|
||||
if request.template_id:
|
||||
template = await self.get_template(tenant_id, request.template_id)
|
||||
else:
|
||||
template = await self.get_latest_published_template(tenant_id)
|
||||
|
||||
if not template:
|
||||
logger.warning(f"[AC-IDSMETA-21] No template found for tenant={tenant_id}")
|
||||
return DecompositionResult(
|
||||
success=False,
|
||||
error="No decomposition template found",
|
||||
latency_ms=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
if template.status != DecompositionTemplateStatus.PUBLISHED.value:
|
||||
logger.warning(
|
||||
f"[AC-IDSMETA-21] Template not published: id={template.id}, "
|
||||
f"status={template.status}"
|
||||
)
|
||||
return DecompositionResult(
|
||||
success=False,
|
||||
error=f"Template status is '{template.status}', not published",
|
||||
template_id=str(template.id),
|
||||
latency_ms=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
# Build prompt for LLM
|
||||
prompt = self._build_extraction_prompt(template, request.text, request.hints)
|
||||
|
||||
# Call LLM to extract structured data
|
||||
try:
|
||||
if not self._llm_client:
|
||||
logger.warning("[AC-IDSMETA-21] No LLM client configured")
|
||||
return DecompositionResult(
|
||||
success=False,
|
||||
error="LLM client not configured",
|
||||
template_id=str(template.id),
|
||||
template_version=template.version,
|
||||
latency_ms=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
llm_response = await self._call_llm(prompt)
|
||||
|
||||
# Parse LLM response as JSON
|
||||
try:
|
||||
# Try to extract JSON from response
|
||||
json_str = self._extract_json_from_response(llm_response)
|
||||
data = json.loads(json_str)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"[AC-IDSMETA-21] Failed to parse LLM response as JSON: {e}")
|
||||
return DecompositionResult(
|
||||
success=False,
|
||||
error=f"Failed to parse LLM response: {str(e)}",
|
||||
template_id=str(template.id),
|
||||
template_version=template.version,
|
||||
latency_ms=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
latency_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
logger.info(
|
||||
f"[AC-IDSMETA-21] Text decomposition complete: tenant={tenant_id}, "
|
||||
f"template_id={template.id}, version={template.version}, "
|
||||
f"latency_ms={latency_ms}"
|
||||
)
|
||||
|
||||
return DecompositionResult(
|
||||
success=True,
|
||||
data=data,
|
||||
template_id=str(template.id),
|
||||
template_version=template.version,
|
||||
confidence=0.9, # TODO: Calculate actual confidence
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[AC-IDSMETA-21] LLM call failed: {e}", exc_info=True)
|
||||
return DecompositionResult(
|
||||
success=False,
|
||||
error=f"LLM call failed: {str(e)}",
|
||||
template_id=str(template.id),
|
||||
template_version=template.version,
|
||||
latency_ms=int((time.time() - start_time) * 1000),
|
||||
)
|
||||
|
||||
def _build_extraction_prompt(
|
||||
self,
|
||||
template: DecompositionTemplate,
|
||||
text: str,
|
||||
hints: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
构建 LLM 提取提示
|
||||
|
||||
Args:
|
||||
template: 拆解模板
|
||||
text: 待拆解文本
|
||||
hints: 额外提示
|
||||
|
||||
Returns:
|
||||
LLM 提示字符串
|
||||
"""
|
||||
schema_desc = json.dumps(template.template_schema, ensure_ascii=False, indent=2)
|
||||
|
||||
prompt_parts = [
|
||||
"你是一个数据提取助手。请根据以下模板结构,从给定的文本中提取结构化数据。",
|
||||
"",
|
||||
"## 输出模板结构",
|
||||
"```json",
|
||||
schema_desc,
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
|
||||
if template.extraction_hints:
|
||||
hints_desc = json.dumps(template.extraction_hints, ensure_ascii=False, indent=2)
|
||||
prompt_parts.extend([
|
||||
"## 提取提示",
|
||||
"```json",
|
||||
hints_desc,
|
||||
"```",
|
||||
"",
|
||||
])
|
||||
|
||||
if hints:
|
||||
extra_hints = json.dumps(hints, ensure_ascii=False, indent=2)
|
||||
prompt_parts.extend([
|
||||
"## 额外提示",
|
||||
"```json",
|
||||
extra_hints,
|
||||
"```",
|
||||
"",
|
||||
])
|
||||
|
||||
if template.example_input and template.example_output:
|
||||
prompt_parts.extend([
|
||||
"## 示例",
|
||||
f"输入: {template.example_input}",
|
||||
f"输出: ```json",
|
||||
json.dumps(template.example_output, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
"",
|
||||
])
|
||||
|
||||
prompt_parts.extend([
|
||||
"## 待提取文本",
|
||||
text,
|
||||
"",
|
||||
"## 输出要求",
|
||||
"请直接输出 JSON 格式的提取结果,不要包含任何解释或额外文本。",
|
||||
"如果某个字段无法从文本中提取,请使用 null 作为值。",
|
||||
])
|
||||
|
||||
return "\n".join(prompt_parts)
|
||||
|
||||
async def _call_llm(self, prompt: str) -> str:
|
||||
"""
|
||||
调用 LLM 获取响应
|
||||
|
||||
Args:
|
||||
prompt: 提示字符串
|
||||
|
||||
Returns:
|
||||
LLM 响应字符串
|
||||
"""
|
||||
if hasattr(self._llm_client, 'chat'):
|
||||
response = await self._llm_client.chat(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.1,
|
||||
)
|
||||
return response.content if hasattr(response, 'content') else str(response)
|
||||
else:
|
||||
raise NotImplementedError("LLM client does not support chat method")
|
||||
|
||||
def _extract_json_from_response(self, response: str) -> str:
|
||||
"""
|
||||
从 LLM 响应中提取 JSON 字符串
|
||||
|
||||
Args:
|
||||
response: LLM 响应字符串
|
||||
|
||||
Returns:
|
||||
JSON 字符串
|
||||
"""
|
||||
import re
|
||||
|
||||
# Try to find JSON in code blocks
|
||||
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', response)
|
||||
if json_match:
|
||||
return json_match.group(1).strip()
|
||||
|
||||
# Try to find JSON object directly
|
||||
json_match = re.search(r'\{[\s\S]*\}', response)
|
||||
if json_match:
|
||||
return json_match.group(0)
|
||||
|
||||
return response.strip()
|
||||
|
|
@ -89,7 +89,7 @@ class DocumentParser(ABC):
|
|||
|
||||
class DocumentParseException(Exception):
|
||||
"""Exception raised when document parsing fails."""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
|
|
@ -105,7 +105,7 @@ class DocumentParseException(Exception):
|
|||
|
||||
class UnsupportedFormatError(DocumentParseException):
|
||||
"""Exception raised when file format is not supported."""
|
||||
|
||||
|
||||
def __init__(self, extension: str, supported: list[str]):
|
||||
super().__init__(
|
||||
f"Unsupported file format: {extension}. "
|
||||
|
|
|
|||
|
|
@ -58,23 +58,23 @@ class ExcelParser(DocumentParser):
|
|||
"""
|
||||
records = []
|
||||
rows = list(sheet.iter_rows(max_row=self._max_rows_per_sheet, values_only=True))
|
||||
|
||||
|
||||
if not rows:
|
||||
return records
|
||||
|
||||
|
||||
headers = rows[0]
|
||||
header_list = [str(h) if h is not None else f"column_{i}" for i, h in enumerate(headers)]
|
||||
|
||||
|
||||
for row in rows[1:]:
|
||||
record = {"_sheet": sheet_name}
|
||||
has_content = False
|
||||
|
||||
|
||||
for i, value in enumerate(row):
|
||||
if i < len(header_list):
|
||||
key = header_list[i]
|
||||
else:
|
||||
key = f"column_{i}"
|
||||
|
||||
|
||||
if value is not None:
|
||||
has_content = True
|
||||
if isinstance(value, (int, float, bool)):
|
||||
|
|
@ -83,10 +83,10 @@ class ExcelParser(DocumentParser):
|
|||
record[key] = str(value)
|
||||
elif self._include_empty_cells:
|
||||
record[key] = None
|
||||
|
||||
|
||||
if has_content or self._include_empty_cells:
|
||||
records.append(record)
|
||||
|
||||
|
||||
return records
|
||||
|
||||
def parse(self, file_path: str | Path) -> ParseResult:
|
||||
|
|
@ -95,46 +95,46 @@ class ExcelParser(DocumentParser):
|
|||
[AC-AISVC-35] Converts spreadsheet data to JSON format.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
|
||||
|
||||
if not path.exists():
|
||||
raise DocumentParseException(
|
||||
f"File not found: {path}",
|
||||
file_path=str(path),
|
||||
parser="excel"
|
||||
)
|
||||
|
||||
|
||||
if not self.supports_extension(path.suffix):
|
||||
raise DocumentParseException(
|
||||
f"Unsupported file extension: {path.suffix}",
|
||||
file_path=str(path),
|
||||
parser="excel"
|
||||
)
|
||||
|
||||
|
||||
openpyxl = self._get_openpyxl()
|
||||
|
||||
|
||||
try:
|
||||
workbook = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
|
||||
|
||||
all_records: list[dict[str, Any]] = []
|
||||
sheet_count = len(workbook.sheetnames)
|
||||
total_rows = 0
|
||||
|
||||
|
||||
for sheet_name in workbook.sheetnames:
|
||||
sheet = workbook[sheet_name]
|
||||
records = self._sheet_to_records(sheet, sheet_name)
|
||||
all_records.extend(records)
|
||||
total_rows += len(records)
|
||||
|
||||
|
||||
workbook.close()
|
||||
|
||||
|
||||
json_str = json.dumps(all_records, ensure_ascii=False, indent=2)
|
||||
file_size = path.stat().st_size
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Parsed Excel (JSON): {path.name}, sheets={sheet_count}, "
|
||||
f"rows={total_rows}, chars={len(json_str)}, size={file_size}"
|
||||
)
|
||||
|
||||
|
||||
return ParseResult(
|
||||
text=json_str,
|
||||
source_path=str(path),
|
||||
|
|
@ -146,7 +146,7 @@ class ExcelParser(DocumentParser):
|
|||
"total_rows": total_rows,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
except DocumentParseException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -177,36 +177,36 @@ class CSVParser(DocumentParser):
|
|||
def _parse_csv_to_records(self, path: Path, encoding: str) -> list[dict[str, Any]]:
|
||||
"""Parse CSV file and return list of record dictionaries."""
|
||||
import csv
|
||||
|
||||
|
||||
records = []
|
||||
|
||||
with open(path, "r", encoding=encoding, newline="") as f:
|
||||
|
||||
with open(path, encoding=encoding, newline="") as f:
|
||||
reader = csv.reader(f, delimiter=self._delimiter)
|
||||
rows = list(reader)
|
||||
|
||||
|
||||
if not rows:
|
||||
return records
|
||||
|
||||
|
||||
headers = rows[0]
|
||||
header_list = [str(h) if h else f"column_{i}" for i, h in enumerate(headers)]
|
||||
|
||||
|
||||
for row in rows[1:]:
|
||||
record = {}
|
||||
has_content = False
|
||||
|
||||
|
||||
for i, value in enumerate(row):
|
||||
if i < len(header_list):
|
||||
key = header_list[i]
|
||||
else:
|
||||
key = f"column_{i}"
|
||||
|
||||
|
||||
if value:
|
||||
has_content = True
|
||||
record[key] = value
|
||||
|
||||
|
||||
if has_content:
|
||||
records.append(record)
|
||||
|
||||
|
||||
return records
|
||||
|
||||
def parse(self, file_path: str | Path) -> ParseResult:
|
||||
|
|
@ -215,14 +215,14 @@ class CSVParser(DocumentParser):
|
|||
[AC-AISVC-35] Converts CSV data to JSON format.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
|
||||
|
||||
if not path.exists():
|
||||
raise DocumentParseException(
|
||||
f"File not found: {path}",
|
||||
file_path=str(path),
|
||||
parser="csv"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
records = self._parse_csv_to_records(path, self._encoding)
|
||||
row_count = len(records)
|
||||
|
|
@ -246,15 +246,15 @@ class CSVParser(DocumentParser):
|
|||
parser="csv",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
json_str = json.dumps(records, ensure_ascii=False, indent=2)
|
||||
file_size = path.stat().st_size
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Parsed CSV (JSON): {path.name}, rows={row_count}, "
|
||||
f"chars={len(json_str)}, size={file_size}"
|
||||
)
|
||||
|
||||
|
||||
return ParseResult(
|
||||
text=json_str,
|
||||
source_path=str(path),
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ Design reference: progress.md Section 7.2 - DocumentParserFactory
|
|||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Type
|
||||
from typing import Any
|
||||
|
||||
from app.services.document.base import (
|
||||
DocumentParser,
|
||||
DocumentParseException,
|
||||
DocumentParser,
|
||||
ParseResult,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
|
|
@ -29,7 +29,7 @@ class DocumentParserFactory:
|
|||
[AC-AISVC-33, AC-AISVC-34, AC-AISVC-35] Auto-selects parser based on file extension.
|
||||
"""
|
||||
|
||||
_parsers: dict[str, Type[DocumentParser]] = {}
|
||||
_parsers: dict[str, type[DocumentParser]] = {}
|
||||
_extension_map: dict[str, str] = {}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -37,7 +37,7 @@ class DocumentParserFactory:
|
|||
"""Initialize default parsers."""
|
||||
if cls._parsers:
|
||||
return
|
||||
|
||||
|
||||
cls._parsers = {
|
||||
"pdf": PDFParser,
|
||||
"pdfplumber": PDFPlumberParser,
|
||||
|
|
@ -46,7 +46,7 @@ class DocumentParserFactory:
|
|||
"csv": CSVParser,
|
||||
"text": TextParser,
|
||||
}
|
||||
|
||||
|
||||
cls._extension_map = {
|
||||
".pdf": "pdf",
|
||||
".docx": "word",
|
||||
|
|
@ -68,7 +68,7 @@ class DocumentParserFactory:
|
|||
def register_parser(
|
||||
cls,
|
||||
name: str,
|
||||
parser_class: Type[DocumentParser],
|
||||
parser_class: type[DocumentParser],
|
||||
extensions: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
|
|
@ -97,17 +97,17 @@ class DocumentParserFactory:
|
|||
[AC-AISVC-33] Creates appropriate parser based on extension.
|
||||
"""
|
||||
cls._initialize()
|
||||
|
||||
|
||||
normalized = extension.lower()
|
||||
if not normalized.startswith("."):
|
||||
normalized = f".{normalized}"
|
||||
|
||||
|
||||
if normalized not in cls._extension_map:
|
||||
raise UnsupportedFormatError(normalized, cls.get_supported_extensions())
|
||||
|
||||
|
||||
parser_name = cls._extension_map[normalized]
|
||||
parser_class = cls._parsers[parser_name]
|
||||
|
||||
|
||||
return parser_class()
|
||||
|
||||
@classmethod
|
||||
|
|
@ -120,24 +120,24 @@ class DocumentParserFactory:
|
|||
"""
|
||||
Parse a document file.
|
||||
[AC-AISVC-33, AC-AISVC-34, AC-AISVC-35] Main entry point for parsing.
|
||||
|
||||
|
||||
Args:
|
||||
file_path: Path to the document file
|
||||
parser_name: Optional specific parser to use
|
||||
parser_config: Optional configuration for the parser
|
||||
|
||||
|
||||
Returns:
|
||||
ParseResult with extracted text and metadata
|
||||
|
||||
|
||||
Raises:
|
||||
UnsupportedFormatError: If file format is not supported
|
||||
DocumentParseException: If parsing fails
|
||||
"""
|
||||
cls._initialize()
|
||||
|
||||
|
||||
path = Path(file_path)
|
||||
extension = path.suffix.lower()
|
||||
|
||||
|
||||
if parser_name:
|
||||
if parser_name not in cls._parsers:
|
||||
raise DocumentParseException(
|
||||
|
|
@ -151,7 +151,7 @@ class DocumentParserFactory:
|
|||
parser = cls.get_parser_for_extension(extension)
|
||||
if parser_config:
|
||||
parser = type(parser)(**parser_config)
|
||||
|
||||
|
||||
return parser.parse(path)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -161,12 +161,12 @@ class DocumentParserFactory:
|
|||
[AC-AISVC-37] Returns parser metadata.
|
||||
"""
|
||||
cls._initialize()
|
||||
|
||||
|
||||
info = []
|
||||
for name, parser_class in cls._parsers.items():
|
||||
temp_instance = parser_class.__new__(parser_class)
|
||||
extensions = temp_instance.get_supported_extensions()
|
||||
|
||||
|
||||
display_names = {
|
||||
"pdf": "PDF 文档",
|
||||
"pdfplumber": "PDF 文档 (pdfplumber)",
|
||||
|
|
@ -175,7 +175,7 @@ class DocumentParserFactory:
|
|||
"csv": "CSV 文件",
|
||||
"text": "文本文件",
|
||||
}
|
||||
|
||||
|
||||
descriptions = {
|
||||
"pdf": "使用 PyMuPDF 解析 PDF 文档,速度快",
|
||||
"pdfplumber": "使用 pdfplumber 解析 PDF 文档,表格提取效果更好",
|
||||
|
|
@ -184,14 +184,14 @@ class DocumentParserFactory:
|
|||
"csv": "解析 CSV 文件,自动检测编码",
|
||||
"text": "解析纯文本文件,支持多种编码",
|
||||
}
|
||||
|
||||
|
||||
info.append({
|
||||
"name": name,
|
||||
"display_name": display_names.get(name, name),
|
||||
"description": descriptions.get(name, ""),
|
||||
"extensions": extensions,
|
||||
})
|
||||
|
||||
|
||||
return info
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -49,47 +49,47 @@ class PDFParser(DocumentParser):
|
|||
[AC-AISVC-33] Extracts text from all pages.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
|
||||
|
||||
if not path.exists():
|
||||
raise DocumentParseException(
|
||||
f"File not found: {path}",
|
||||
file_path=str(path),
|
||||
parser="pdf"
|
||||
)
|
||||
|
||||
|
||||
if not self.supports_extension(path.suffix):
|
||||
raise DocumentParseException(
|
||||
f"Unsupported file extension: {path.suffix}",
|
||||
file_path=str(path),
|
||||
parser="pdf"
|
||||
)
|
||||
|
||||
|
||||
fitz = self._get_fitz()
|
||||
|
||||
|
||||
try:
|
||||
doc = fitz.open(path)
|
||||
|
||||
|
||||
pages: list[PageText] = []
|
||||
text_parts = []
|
||||
page_count = len(doc)
|
||||
|
||||
|
||||
for page_num in range(page_count):
|
||||
page = doc[page_num]
|
||||
text = page.get_text().strip()
|
||||
if text:
|
||||
pages.append(PageText(page=page_num + 1, text=text))
|
||||
text_parts.append(f"[Page {page_num + 1}]\n{text}")
|
||||
|
||||
|
||||
doc.close()
|
||||
|
||||
|
||||
full_text = "\n\n".join(text_parts)
|
||||
file_size = path.stat().st_size
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Parsed PDF: {path.name}, pages={page_count}, "
|
||||
f"chars={len(full_text)}, size={file_size}"
|
||||
)
|
||||
|
||||
|
||||
return ParseResult(
|
||||
text=full_text,
|
||||
source_path=str(path),
|
||||
|
|
@ -101,7 +101,7 @@ class PDFParser(DocumentParser):
|
|||
},
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
except DocumentParseException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -121,7 +121,7 @@ class PDFPlumberParser(DocumentParser):
|
|||
"""
|
||||
Alternative PDF parser using pdfplumber.
|
||||
[AC-AISVC-33] Uses pdfplumber for text extraction.
|
||||
|
||||
|
||||
pdfplumber is better for table extraction but slower than PyMuPDF.
|
||||
"""
|
||||
|
||||
|
|
@ -149,46 +149,46 @@ class PDFPlumberParser(DocumentParser):
|
|||
[AC-AISVC-33] Extracts text and optionally tables.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
|
||||
|
||||
if not path.exists():
|
||||
raise DocumentParseException(
|
||||
f"File not found: {path}",
|
||||
file_path=str(path),
|
||||
parser="pdfplumber"
|
||||
)
|
||||
|
||||
|
||||
pdfplumber = self._get_pdfplumber()
|
||||
|
||||
|
||||
try:
|
||||
pages: list[PageText] = []
|
||||
text_parts = []
|
||||
page_count = 0
|
||||
|
||||
|
||||
with pdfplumber.open(path) as pdf:
|
||||
page_count = len(pdf.pages)
|
||||
|
||||
|
||||
for page_num, page in enumerate(pdf.pages):
|
||||
text = page.extract_text() or ""
|
||||
|
||||
|
||||
if self._extract_tables:
|
||||
tables = page.extract_tables()
|
||||
for table in tables:
|
||||
table_text = self._format_table(table)
|
||||
text += f"\n\n{table_text}"
|
||||
|
||||
|
||||
text = text.strip()
|
||||
if text:
|
||||
pages.append(PageText(page=page_num + 1, text=text))
|
||||
text_parts.append(f"[Page {page_num + 1}]\n{text}")
|
||||
|
||||
|
||||
full_text = "\n\n".join(text_parts)
|
||||
file_size = path.stat().st_size
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Parsed PDF (pdfplumber): {path.name}, pages={page_count}, "
|
||||
f"chars={len(full_text)}, size={file_size}"
|
||||
)
|
||||
|
||||
|
||||
return ParseResult(
|
||||
text=full_text,
|
||||
source_path=str(path),
|
||||
|
|
@ -201,7 +201,7 @@ class PDFPlumberParser(DocumentParser):
|
|||
},
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
except DocumentParseException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -216,12 +216,12 @@ class PDFPlumberParser(DocumentParser):
|
|||
"""Format a table as text."""
|
||||
if not table:
|
||||
return ""
|
||||
|
||||
|
||||
lines = []
|
||||
for row in table:
|
||||
cells = [str(cell) if cell else "" for cell in row]
|
||||
lines.append(" | ".join(cells))
|
||||
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def get_supported_extensions(self) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -35,15 +35,15 @@ class TextParser(DocumentParser):
|
|||
"""
|
||||
for enc in ENCODINGS_TO_TRY:
|
||||
try:
|
||||
with open(path, "r", encoding=enc) as f:
|
||||
with open(path, encoding=enc) as f:
|
||||
text = f.read()
|
||||
logger.info(f"Successfully parsed with encoding: {enc}")
|
||||
return text, enc
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
|
||||
|
||||
raise DocumentParseException(
|
||||
f"Failed to decode file with any known encoding",
|
||||
"Failed to decode file with any known encoding",
|
||||
file_path=str(path),
|
||||
parser="text"
|
||||
)
|
||||
|
|
@ -54,25 +54,25 @@ class TextParser(DocumentParser):
|
|||
[AC-AISVC-33] Direct file reading.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
|
||||
|
||||
if not path.exists():
|
||||
raise DocumentParseException(
|
||||
f"File not found: {path}",
|
||||
file_path=str(path),
|
||||
parser="text"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
text, encoding_used = self._try_encodings(path)
|
||||
|
||||
|
||||
file_size = path.stat().st_size
|
||||
line_count = text.count("\n") + 1
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Parsed text: {path.name}, lines={line_count}, "
|
||||
f"chars={len(text)}, size={file_size}, encoding={encoding_used}"
|
||||
)
|
||||
|
||||
|
||||
return ParseResult(
|
||||
text=text,
|
||||
source_path=str(path),
|
||||
|
|
@ -83,7 +83,7 @@ class TextParser(DocumentParser):
|
|||
"encoding": encoding_used,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
except DocumentParseException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue