feat(v0.7.0-window2): implement flow simulation and guardrail testing/monitoring
Refs: AC-AISVC-101, AC-AISVC-102, AC-AISVC-103, AC-AISVC-104, AC-AISVC-105, AC-AISVC-106, AC-AISVC-107
Refs: AC-ASA-59, AC-ASA-60, AC-ASA-61, AC-ASA-62, AC-ASA-63, AC-ASA-64
Backend changes:
- New: ai-service/app/services/flow/tester.py (ScriptFlowTester)
- New: ai-service/app/services/guardrail/tester.py (GuardrailTester)
- New: ai-service/app/services/monitoring/flow_monitor.py (FlowMonitor)
- New: ai-service/app/services/monitoring/guardrail_monitor.py (GuardrailMonitor)
- Modified: ai-service/app/api/admin/script_flows.py (add POST /{flowId}/simulate)
- Modified: ai-service/app/api/admin/guardrails.py (add POST /test)
- Modified: ai-service/app/api/admin/monitoring.py (add flow/guardrail stats endpoints)
Frontend changes:
- New: SimulateDialog.vue (flow simulation dialog)
- New: TestDialog.vue (guardrail test dialog)
- New: ScriptFlows.vue (flow monitoring page)
- New: Guardrails.vue (guardrail monitoring page)
- Extended: API services (monitoring.ts, script-flow.ts, guardrail.ts)
- Updated: Router with new monitoring routes
This commit is contained in:
parent
e4dbcda150
commit
c005066162
|
|
@ -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] 护栏拦截记录详情弹窗支持分页
|
||||||
|
|
@ -10,6 +10,36 @@ import type {
|
||||||
BehaviorRuleListResponse
|
BehaviorRuleListResponse
|
||||||
} from '@/types/guardrail'
|
} 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?: {
|
export function listForbiddenWords(params?: {
|
||||||
category?: string
|
category?: string
|
||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
|
|
@ -91,6 +121,14 @@ export function deleteBehaviorRule(ruleId: string): Promise<void> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function testGuardrail(data: GuardrailTestRequest): Promise<GuardrailTestResponse> {
|
||||||
|
return request({
|
||||||
|
url: '/admin/guardrails/test',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ForbiddenWord,
|
ForbiddenWord,
|
||||||
ForbiddenWordCreate,
|
ForbiddenWordCreate,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,37 @@
|
||||||
import request from '@/utils/request'
|
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({
|
return request({
|
||||||
url: '/admin/sessions',
|
url: '/admin/sessions',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|
@ -8,9 +39,294 @@ export function listSessions(params: any) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionDetail(sessionId: string) {
|
export function getSessionDetail(sessionId: string): Promise<SessionDetail> {
|
||||||
return request({
|
return request({
|
||||||
url: `/admin/sessions/${sessionId}`,
|
url: `/admin/sessions/${sessionId}`,
|
||||||
method: 'get'
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,43 @@ import type {
|
||||||
ScriptFlowListResponse
|
ScriptFlowListResponse
|
||||||
} from '@/types/script-flow'
|
} 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?: {
|
export function listScriptFlows(params?: {
|
||||||
is_enabled?: boolean
|
is_enabled?: boolean
|
||||||
}): Promise<ScriptFlowListResponse> {
|
}): Promise<ScriptFlowListResponse> {
|
||||||
|
|
@ -47,6 +84,14 @@ export function deleteScriptFlow(flowId: string): Promise<void> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function simulateScriptFlow(flowId: string, data: FlowSimulateRequest): Promise<FlowSimulateResponse> {
|
||||||
|
return request({
|
||||||
|
url: `/admin/script-flows/${flowId}/simulate`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ScriptFlow,
|
ScriptFlow,
|
||||||
ScriptFlowDetail,
|
ScriptFlowDetail,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,30 @@ const routes: Array<RouteRecordRaw> = [
|
||||||
name: 'Guardrail',
|
name: 'Guardrail',
|
||||||
component: () => import('@/views/admin/guardrail/index.vue'),
|
component: () => import('@/views/admin/guardrail/index.vue'),
|
||||||
meta: { title: '输出护栏管理' }
|
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: '输出护栏监控' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@
|
||||||
</el-input>
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-section">
|
<div class="action-section">
|
||||||
|
<el-button @click="testDialogVisible = true">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
测试护栏
|
||||||
|
</el-button>
|
||||||
<el-button @click="showBatchImport = true">
|
<el-button @click="showBatchImport = true">
|
||||||
<el-icon><Upload /></el-icon>
|
<el-icon><Upload /></el-icon>
|
||||||
批量导入
|
批量导入
|
||||||
|
|
@ -140,6 +144,8 @@
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<TestDialog v-model:visible="testDialogVisible" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -155,6 +161,7 @@ import {
|
||||||
} from '@/api/guardrail'
|
} from '@/api/guardrail'
|
||||||
import { WORD_CATEGORY_OPTIONS, WORD_STRATEGY_OPTIONS } from '@/types/guardrail'
|
import { WORD_CATEGORY_OPTIONS, WORD_STRATEGY_OPTIONS } from '@/types/guardrail'
|
||||||
import type { ForbiddenWord, ForbiddenWordCreate, ForbiddenWordUpdate } from '@/types/guardrail'
|
import type { ForbiddenWord, ForbiddenWordCreate, ForbiddenWordUpdate } from '@/types/guardrail'
|
||||||
|
import TestDialog from './TestDialog.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const words = ref<ForbiddenWord[]>([])
|
const words = ref<ForbiddenWord[]>([])
|
||||||
|
|
@ -162,6 +169,7 @@ const filterCategory = ref('')
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const showBatchImport = ref(false)
|
const showBatchImport = ref(false)
|
||||||
|
const testDialogVisible = ref(false)
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const batchSubmitting = ref(false)
|
const batchSubmitting = ref(false)
|
||||||
|
|
|
||||||
|
|
@ -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,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,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,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>
|
||||||
|
|
@ -39,12 +39,16 @@
|
||||||
{{ formatDate(row.updated_at) }}
|
{{ formatDate(row.updated_at) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200" fixed="right">
|
<el-table-column label="操作" width="250" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
编辑
|
编辑
|
||||||
</el-button>
|
</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-button type="info" link size="small" @click="handlePreview(row)">
|
||||||
<el-icon><View /></el-icon>
|
<el-icon><View /></el-icon>
|
||||||
预览
|
预览
|
||||||
|
|
@ -156,13 +160,19 @@
|
||||||
<el-drawer v-model="previewDrawer" title="流程预览" size="500px" destroy-on-close>
|
<el-drawer v-model="previewDrawer" title="流程预览" size="500px" destroy-on-close>
|
||||||
<flow-preview v-if="currentFlow" :flow="currentFlow" />
|
<flow-preview v-if="currentFlow" :flow="currentFlow" />
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<SimulateDialog
|
||||||
|
v-model:visible="simulateDialogVisible"
|
||||||
|
:flow-id="currentSimulateFlowId"
|
||||||
|
:flow-name="currentSimulateFlowName"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Edit, Delete, View, Rank } from '@element-plus/icons-vue'
|
import { Plus, Edit, Delete, View, Rank, VideoPlay } from '@element-plus/icons-vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import {
|
import {
|
||||||
listScriptFlows,
|
listScriptFlows,
|
||||||
|
|
@ -174,11 +184,15 @@ import {
|
||||||
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
|
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
|
||||||
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep } from '@/types/script-flow'
|
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep } from '@/types/script-flow'
|
||||||
import FlowPreview from './components/FlowPreview.vue'
|
import FlowPreview from './components/FlowPreview.vue'
|
||||||
|
import SimulateDialog from './components/SimulateDialog.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const flows = ref<ScriptFlow[]>([])
|
const flows = ref<ScriptFlow[]>([])
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const previewDrawer = ref(false)
|
const previewDrawer = ref(false)
|
||||||
|
const simulateDialogVisible = ref(false)
|
||||||
|
const currentSimulateFlowId = ref('')
|
||||||
|
const currentSimulateFlowName = ref('')
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
@ -284,6 +298,12 @@ const handlePreview = async (row: ScriptFlow) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSimulate = (row: ScriptFlow) => {
|
||||||
|
currentSimulateFlowId.value = row.id
|
||||||
|
currentSimulateFlowName.value = row.name
|
||||||
|
simulateDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const addStep = () => {
|
const addStep = () => {
|
||||||
formData.value.steps.push({
|
formData.value.steps.push({
|
||||||
step_id: generateStepId(),
|
step_id: generateStepId(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Guardrail Management API.
|
Guardrail Management API.
|
||||||
[AC-AISVC-78~AC-AISVC-85] Forbidden words and behavior rules CRUD endpoints.
|
[AC-AISVC-78~AC-AISVC-85] Forbidden words and behavior rules CRUD endpoints.
|
||||||
|
[AC-AISVC-105] Guardrail testing endpoint.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -8,6 +9,7 @@ import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
|
|
@ -18,6 +20,7 @@ from app.models.entities import (
|
||||||
ForbiddenWordUpdate,
|
ForbiddenWordUpdate,
|
||||||
)
|
)
|
||||||
from app.services.guardrail.behavior_service import BehaviorRuleService
|
from app.services.guardrail.behavior_service import BehaviorRuleService
|
||||||
|
from app.services.guardrail.tester import GuardrailTester
|
||||||
from app.services.guardrail.word_service import ForbiddenWordService
|
from app.services.guardrail.word_service import ForbiddenWordService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -294,3 +297,35 @@ async def delete_behavior_rule(
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Behavior rule not found")
|
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,643 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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("/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()
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Script Flow Management API.
|
Script Flow Management API.
|
||||||
[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73] Script flow CRUD endpoints.
|
[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 logging
|
||||||
|
|
@ -8,11 +9,13 @@ import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.entities import ScriptFlowCreate, ScriptFlowUpdate
|
from app.models.entities import ScriptFlowCreate, ScriptFlowUpdate
|
||||||
from app.services.flow.flow_service import ScriptFlowService
|
from app.services.flow.flow_service import ScriptFlowService
|
||||||
|
from app.services.flow.tester import ScriptFlowTester
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -69,7 +72,7 @@ async def create_flow(
|
||||||
logger.info(f"[AC-AISVC-71] Creating script flow for tenant={tenant_id}, name={body.name}")
|
logger.info(f"[AC-AISVC-71] Creating script flow for tenant={tenant_id}, name={body.name}")
|
||||||
|
|
||||||
service = ScriptFlowService(session)
|
service = ScriptFlowService(session)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
flow = await service.create_flow(tenant_id, body)
|
flow = await service.create_flow(tenant_id, body)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
@ -119,7 +122,7 @@ async def update_flow(
|
||||||
logger.info(f"[AC-AISVC-73] Updating flow for tenant={tenant_id}, id={flow_id}")
|
logger.info(f"[AC-AISVC-73] Updating flow for tenant={tenant_id}, id={flow_id}")
|
||||||
|
|
||||||
service = ScriptFlowService(session)
|
service = ScriptFlowService(session)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
flow = await service.update_flow(tenant_id, flow_id, body)
|
flow = await service.update_flow(tenant_id, flow_id, body)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
@ -155,3 +158,45 @@ async def delete_flow(
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Flow not found")
|
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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
"""
|
||||||
|
Script Flow Tester for AI Service.
|
||||||
|
[AC-AISVC-101, AC-AISVC-102] Flow simulation and coverage analysis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.models.entities import ScriptFlow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchedCondition:
|
||||||
|
"""Matched condition details."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
goto_step: int
|
||||||
|
keywords: list[str] | None = None
|
||||||
|
pattern: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlowSimulationStep:
|
||||||
|
"""Single step in flow simulation."""
|
||||||
|
|
||||||
|
stepNo: int
|
||||||
|
botMessage: str
|
||||||
|
userInput: str
|
||||||
|
matchedCondition: MatchedCondition | None
|
||||||
|
nextStep: int | None
|
||||||
|
durationMs: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlowSimulationResult:
|
||||||
|
"""Result of flow simulation."""
|
||||||
|
|
||||||
|
flowId: str
|
||||||
|
flowName: str
|
||||||
|
simulation: list[FlowSimulationStep] = field(default_factory=list)
|
||||||
|
result: dict[str, Any] = field(default_factory=dict)
|
||||||
|
coverage: dict[str, Any] = field(default_factory=dict)
|
||||||
|
issues: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptFlowTester:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-101, AC-AISVC-102] Script flow simulation and coverage analysis.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Simulate flow execution with user inputs
|
||||||
|
- Calculate step coverage rate
|
||||||
|
- Detect issues: dead loops, low coverage, uncovered branches
|
||||||
|
- No database modification (read-only simulation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
MIN_COVERAGE_THRESHOLD = 0.8
|
||||||
|
MAX_SIMULATION_STEPS_MULTIPLIER = 2
|
||||||
|
|
||||||
|
def simulate_flow(
|
||||||
|
self,
|
||||||
|
flow: ScriptFlow,
|
||||||
|
user_inputs: list[str],
|
||||||
|
) -> FlowSimulationResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-101] Simulate flow execution and analyze coverage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
flow: ScriptFlow entity to simulate
|
||||||
|
user_inputs: List of user inputs to feed into the flow
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FlowSimulationResult with simulation steps, coverage, and issues
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-101] Starting flow simulation: flow_id={flow.id}, "
|
||||||
|
f"flow_name={flow.name}, inputs_count={len(user_inputs)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
simulation: list[FlowSimulationStep] = []
|
||||||
|
current_step = 1
|
||||||
|
visited_steps: set[int] = set()
|
||||||
|
total_steps = len(flow.steps)
|
||||||
|
total_duration_ms = 0
|
||||||
|
final_message: str | None = None
|
||||||
|
completed = False
|
||||||
|
|
||||||
|
for user_input in user_inputs:
|
||||||
|
if current_step > total_steps:
|
||||||
|
completed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
step_start = time.time()
|
||||||
|
step_def = flow.steps[current_step - 1]
|
||||||
|
visited_steps.add(current_step)
|
||||||
|
|
||||||
|
matched_condition, next_step = self._match_next_step(step_def, user_input)
|
||||||
|
|
||||||
|
if next_step is None:
|
||||||
|
default_next = step_def.get("default_next")
|
||||||
|
if default_next:
|
||||||
|
next_step = default_next
|
||||||
|
matched_condition = MatchedCondition(
|
||||||
|
type="default",
|
||||||
|
goto_step=default_next,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
next_step = current_step
|
||||||
|
|
||||||
|
simulation.append(
|
||||||
|
FlowSimulationStep(
|
||||||
|
stepNo=current_step,
|
||||||
|
botMessage=step_def.get("content", ""),
|
||||||
|
userInput=user_input,
|
||||||
|
matchedCondition=matched_condition,
|
||||||
|
nextStep=next_step,
|
||||||
|
durationMs=int((time.time() - step_start) * 1000),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
total_duration_ms += simulation[-1].durationMs
|
||||||
|
|
||||||
|
if next_step > total_steps:
|
||||||
|
completed = True
|
||||||
|
final_message = step_def.get("content", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
current_step = next_step
|
||||||
|
|
||||||
|
if len(simulation) > total_steps * self.MAX_SIMULATION_STEPS_MULTIPLIER:
|
||||||
|
logger.warning(
|
||||||
|
f"[AC-AISVC-101] Simulation exceeded max steps: "
|
||||||
|
f"flow_id={flow.id}, steps={len(simulation)}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
covered_steps = len(visited_steps)
|
||||||
|
coverage_rate = covered_steps / total_steps if total_steps > 0 else 0.0
|
||||||
|
uncovered_steps = set(range(1, total_steps + 1)) - visited_steps
|
||||||
|
|
||||||
|
issues = self._detect_issues(
|
||||||
|
simulation=simulation,
|
||||||
|
total_steps=total_steps,
|
||||||
|
coverage_rate=coverage_rate,
|
||||||
|
uncovered_steps=uncovered_steps,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = FlowSimulationResult(
|
||||||
|
flowId=str(flow.id),
|
||||||
|
flowName=flow.name,
|
||||||
|
simulation=simulation,
|
||||||
|
result={
|
||||||
|
"completed": completed,
|
||||||
|
"totalSteps": total_steps,
|
||||||
|
"totalDurationMs": total_duration_ms,
|
||||||
|
"finalMessage": final_message,
|
||||||
|
},
|
||||||
|
coverage={
|
||||||
|
"totalSteps": total_steps,
|
||||||
|
"coveredSteps": covered_steps,
|
||||||
|
"coverageRate": round(coverage_rate, 2),
|
||||||
|
"uncoveredSteps": sorted(list(uncovered_steps)),
|
||||||
|
},
|
||||||
|
issues=issues,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-101] Flow simulation completed: flow_id={flow.id}, "
|
||||||
|
f"coverage_rate={coverage_rate:.2%}, issues_count={len(issues)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _match_next_step(
|
||||||
|
self,
|
||||||
|
step_def: dict[str, Any],
|
||||||
|
user_input: str,
|
||||||
|
) -> tuple[MatchedCondition | None, int | None]:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-101] Match user input against next_conditions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
step_def: Current step definition
|
||||||
|
user_input: User's input message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (matched_condition, goto_step) or (None, None)
|
||||||
|
"""
|
||||||
|
next_conditions = step_def.get("next_conditions", [])
|
||||||
|
if not next_conditions:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
user_input_lower = user_input.lower()
|
||||||
|
|
||||||
|
for condition in next_conditions:
|
||||||
|
keywords = condition.get("keywords", [])
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword.lower() in user_input_lower:
|
||||||
|
matched = MatchedCondition(
|
||||||
|
type="keyword",
|
||||||
|
keywords=keywords,
|
||||||
|
goto_step=condition.get("goto_step"),
|
||||||
|
)
|
||||||
|
return matched, condition.get("goto_step")
|
||||||
|
|
||||||
|
pattern = condition.get("pattern")
|
||||||
|
if pattern:
|
||||||
|
try:
|
||||||
|
if re.search(pattern, user_input, re.IGNORECASE):
|
||||||
|
matched = MatchedCondition(
|
||||||
|
type="pattern",
|
||||||
|
pattern=pattern,
|
||||||
|
goto_step=condition.get("goto_step"),
|
||||||
|
)
|
||||||
|
return matched, condition.get("goto_step")
|
||||||
|
except re.error:
|
||||||
|
logger.warning(f"Invalid regex pattern: {pattern}")
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _detect_issues(
|
||||||
|
self,
|
||||||
|
simulation: list[FlowSimulationStep],
|
||||||
|
total_steps: int,
|
||||||
|
coverage_rate: float,
|
||||||
|
uncovered_steps: set[int],
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-102] Detect issues in flow simulation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
simulation: List of simulation steps
|
||||||
|
total_steps: Total number of steps in flow
|
||||||
|
coverage_rate: Coverage rate (0.0 to 1.0)
|
||||||
|
uncovered_steps: Set of uncovered step numbers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of issue descriptions
|
||||||
|
"""
|
||||||
|
issues: list[str] = []
|
||||||
|
|
||||||
|
if coverage_rate < self.MIN_COVERAGE_THRESHOLD:
|
||||||
|
issues.append(
|
||||||
|
f"流程覆盖率低于 {int(self.MIN_COVERAGE_THRESHOLD * 100)}%,建议增加测试用例"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(simulation) > total_steps * self.MAX_SIMULATION_STEPS_MULTIPLIER:
|
||||||
|
issues.append("检测到可能的死循环")
|
||||||
|
|
||||||
|
if uncovered_steps:
|
||||||
|
issues.append(f"未覆盖步骤:{sorted(list(uncovered_steps))}")
|
||||||
|
|
||||||
|
step_visit_count: dict[int, int] = {}
|
||||||
|
for step in simulation:
|
||||||
|
step_visit_count[step.stepNo] = step_visit_count.get(step.stepNo, 0) + 1
|
||||||
|
|
||||||
|
for step_no, count in step_visit_count.items():
|
||||||
|
if count > 3:
|
||||||
|
issues.append(f"步骤 {step_no} 被重复访问 {count} 次,可能存在逻辑问题")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
def to_dict(self, result: FlowSimulationResult) -> dict[str, Any]:
|
||||||
|
"""Convert simulation result to API response dict."""
|
||||||
|
simulation_data = []
|
||||||
|
for step in result.simulation:
|
||||||
|
step_data = {
|
||||||
|
"stepNo": step.stepNo,
|
||||||
|
"botMessage": step.botMessage,
|
||||||
|
"userInput": step.userInput,
|
||||||
|
"nextStep": step.nextStep,
|
||||||
|
"durationMs": step.durationMs,
|
||||||
|
}
|
||||||
|
if step.matchedCondition:
|
||||||
|
step_data["matchedCondition"] = {
|
||||||
|
"type": step.matchedCondition.type,
|
||||||
|
"gotoStep": step.matchedCondition.goto_step,
|
||||||
|
}
|
||||||
|
if step.matchedCondition.keywords:
|
||||||
|
step_data["matchedCondition"]["keywords"] = step.matchedCondition.keywords
|
||||||
|
if step.matchedCondition.pattern:
|
||||||
|
step_data["matchedCondition"]["pattern"] = step.matchedCondition.pattern
|
||||||
|
else:
|
||||||
|
step_data["matchedCondition"] = None
|
||||||
|
simulation_data.append(step_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"flowId": result.flowId,
|
||||||
|
"flowName": result.flowName,
|
||||||
|
"simulation": simulation_data,
|
||||||
|
"result": result.result,
|
||||||
|
"coverage": result.coverage,
|
||||||
|
"issues": result.issues,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
"""
|
||||||
|
Guardrail Tester for AI Service.
|
||||||
|
[AC-AISVC-105] Forbidden word testing service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.entities import ForbiddenWord, ForbiddenWordStrategy
|
||||||
|
from app.services.guardrail.word_service import ForbiddenWordService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TriggeredWordInfo:
|
||||||
|
"""Information about a triggered forbidden word."""
|
||||||
|
|
||||||
|
word: str
|
||||||
|
category: str
|
||||||
|
strategy: str
|
||||||
|
replacement: str | None = None
|
||||||
|
fallbackReply: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
result = {
|
||||||
|
"word": self.word,
|
||||||
|
"category": self.category,
|
||||||
|
"strategy": self.strategy,
|
||||||
|
}
|
||||||
|
if self.replacement is not None:
|
||||||
|
result["replacement"] = self.replacement
|
||||||
|
if self.fallbackReply is not None:
|
||||||
|
result["fallbackReply"] = self.fallbackReply
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GuardrailTestResult:
|
||||||
|
"""Result of testing a single text."""
|
||||||
|
|
||||||
|
originalText: str
|
||||||
|
triggered: bool
|
||||||
|
triggeredWords: list[TriggeredWordInfo] = field(default_factory=list)
|
||||||
|
filteredText: str = ""
|
||||||
|
blocked: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"originalText": self.originalText,
|
||||||
|
"triggered": self.triggered,
|
||||||
|
"triggeredWords": [w.to_dict() for w in self.triggeredWords],
|
||||||
|
"filteredText": self.filteredText,
|
||||||
|
"blocked": self.blocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GuardrailTestSummary:
|
||||||
|
"""Summary of guardrail testing."""
|
||||||
|
|
||||||
|
totalTests: int
|
||||||
|
triggeredCount: int
|
||||||
|
blockedCount: int
|
||||||
|
triggerRate: float
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"totalTests": self.totalTests,
|
||||||
|
"triggeredCount": self.triggeredCount,
|
||||||
|
"blockedCount": self.blockedCount,
|
||||||
|
"triggerRate": round(self.triggerRate, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GuardrailTestResponse:
|
||||||
|
"""Full response for guardrail testing."""
|
||||||
|
|
||||||
|
results: list[GuardrailTestResult]
|
||||||
|
summary: GuardrailTestSummary
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"results": [r.to_dict() for r in self.results],
|
||||||
|
"summary": self.summary.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GuardrailTester:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-105] Guardrail testing service.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Test forbidden word detection
|
||||||
|
- Apply filter strategies (mask/replace/block)
|
||||||
|
- Return detailed detection results
|
||||||
|
- No database modification (read-only test)
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_FALLBACK_REPLY = "抱歉,让我换个方式回答您"
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self._session = session
|
||||||
|
self._word_service = ForbiddenWordService(session)
|
||||||
|
|
||||||
|
async def test_guardrail(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
test_texts: list[str],
|
||||||
|
) -> GuardrailTestResponse:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-105] Test forbidden word detection and filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID for isolation
|
||||||
|
test_texts: List of texts to test
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GuardrailTestResponse with results and summary
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-105] Testing guardrail for tenant={tenant_id}, "
|
||||||
|
f"texts_count={len(test_texts)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
words = await self._word_service.get_enabled_words_for_filtering(tenant_id)
|
||||||
|
|
||||||
|
results: list[GuardrailTestResult] = []
|
||||||
|
triggered_count = 0
|
||||||
|
blocked_count = 0
|
||||||
|
|
||||||
|
for text in test_texts:
|
||||||
|
result = self._test_single_text(text, words)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
if result.triggered:
|
||||||
|
triggered_count += 1
|
||||||
|
if result.blocked:
|
||||||
|
blocked_count += 1
|
||||||
|
|
||||||
|
total_tests = len(test_texts)
|
||||||
|
trigger_rate = triggered_count / total_tests if total_tests > 0 else 0.0
|
||||||
|
|
||||||
|
summary = GuardrailTestSummary(
|
||||||
|
totalTests=total_tests,
|
||||||
|
triggeredCount=triggered_count,
|
||||||
|
blockedCount=blocked_count,
|
||||||
|
triggerRate=trigger_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-105] Guardrail test completed: tenant={tenant_id}, "
|
||||||
|
f"triggered={triggered_count}/{total_tests}, blocked={blocked_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return GuardrailTestResponse(results=results, summary=summary)
|
||||||
|
|
||||||
|
def _test_single_text(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
words: list[ForbiddenWord],
|
||||||
|
) -> GuardrailTestResult:
|
||||||
|
"""Test a single text against forbidden words."""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return GuardrailTestResult(
|
||||||
|
originalText=text,
|
||||||
|
triggered=False,
|
||||||
|
filteredText=text,
|
||||||
|
blocked=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
triggered_words: list[TriggeredWordInfo] = []
|
||||||
|
filtered_text = text
|
||||||
|
blocked = False
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
if word.word in filtered_text:
|
||||||
|
triggered_words.append(
|
||||||
|
TriggeredWordInfo(
|
||||||
|
word=word.word,
|
||||||
|
category=word.category,
|
||||||
|
strategy=word.strategy,
|
||||||
|
replacement=word.replacement,
|
||||||
|
fallbackReply=word.fallback_reply,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if word.strategy == ForbiddenWordStrategy.BLOCK.value:
|
||||||
|
blocked = True
|
||||||
|
fallback = word.fallback_reply or self.DEFAULT_FALLBACK_REPLY
|
||||||
|
return GuardrailTestResult(
|
||||||
|
originalText=text,
|
||||||
|
triggered=True,
|
||||||
|
triggeredWords=triggered_words,
|
||||||
|
filteredText=fallback,
|
||||||
|
blocked=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif word.strategy == ForbiddenWordStrategy.MASK.value:
|
||||||
|
filtered_text = filtered_text.replace(word.word, "*" * len(word.word))
|
||||||
|
|
||||||
|
elif word.strategy == ForbiddenWordStrategy.REPLACE.value:
|
||||||
|
replacement = word.replacement or ""
|
||||||
|
filtered_text = filtered_text.replace(word.word, replacement)
|
||||||
|
|
||||||
|
return GuardrailTestResult(
|
||||||
|
originalText=text,
|
||||||
|
triggered=len(triggered_words) > 0,
|
||||||
|
triggeredWords=triggered_words,
|
||||||
|
filteredText=filtered_text,
|
||||||
|
blocked=blocked,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""
|
||||||
|
Monitoring services for AI Service.
|
||||||
|
[AC-AISVC-91~AC-AISVC-110] Monitoring services for dashboard, intent rules, prompt templates, and conversations.
|
||||||
|
[AC-AISVC-103, AC-AISVC-104] Flow monitoring.
|
||||||
|
[AC-AISVC-106, AC-AISVC-107] Guardrail monitoring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.services.monitoring.cache import MonitoringCache, get_monitoring_cache
|
||||||
|
from app.services.monitoring.dashboard_service import DashboardService
|
||||||
|
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
|
||||||
|
from app.services.monitoring.recorder import MonitoringRecorder, StepMetrics
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MonitoringCache",
|
||||||
|
"get_monitoring_cache",
|
||||||
|
"DashboardService",
|
||||||
|
"IntentMonitor",
|
||||||
|
"PromptMonitor",
|
||||||
|
"FlowMonitor",
|
||||||
|
"GuardrailMonitor",
|
||||||
|
"MonitoringRecorder",
|
||||||
|
"StepMetrics",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,450 @@
|
||||||
|
"""
|
||||||
|
Flow monitoring service for AI Service.
|
||||||
|
[AC-AISVC-103, AC-AISVC-104] Flow statistics and execution records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import col
|
||||||
|
|
||||||
|
from app.models.entities import FlowInstance, FlowInstanceStatus, ScriptFlow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DropOffPoint:
|
||||||
|
"""Drop-off point analysis for a flow step."""
|
||||||
|
|
||||||
|
stepNo: int
|
||||||
|
dropOffCount: int
|
||||||
|
dropOffRate: float
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"stepNo": self.stepNo,
|
||||||
|
"dropOffCount": self.dropOffCount,
|
||||||
|
"dropOffRate": round(self.dropOffRate, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlowStats:
|
||||||
|
"""Statistics for a single flow."""
|
||||||
|
|
||||||
|
flowId: str
|
||||||
|
flowName: str
|
||||||
|
activationCount: int
|
||||||
|
completionCount: int
|
||||||
|
completionRate: float
|
||||||
|
avgDuration: float
|
||||||
|
avgStepsCompleted: float
|
||||||
|
dropOffPoints: list[DropOffPoint] = field(default_factory=list)
|
||||||
|
lastActivatedAt: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"flowId": self.flowId,
|
||||||
|
"flowName": self.flowName,
|
||||||
|
"activationCount": self.activationCount,
|
||||||
|
"completionCount": self.completionCount,
|
||||||
|
"completionRate": round(self.completionRate, 2),
|
||||||
|
"avgDuration": round(self.avgDuration, 1),
|
||||||
|
"avgStepsCompleted": round(self.avgStepsCompleted, 1),
|
||||||
|
"dropOffPoints": [d.to_dict() for d in self.dropOffPoints],
|
||||||
|
"lastActivatedAt": self.lastActivatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlowExecutionRecord:
|
||||||
|
"""A single flow execution record."""
|
||||||
|
|
||||||
|
instanceId: str
|
||||||
|
sessionId: str
|
||||||
|
flowId: str
|
||||||
|
flowName: str
|
||||||
|
currentStep: int
|
||||||
|
totalSteps: int
|
||||||
|
status: str
|
||||||
|
startedAt: str
|
||||||
|
updatedAt: str
|
||||||
|
completedAt: str | None
|
||||||
|
context: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"instanceId": self.instanceId,
|
||||||
|
"sessionId": self.sessionId,
|
||||||
|
"flowId": self.flowId,
|
||||||
|
"flowName": self.flowName,
|
||||||
|
"currentStep": self.currentStep,
|
||||||
|
"totalSteps": self.totalSteps,
|
||||||
|
"status": self.status,
|
||||||
|
"startedAt": self.startedAt,
|
||||||
|
"updatedAt": self.updatedAt,
|
||||||
|
"completedAt": self.completedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlowStatsResult:
|
||||||
|
"""Result of flow statistics query."""
|
||||||
|
|
||||||
|
totalActivations: int
|
||||||
|
totalCompletions: int
|
||||||
|
completionRate: float
|
||||||
|
flows: list[FlowStats]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"totalActivations": self.totalActivations,
|
||||||
|
"totalCompletions": self.totalCompletions,
|
||||||
|
"completionRate": round(self.completionRate, 2),
|
||||||
|
"flows": [f.to_dict() for f in self.flows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FlowMonitor:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-103, AC-AISVC-104] Flow monitoring service.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Aggregate flow activation statistics
|
||||||
|
- Calculate completion rates and average durations
|
||||||
|
- Analyze drop-off points
|
||||||
|
- Query flow execution records
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_flow_stats(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
start_date: datetime | None = None,
|
||||||
|
end_date: datetime | None = None,
|
||||||
|
) -> FlowStatsResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-103] Get aggregated statistics for all flows.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID for isolation
|
||||||
|
start_date: Optional start date filter
|
||||||
|
end_date: Optional end date filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FlowStatsResult with aggregated statistics
|
||||||
|
"""
|
||||||
|
flow_stmt = select(ScriptFlow).where(ScriptFlow.tenant_id == tenant_id)
|
||||||
|
flow_result = await self._session.execute(flow_stmt)
|
||||||
|
flows = flow_result.scalars().all()
|
||||||
|
|
||||||
|
flow_stats_list: list[FlowStats] = []
|
||||||
|
total_activations = 0
|
||||||
|
total_completions = 0
|
||||||
|
|
||||||
|
for flow in flows:
|
||||||
|
stats = await self._get_single_flow_stats(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
flow=flow,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
flow_stats_list.append(stats)
|
||||||
|
total_activations += stats.activationCount
|
||||||
|
total_completions += stats.completionCount
|
||||||
|
|
||||||
|
flow_stats_list.sort(key=lambda x: x.activationCount, reverse=True)
|
||||||
|
|
||||||
|
completion_rate = (
|
||||||
|
total_completions / total_activations if total_activations > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-103] Retrieved stats for {len(flows)} flows, "
|
||||||
|
f"tenant={tenant_id}, total_activations={total_activations}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return FlowStatsResult(
|
||||||
|
totalActivations=total_activations,
|
||||||
|
totalCompletions=total_completions,
|
||||||
|
completionRate=completion_rate,
|
||||||
|
flows=flow_stats_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_single_flow_stats(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
flow: ScriptFlow,
|
||||||
|
start_date: datetime | None,
|
||||||
|
end_date: datetime | None,
|
||||||
|
) -> FlowStats:
|
||||||
|
"""Get statistics for a single flow."""
|
||||||
|
base_stmt = select(FlowInstance).where(
|
||||||
|
FlowInstance.tenant_id == tenant_id,
|
||||||
|
FlowInstance.flow_id == flow.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
base_stmt = base_stmt.where(FlowInstance.started_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
base_stmt = base_stmt.where(FlowInstance.started_at <= end_date)
|
||||||
|
|
||||||
|
count_stmt = select(func.count(FlowInstance.id)).select_from(base_stmt.subquery())
|
||||||
|
activation_result = await self._session.execute(count_stmt)
|
||||||
|
activation_count = activation_result.scalar() or 0
|
||||||
|
|
||||||
|
completion_stmt = select(func.count(FlowInstance.id)).select_from(
|
||||||
|
base_stmt.where(
|
||||||
|
FlowInstance.status == FlowInstanceStatus.COMPLETED.value
|
||||||
|
).subquery()
|
||||||
|
)
|
||||||
|
completion_result = await self._session.execute(completion_stmt)
|
||||||
|
completion_count = completion_result.scalar() or 0
|
||||||
|
|
||||||
|
completion_rate = completion_count / activation_count if activation_count > 0 else 0.0
|
||||||
|
|
||||||
|
avg_duration = await self._get_avg_duration(flow.id, start_date, end_date)
|
||||||
|
|
||||||
|
avg_steps = await self._get_avg_steps_completed(flow.id, start_date, end_date)
|
||||||
|
|
||||||
|
drop_off_points = await self._analyze_drop_off_points(
|
||||||
|
tenant_id, flow, start_date, end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
last_activated_stmt = (
|
||||||
|
select(FlowInstance.started_at)
|
||||||
|
.where(FlowInstance.flow_id == flow.id)
|
||||||
|
.order_by(col(FlowInstance.started_at).desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_activated_result = await self._session.execute(last_activated_stmt)
|
||||||
|
last_activated = last_activated_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
return FlowStats(
|
||||||
|
flowId=str(flow.id),
|
||||||
|
flowName=flow.name,
|
||||||
|
activationCount=activation_count,
|
||||||
|
completionCount=completion_count,
|
||||||
|
completionRate=completion_rate,
|
||||||
|
avgDuration=avg_duration,
|
||||||
|
avgStepsCompleted=avg_steps,
|
||||||
|
dropOffPoints=drop_off_points,
|
||||||
|
lastActivatedAt=last_activated.isoformat() if last_activated else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_avg_duration(
|
||||||
|
self,
|
||||||
|
flow_id: uuid.UUID,
|
||||||
|
start_date: datetime | None,
|
||||||
|
end_date: datetime | None,
|
||||||
|
) -> float:
|
||||||
|
"""Get average completion duration in seconds."""
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
func.avg(
|
||||||
|
func.extract("epoch", FlowInstance.completed_at) -
|
||||||
|
func.extract("epoch", FlowInstance.started_at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
FlowInstance.flow_id == flow_id,
|
||||||
|
FlowInstance.status == FlowInstanceStatus.COMPLETED.value,
|
||||||
|
FlowInstance.completed_at.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
stmt = stmt.where(FlowInstance.started_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
stmt = stmt.where(FlowInstance.started_at <= end_date)
|
||||||
|
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
avg_duration = result.scalar()
|
||||||
|
return float(avg_duration) if avg_duration else 0.0
|
||||||
|
|
||||||
|
async def _get_avg_steps_completed(
|
||||||
|
self,
|
||||||
|
flow_id: uuid.UUID,
|
||||||
|
start_date: datetime | None,
|
||||||
|
end_date: datetime | None,
|
||||||
|
) -> float:
|
||||||
|
"""Get average steps completed."""
|
||||||
|
stmt = (
|
||||||
|
select(func.avg(FlowInstance.current_step))
|
||||||
|
.where(FlowInstance.flow_id == flow_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
stmt = stmt.where(FlowInstance.started_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
stmt = stmt.where(FlowInstance.started_at <= end_date)
|
||||||
|
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
avg_steps = result.scalar()
|
||||||
|
return float(avg_steps) if avg_steps else 0.0
|
||||||
|
|
||||||
|
async def _analyze_drop_off_points(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
flow: ScriptFlow,
|
||||||
|
start_date: datetime | None,
|
||||||
|
end_date: datetime | None,
|
||||||
|
) -> list[DropOffPoint]:
|
||||||
|
"""Analyze drop-off points for a flow."""
|
||||||
|
total_steps = len(flow.steps)
|
||||||
|
if total_steps == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
drop_off_points: list[DropOffPoint] = []
|
||||||
|
|
||||||
|
base_stmt = select(FlowInstance).where(
|
||||||
|
FlowInstance.tenant_id == tenant_id,
|
||||||
|
FlowInstance.flow_id == flow.id,
|
||||||
|
FlowInstance.status != FlowInstanceStatus.COMPLETED.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
base_stmt = base_stmt.where(FlowInstance.started_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
base_stmt = base_stmt.where(FlowInstance.started_at <= end_date)
|
||||||
|
|
||||||
|
total_instances_stmt = select(func.count(FlowInstance.id)).select_from(
|
||||||
|
select(FlowInstance)
|
||||||
|
.where(
|
||||||
|
FlowInstance.tenant_id == tenant_id,
|
||||||
|
FlowInstance.flow_id == flow.id,
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
if start_date:
|
||||||
|
total_instances_stmt = total_instances_stmt.where(
|
||||||
|
FlowInstance.started_at >= start_date
|
||||||
|
)
|
||||||
|
if end_date:
|
||||||
|
total_instances_stmt = total_instances_stmt.where(
|
||||||
|
FlowInstance.started_at <= end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
total_result = await self._session.execute(
|
||||||
|
select(func.count(FlowInstance.id)).where(
|
||||||
|
FlowInstance.tenant_id == tenant_id,
|
||||||
|
FlowInstance.flow_id == flow.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_instances = total_result.scalar() or 0
|
||||||
|
|
||||||
|
if total_instances == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for step_no in range(1, total_steps + 1):
|
||||||
|
drop_off_stmt = select(func.count(FlowInstance.id)).where(
|
||||||
|
FlowInstance.tenant_id == tenant_id,
|
||||||
|
FlowInstance.flow_id == flow.id,
|
||||||
|
FlowInstance.current_step == step_no,
|
||||||
|
FlowInstance.status != FlowInstanceStatus.COMPLETED.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
drop_off_stmt = drop_off_stmt.where(FlowInstance.started_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
drop_off_stmt = drop_off_stmt.where(FlowInstance.started_at <= end_date)
|
||||||
|
|
||||||
|
drop_off_result = await self._session.execute(drop_off_stmt)
|
||||||
|
drop_off_count = drop_off_result.scalar() or 0
|
||||||
|
|
||||||
|
if drop_off_count > 0:
|
||||||
|
drop_off_rate = drop_off_count / total_instances
|
||||||
|
drop_off_points.append(
|
||||||
|
DropOffPoint(
|
||||||
|
stepNo=step_no,
|
||||||
|
dropOffCount=drop_off_count,
|
||||||
|
dropOffRate=drop_off_rate,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
drop_off_points.sort(key=lambda x: x.dropOffCount, reverse=True)
|
||||||
|
|
||||||
|
return drop_off_points[:5]
|
||||||
|
|
||||||
|
async def get_flow_executions(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
flow_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[FlowExecutionRecord], int]:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-104] Get execution records for a specific flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID for isolation
|
||||||
|
flow_id: Flow ID to query
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
page_size: Number of records per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (execution_records, total_count)
|
||||||
|
"""
|
||||||
|
flow = await self._get_flow(flow_id)
|
||||||
|
flow_name = flow.name if flow else "Unknown"
|
||||||
|
total_steps = len(flow.steps) if flow else 0
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
count_stmt = select(func.count(FlowInstance.id)).where(
|
||||||
|
FlowInstance.tenant_id == tenant_id,
|
||||||
|
FlowInstance.flow_id == flow_id,
|
||||||
|
)
|
||||||
|
count_result = await self._session.execute(count_stmt)
|
||||||
|
total_count = count_result.scalar() or 0
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(FlowInstance)
|
||||||
|
.where(
|
||||||
|
FlowInstance.tenant_id == tenant_id,
|
||||||
|
FlowInstance.flow_id == flow_id,
|
||||||
|
)
|
||||||
|
.order_by(col(FlowInstance.started_at).desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
execution_records = []
|
||||||
|
for instance in instances:
|
||||||
|
execution_records.append(
|
||||||
|
FlowExecutionRecord(
|
||||||
|
instanceId=str(instance.id),
|
||||||
|
sessionId=instance.session_id,
|
||||||
|
flowId=str(instance.flow_id),
|
||||||
|
flowName=flow_name,
|
||||||
|
currentStep=instance.current_step,
|
||||||
|
totalSteps=total_steps,
|
||||||
|
status=instance.status,
|
||||||
|
startedAt=instance.started_at.isoformat(),
|
||||||
|
updatedAt=instance.updated_at.isoformat(),
|
||||||
|
completedAt=instance.completed_at.isoformat() if instance.completed_at else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-104] Retrieved {len(execution_records)} execution records for flow={flow_id}, "
|
||||||
|
f"tenant={tenant_id}, page={page}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return execution_records, total_count
|
||||||
|
|
||||||
|
async def _get_flow(self, flow_id: uuid.UUID) -> ScriptFlow | None:
|
||||||
|
"""Get flow by ID."""
|
||||||
|
stmt = select(ScriptFlow).where(ScriptFlow.id == flow_id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
"""
|
||||||
|
Guardrail monitoring service for AI Service.
|
||||||
|
[AC-AISVC-106, AC-AISVC-107] Guardrail statistics and block records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import col
|
||||||
|
|
||||||
|
from app.models.entities import ForbiddenWord
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WordStats:
|
||||||
|
"""Statistics for a single forbidden word."""
|
||||||
|
|
||||||
|
wordId: str
|
||||||
|
word: str
|
||||||
|
category: str
|
||||||
|
strategy: str
|
||||||
|
hitCount: int
|
||||||
|
blockCount: int
|
||||||
|
lastHitAt: str | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"wordId": self.wordId,
|
||||||
|
"word": self.word,
|
||||||
|
"category": self.category,
|
||||||
|
"strategy": self.strategy,
|
||||||
|
"hitCount": self.hitCount,
|
||||||
|
"blockCount": self.blockCount,
|
||||||
|
"lastHitAt": self.lastHitAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GuardrailBlockRecord:
|
||||||
|
"""A single block record for a forbidden word."""
|
||||||
|
|
||||||
|
recordId: str
|
||||||
|
sessionId: str
|
||||||
|
originalText: str
|
||||||
|
filteredText: str
|
||||||
|
strategy: str
|
||||||
|
blockedAt: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"recordId": self.recordId,
|
||||||
|
"sessionId": self.sessionId,
|
||||||
|
"originalText": self.originalText,
|
||||||
|
"filteredText": self.filteredText,
|
||||||
|
"strategy": self.strategy,
|
||||||
|
"blockedAt": self.blockedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GuardrailStatsResult:
|
||||||
|
"""Result of guardrail statistics query."""
|
||||||
|
|
||||||
|
totalBlocks: int
|
||||||
|
totalTriggers: int
|
||||||
|
blockRate: float
|
||||||
|
words: list[WordStats]
|
||||||
|
categoryBreakdown: dict[str, int]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"totalBlocks": self.totalBlocks,
|
||||||
|
"totalTriggers": self.totalTriggers,
|
||||||
|
"blockRate": round(self.blockRate, 3),
|
||||||
|
"words": [w.to_dict() for w in self.words],
|
||||||
|
"categoryBreakdown": self.categoryBreakdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GuardrailMonitor:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-106, AC-AISVC-107] Guardrail monitoring service.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Aggregate guardrail trigger statistics
|
||||||
|
- Calculate block rates
|
||||||
|
- Query block records for a specific word
|
||||||
|
- Category breakdown analysis
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_guardrail_stats(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
category: str | None = None,
|
||||||
|
) -> GuardrailStatsResult:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-106] Get aggregated statistics for all guardrails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID for isolation
|
||||||
|
category: Optional category filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GuardrailStatsResult with aggregated statistics
|
||||||
|
"""
|
||||||
|
stmt = select(ForbiddenWord).where(
|
||||||
|
ForbiddenWord.tenant_id == tenant_id,
|
||||||
|
ForbiddenWord.is_enabled.is_(True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
stmt = stmt.where(ForbiddenWord.category == category)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(col(ForbiddenWord.hit_count).desc())
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
words = result.scalars().all()
|
||||||
|
|
||||||
|
total_triggers = sum(w.hit_count for w in words)
|
||||||
|
total_blocks = sum(
|
||||||
|
w.hit_count for w in words
|
||||||
|
if w.strategy == "block"
|
||||||
|
)
|
||||||
|
|
||||||
|
block_rate = total_blocks / total_triggers if total_triggers > 0 else 0.0
|
||||||
|
|
||||||
|
word_stats: list[WordStats] = []
|
||||||
|
category_breakdown: dict[str, int] = {}
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
block_count = word.hit_count if word.strategy == "block" else 0
|
||||||
|
|
||||||
|
word_stats.append(
|
||||||
|
WordStats(
|
||||||
|
wordId=str(word.id),
|
||||||
|
word=word.word,
|
||||||
|
category=word.category,
|
||||||
|
strategy=word.strategy,
|
||||||
|
hitCount=word.hit_count,
|
||||||
|
blockCount=block_count,
|
||||||
|
lastHitAt=word.updated_at.isoformat() if word.hit_count > 0 else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if word.category in category_breakdown:
|
||||||
|
category_breakdown[word.category] += word.hit_count
|
||||||
|
else:
|
||||||
|
category_breakdown[word.category] = word.hit_count
|
||||||
|
|
||||||
|
for cat in ["competitor", "sensitive", "political", "custom"]:
|
||||||
|
if cat not in category_breakdown:
|
||||||
|
category_breakdown[cat] = 0
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-106] Retrieved stats for {len(words)} words, "
|
||||||
|
f"tenant={tenant_id}, total_triggers={total_triggers}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return GuardrailStatsResult(
|
||||||
|
totalBlocks=total_blocks,
|
||||||
|
totalTriggers=total_triggers,
|
||||||
|
blockRate=block_rate,
|
||||||
|
words=word_stats,
|
||||||
|
categoryBreakdown=category_breakdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_word_blocks(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
word_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[GuardrailBlockRecord], int]:
|
||||||
|
"""
|
||||||
|
[AC-AISVC-107] Get block records for a specific forbidden word.
|
||||||
|
|
||||||
|
Note: This is a simplified implementation that returns mock data
|
||||||
|
based on the word's hit count. In a production system, you would
|
||||||
|
have a separate table to track individual block events.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID for isolation
|
||||||
|
word_id: Word ID to query
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
page_size: Number of records per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (block_records, total_count)
|
||||||
|
"""
|
||||||
|
word_stmt = select(ForbiddenWord).where(
|
||||||
|
ForbiddenWord.tenant_id == tenant_id,
|
||||||
|
ForbiddenWord.id == word_id,
|
||||||
|
)
|
||||||
|
word_result = await self._session.execute(word_stmt)
|
||||||
|
word = word_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not word:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
total_count = word.hit_count if word.strategy == "block" else 0
|
||||||
|
|
||||||
|
records: list[GuardrailBlockRecord] = []
|
||||||
|
|
||||||
|
if total_count > 0:
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
end_idx = min(offset + page_size, total_count)
|
||||||
|
|
||||||
|
for i in range(offset, end_idx):
|
||||||
|
records.append(
|
||||||
|
GuardrailBlockRecord(
|
||||||
|
recordId=f"block_{word_id}_{i + 1}",
|
||||||
|
sessionId=f"session_{i + 1}",
|
||||||
|
originalText=f"包含禁词 '{word.word}' 的文本样例",
|
||||||
|
filteredText=word.fallback_reply or "抱歉,让我换个方式回答您",
|
||||||
|
strategy=word.strategy,
|
||||||
|
blockedAt=word.updated_at.isoformat(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-AISVC-107] Retrieved {len(records)} block records for word={word_id}, "
|
||||||
|
f"tenant={tenant_id}, page={page}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return records, total_count
|
||||||
Loading…
Reference in New Issue