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
|
||||
} from '@/types/guardrail'
|
||||
|
||||
export interface GuardrailTestRequest {
|
||||
testTexts: string[]
|
||||
}
|
||||
|
||||
export interface GuardrailTestResponse {
|
||||
results: GuardrailTestResult[]
|
||||
summary: {
|
||||
totalTests: number
|
||||
triggeredCount: number
|
||||
blockedCount: number
|
||||
triggerRate: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface GuardrailTestResult {
|
||||
originalText: string
|
||||
triggered: boolean
|
||||
triggeredWords: TriggeredWordInfo[]
|
||||
filteredText: string
|
||||
blocked: boolean
|
||||
}
|
||||
|
||||
export interface TriggeredWordInfo {
|
||||
word: string
|
||||
category: string
|
||||
strategy: string
|
||||
replacement?: string
|
||||
fallbackReply?: string
|
||||
}
|
||||
|
||||
export function listForbiddenWords(params?: {
|
||||
category?: string
|
||||
is_enabled?: boolean
|
||||
|
|
@ -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 {
|
||||
ForbiddenWord,
|
||||
ForbiddenWordCreate,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,37 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export function listSessions(params: any) {
|
||||
export interface Session {
|
||||
sessionId: string
|
||||
tenantId: string
|
||||
messageCount: number
|
||||
status: string
|
||||
channelType: string
|
||||
startTime: string
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
sessionId: string
|
||||
messages: Array<{
|
||||
role: string
|
||||
content: string
|
||||
timestamp: string
|
||||
}>
|
||||
trace?: {
|
||||
retrieval?: Array<{
|
||||
score: number
|
||||
source?: string
|
||||
content: string
|
||||
}>
|
||||
tools?: Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
data: Session[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export function listSessions(params?: { page?: number; pageSize?: number; status?: string }): Promise<SessionListResponse> {
|
||||
return request({
|
||||
url: '/admin/sessions',
|
||||
method: 'get',
|
||||
|
|
@ -8,9 +39,294 @@ export function listSessions(params: any) {
|
|||
})
|
||||
}
|
||||
|
||||
export function getSessionDetail(sessionId: string) {
|
||||
export function getSessionDetail(sessionId: string): Promise<SessionDetail> {
|
||||
return request({
|
||||
url: `/admin/sessions/${sessionId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export interface IntentRuleTestRequest {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface IntentRuleTestResult {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
results: IntentRuleTestCase[]
|
||||
summary: {
|
||||
totalTests: number
|
||||
matchedCount: number
|
||||
matchRate: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface IntentRuleTestCase {
|
||||
message: string
|
||||
matched: boolean
|
||||
matchedKeywords: string[]
|
||||
matchedPatterns: string[]
|
||||
matchType: string | null
|
||||
priority: number
|
||||
priorityRank: number
|
||||
conflictRules: ConflictRule[]
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export interface ConflictRule {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
priority: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface IntentRuleStatsResponse {
|
||||
totalHits: number
|
||||
totalConversations: number
|
||||
hitRate: number
|
||||
rules: IntentRuleStatItem[]
|
||||
}
|
||||
|
||||
export interface IntentRuleStatItem {
|
||||
ruleId: string
|
||||
ruleName: string
|
||||
hitCount: number
|
||||
hitRate: number
|
||||
avgResponseTime: number
|
||||
lastHitTime: string | null
|
||||
responseType: string
|
||||
}
|
||||
|
||||
export interface IntentRuleHitsResponse {
|
||||
records: IntentRuleHitRecord[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface IntentRuleHitRecord {
|
||||
conversationId: string
|
||||
sessionId: string
|
||||
userMessage: string
|
||||
matchedKeywords: string[]
|
||||
matchedPatterns: string[]
|
||||
responseType: string
|
||||
executionResult: string
|
||||
hitTime: string
|
||||
}
|
||||
|
||||
export interface PromptPreviewRequest {
|
||||
variables?: Record<string, string>
|
||||
sampleHistory?: Array<{ role: string; content: string }>
|
||||
sampleMessage?: string
|
||||
}
|
||||
|
||||
export interface PromptPreviewResponse {
|
||||
templateId: string
|
||||
templateName: string
|
||||
version: number
|
||||
rawContent: string
|
||||
variables: Array<{ name: string; value: string }>
|
||||
renderedContent: string
|
||||
estimatedTokens: number
|
||||
tokenCount: {
|
||||
systemPrompt: number
|
||||
history: number
|
||||
currentMessage: number
|
||||
total: number
|
||||
}
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface PromptTemplateStatsResponse {
|
||||
totalUsage: number
|
||||
templates: PromptTemplateStatItem[]
|
||||
sceneBreakdown: Record<string, number>
|
||||
}
|
||||
|
||||
export interface PromptTemplateStatItem {
|
||||
templateId: string
|
||||
templateName: string
|
||||
scene: string
|
||||
usageCount: number
|
||||
avgTokens: number
|
||||
avgPromptTokens: number
|
||||
avgCompletionTokens: number
|
||||
lastUsedTime: string | null
|
||||
}
|
||||
|
||||
export interface FlowStatsResponse {
|
||||
totalActivations: number
|
||||
totalCompletions: number
|
||||
completionRate: number
|
||||
flows: FlowStatItem[]
|
||||
}
|
||||
|
||||
export interface FlowStatItem {
|
||||
flowId: string
|
||||
flowName: string
|
||||
activationCount: number
|
||||
completionCount: number
|
||||
completionRate: number
|
||||
avgDuration: number
|
||||
avgStepsCompleted: number
|
||||
dropOffPoints: DropOffPoint[]
|
||||
lastActivatedAt: string | null
|
||||
}
|
||||
|
||||
export interface DropOffPoint {
|
||||
stepNo: number
|
||||
dropOffCount: number
|
||||
dropOffRate: number
|
||||
}
|
||||
|
||||
export interface FlowExecutionsResponse {
|
||||
data: FlowExecutionRecord[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface FlowExecutionRecord {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
flowId: string
|
||||
flowName: string
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
status: string
|
||||
startedAt: string
|
||||
updatedAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export interface GuardrailStatsResponse {
|
||||
totalBlocks: number
|
||||
totalTriggers: number
|
||||
blockRate: number
|
||||
words: GuardrailWordStats[]
|
||||
categoryBreakdown: Record<string, number>
|
||||
}
|
||||
|
||||
export interface GuardrailWordStats {
|
||||
wordId: string
|
||||
word: string
|
||||
category: string
|
||||
strategy: string
|
||||
hitCount: number
|
||||
blockCount: number
|
||||
lastHitAt: string | null
|
||||
}
|
||||
|
||||
export interface GuardrailBlocksResponse {
|
||||
data: GuardrailBlockRecord[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GuardrailBlockRecord {
|
||||
recordId: string
|
||||
sessionId: string
|
||||
originalText: string
|
||||
filteredText: string
|
||||
strategy: string
|
||||
blockedAt: string
|
||||
}
|
||||
|
||||
export function testIntentRule(ruleId: string, data: IntentRuleTestRequest): Promise<IntentRuleTestResult> {
|
||||
return request({
|
||||
url: `/admin/intent-rules/${ruleId}/test`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getIntentRuleStats(params?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
responseType?: string
|
||||
}): Promise<IntentRuleStatsResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/intent-rules',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getIntentRuleHits(
|
||||
ruleId: string,
|
||||
params?: { page?: number; pageSize?: number }
|
||||
): Promise<IntentRuleHitsResponse> {
|
||||
return request({
|
||||
url: `/admin/monitoring/intent-rules/${ruleId}/hits`,
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function previewPromptTemplate(
|
||||
tplId: string,
|
||||
data: PromptPreviewRequest
|
||||
): Promise<PromptPreviewResponse> {
|
||||
return request({
|
||||
url: `/admin/prompt-templates/${tplId}/preview`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function getPromptTemplateStats(params?: {
|
||||
scene?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<PromptTemplateStatsResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/prompt-templates',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getFlowStats(params?: {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}): Promise<FlowStatsResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/script-flows',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getFlowExecutions(
|
||||
flowId: string,
|
||||
params?: { page?: number; pageSize?: number }
|
||||
): Promise<FlowExecutionsResponse> {
|
||||
return request({
|
||||
url: `/admin/monitoring/script-flows/${flowId}/executions`,
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getGuardrailStats(params?: {
|
||||
category?: string
|
||||
}): Promise<GuardrailStatsResponse> {
|
||||
return request({
|
||||
url: '/admin/monitoring/guardrails',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getGuardrailBlocks(
|
||||
wordId: string,
|
||||
params?: { page?: number; pageSize?: number }
|
||||
): Promise<GuardrailBlocksResponse> {
|
||||
return request({
|
||||
url: `/admin/monitoring/guardrails/${wordId}/blocks`,
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,43 @@ import type {
|
|||
ScriptFlowListResponse
|
||||
} from '@/types/script-flow'
|
||||
|
||||
export interface FlowSimulateRequest {
|
||||
userInputs: string[]
|
||||
}
|
||||
|
||||
export interface FlowSimulateResponse {
|
||||
flowId: string
|
||||
flowName: string
|
||||
simulation: FlowSimulationStep[]
|
||||
result: {
|
||||
completed: boolean
|
||||
totalSteps: number
|
||||
totalDurationMs: number
|
||||
finalMessage: string | null
|
||||
}
|
||||
coverage: {
|
||||
totalSteps: number
|
||||
coveredSteps: number
|
||||
coverageRate: number
|
||||
uncoveredSteps: number[]
|
||||
}
|
||||
issues: string[]
|
||||
}
|
||||
|
||||
export interface FlowSimulationStep {
|
||||
stepNo: number
|
||||
botMessage: string
|
||||
userInput: string
|
||||
matchedCondition: {
|
||||
type: string
|
||||
gotoStep: number
|
||||
keywords?: string[]
|
||||
pattern?: string
|
||||
} | null
|
||||
nextStep: number | null
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export function listScriptFlows(params?: {
|
||||
is_enabled?: boolean
|
||||
}): Promise<ScriptFlowListResponse> {
|
||||
|
|
@ -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 {
|
||||
ScriptFlow,
|
||||
ScriptFlowDetail,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,30 @@ const routes: Array<RouteRecordRaw> = [
|
|||
name: 'Guardrail',
|
||||
component: () => import('@/views/admin/guardrail/index.vue'),
|
||||
meta: { title: '输出护栏管理' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/intent-rules',
|
||||
name: 'IntentRuleMonitoring',
|
||||
component: () => import('@/views/admin/monitoring/IntentRules.vue'),
|
||||
meta: { title: '意图规则监控' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/prompt-templates',
|
||||
name: 'PromptTemplateMonitoring',
|
||||
component: () => import('@/views/admin/monitoring/PromptTemplates.vue'),
|
||||
meta: { title: 'Prompt 模板监控' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/script-flows',
|
||||
name: 'ScriptFlowMonitoring',
|
||||
component: () => import('@/views/admin/monitoring/ScriptFlows.vue'),
|
||||
meta: { title: '话术流程监控' }
|
||||
},
|
||||
{
|
||||
path: '/admin/monitoring/guardrails',
|
||||
name: 'GuardrailMonitoring',
|
||||
component: () => import('@/views/admin/monitoring/Guardrails.vue'),
|
||||
meta: { title: '输出护栏监控' }
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@
|
|||
</el-input>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<el-button @click="testDialogVisible = true">
|
||||
<el-icon><Search /></el-icon>
|
||||
测试护栏
|
||||
</el-button>
|
||||
<el-button @click="showBatchImport = true">
|
||||
<el-icon><Upload /></el-icon>
|
||||
批量导入
|
||||
|
|
@ -140,6 +144,8 @@
|
|||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<TestDialog v-model:visible="testDialogVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -155,6 +161,7 @@ import {
|
|||
} from '@/api/guardrail'
|
||||
import { WORD_CATEGORY_OPTIONS, WORD_STRATEGY_OPTIONS } from '@/types/guardrail'
|
||||
import type { ForbiddenWord, ForbiddenWordCreate, ForbiddenWordUpdate } from '@/types/guardrail'
|
||||
import TestDialog from './TestDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const words = ref<ForbiddenWord[]>([])
|
||||
|
|
@ -162,6 +169,7 @@ const filterCategory = ref('')
|
|||
const searchKeyword = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const showBatchImport = ref(false)
|
||||
const testDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const batchSubmitting = ref(false)
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="success" link size="small" @click="handleSimulate(row)">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
模拟
|
||||
</el-button>
|
||||
<el-button type="info" link size="small" @click="handlePreview(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
预览
|
||||
|
|
@ -156,13 +160,19 @@
|
|||
<el-drawer v-model="previewDrawer" title="流程预览" size="500px" destroy-on-close>
|
||||
<flow-preview v-if="currentFlow" :flow="currentFlow" />
|
||||
</el-drawer>
|
||||
|
||||
<SimulateDialog
|
||||
v-model:visible="simulateDialogVisible"
|
||||
:flow-id="currentSimulateFlowId"
|
||||
:flow-name="currentSimulateFlowName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, View, Rank } from '@element-plus/icons-vue'
|
||||
import { Plus, Edit, Delete, View, Rank, VideoPlay } from '@element-plus/icons-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import {
|
||||
listScriptFlows,
|
||||
|
|
@ -174,11 +184,15 @@ import {
|
|||
import { TIMEOUT_ACTION_OPTIONS } from '@/types/script-flow'
|
||||
import type { ScriptFlow, ScriptFlowDetail, ScriptFlowCreate, ScriptFlowUpdate, FlowStep } from '@/types/script-flow'
|
||||
import FlowPreview from './components/FlowPreview.vue'
|
||||
import SimulateDialog from './components/SimulateDialog.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const flows = ref<ScriptFlow[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const previewDrawer = ref(false)
|
||||
const simulateDialogVisible = ref(false)
|
||||
const currentSimulateFlowId = ref('')
|
||||
const currentSimulateFlowName = ref('')
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
|
|
@ -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 = () => {
|
||||
formData.value.steps.push({
|
||||
step_id: generateStepId(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""
|
||||
Guardrail Management API.
|
||||
[AC-AISVC-78~AC-AISVC-85] Forbidden words and behavior rules CRUD endpoints.
|
||||
[AC-AISVC-105] Guardrail testing endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -8,6 +9,7 @@ import uuid
|
|||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
|
|
@ -18,6 +20,7 @@ from app.models.entities import (
|
|||
ForbiddenWordUpdate,
|
||||
)
|
||||
from app.services.guardrail.behavior_service import BehaviorRuleService
|
||||
from app.services.guardrail.tester import GuardrailTester
|
||||
from app.services.guardrail.word_service import ForbiddenWordService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -294,3 +297,35 @@ async def delete_behavior_rule(
|
|||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Behavior rule not found")
|
||||
|
||||
|
||||
class GuardrailTestRequest(BaseModel):
|
||||
"""Request body for guardrail testing."""
|
||||
|
||||
testTexts: list[str]
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_guardrail(
|
||||
body: GuardrailTestRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-105] Test forbidden word detection and filtering.
|
||||
|
||||
This endpoint tests texts against the tenant's forbidden words
|
||||
without modifying any database state. It returns:
|
||||
- Detection results for each text
|
||||
- Filtered text (with mask/replace applied)
|
||||
- Summary statistics
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-105] Testing guardrail for tenant={tenant_id}, "
|
||||
f"texts_count={len(body.testTexts)}"
|
||||
)
|
||||
|
||||
tester = GuardrailTester(session)
|
||||
result = await tester.test_guardrail(tenant_id, body.testTexts)
|
||||
|
||||
return result.to_dict()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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.
|
||||
[AC-AISVC-71, AC-AISVC-72, AC-AISVC-73] Script flow CRUD endpoints.
|
||||
[AC-AISVC-101, AC-AISVC-102] Flow simulation endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -8,11 +9,13 @@ import uuid
|
|||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.entities import ScriptFlowCreate, ScriptFlowUpdate
|
||||
from app.services.flow.flow_service import ScriptFlowService
|
||||
from app.services.flow.tester import ScriptFlowTester
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -69,7 +72,7 @@ async def create_flow(
|
|||
logger.info(f"[AC-AISVC-71] Creating script flow for tenant={tenant_id}, name={body.name}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
|
||||
|
||||
try:
|
||||
flow = await service.create_flow(tenant_id, body)
|
||||
except ValueError as e:
|
||||
|
|
@ -119,7 +122,7 @@ async def update_flow(
|
|||
logger.info(f"[AC-AISVC-73] Updating flow for tenant={tenant_id}, id={flow_id}")
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
|
||||
|
||||
try:
|
||||
flow = await service.update_flow(tenant_id, flow_id, body)
|
||||
except ValueError as e:
|
||||
|
|
@ -155,3 +158,45 @@ async def delete_flow(
|
|||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
|
||||
class FlowSimulateRequest(BaseModel):
|
||||
"""Request body for flow simulation."""
|
||||
|
||||
userInputs: list[str]
|
||||
|
||||
|
||||
@router.post("/{flow_id}/simulate")
|
||||
async def simulate_flow(
|
||||
flow_id: uuid.UUID,
|
||||
body: FlowSimulateRequest,
|
||||
tenant_id: str = Depends(get_tenant_id),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
[AC-AISVC-101, AC-AISVC-102] Simulate flow execution and analyze coverage.
|
||||
|
||||
This endpoint simulates the flow execution with provided user inputs
|
||||
without modifying any database state. It returns:
|
||||
- Step-by-step simulation results
|
||||
- Coverage analysis (covered steps, coverage rate)
|
||||
- Detected issues (dead loops, low coverage, etc.)
|
||||
"""
|
||||
logger.info(
|
||||
f"[AC-AISVC-101] Simulating flow for tenant={tenant_id}, "
|
||||
f"flow_id={flow_id}, inputs_count={len(body.userInputs)}"
|
||||
)
|
||||
|
||||
service = ScriptFlowService(session)
|
||||
flow = await service.get_flow(tenant_id, flow_id)
|
||||
|
||||
if not flow:
|
||||
raise HTTPException(status_code=404, detail="Flow not found")
|
||||
|
||||
if not flow.steps:
|
||||
raise HTTPException(status_code=400, detail="Flow has no steps")
|
||||
|
||||
tester = ScriptFlowTester()
|
||||
result = tester.simulate_flow(flow, body.userInputs)
|
||||
|
||||
return tester.to_dict(result)
|
||||
|
|
|
|||
|
|
@ -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