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:
MerCry 2026-02-27 23:11:59 +08:00
parent e4dbcda150
commit c005066162
19 changed files with 3941 additions and 6 deletions

View File

@ -0,0 +1,112 @@
# v0.7.0 窗口2话术流程 + 输出护栏 - 进度文档
## 1. 任务概述
实现 v0.7.0 迭代中话术流程和输出护栏的测试与监控功能,包括前端页面和后端 API。
## 2. 需求文档引用
- spec/ai-service-admin/requirements.md - 第10节v0.7.0AC-ASA-59 ~ AC-ASA-64
- spec/ai-service/requirements.md - 第13节v0.7.0AC-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] 护栏拦截记录详情弹窗支持分页

View File

@ -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,

View File

@ -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
})
}

View File

@ -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,

View File

@ -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: '输出护栏监控' }
}
]

View File

@ -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)

View File

@ -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="请输入测试文本,每行一条&#10;例如:&#10;我们的产品比竞品 A 更好&#10;可以给您赔偿 1000 元&#10;这是正常的回复"
/>
<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>

View File

@ -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>

View File

@ -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>

View File

@ -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="请输入用户回复,每行一条&#10;例如:&#10;12345678901234&#10;质量问题&#10;是的"
/>
<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>

View File

@ -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(),

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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,
}

View File

@ -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,
)

View File

@ -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",
]

View File

@ -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()

View File

@ -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