2 Commits
4438545693
...
23f195326d
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
23f195326d |
feat(llm-trace): 提示词追踪增强与缺陷修复
新增追踪字段与落库链路: - FAQ 命中详情(faqId/faqQuestion/faqMatchType/faqScore) - 错误分类(errorType/errorMessage)与 Token 用量三档 - MCP 工具调用事件(toolCallsJson)、多轮历史(historyMessagesJson/historyTurns) - RAG 命中片段(ragHitsJson)、检索模式(searchMode)、账户/API Key - 数据库迁移新增 13 列 + 3 索引,同步 init-database.sql 前端增强: - 筛选新增 API Key / 错误类型下拉,列表新增 RAG 列与刷新按钮 - 详情 Tab 扩展 FAQ / 工具调用 / 对话上下文,Prompt 关键词搜索高亮 - 新增会话时间线抽屉、调用趋势图(Chart.js 双轴折线) 缺陷修复: - MCP 工具事件采集改用 ToolContext 显式收集器,修复 Reactor 流式跨线程丢 ThreadLocal 导致 toolCallsJson 为空 - 趋势统计 GROUP BY 别名改为 date_trunc 表达式,修复 PostgreSQL 语法错误 - 详情表格移除重复 row-key,新增 /error-types 与 /trend 接口 |
1 week ago |
|
|
2fe322aa7d |
完成「LLM 调用追踪面板」开发
|
1 week ago |
29 changed files with 2448 additions and 251 deletions
-
10CLAUDE.md
-
4frontend/components.d.ts
-
4frontend/src/api/api-key.ts
-
72frontend/src/api/llm-trace.ts
-
66frontend/src/components/PromptSearchHighlight.vue
-
1frontend/src/router/index.ts
-
1frontend/src/stores/navigation.ts
-
705frontend/src/views/PromptTracePanel.vue
-
12frontend/src/views/RoleManager.vue
-
13frontend/src/views/SystemConfigManager.vue
-
384src/main/java/com/wok/supportbot/app/AssistantApp.java
-
32src/main/java/com/wok/supportbot/app/ChatContext.java
-
30src/main/java/com/wok/supportbot/app/ChatPipeline.java
-
27src/main/java/com/wok/supportbot/app/ChatRequest.java
-
36src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
-
118src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
10src/main/java/com/wok/supportbot/controller/AiController.java
-
192src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java
-
2src/main/java/com/wok/supportbot/controller/OpenApiController.java
-
12src/main/java/com/wok/supportbot/dao/LlmCallTraceMapper.java
-
192src/main/java/com/wok/supportbot/entity/LlmCallTrace.java
-
99src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
-
7src/main/java/com/wok/supportbot/rag/RagContext.java
-
40src/main/java/com/wok/supportbot/rag/RagPipeline.java
-
292src/main/java/com/wok/supportbot/service/LlmCallTraceService.java
-
4src/main/java/com/wok/supportbot/service/RagHitLogService.java
-
156src/main/resources/init-database.sql
-
123src/test/java/com/wok/supportbot/Phase1ComponentTests.java
-
55src/test/java/com/wok/supportbot/SupportBotApplicationTests.java
@ -0,0 +1,72 @@ |
|||||
|
import request from './request' |
||||
|
import type { ApiResponse } from '@/types/api' |
||||
|
|
||||
|
/** LLM 调用追踪列表查询参数 */ |
||||
|
export interface LlmTraceQuery { |
||||
|
page?: number |
||||
|
size?: number |
||||
|
roleId?: string |
||||
|
conversationId?: string |
||||
|
intent?: string |
||||
|
startTime?: string |
||||
|
endTime?: string |
||||
|
keyword?: string |
||||
|
apiKeyId?: string |
||||
|
errorType?: string |
||||
|
} |
||||
|
|
||||
|
/** 分页查询调用记录 */ |
||||
|
export function listLlmTraces(query: LlmTraceQuery = {}): Promise<ApiResponse> { |
||||
|
const params = new URLSearchParams() |
||||
|
params.set('page', String(query.page ?? 1)) |
||||
|
params.set('size', String(query.size ?? 20)) |
||||
|
if (query.roleId) params.set('roleId', query.roleId) |
||||
|
if (query.conversationId) params.set('conversationId', query.conversationId) |
||||
|
if (query.intent) params.set('intent', query.intent) |
||||
|
if (query.startTime) params.set('startTime', query.startTime) |
||||
|
if (query.endTime) params.set('endTime', query.endTime) |
||||
|
if (query.keyword) params.set('keyword', query.keyword) |
||||
|
if (query.apiKeyId) params.set('apiKeyId', query.apiKeyId) |
||||
|
if (query.errorType) params.set('errorType', query.errorType) |
||||
|
return request.get(`/llm-trace/list?${params.toString()}`).then(r => r.data) |
||||
|
} |
||||
|
|
||||
|
/** 单条详情(含完整 system prompt) */ |
||||
|
export function getLlmTrace(id: string): Promise<ApiResponse> { |
||||
|
return request.get(`/llm-trace/${id}`).then(r => r.data) |
||||
|
} |
||||
|
|
||||
|
/** 清理 N 天前的记录(后端返回 {success, message, deleted},无 data 字段) */ |
||||
|
export interface CleanLlmTracesResult { success: boolean; message?: string; deleted?: number } |
||||
|
export function cleanLlmTraces(keepDays: number): Promise<CleanLlmTracesResult> { |
||||
|
return request.post('/llm-trace/clean', { keepDays }).then(r => r.data) |
||||
|
} |
||||
|
|
||||
|
/** 聚合统计(按角色或模型分组) */ |
||||
|
export function getLlmTraceStats(groupBy: 'role' | 'model' = 'role'): Promise<ApiResponse> { |
||||
|
return request.get(`/llm-trace/stats?groupBy=${groupBy}`).then(r => r.data) |
||||
|
} |
||||
|
|
||||
|
/** 可选错误类型列表 */ |
||||
|
export function getLlmTraceErrorTypes(): Promise<ApiResponse> { |
||||
|
return request.get('/llm-trace/error-types').then(r => r.data) |
||||
|
} |
||||
|
|
||||
|
/** 单会话调用记录(会话时间线,复用 list 接口按 conversationId 过滤) */ |
||||
|
export function getLlmTraceConversation(conversationId: string): Promise<ApiResponse> { |
||||
|
return request.get(`/llm-trace/list?conversationId=${encodeURIComponent(conversationId)}&size=100`).then(r => r.data) |
||||
|
} |
||||
|
|
||||
|
/** 时间维度趋势统计 */ |
||||
|
export interface LlmTraceTrendQuery { |
||||
|
groupBy?: 'HOUR' | 'DAY' |
||||
|
startTime?: string |
||||
|
endTime?: string |
||||
|
} |
||||
|
export function getLlmTraceTrend(query: LlmTraceTrendQuery = {}): Promise<ApiResponse> { |
||||
|
const params = new URLSearchParams() |
||||
|
params.set('groupBy', query.groupBy ?? 'HOUR') |
||||
|
if (query.startTime) params.set('startTime', query.startTime) |
||||
|
if (query.endTime) params.set('endTime', query.endTime) |
||||
|
return request.get(`/llm-trace/trend?${params.toString()}`).then(r => r.data) |
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
<template> |
||||
|
<div class="prompt-highlight"> |
||||
|
<div class="ph-toolbar"> |
||||
|
<t-input v-model="keyword" placeholder="在提示词中搜索高亮" clearable size="small" style="width: 240px;"> |
||||
|
<template #prefix-icon><SearchIcon /></template> |
||||
|
</t-input> |
||||
|
<span v-if="keyword" class="ph-count">{{ matchCount }} 处匹配</span> |
||||
|
</div> |
||||
|
<div class="ph-body" v-html="highlighted"></div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, computed } from 'vue' |
||||
|
import { SearchIcon } from 'tdesign-icons-vue-next' |
||||
|
|
||||
|
const props = defineProps<{ text: string }>() |
||||
|
|
||||
|
const keyword = ref('') |
||||
|
|
||||
|
/** 匹配数量(基于原始文本,忽略大小写) */ |
||||
|
const matchCount = computed(() => { |
||||
|
if (!keyword.value.trim() || !props.text) return 0 |
||||
|
const regex = new RegExp(escapeRegex(keyword.value.trim()), 'gi') |
||||
|
return (props.text.match(regex) || []).length |
||||
|
}) |
||||
|
|
||||
|
/** 高亮后的 HTML(原始文本转义 + 关键词包裹 mark,防止 XSS) */ |
||||
|
const highlighted = computed(() => { |
||||
|
const escaped = escapeHtml(props.text || '') |
||||
|
if (!keyword.value.trim()) return escaped |
||||
|
const kw = escapeHtml(keyword.value.trim()) |
||||
|
const regex = new RegExp(`(${escapeRegex(kw)})`, 'gi') |
||||
|
return escaped.replace(regex, '<mark class="ph-mark">$1</mark>') |
||||
|
}) |
||||
|
|
||||
|
function escapeHtml(s: string): string { |
||||
|
return s |
||||
|
.replace(/&/g, '&') |
||||
|
.replace(/</g, '<') |
||||
|
.replace(/>/g, '>') |
||||
|
.replace(/"/g, '"') |
||||
|
} |
||||
|
|
||||
|
function escapeRegex(s: string): string { |
||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.prompt-highlight { display: flex; flex-direction: column; gap: 8px; } |
||||
|
.ph-toolbar { display: flex; align-items: center; gap: 10px; } |
||||
|
.ph-count { font-size: 12px; color: var(--td-text-color-secondary); } |
||||
|
.ph-body { |
||||
|
white-space: pre-wrap; |
||||
|
word-break: break-word; |
||||
|
font-size: 13px; |
||||
|
line-height: 1.6; |
||||
|
background: var(--td-bg-color-secondarycontainer, #f7f7f7); |
||||
|
padding: 12px; |
||||
|
border-radius: 6px; |
||||
|
max-height: 480px; |
||||
|
overflow: auto; |
||||
|
} |
||||
|
:deep(.ph-mark) { background: var(--td-warning-color-1, #fff3e0); color: var(--td-text-color-primary); padding: 0 1px; } |
||||
|
</style> |
||||
@ -0,0 +1,705 @@ |
|||||
|
<template> |
||||
|
<t-card title="提示词追踪" :bordered="false"> |
||||
|
<p class="desc-text">记录每次 LLM 调用的完整现场(system prompt / 模型参数 / 耗时 / 意图),用于提示词优化调试。记录自动保留 N 天后清理。</p> |
||||
|
|
||||
|
<!-- 聚合统计 --> |
||||
|
<div class="stats-bar"> |
||||
|
<t-radio-group v-model="groupBy" variant="default-filled" size="small" @change="loadStats"> |
||||
|
<t-radio-button value="role">按角色</t-radio-button> |
||||
|
<t-radio-button value="model">按模型</t-radio-button> |
||||
|
</t-radio-group> |
||||
|
<div class="stat-card"><span class="stat-num">{{ statSummary.count }}</span><span class="stat-label">总调用</span></div> |
||||
|
<div class="stat-card"><span class="stat-num">{{ statSummary.avgLatency }}</span><span class="stat-label">平均耗时(ms)</span></div> |
||||
|
<div class="stat-card"><span class="stat-num">{{ statSummary.faqRate }}</span><span class="stat-label">FAQ 命中率</span></div> |
||||
|
</div> |
||||
|
<t-table :data="stats" :columns="statsColumns" row-key="key" size="small" :loading="statsLoading" :pagination="false" style="margin-bottom:16px;" /> |
||||
|
|
||||
|
<!-- 筛选栏 --> |
||||
|
<div class="toolbar"> |
||||
|
<t-select v-model="filterRoleId" :options="roleOptions" placeholder="全部角色" clearable size="small" style="width:150px;" @change="onFilterChange" /> |
||||
|
<t-input v-model="filterConversationId" placeholder="会话 ID" clearable size="small" style="width:180px;" @enter="onSearch" /> |
||||
|
<t-select v-model="filterIntent" :options="intentOptions" placeholder="全部意图" clearable size="small" style="width:120px;" @change="onFilterChange" /> |
||||
|
<t-select v-model="filterApiKeyId" :options="apiKeyOptions" placeholder="全部 API Key" clearable size="small" style="width:160px;" @change="onFilterChange" /> |
||||
|
<t-select v-model="filterErrorType" :options="errorTypeOptions" placeholder="全部错误类型" clearable size="small" style="width:140px;" @change="onFilterChange" /> |
||||
|
<t-date-picker v-model="filterStart" placeholder="开始日期" clearable size="small" style="width:140px;" @change="onFilterChange" /> |
||||
|
<t-date-picker v-model="filterEnd" placeholder="结束日期" clearable size="small" style="width:140px;" @change="onFilterChange" /> |
||||
|
<t-input v-model="filterKeyword" placeholder="搜索用户消息 / AI 回复" clearable size="small" style="width:200px;" @enter="onSearch" /> |
||||
|
<div style="flex:1;" /> |
||||
|
<t-button size="small" variant="outline" @click="refreshAll">刷新</t-button> |
||||
|
<t-button size="small" :disabled="selectedRowKeys.length !== 2" @click="openCompare">对比({{ selectedRowKeys.length }}/2)</t-button> |
||||
|
<t-button size="small" variant="outline" @click="openTrend">趋势</t-button> |
||||
|
<t-button size="small" variant="outline" @click="openClean">清理</t-button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 调用列表 --> |
||||
|
<t-table :data="traces" :columns="columns" row-key="id" :loading="loading" |
||||
|
:selected-row-keys="selectedRowKeys" |
||||
|
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" |
||||
|
@page-change="onPageChange" @select-change="onSelectChange"> |
||||
|
<template #roleName="{ row }"> |
||||
|
<span v-if="row.roleName">{{ row.roleName }}</span> |
||||
|
<span v-else class="muted">(无角色)</span> |
||||
|
</template> |
||||
|
<template #intent="{ row }"> |
||||
|
<t-tag size="small" variant="light" :theme="intentTheme(row.intent)">{{ intentLabel(row.intent) }}</t-tag> |
||||
|
</template> |
||||
|
<template #faqHit="{ row }"> |
||||
|
<t-tag size="small" variant="light" :theme="row.faqHit ? 'success' : 'default'">{{ row.faqHit ? '命中' : '未命中' }}</t-tag> |
||||
|
</template> |
||||
|
<template #enableRag="{ row }"> |
||||
|
<t-tag size="small" variant="light" :theme="row.enableRag ? 'primary' : 'default'">{{ row.enableRag ? '是' : '否' }}</t-tag> |
||||
|
</template> |
||||
|
<template #status="{ row }"> |
||||
|
<t-tag size="small" variant="light" :theme="statusTheme(row.status)">{{ statusLabel(row.status) }}</t-tag> |
||||
|
</template> |
||||
|
<template #latencyMs="{ row }"> |
||||
|
<span>{{ row.latencyMs }}ms</span> |
||||
|
</template> |
||||
|
<template #op="{ row }"> |
||||
|
<t-button size="small" variant="text" @click="openDetail(row)">详情</t-button> |
||||
|
<t-button size="small" variant="text" @click="openTimeline(row)">时间线</t-button> |
||||
|
</template> |
||||
|
</t-table> |
||||
|
|
||||
|
<!-- 详情抽屉 --> |
||||
|
<t-drawer v-model:visible="detailVisible" size="large" header="调用详情" :footer="false"> |
||||
|
<t-loading :loading="detailLoading" show-overlay> |
||||
|
<div v-if="detail"> |
||||
|
<div class="detail-meta"> |
||||
|
<t-tag size="small" variant="light" :theme="intentTheme(detail.intent)">{{ intentLabel(detail.intent) }}</t-tag> |
||||
|
<t-tag size="small" variant="light" :theme="statusTheme(detail.status)">{{ statusLabel(detail.status) }}</t-tag> |
||||
|
<t-tag v-if="detail.searchMode" size="small" variant="outline">{{ detail.searchMode }}</t-tag> |
||||
|
<t-tag v-if="detail.errorType" size="small" variant="light" theme="danger">{{ detail.errorType }}</t-tag> |
||||
|
<span class="muted">{{ detail.roleName || '(无角色)' }} · {{ detail.modelName || '未知模型' }} · {{ detail.provider }} · {{ detail.latencyMs }}ms</span> |
||||
|
</div> |
||||
|
<div v-if="detail.errorMessage" class="error-box">{{ detail.errorMessage }}</div> |
||||
|
<t-tabs v-model="detailTab"> |
||||
|
<t-tab-panel value="system" label="系统提示词"> |
||||
|
<div class="prompt-segments"> |
||||
|
<div class="seg" v-if="detail.globalPrompt"><span class="seg-tag">全局</span><pre class="seg-text">{{ detail.globalPrompt }}</pre></div> |
||||
|
<div class="seg" v-if="detail.rolePrompt"><span class="seg-tag">角色</span><pre class="seg-text">{{ detail.rolePrompt }}</pre></div> |
||||
|
<div class="seg" v-if="detail.ragContext"><span class="seg-tag">RAG 资料</span><pre class="seg-text">{{ detail.ragContext }}</pre></div> |
||||
|
</div> |
||||
|
<div class="block-title">最终完整 system prompt</div> |
||||
|
<PromptSearchHighlight :text="detail.systemPrompt || '(空)'" /> |
||||
|
<div class="detail-actions"> |
||||
|
<t-button size="small" @click="copyPrompt">复制完整 Prompt</t-button> |
||||
|
<t-button size="small" variant="outline" :disabled="!detail.roleId" @click="jumpEditRole">编辑角色提示词</t-button> |
||||
|
<t-button size="small" variant="outline" @click="jumpEditGlobal">编辑全局提示词</t-button> |
||||
|
</div> |
||||
|
</t-tab-panel> |
||||
|
<t-tab-panel value="user" label="用户消息"><pre class="full-prompt">{{ detail.userMessage }}</pre></t-tab-panel> |
||||
|
<t-tab-panel value="ai" label="AI 回复"> |
||||
|
<div v-if="detail.totalTokens || detail.promptTokens || detail.completionTokens" class="muted" style="margin-bottom:8px;"> |
||||
|
Token:prompt {{ detail.promptTokens ?? '-' }} / completion {{ detail.completionTokens ?? '-' }} / 合计 {{ detail.totalTokens ?? '-' }} |
||||
|
</div> |
||||
|
<pre class="full-prompt">{{ detail.aiResponse }}<span v-if="detail.aiResponseTruncated" class="muted">(已截断)</span></pre> |
||||
|
</t-tab-panel> |
||||
|
<t-tab-panel value="rag" label="RAG 资料"> |
||||
|
<div class="muted" style="margin-bottom:8px;">检索模式:{{ detail.searchMode || '-' }} · 命中:{{ detail.hitCount ?? '-' }} 条</div> |
||||
|
<t-table v-if="detailRagHits.length" :data="detailRagHits" :columns="ragHitsColumns" size="small" :pagination="false" style="margin-bottom:12px;" /> |
||||
|
<div class="block-title">注入的 RAG 上下文</div> |
||||
|
<pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre> |
||||
|
</t-tab-panel> |
||||
|
<t-tab-panel value="faq" label="FAQ"> |
||||
|
<t-empty v-if="!detail.faqQuestion && !detail.faqId" description="本次调用未命中 FAQ" /> |
||||
|
<div v-else class="faq-box"> |
||||
|
<div class="faq-row"><span class="faq-label">FAQ ID</span><span>{{ detail.faqId ?? '-' }}</span></div> |
||||
|
<div class="faq-row"><span class="faq-label">问题</span><span>{{ detail.faqQuestion ?? '-' }}</span></div> |
||||
|
<div class="faq-row"><span class="faq-label">匹配方式</span><span>{{ detail.faqMatchType || '-' }}</span></div> |
||||
|
<div class="faq-row"><span class="faq-label">相似度</span><span>{{ formatScore(detail.faqScore) }}</span></div> |
||||
|
</div> |
||||
|
</t-tab-panel> |
||||
|
<t-tab-panel value="tools" label="工具调用"> |
||||
|
<t-table v-if="detailToolCalls.length" :data="detailToolCalls" :columns="toolCallsColumns" size="small" :pagination="false"> |
||||
|
<template #latencyMs="{ row }"><span>{{ row.latencyMs ?? '-' }}ms</span></template> |
||||
|
<template #error="{ row }"> |
||||
|
<t-tag v-if="row.error" size="small" variant="light" theme="danger">失败</t-tag> |
||||
|
<span v-else class="muted">-</span> |
||||
|
</template> |
||||
|
</t-table> |
||||
|
<t-empty v-else description="本次调用未触发 MCP 工具" /> |
||||
|
</t-tab-panel> |
||||
|
<t-tab-panel value="context" label="对话上下文"> |
||||
|
<div v-if="detail.historyTurns" class="muted" style="margin-bottom:8px;">历史轮数:{{ detail.historyTurns }}</div> |
||||
|
<t-table v-if="detailHistory.length" :data="detailHistory" :columns="historyColumns" size="small" :pagination="false" /> |
||||
|
<t-empty v-else description="无多轮历史记录" /> |
||||
|
</t-tab-panel> |
||||
|
</t-tabs> |
||||
|
</div> |
||||
|
<t-empty v-else description="记录不存在或已被清理" /> |
||||
|
</t-loading> |
||||
|
</t-drawer> |
||||
|
|
||||
|
<!-- 对比抽屉 --> |
||||
|
<t-drawer v-model:visible="compareVisible" size="large" header="并排对比" :footer="false"> |
||||
|
<div v-if="compareA && compareB" class="compare-grid"> |
||||
|
<div v-for="(c, idx) in [compareA, compareB]" :key="idx" class="compare-col"> |
||||
|
<div class="compare-head">{{ c.roleName || '(无角色)' }} · {{ c.intent }} · {{ formatDate(c.createTime) }}</div> |
||||
|
<div class="compare-meta"> |
||||
|
<t-tag size="small" variant="light" :theme="c.enableRag ? 'primary' : 'default'">RAG {{ c.enableRag ? '开' : '关' }}</t-tag> |
||||
|
<t-tag v-if="c.searchMode" size="small" variant="outline">{{ c.searchMode }}</t-tag> |
||||
|
<t-tag v-if="c.errorType" size="small" variant="light" theme="danger">{{ c.errorType }}</t-tag> |
||||
|
<span v-if="c.faqMatchType" class="muted">FAQ:{{ c.faqMatchType }}</span> |
||||
|
<span v-if="c.totalTokens" class="muted">Token:{{ c.totalTokens }}</span> |
||||
|
</div> |
||||
|
<div class="block-title">系统提示词</div> |
||||
|
<pre class="full-prompt small">{{ c.systemPrompt || '(空)' }}</pre> |
||||
|
<div class="block-title">用户消息</div> |
||||
|
<pre class="full-prompt small">{{ c.userMessage }}</pre> |
||||
|
<div class="block-title">AI 回复</div> |
||||
|
<pre class="full-prompt small">{{ c.aiResponse }}</pre> |
||||
|
</div> |
||||
|
</div> |
||||
|
<t-empty v-else description="请先在列表中勾选 2 条记录" /> |
||||
|
</t-drawer> |
||||
|
|
||||
|
<!-- 会话时间线抽屉 --> |
||||
|
<t-drawer v-model:visible="timelineVisible" size="medium" header="会话时间线" :footer="false"> |
||||
|
<t-loading :loading="timelineLoading" show-overlay> |
||||
|
<div class="muted" style="margin-bottom:12px;">会话 ID:{{ timelineConversationId }}</div> |
||||
|
<t-empty v-if="!timelineLoading && !timelineRows.length" description="该会话无调用记录" /> |
||||
|
<t-timeline v-else> |
||||
|
<t-timeline-item v-for="row in timelineRows" :key="row.id"> |
||||
|
<div class="tl-head"> |
||||
|
<t-tag size="small" variant="light" :theme="intentTheme(row.intent)">{{ intentLabel(row.intent) }}</t-tag> |
||||
|
<t-tag size="small" variant="light" :theme="statusTheme(row.status)">{{ statusLabel(row.status) }}</t-tag> |
||||
|
<span class="muted">{{ formatDate(row.createTime) }} · {{ row.latencyMs }}ms</span> |
||||
|
</div> |
||||
|
<div class="tl-user">{{ row.userMessage }}</div> |
||||
|
<div class="tl-ai">{{ row.aiResponse }}</div> |
||||
|
</t-timeline-item> |
||||
|
</t-timeline> |
||||
|
</t-loading> |
||||
|
</t-drawer> |
||||
|
|
||||
|
<!-- 趋势图抽屉 --> |
||||
|
<t-drawer v-model:visible="trendVisible" size="large" header="调用趋势" :footer="false" @opened="loadTrend" @closed="destroyTrendChart"> |
||||
|
<div class="trend-toolbar"> |
||||
|
<t-radio-group v-model="trendRange" variant="default-filled" size="small" @change="loadTrend"> |
||||
|
<t-radio-button value="24h">近 24 小时</t-radio-button> |
||||
|
<t-radio-button value="7d">近 7 天</t-radio-button> |
||||
|
</t-radio-group> |
||||
|
<t-radio-group v-model="trendGroupBy" variant="default-filled" size="small" @change="loadTrend"> |
||||
|
<t-radio-button value="HOUR">按小时</t-radio-button> |
||||
|
<t-radio-button value="DAY">按天</t-radio-button> |
||||
|
</t-radio-group> |
||||
|
<div style="flex:1;" /> |
||||
|
<t-button size="small" variant="outline" :loading="trendLoading" @click="loadTrend">刷新</t-button> |
||||
|
</div> |
||||
|
<div class="trend-chart-wrap"> |
||||
|
<canvas ref="trendChartRef"></canvas> |
||||
|
</div> |
||||
|
</t-drawer> |
||||
|
|
||||
|
<!-- 清理弹窗 --> |
||||
|
<t-dialog v-model:visible="cleanVisible" header="清理调用记录" width="420px" :footer="false"> |
||||
|
<t-form label-align="top"> |
||||
|
<t-form-item label="保留最近 N 天(删除 N 天前的记录)"> |
||||
|
<t-input-number v-model="cleanKeepDays" :min="1" :max="365" style="width:100%;" /> |
||||
|
</t-form-item> |
||||
|
</t-form> |
||||
|
<p class="muted" style="font-size:12px;">最小保留 1 天,清理动作不可恢复,请谨慎操作。</p> |
||||
|
<div class="dialog-footer"> |
||||
|
<t-button variant="outline" @click="cleanVisible = false">取消</t-button> |
||||
|
<t-button theme="danger" :loading="cleaning" @click="doClean">确认清理</t-button> |
||||
|
</div> |
||||
|
</t-dialog> |
||||
|
</t-card> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { listLlmTraces, getLlmTrace, cleanLlmTraces, getLlmTraceStats, getLlmTraceErrorTypes, getLlmTraceConversation, getLlmTraceTrend } from '@/api/llm-trace' |
||||
|
import { getAllRoles } from '@/api/role' |
||||
|
import { listAllApiKeys } from '@/api/api-key' |
||||
|
import PromptSearchHighlight from '@/components/PromptSearchHighlight.vue' |
||||
|
import { toast } from '@/utils/toast' |
||||
|
import { formatDate } from '@/utils/format' |
||||
|
import { useDebounce } from '@/composables/useDebounce' |
||||
|
import { useConfirm } from '@/composables/useConfirm' |
||||
|
import { palette } from '@/utils/palette' |
||||
|
|
||||
|
const router = useRouter() |
||||
|
const { confirm } = useConfirm() |
||||
|
const { debounce } = useDebounce() |
||||
|
|
||||
|
// ===== 意图 / 状态映射 ===== |
||||
|
const INTENT_LABEL: Record<string, string> = { CHAT: '普通对话', CHITCHAT: '闲聊', FAQ: 'FAQ', RAG: 'RAG' } |
||||
|
const STATUS_LABEL: Record<string, string> = { COMPLETE: '完成', ERROR: '失败', CANCEL: '断连', FAQ: 'FAQ', BYPASS: '熔断降级' } |
||||
|
function intentLabel(v: string) { return INTENT_LABEL[v] || v || '-' } |
||||
|
function intentTheme(v: string) { return ({ CHITCHAT: 'warning', FAQ: 'success', RAG: 'primary', CHAT: 'default' } as Record<string, string>)[v] || 'default' } |
||||
|
function statusLabel(v: string) { return STATUS_LABEL[v] || v || '-' } |
||||
|
function statusTheme(v: string) { return ({ COMPLETE: 'success', ERROR: 'danger', CANCEL: 'warning', FAQ: 'success', BYPASS: 'warning' } as Record<string, string>)[v] || 'default' } |
||||
|
|
||||
|
const intentOptions = [ |
||||
|
{ label: '普通对话', value: 'CHAT' }, |
||||
|
{ label: '闲聊', value: 'CHITCHAT' }, |
||||
|
{ label: 'FAQ', value: 'FAQ' }, |
||||
|
{ label: 'RAG', value: 'RAG' }, |
||||
|
] |
||||
|
|
||||
|
// ===== 筛选状态 ===== |
||||
|
const filterRoleId = ref('') |
||||
|
const filterConversationId = ref('') |
||||
|
const filterIntent = ref('') |
||||
|
const filterStart = ref('') |
||||
|
const filterEnd = ref('') |
||||
|
const filterKeyword = ref('') |
||||
|
const filterApiKeyId = ref('') |
||||
|
const filterErrorType = ref('') |
||||
|
const roleOptions = ref<{ label: string; value: string }[]>([]) |
||||
|
const apiKeyOptions = ref<{ label: string; value: string }[]>([]) |
||||
|
const errorTypeOptions = ref<{ label: string; value: string }[]>([]) |
||||
|
|
||||
|
// ===== 列表状态 ===== |
||||
|
const traces = ref<any[]>([]) |
||||
|
const loading = ref(false) |
||||
|
const page = ref(1) |
||||
|
const pageSize = ref(20) |
||||
|
const total = ref(0) |
||||
|
const selectedRowKeys = ref<string[]>([]) |
||||
|
|
||||
|
const columns = [ |
||||
|
{ colKey: 'row-select', type: 'multiple', width: 40 }, |
||||
|
{ colKey: 'createTime', title: '时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.createTime) }, |
||||
|
{ colKey: 'roleName', title: '角色', width: 120 }, |
||||
|
{ colKey: 'intent', title: '意图', width: 100 }, |
||||
|
{ colKey: 'enableRag', title: 'RAG', width: 70 }, |
||||
|
{ colKey: 'modelName', title: '模型', width: 140, ellipsis: true }, |
||||
|
{ colKey: 'latencyMs', title: '耗时', width: 90 }, |
||||
|
{ colKey: 'faqHit', title: 'FAQ', width: 80 }, |
||||
|
{ colKey: 'status', title: '状态', width: 100 }, |
||||
|
{ colKey: 'userMessage', title: '用户消息', ellipsis: true }, |
||||
|
{ colKey: 'op', title: '操作', width: 150, fixed: 'right' }, |
||||
|
] |
||||
|
|
||||
|
// ===== 聚合统计状态 ===== |
||||
|
const groupBy = ref<'role' | 'model'>('role') |
||||
|
const stats = ref<any[]>([]) |
||||
|
const statsLoading = ref(false) |
||||
|
const statsColumns = [ |
||||
|
{ colKey: 'key', title: '分组', ellipsis: true }, |
||||
|
{ colKey: 'count', title: '调用数', width: 100 }, |
||||
|
{ colKey: 'avgLatencyMs', title: '平均耗时(ms)', width: 120 }, |
||||
|
{ colKey: 'faqHitRate', title: 'FAQ 命中率', width: 110, cell: (_: any, { row }: any) => `${((row.faqHitRate || 0) * 100).toFixed(1)}%` }, |
||||
|
] |
||||
|
|
||||
|
// ===== 详情状态 ===== |
||||
|
const detailVisible = ref(false) |
||||
|
const detailLoading = ref(false) |
||||
|
const detail = ref<any>(null) |
||||
|
const detailTab = ref('system') |
||||
|
|
||||
|
const detailRagHits = computed(() => parseJsonArray(detail.value?.ragHitsJson)) |
||||
|
const detailToolCalls = computed(() => parseJsonArray(detail.value?.toolCallsJson)) |
||||
|
const detailHistory = computed(() => parseJsonArray(detail.value?.historyMessagesJson)) |
||||
|
|
||||
|
const ragHitsColumns = [ |
||||
|
{ colKey: 'title', title: '标题', ellipsis: true }, |
||||
|
{ colKey: 'documentId', title: '文档 ID', width: 150, ellipsis: true }, |
||||
|
{ colKey: 'chunkIndex', title: '分块', width: 70 }, |
||||
|
{ colKey: 'score', title: '相似度', width: 90, cell: (_: any, { row }: any) => formatScore(row.score) }, |
||||
|
{ colKey: 'searchMode', title: '检索模式', width: 100 }, |
||||
|
] |
||||
|
|
||||
|
const toolCallsColumns = [ |
||||
|
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true }, |
||||
|
{ colKey: 'input', title: '输入', ellipsis: true }, |
||||
|
{ colKey: 'result', title: '结果', ellipsis: true }, |
||||
|
{ colKey: 'latencyMs', title: '耗时', width: 90 }, |
||||
|
{ colKey: 'error', title: '错误', width: 70 }, |
||||
|
] |
||||
|
|
||||
|
const historyColumns = [ |
||||
|
{ colKey: 'role', title: '角色', width: 100 }, |
||||
|
{ colKey: 'content', title: '内容', ellipsis: true }, |
||||
|
] |
||||
|
|
||||
|
function formatScore(v: any): string { |
||||
|
if (v === null || v === undefined || v === '') return '-' |
||||
|
const n = Number(v) |
||||
|
if (Number.isFinite(n)) return n.toFixed(4) |
||||
|
return String(v) |
||||
|
} |
||||
|
|
||||
|
// ===== 对比状态 ===== |
||||
|
const compareVisible = ref(false) |
||||
|
const compareA = ref<any>(null) |
||||
|
const compareB = ref<any>(null) |
||||
|
|
||||
|
// ===== 清理状态 ===== |
||||
|
const cleanVisible = ref(false) |
||||
|
const cleanKeepDays = ref(30) |
||||
|
const cleaning = ref(false) |
||||
|
|
||||
|
// ===== 会话时间线状态 ===== |
||||
|
const timelineVisible = ref(false) |
||||
|
const timelineLoading = ref(false) |
||||
|
const timelineConversationId = ref('') |
||||
|
const timelineRows = ref<any[]>([]) |
||||
|
|
||||
|
// ===== 趋势图状态 ===== |
||||
|
const trendVisible = ref(false) |
||||
|
const trendGroupBy = ref<'HOUR' | 'DAY'>('HOUR') |
||||
|
const trendRange = ref('24h') |
||||
|
const trendLoading = ref(false) |
||||
|
const trendRows = ref<any[]>([]) |
||||
|
const trendChartRef = ref<HTMLCanvasElement | null>(null) |
||||
|
let trendChart: any = null |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
await loadRoles() |
||||
|
loadApiKeys() |
||||
|
loadErrorTypes() |
||||
|
loadList() |
||||
|
loadStats() |
||||
|
}) |
||||
|
|
||||
|
async function loadRoles() { |
||||
|
try { |
||||
|
const r = await getAllRoles() |
||||
|
if (r.success) { |
||||
|
roleOptions.value = (r.data || []).map((ro: any) => ({ label: ro.name, value: String(ro.id) })) |
||||
|
} |
||||
|
} catch (e: any) { console.warn('角色下拉加载失败:' + e.message) } |
||||
|
} |
||||
|
|
||||
|
async function loadApiKeys() { |
||||
|
try { |
||||
|
const r = await listAllApiKeys() |
||||
|
if (r.success) { |
||||
|
apiKeyOptions.value = (r.data || []).map((k: any) => ({ label: k.name || ('API Key ' + k.id), value: String(k.id) })) |
||||
|
} |
||||
|
} catch (e: any) { console.warn('API Key 下拉加载失败:' + e.message) } |
||||
|
} |
||||
|
|
||||
|
async function loadErrorTypes() { |
||||
|
try { |
||||
|
const r = await getLlmTraceErrorTypes() |
||||
|
if (r.success) { |
||||
|
errorTypeOptions.value = (r.data || []).map((t: any) => ({ label: t.label, value: t.value })) |
||||
|
} |
||||
|
} catch (e: any) { console.warn('错误类型下拉加载失败:' + e.message) } |
||||
|
} |
||||
|
|
||||
|
// ===== 聚合统计 ===== |
||||
|
const statSummary = computed(() => { |
||||
|
let count = 0, latencySum = 0, faqHits = 0 |
||||
|
for (const s of stats.value) { |
||||
|
count += s.count || 0 |
||||
|
latencySum += (s.count || 0) * (s.avgLatencyMs || 0) |
||||
|
faqHits += Math.round((s.count || 0) * (s.faqHitRate || 0)) |
||||
|
} |
||||
|
const avgLatency = count > 0 ? Math.round(latencySum / count) : '-' |
||||
|
const faqRate = count > 0 ? ((faqHits / count) * 100).toFixed(1) + '%' : '-' |
||||
|
return { count, avgLatency, faqRate } |
||||
|
}) |
||||
|
|
||||
|
async function loadStats() { |
||||
|
statsLoading.value = true |
||||
|
try { |
||||
|
const r = await getLlmTraceStats(groupBy.value) |
||||
|
if (r.success) { |
||||
|
const rows = (r.data || []).map((s: any) => { |
||||
|
const key = groupBy.value === 'model' |
||||
|
? `${s.modelName || '-'}|${s.provider || '-'}` |
||||
|
: `${s.roleId ?? s.roleName ?? '(无角色)'}` |
||||
|
const faqRate = s.count > 0 ? ((s.faqHitCount || 0) / s.count) : 0 |
||||
|
return { ...s, key, faqHitRate: faqRate } |
||||
|
}) |
||||
|
stats.value = rows |
||||
|
} else { |
||||
|
toast(r.message || '加载统计失败', 'error') |
||||
|
} |
||||
|
} catch (e: any) { |
||||
|
toast('加载统计失败:' + e.message, 'error') |
||||
|
} finally { |
||||
|
statsLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ===== 列表 ===== |
||||
|
async function loadList() { |
||||
|
loading.value = true |
||||
|
try { |
||||
|
const r = await listLlmTraces({ |
||||
|
page: page.value, size: pageSize.value, |
||||
|
roleId: filterRoleId.value || undefined, |
||||
|
conversationId: filterConversationId.value || undefined, |
||||
|
intent: filterIntent.value || undefined, |
||||
|
startTime: filterStart.value ? filterStart.value + ' 00:00:00' : undefined, |
||||
|
endTime: filterEnd.value ? filterEnd.value + ' 23:59:59' : undefined, |
||||
|
keyword: filterKeyword.value || undefined, |
||||
|
apiKeyId: filterApiKeyId.value || undefined, |
||||
|
errorType: filterErrorType.value || undefined, |
||||
|
}) |
||||
|
if (r.success) { |
||||
|
traces.value = r.data?.records || r.data || [] |
||||
|
total.value = r.data?.total || r.total || 0 |
||||
|
} else { |
||||
|
toast(r.message || '加载失败', 'error') |
||||
|
} |
||||
|
} catch (e: any) { |
||||
|
toast('加载失败:' + e.message, 'error') |
||||
|
} finally { |
||||
|
loading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function onPageChange(info: { current: number; pageSize: number }) { |
||||
|
page.value = info.current |
||||
|
pageSize.value = info.pageSize |
||||
|
selectedRowKeys.value = [] |
||||
|
loadList() |
||||
|
} |
||||
|
|
||||
|
function onFilterChange() { |
||||
|
page.value = 1 |
||||
|
selectedRowKeys.value = [] |
||||
|
loadList() |
||||
|
} |
||||
|
|
||||
|
function refreshAll() { |
||||
|
loadList() |
||||
|
loadStats() |
||||
|
} |
||||
|
|
||||
|
const onSearch = debounce(() => { page.value = 1; selectedRowKeys.value = []; loadList() }) |
||||
|
|
||||
|
function onSelectChange(keys: (string | number)[], options?: { currentRowKey?: string | number }) { |
||||
|
const normalized = keys.map(String) |
||||
|
if (normalized.length > 2) { |
||||
|
toast('最多选择 2 条进行对比', 'warning') |
||||
|
// 保留最近勾选的一条 + 已选的第一条,避免勾选第 3 条时静默丢弃新勾选项 |
||||
|
const current = String(options?.currentRowKey ?? normalized[normalized.length - 1]) |
||||
|
const others = normalized.filter(k => k !== current) |
||||
|
selectedRowKeys.value = [current, ...others].slice(0, 2) |
||||
|
} else { |
||||
|
selectedRowKeys.value = normalized |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ===== 详情 ===== |
||||
|
async function openDetail(row: any) { |
||||
|
detailVisible.value = true |
||||
|
detailLoading.value = true |
||||
|
detail.value = null |
||||
|
detailTab.value = 'system' |
||||
|
try { |
||||
|
const r = await getLlmTrace(row.id) |
||||
|
if (r.success) { |
||||
|
detail.value = r.data |
||||
|
} else { |
||||
|
detail.value = null |
||||
|
} |
||||
|
} catch (e: any) { |
||||
|
toast('加载详情失败:' + e.message, 'error') |
||||
|
} finally { |
||||
|
detailLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function copyPrompt() { |
||||
|
if (!detail.value?.systemPrompt) return |
||||
|
try { |
||||
|
await navigator.clipboard.writeText(detail.value.systemPrompt) |
||||
|
toast('已复制完整 Prompt', 'success') |
||||
|
} catch { |
||||
|
toast('复制失败', 'error') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function jumpEditRole() { |
||||
|
if (detail.value?.roleId) { |
||||
|
router.push({ path: '/settings/role', query: { roleId: String(detail.value.roleId) } }) |
||||
|
} |
||||
|
} |
||||
|
function jumpEditGlobal() { |
||||
|
router.push({ path: '/settings/system-config', query: { key: 'ai_system_prompt' } }) |
||||
|
} |
||||
|
|
||||
|
// ===== 对比 ===== |
||||
|
async function openCompare() { |
||||
|
if (selectedRowKeys.value.length !== 2) return |
||||
|
const ids = selectedRowKeys.value |
||||
|
try { |
||||
|
const [a, b] = await Promise.all([getLlmTrace(ids[0]), getLlmTrace(ids[1])]) |
||||
|
if (!a.success || !b.success) { |
||||
|
toast(a.message || b.message || '加载对比数据失败', 'error') |
||||
|
return |
||||
|
} |
||||
|
compareA.value = a.data |
||||
|
compareB.value = b.data |
||||
|
compareVisible.value = true |
||||
|
} catch (e: any) { |
||||
|
toast('加载对比数据失败:' + e.message, 'error') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ===== 清理 ===== |
||||
|
function openClean() { |
||||
|
cleanKeepDays.value = 30 |
||||
|
cleanVisible.value = true |
||||
|
} |
||||
|
|
||||
|
async function doClean() { |
||||
|
if (!(await confirm('确认清理 ' + cleanKeepDays.value + ' 天前的调用记录?'))) return |
||||
|
cleaning.value = true |
||||
|
try { |
||||
|
const r = await cleanLlmTraces(cleanKeepDays.value) |
||||
|
if (r.success) { |
||||
|
toast('清理完成,删除 ' + (r.deleted ?? 0) + ' 条', 'success') |
||||
|
cleanVisible.value = false |
||||
|
loadList() |
||||
|
loadStats() |
||||
|
} else { |
||||
|
toast(r.message || '清理失败', 'error') |
||||
|
} |
||||
|
} catch (e: any) { |
||||
|
toast('清理失败:' + e.message, 'error') |
||||
|
} finally { |
||||
|
cleaning.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ===== 会话时间线 ===== |
||||
|
async function openTimeline(row: any) { |
||||
|
if (!row.conversationId) { toast('该记录无会话 ID', 'warning'); return } |
||||
|
timelineVisible.value = true |
||||
|
timelineConversationId.value = row.conversationId |
||||
|
timelineLoading.value = true |
||||
|
timelineRows.value = [] |
||||
|
try { |
||||
|
const r = await getLlmTraceConversation(row.conversationId) |
||||
|
if (r.success) { |
||||
|
timelineRows.value = r.data || [] |
||||
|
} else { |
||||
|
toast(r.message || '加载会话时间线失败', 'error') |
||||
|
} |
||||
|
} catch (e: any) { |
||||
|
toast('加载会话时间线失败:' + e.message, 'error') |
||||
|
} finally { |
||||
|
timelineLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ===== 趋势图 ===== |
||||
|
function openTrend() { |
||||
|
trendVisible.value = true |
||||
|
} |
||||
|
|
||||
|
function trendTimeRange(): { startTime?: string; endTime?: string } { |
||||
|
const end = new Date() |
||||
|
if (trendRange.value === '24h') { |
||||
|
const start = new Date(end.getTime() - 24 * 3600 * 1000) |
||||
|
return { startTime: fmtDateTime(start), endTime: fmtDateTime(end) } |
||||
|
} |
||||
|
if (trendRange.value === '7d') { |
||||
|
const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000) |
||||
|
return { startTime: fmtDateTime(start), endTime: fmtDateTime(end) } |
||||
|
} |
||||
|
return {} |
||||
|
} |
||||
|
|
||||
|
function fmtDateTime(d: Date): string { |
||||
|
const p = (n: number) => String(n).padStart(2, '0') |
||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` |
||||
|
} |
||||
|
|
||||
|
async function loadTrend() { |
||||
|
trendLoading.value = true |
||||
|
try { |
||||
|
const range = trendTimeRange() |
||||
|
const r = await getLlmTraceTrend({ groupBy: trendGroupBy.value, ...range }) |
||||
|
if (r.success) { |
||||
|
trendRows.value = r.data || [] |
||||
|
await nextTick() |
||||
|
renderTrendChart() |
||||
|
} else { |
||||
|
toast(r.message || '加载趋势失败', 'error') |
||||
|
} |
||||
|
} catch (e: any) { |
||||
|
toast('加载趋势失败:' + e.message, 'error') |
||||
|
} finally { |
||||
|
trendLoading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function renderTrendChart() { |
||||
|
const { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler } = await import('chart.js') |
||||
|
Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler) |
||||
|
const labels = trendRows.value.map((s: any) => s.timeBucket || '') |
||||
|
if (trendChart) { trendChart.destroy(); trendChart = null } |
||||
|
if (!trendChartRef.value) return |
||||
|
trendChart = new Chart(trendChartRef.value, { |
||||
|
type: 'line', |
||||
|
data: { labels, datasets: [ |
||||
|
{ label: '调用量', data: trendRows.value.map((s: any) => s.callCount ?? 0), borderColor: palette.blue, backgroundColor: `rgba(${palette.blueRgb},0.1)`, fill: true, tension: 0.3, pointRadius: 3, yAxisID: 'y' }, |
||||
|
{ label: '平均耗时(ms)', data: trendRows.value.map((s: any) => s.avgLatencyMs ?? 0), borderColor: palette.purple, backgroundColor: `rgba(${palette.purpleRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' }, |
||||
|
{ label: '错误率(%)', data: trendRows.value.map((s: any) => s.errorRate ?? 0), borderColor: palette.red, backgroundColor: `rgba(${palette.redRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' }, |
||||
|
{ label: 'FAQ 命中率(%)', data: trendRows.value.map((s: any) => s.faqHitRate ?? 0), borderColor: palette.green, backgroundColor: `rgba(${palette.greenRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' }, |
||||
|
{ label: '总 Token', data: trendRows.value.map((s: any) => s.totalTokens ?? 0), borderColor: palette.orange, backgroundColor: `rgba(${palette.orangeRgb},0.1)`, fill: false, tension: 0.3, pointRadius: 3, yAxisID: 'y1' }, |
||||
|
] }, |
||||
|
options: { |
||||
|
responsive: true, maintainAspectRatio: false, |
||||
|
interaction: { mode: 'index', intersect: false }, |
||||
|
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 12 } } } }, |
||||
|
scales: { |
||||
|
y: { type: 'linear', display: true, position: 'left', beginAtZero: true, title: { display: true, text: '调用量' } }, |
||||
|
y1: { type: 'linear', display: true, position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: '耗时/率/Token' } }, |
||||
|
x: { grid: { display: false } }, |
||||
|
}, |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
function destroyTrendChart() { if (trendChart) { trendChart.destroy(); trendChart = null } } |
||||
|
|
||||
|
onBeforeUnmount(() => destroyTrendChart()) |
||||
|
|
||||
|
// ===== JSON 解析辅助 ===== |
||||
|
function parseJsonArray(text: string | null | undefined): any[] { |
||||
|
if (!text) return [] |
||||
|
try { |
||||
|
const arr = JSON.parse(text) |
||||
|
return Array.isArray(arr) ? arr : [] |
||||
|
} catch { |
||||
|
return [] |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.desc-text { color: var(--color-text-secondary); font-size: 13px; margin: 0 0 16px; } |
||||
|
.toolbar { display: flex; align-items: center; gap: 8px; margin: 12px 0; flex-wrap: wrap; } |
||||
|
.stats-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; flex-wrap: wrap; } |
||||
|
.stat-card { display: flex; flex-direction: column; align-items: center; min-width: 80px; padding: 4px 12px; border-radius: 6px; background: var(--color-bg-secondary, #f3f3f3); } |
||||
|
.stat-num { font-size: 18px; font-weight: 600; color: var(--color-text-primary); } |
||||
|
.stat-label { font-size: 12px; color: var(--color-text-secondary); } |
||||
|
.muted { color: var(--color-text-secondary); } |
||||
|
.detail-meta { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; } |
||||
|
.block-title { font-size: 13px; font-weight: 600; margin: 12px 0 6px; } |
||||
|
.prompt-segments { display: flex; flex-direction: column; gap: 8px; } |
||||
|
.seg { border: 1px solid var(--color-border, #e7e7e7); border-radius: 6px; padding: 8px; } |
||||
|
.seg-tag { display: inline-block; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 4px; } |
||||
|
.seg-text, .full-prompt { white-space: pre-wrap; word-break: break-word; font-family: inherit; margin: 0; font-size: 13px; line-height: 1.6; } |
||||
|
.full-prompt { background: var(--color-bg-secondary, #f7f7f7); padding: 12px; border-radius: 6px; max-height: 480px; overflow: auto; } |
||||
|
.full-prompt.small { max-height: 300px; font-size: 12px; } |
||||
|
.detail-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; } |
||||
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } |
||||
|
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } |
||||
|
.compare-col { min-width: 0; } |
||||
|
.compare-head { font-weight: 600; margin-bottom: 8px; } |
||||
|
.compare-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } |
||||
|
.error-box { background: var(--td-error-color-1, #fdecec); color: var(--td-error-color, #d54941); padding: 8px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; word-break: break-word; white-space: pre-wrap; } |
||||
|
.faq-box { border: 1px solid var(--color-border, #e7e7e7); border-radius: 6px; padding: 12px; display: flex; flex-direction: column; gap: 10px; } |
||||
|
.faq-row { display: flex; gap: 16px; font-size: 13px; } |
||||
|
.faq-label { flex-shrink: 0; width: 80px; color: var(--color-text-secondary); } |
||||
|
.tl-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } |
||||
|
.tl-user { font-size: 13px; color: var(--color-text-secondary); margin-bottom: 4px; white-space: pre-wrap; word-break: break-word; } |
||||
|
.tl-ai { font-size: 13px; background: var(--color-bg-secondary, #f7f7f7); padding: 8px 12px; border-radius: 6px; white-space: pre-wrap; word-break: break-word; } |
||||
|
.trend-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } |
||||
|
.trend-chart-wrap { position: relative; height: 420px; } |
||||
|
</style> |
||||
@ -0,0 +1,36 @@ |
|||||
|
package com.wok.supportbot.config; |
||||
|
|
||||
|
import org.springframework.context.annotation.Bean; |
||||
|
import org.springframework.context.annotation.Configuration; |
||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; |
||||
|
|
||||
|
import java.util.concurrent.ThreadPoolExecutor; |
||||
|
|
||||
|
/** |
||||
|
* 异步执行器配置。 |
||||
|
* <p> |
||||
|
* 为日志/追踪类异步写入提供有界线程池,避免 Spring 默认 {@code SimpleAsyncTaskExecutor} |
||||
|
* 每任务新建一线程导致的高并发线程爆炸与无背压问题。 |
||||
|
*/ |
||||
|
@Configuration |
||||
|
public class AsyncExecutorConfig { |
||||
|
|
||||
|
/** |
||||
|
* LLM 追踪等日志类异步写入线程池。 |
||||
|
* 有界队列 + CallerRunsPolicy:队列满时由调用线程执行,保证不丢数据(背压优先于丢弃)。 |
||||
|
*/ |
||||
|
@Bean("traceExecutor") |
||||
|
public ThreadPoolTaskExecutor traceExecutor() { |
||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); |
||||
|
executor.setCorePoolSize(4); |
||||
|
executor.setMaxPoolSize(8); |
||||
|
executor.setQueueCapacity(1000); |
||||
|
executor.setThreadNamePrefix("llm-trace-"); |
||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); |
||||
|
// 优雅停机时等待队列中的追踪/日志任务完成,避免丢记录 |
||||
|
executor.setWaitForTasksToCompleteOnShutdown(true); |
||||
|
executor.setAwaitTerminationSeconds(10); |
||||
|
executor.initialize(); |
||||
|
return executor; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,192 @@ |
|||||
|
package com.wok.supportbot.controller; |
||||
|
|
||||
|
import com.wok.supportbot.entity.LlmCallTrace; |
||||
|
import com.wok.supportbot.service.LlmCallTraceService; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.access.prepost.PreAuthorize; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.PostMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestBody; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
/** |
||||
|
* LLM 调用追踪接口(仅 admin 可访问)。 |
||||
|
* <p> |
||||
|
* 提供调用记录的分页查询、详情、聚合统计与清理。 |
||||
|
* 端点仅挂管理路径(/llm-trace),绝不进入 /ai/** 或 /open-api/**(SDK 开放路径), |
||||
|
* 防止 conversation_id 跨租户越权。 |
||||
|
*/ |
||||
|
@Slf4j |
||||
|
@RestController |
||||
|
@RequestMapping("/llm-trace") |
||||
|
@PreAuthorize("hasRole('admin')") |
||||
|
public class LlmCallTraceController { |
||||
|
|
||||
|
@Autowired |
||||
|
private LlmCallTraceService llmCallTraceService; |
||||
|
|
||||
|
/** 清理的最小保留天数(防止误清全部) */ |
||||
|
private static final int MIN_KEEP_DAYS = 1; |
||||
|
|
||||
|
/** 清理的最大保留天数(防止超过 PostgreSQL make_interval 合理范围) */ |
||||
|
private static final int MAX_KEEP_DAYS = 3650; |
||||
|
|
||||
|
/** |
||||
|
* 分页查询调用记录(列表不含大 TEXT 字段)。 |
||||
|
*/ |
||||
|
@GetMapping("/list") |
||||
|
public ResponseEntity<Map<String, Object>> list( |
||||
|
@RequestParam(defaultValue = "1") int page, |
||||
|
@RequestParam(defaultValue = "20") int size, |
||||
|
@RequestParam(required = false) Long roleId, |
||||
|
@RequestParam(required = false) String conversationId, |
||||
|
@RequestParam(required = false) String intent, |
||||
|
@RequestParam(required = false) String startTime, |
||||
|
@RequestParam(required = false) String endTime, |
||||
|
@RequestParam(required = false) String keyword, |
||||
|
@RequestParam(required = false) String errorType) { |
||||
|
try { |
||||
|
Map<String, Object> result = llmCallTraceService.pageQuery( |
||||
|
page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType); |
||||
|
Map<String, Object> data = new LinkedHashMap<>(); |
||||
|
data.put("success", true); |
||||
|
data.put("data", result.get("records")); |
||||
|
data.put("total", result.get("total")); |
||||
|
data.put("page", result.get("page")); |
||||
|
data.put("size", result.get("size")); |
||||
|
data.put("pages", result.get("pages")); |
||||
|
return ResponseEntity.ok(data); |
||||
|
} catch (Exception e) { |
||||
|
log.error("查询 LLM 调用追踪失败", e); |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "查询失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 单条详情(含完整 system prompt)。 |
||||
|
*/ |
||||
|
@GetMapping("/{id}") |
||||
|
public ResponseEntity<Map<String, Object>> detail(@PathVariable Long id) { |
||||
|
try { |
||||
|
LlmCallTrace trace = llmCallTraceService.getDetail(id); |
||||
|
if (trace == null) { |
||||
|
return ResponseEntity.status(404).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "调用记录不存在" |
||||
|
)); |
||||
|
} |
||||
|
return ResponseEntity.ok(Map.of("success", true, "data", trace)); |
||||
|
} catch (Exception e) { |
||||
|
log.error("查询 LLM 调用追踪详情失败: id={}", id, e); |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "查询失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清理 N 天前的记录(POST,避免 DELETE + RequestBody 路径冲突)。 |
||||
|
* 保留天数设下限,防止误清全部。 |
||||
|
*/ |
||||
|
@PostMapping("/clean") |
||||
|
public ResponseEntity<Map<String, Object>> clean(@RequestBody Map<String, Object> body) { |
||||
|
try { |
||||
|
Object raw = body.get("keepDays"); |
||||
|
int keepDays = raw instanceof Number n ? n.intValue() : Integer.parseInt(String.valueOf(raw)); |
||||
|
if (keepDays < MIN_KEEP_DAYS || keepDays > MAX_KEEP_DAYS) { |
||||
|
return ResponseEntity.badRequest().body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "保留天数必须在 " + MIN_KEEP_DAYS + " ~ " + MAX_KEEP_DAYS + " 天之间" |
||||
|
)); |
||||
|
} |
||||
|
int deleted = llmCallTraceService.cleanBefore(keepDays); |
||||
|
log.info("管理员清理 LLM 调用追踪:操作人={}, 保留 {} 天, 删除 {} 条", currentUser(), keepDays, deleted); |
||||
|
return ResponseEntity.ok(Map.of( |
||||
|
"success", true, |
||||
|
"message", "清理完成", |
||||
|
"deleted", deleted |
||||
|
)); |
||||
|
} catch (Exception e) { |
||||
|
log.error("清理 LLM 调用追踪失败", e); |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "清理失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 聚合统计(按角色或模型分组)。 |
||||
|
*/ |
||||
|
@GetMapping("/stats") |
||||
|
public ResponseEntity<Map<String, Object>> stats(@RequestParam(defaultValue = "role") String groupBy) { |
||||
|
try { |
||||
|
List<Map<String, Object>> rows = llmCallTraceService.stats(groupBy); |
||||
|
return ResponseEntity.ok(Map.of("success", true, "data", rows)); |
||||
|
} catch (Exception e) { |
||||
|
log.error("查询 LLM 调用追踪统计失败", e); |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "统计失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 可选错误类型列表(供前端筛选下拉)。 |
||||
|
*/ |
||||
|
@GetMapping("/error-types") |
||||
|
public ResponseEntity<Map<String, Object>> errorTypes() { |
||||
|
List<Map<String, String>> types = List.of( |
||||
|
Map.of("label", "LLM 调用失败", "value", "LLM_API"), |
||||
|
Map.of("label", "MCP 工具异常", "value", "MCP"), |
||||
|
Map.of("label", "熔断降级", "value", "CIRCUIT_BREAK"), |
||||
|
Map.of("label", "参数校验", "value", "VALIDATION"), |
||||
|
Map.of("label", "未知", "value", "UNKNOWN") |
||||
|
); |
||||
|
return ResponseEntity.ok(Map.of("success", true, "data", types)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 时间维度趋势统计(按小时或天聚合)。 |
||||
|
*/ |
||||
|
@GetMapping("/trend") |
||||
|
public ResponseEntity<Map<String, Object>> trend( |
||||
|
@RequestParam(defaultValue = "HOUR") String groupBy, |
||||
|
@RequestParam(required = false) String startTime, |
||||
|
@RequestParam(required = false) String endTime) { |
||||
|
try { |
||||
|
List<Map<String, Object>> rows = llmCallTraceService.trend(groupBy, startTime, endTime); |
||||
|
return ResponseEntity.ok(Map.of("success", true, "data", rows)); |
||||
|
} catch (Exception e) { |
||||
|
log.error("查询 LLM 调用追踪趋势失败", e); |
||||
|
return ResponseEntity.status(500).body(Map.of( |
||||
|
"success", false, |
||||
|
"message", "统计失败:" + e.getMessage() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前登录用户名(用于审计日志,best-effort)。 |
||||
|
*/ |
||||
|
private String currentUser() { |
||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
return auth != null ? auth.getName() : "unknown"; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
package com.wok.supportbot.dao; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import com.wok.supportbot.entity.LlmCallTrace; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
/** |
||||
|
* LLM 调用追踪 Mapper |
||||
|
*/ |
||||
|
@Mapper |
||||
|
public interface LlmCallTraceMapper extends BaseMapper<LlmCallTrace> { |
||||
|
} |
||||
@ -0,0 +1,192 @@ |
|||||
|
package com.wok.supportbot.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.FieldFill; |
||||
|
import com.baomidou.mybatisplus.annotation.IdType; |
||||
|
import com.baomidou.mybatisplus.annotation.TableField; |
||||
|
import com.baomidou.mybatisplus.annotation.TableId; |
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize; |
||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.io.Serial; |
||||
|
import java.io.Serializable; |
||||
|
import java.util.Date; |
||||
|
|
||||
|
/** |
||||
|
* LLM 调用追踪实体 |
||||
|
* <p> |
||||
|
* 记录每次 LLM 对话的完整"案发现场"(最终 system prompt、分段来源、用户消息、AI 回复、 |
||||
|
* 角色、模型参数、耗时、意图等),供管理员反推优化提示词。 |
||||
|
* append-only,无逻辑删除。 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@AllArgsConstructor |
||||
|
@NoArgsConstructor |
||||
|
@TableName("llm_call_trace") |
||||
|
public class LlmCallTrace implements Serializable { |
||||
|
|
||||
|
@Serial |
||||
|
@TableField(exist = false) |
||||
|
private static final long serialVersionUID = 1L; |
||||
|
|
||||
|
/** 主键ID(雪花算法) */ |
||||
|
@TableId(value = "id", type = IdType.ASSIGN_ID) |
||||
|
@JsonSerialize(using = ToStringSerializer.class) |
||||
|
private Long id; |
||||
|
|
||||
|
/** 会话ID */ |
||||
|
@TableField("conversation_id") |
||||
|
private String conversationId; |
||||
|
|
||||
|
/** 客服角色ID(可空,无角色时为 null) */ |
||||
|
@TableField("role_id") |
||||
|
@JsonSerialize(using = ToStringSerializer.class) |
||||
|
private Long roleId; |
||||
|
|
||||
|
/** 角色名称快照(角色改名后仍可追溯) */ |
||||
|
@TableField("role_name") |
||||
|
private String roleName; |
||||
|
|
||||
|
/** 账户ID(可空,预留租户/个人信息删除权) */ |
||||
|
@TableField("account_id") |
||||
|
private String accountId; |
||||
|
|
||||
|
/** API Key ID(可空,预留租户隔离) */ |
||||
|
@TableField("api_key_id") |
||||
|
@JsonSerialize(using = ToStringSerializer.class) |
||||
|
private Long apiKeyId; |
||||
|
|
||||
|
/** 意图:CHAT / CHITCHAT / FAQ / RAG */ |
||||
|
@TableField("intent") |
||||
|
private String intent; |
||||
|
|
||||
|
/** 是否启用 RAG 增强 */ |
||||
|
@TableField("enable_rag") |
||||
|
private Boolean enableRag; |
||||
|
|
||||
|
/** 最终注入 LLM 的完整 system prompt */ |
||||
|
@TableField("system_prompt") |
||||
|
private String systemPrompt; |
||||
|
|
||||
|
/** 全局提示词快照(可空) */ |
||||
|
@TableField("global_prompt") |
||||
|
private String globalPrompt; |
||||
|
|
||||
|
/** 角色提示词快照(可空) */ |
||||
|
@TableField("role_prompt") |
||||
|
private String rolePrompt; |
||||
|
|
||||
|
/** 用户原始消息(脱敏后) */ |
||||
|
@TableField("user_message") |
||||
|
private String userMessage; |
||||
|
|
||||
|
/** AI 回复(截断,保留头部+尾部) */ |
||||
|
@TableField("ai_response") |
||||
|
private String aiResponse; |
||||
|
|
||||
|
/** AI 回复是否被截断 */ |
||||
|
@TableField("ai_response_truncated") |
||||
|
private Boolean aiResponseTruncated; |
||||
|
|
||||
|
/** MCP 工具调用事件 JSON 数组(可空) */ |
||||
|
@TableField("tool_calls_json") |
||||
|
private String toolCallsJson; |
||||
|
|
||||
|
/** 本次注入 LLM 的历史消息 JSON(可空) */ |
||||
|
@TableField("history_messages_json") |
||||
|
private String historyMessagesJson; |
||||
|
|
||||
|
/** 历史消息轮数(可空) */ |
||||
|
@TableField("history_turns") |
||||
|
private Integer historyTurns; |
||||
|
|
||||
|
/** RAG 资料块(可空) */ |
||||
|
@TableField("rag_context") |
||||
|
private String ragContext; |
||||
|
|
||||
|
/** 是否 FAQ 命中 */ |
||||
|
@TableField("faq_hit") |
||||
|
private Boolean faqHit; |
||||
|
|
||||
|
/** FAQ 命中 ID(可空,未命中或非 FAQ 意图为 null) */ |
||||
|
@TableField("faq_id") |
||||
|
@JsonSerialize(using = ToStringSerializer.class) |
||||
|
private Long faqId; |
||||
|
|
||||
|
/** FAQ 标准问题快照(可空) */ |
||||
|
@TableField("faq_question") |
||||
|
private String faqQuestion; |
||||
|
|
||||
|
/** FAQ 匹配类型:EXACT / KEYWORD / SEMANTIC(可空) */ |
||||
|
@TableField("faq_match_type") |
||||
|
private String faqMatchType; |
||||
|
|
||||
|
/** FAQ 匹配分数(0.0 ~ 1.0,可空) */ |
||||
|
@TableField("faq_score") |
||||
|
private Double faqScore; |
||||
|
|
||||
|
/** 检索模式:VECTOR / KEYWORD / HYBRID(可空) */ |
||||
|
@TableField("search_mode") |
||||
|
private String searchMode; |
||||
|
|
||||
|
/** 命中文档数(可空) */ |
||||
|
@TableField("hit_count") |
||||
|
private Integer hitCount; |
||||
|
|
||||
|
/** RAG 命中片段详情 JSON 数组(含 documentId/title/chunkIndex/score/searchMode,可空) */ |
||||
|
@TableField("rag_hits_json") |
||||
|
private String ragHitsJson; |
||||
|
|
||||
|
/** 模型名称 */ |
||||
|
@TableField("model_name") |
||||
|
private String modelName; |
||||
|
|
||||
|
/** 提供商 */ |
||||
|
@TableField("provider") |
||||
|
private String provider; |
||||
|
|
||||
|
/** 温度参数 */ |
||||
|
@TableField("temperature") |
||||
|
private Double temperature; |
||||
|
|
||||
|
/** 最大 Token */ |
||||
|
@TableField("max_tokens") |
||||
|
private Integer maxTokens; |
||||
|
|
||||
|
/** 提示词 token 数(可空,非 LLM 调用路径无此值) */ |
||||
|
@TableField("prompt_tokens") |
||||
|
private Integer promptTokens; |
||||
|
|
||||
|
/** 生成 token 数(可空) */ |
||||
|
@TableField("completion_tokens") |
||||
|
private Integer completionTokens; |
||||
|
|
||||
|
/** 总 token 数(可空) */ |
||||
|
@TableField("total_tokens") |
||||
|
private Integer totalTokens; |
||||
|
|
||||
|
/** 调用耗时(毫秒) */ |
||||
|
@TableField("latency_ms") |
||||
|
private Integer latencyMs; |
||||
|
|
||||
|
/** 错误类型:LLM_API / MCP / CIRCUIT_BREAK / VALIDATION / UNKNOWN(可空,非失败调用为 null) */ |
||||
|
@TableField("error_type") |
||||
|
private String errorType; |
||||
|
|
||||
|
/** 错误原始消息(已脱敏,可空) */ |
||||
|
@TableField("error_message") |
||||
|
private String errorMessage; |
||||
|
|
||||
|
/** 状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS */ |
||||
|
@TableField("status") |
||||
|
private String status; |
||||
|
|
||||
|
/** 创建时间 */ |
||||
|
@TableField(value = "create_time", fill = FieldFill.INSERT) |
||||
|
private Date createTime; |
||||
|
} |
||||
@ -0,0 +1,292 @@ |
|||||
|
package com.wok.supportbot.service; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; |
||||
|
import com.wok.supportbot.dao.LlmCallTraceMapper; |
||||
|
import com.wok.supportbot.entity.LlmCallTrace; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.scheduling.annotation.Async; |
||||
|
import org.springframework.scheduling.annotation.Scheduled; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
/** |
||||
|
* LLM 调用追踪服务。 |
||||
|
* <p> |
||||
|
* 异步写入每次 LLM 调用的完整现场,并提供分页查询、详情、聚合统计与自动清理。 |
||||
|
* 列表查询不 select 大 TEXT 字段(system_prompt 等),避免分页 payload 膨胀。 |
||||
|
*/ |
||||
|
@Service |
||||
|
@Slf4j |
||||
|
public class LlmCallTraceService { |
||||
|
|
||||
|
@Autowired |
||||
|
private LlmCallTraceMapper llmCallTraceMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@Autowired |
||||
|
private SystemConfigService systemConfigService; |
||||
|
|
||||
|
/** 列表查询时 user_message 的摘要长度 */ |
||||
|
private static final int SUMMARY_LENGTH = 120; |
||||
|
|
||||
|
/** 列表查询需要排除的大 TEXT 字段 */ |
||||
|
private static final Set<String> BIG_TEXT_FIELDS = |
||||
|
Set.of("system_prompt", "global_prompt", "role_prompt", "rag_context", "ai_response", |
||||
|
"error_message", "tool_calls_json", "rag_hits_json", "history_messages_json"); |
||||
|
|
||||
|
/** 默认保留天数 */ |
||||
|
private static final int DEFAULT_RETENTION_DAYS = 30; |
||||
|
|
||||
|
/** |
||||
|
* 异步记录一次 LLM 调用追踪(不阻塞对话主流程)。 |
||||
|
* |
||||
|
* @param trace 追踪实体 |
||||
|
*/ |
||||
|
@Async("traceExecutor") |
||||
|
public void recordAsync(LlmCallTrace trace) { |
||||
|
try { |
||||
|
llmCallTraceMapper.insert(trace); |
||||
|
log.debug("记录 LLM 调用追踪: id={}, status={}, latencyMs={}", trace.getId(), trace.getStatus(), trace.getLatencyMs()); |
||||
|
} catch (Exception e) { |
||||
|
log.error("记录 LLM 调用追踪失败: status={}, chatId={}, error={}", trace.getStatus(), trace.getConversationId(), e.getMessage()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 分页查询(列表不含大 TEXT 字段,user_message 截断为摘要)。 |
||||
|
*/ |
||||
|
public Map<String, Object> pageQuery(int page, int size, Long roleId, String conversationId, |
||||
|
String intent, String startTime, String endTime, String keyword, |
||||
|
String errorType) { |
||||
|
if (page < 1) page = 1; |
||||
|
if (page > 10000) page = 10000; |
||||
|
if (size < 1 || size > 100) size = 20; |
||||
|
|
||||
|
// 总数(只加 WHERE 条件) |
||||
|
Long total = llmCallTraceMapper.selectCount( |
||||
|
buildWhere(new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType)); |
||||
|
if (total == null) total = 0L; |
||||
|
|
||||
|
// 列表(排除大字段 + 排序 + 分页) |
||||
|
QueryWrapper<LlmCallTrace> listWrapper = buildWhere( |
||||
|
new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType); |
||||
|
listWrapper.select(LlmCallTrace.class, field -> !BIG_TEXT_FIELDS.contains(field.getColumn())); |
||||
|
listWrapper.orderByDesc("create_time").orderByDesc("id"); |
||||
|
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1L) * size)); |
||||
|
List<LlmCallTrace> records = llmCallTraceMapper.selectList(listWrapper); |
||||
|
|
||||
|
// user_message 截断为摘要,避免列表 payload 过大 |
||||
|
for (LlmCallTrace r : records) { |
||||
|
if (r.getUserMessage() != null && r.getUserMessage().length() > SUMMARY_LENGTH) { |
||||
|
r.setUserMessage(r.getUserMessage().substring(0, SUMMARY_LENGTH) + "…"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Map<String, Object> result = new LinkedHashMap<>(); |
||||
|
result.put("records", records); |
||||
|
result.put("total", total); |
||||
|
result.put("page", page); |
||||
|
result.put("size", size); |
||||
|
result.put("pages", (total + size - 1) / size); |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 单条详情(含全部字段,system_prompt 全文)。 |
||||
|
*/ |
||||
|
public LlmCallTrace getDetail(Long id) { |
||||
|
return llmCallTraceMapper.selectById(id); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 聚合统计:按角色(role)或按模型(model)分组。 |
||||
|
*/ |
||||
|
public List<Map<String, Object>> stats(String groupBy) { |
||||
|
String sql; |
||||
|
if ("model".equalsIgnoreCase(groupBy)) { |
||||
|
sql = "SELECT model_name, provider, COUNT(*) AS count, " |
||||
|
+ "ROUND(AVG(latency_ms)) AS avg_latency_ms, " |
||||
|
+ "COUNT(*) FILTER (WHERE faq_hit = true) AS faq_hit_count " |
||||
|
+ "FROM llm_call_trace WHERE model_name IS NOT NULL " |
||||
|
+ "GROUP BY model_name, provider ORDER BY count DESC"; |
||||
|
} else { |
||||
|
sql = "SELECT role_id::text AS role_id, role_name, COUNT(*) AS count, " |
||||
|
+ "ROUND(AVG(latency_ms)) AS avg_latency_ms, " |
||||
|
+ "COUNT(*) FILTER (WHERE faq_hit = true) AS faq_hit_count " |
||||
|
+ "FROM llm_call_trace GROUP BY role_id, role_name ORDER BY count DESC"; |
||||
|
} |
||||
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql); |
||||
|
List<Map<String, Object>> result = new ArrayList<>(rows.size()); |
||||
|
for (Map<String, Object> row : rows) { |
||||
|
result.add(snakeToCamel(row)); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 时间维度趋势:按小时或天聚合调用量 / 平均耗时 / 错误率 / FAQ 命中率 / token 用量。 |
||||
|
* |
||||
|
* @param groupBy HOUR 或 DAY(其余值按 HOUR 处理) |
||||
|
* @param startTime 可选开始时间(含),格式 yyyy-MM-dd HH:mm:ss |
||||
|
* @param endTime 可选结束时间(含),格式 yyyy-MM-dd HH:mm:ss |
||||
|
* @return 按时间桶升序排列的统计列表 |
||||
|
*/ |
||||
|
public List<Map<String, Object>> trend(String groupBy, String startTime, String endTime) { |
||||
|
boolean byDay = "DAY".equalsIgnoreCase(groupBy); |
||||
|
String bucket = byDay ? "day" : "hour"; |
||||
|
String timeFormat = byDay ? "'YYYY-MM-DD'" : "'YYYY-MM-DD HH24'"; |
||||
|
|
||||
|
StringBuilder sql = new StringBuilder(); |
||||
|
sql.append("SELECT to_char(date_trunc('").append(bucket).append("', create_time), ") |
||||
|
.append(timeFormat).append(") AS time_bucket, ") |
||||
|
.append("COUNT(*) AS call_count, ") |
||||
|
.append("ROUND(AVG(latency_ms)) AS avg_latency_ms, ") |
||||
|
.append("ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'ERROR') / NULLIF(COUNT(*), 0), 2) AS error_rate, ") |
||||
|
.append("ROUND(100.0 * COUNT(*) FILTER (WHERE faq_hit = true) / NULLIF(COUNT(*), 0), 2) AS faq_hit_rate, ") |
||||
|
.append("COALESCE(SUM(total_tokens), 0) AS total_tokens ") |
||||
|
.append("FROM llm_call_trace WHERE 1=1 "); |
||||
|
List<Object> params = new ArrayList<>(); |
||||
|
if (startTime != null && !startTime.isBlank()) { |
||||
|
sql.append("AND create_time >= ? "); |
||||
|
params.add(startTime.trim()); |
||||
|
} |
||||
|
if (endTime != null && !endTime.isBlank()) { |
||||
|
sql.append("AND create_time <= ? "); |
||||
|
params.add(endTime.trim()); |
||||
|
} |
||||
|
// PostgreSQL 不允许 GROUP BY 引用 SELECT 输出列别名,故此处重复 date_trunc 表达式; |
||||
|
// ORDER BY 则可引用别名 time_bucket |
||||
|
sql.append("GROUP BY date_trunc('").append(bucket).append("', create_time) ORDER BY time_bucket"); |
||||
|
|
||||
|
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray()); |
||||
|
List<Map<String, Object>> result = new ArrayList<>(rows.size()); |
||||
|
for (Map<String, Object> row : rows) { |
||||
|
result.add(snakeToCamel(row)); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 分批删除 N 天前的记录,避免单次大表 DELETE 锁表。 |
||||
|
* |
||||
|
* @param keepDays 保留天数 |
||||
|
* @return 删除条数 |
||||
|
*/ |
||||
|
public int cleanBefore(int keepDays) { |
||||
|
int batchSize = 5000; |
||||
|
int totalDeleted = 0; |
||||
|
while (true) { |
||||
|
int deleted = jdbcTemplate.update( |
||||
|
"DELETE FROM llm_call_trace WHERE id IN (" |
||||
|
+ "SELECT id FROM llm_call_trace " |
||||
|
+ "WHERE create_time < NOW() - make_interval(days => ?) LIMIT ?)", |
||||
|
keepDays, batchSize); |
||||
|
if (deleted <= 0) { |
||||
|
break; |
||||
|
} |
||||
|
totalDeleted += deleted; |
||||
|
if (deleted < batchSize) { |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
return totalDeleted; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 每日凌晨 2 点自动清理过期追踪记录(保留天数从 system_config 读取)。 |
||||
|
*/ |
||||
|
@Scheduled(cron = "0 0 2 * * ?") |
||||
|
public void scheduledClean() { |
||||
|
int keepDays = retentionDays(); |
||||
|
try { |
||||
|
int deleted = cleanBefore(keepDays); |
||||
|
if (deleted > 0) { |
||||
|
log.info("LLM 调用追踪自动清理完成:删除 {} 条(保留 {} 天)", deleted, keepDays); |
||||
|
} |
||||
|
} catch (Exception e) { |
||||
|
log.error("LLM 调用追踪自动清理失败: {}", e.getMessage()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 读取保留天数配置,非法值回退默认 30 天。 |
||||
|
*/ |
||||
|
private int retentionDays() { |
||||
|
String value = systemConfigService.getValueByKey("llm_trace_retention_days"); |
||||
|
if (value != null && value.matches("\\d+")) { |
||||
|
int days = Integer.parseInt(value); |
||||
|
if (days >= 1) { |
||||
|
return days; |
||||
|
} |
||||
|
} |
||||
|
return DEFAULT_RETENTION_DAYS; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 组装 WHERE 条件(供 count 与 list 复用)。 |
||||
|
*/ |
||||
|
private QueryWrapper<LlmCallTrace> buildWhere(QueryWrapper<LlmCallTrace> wrapper, Long roleId, |
||||
|
String conversationId, String intent, String startTime, String endTime, String keyword, |
||||
|
String errorType) { |
||||
|
if (roleId != null) { |
||||
|
wrapper.eq("role_id", roleId); |
||||
|
} |
||||
|
if (conversationId != null && !conversationId.isBlank()) { |
||||
|
wrapper.eq("conversation_id", conversationId.trim()); |
||||
|
} |
||||
|
if (intent != null && !intent.isBlank()) { |
||||
|
wrapper.eq("intent", intent.trim().toUpperCase()); |
||||
|
} |
||||
|
if (errorType != null && !errorType.isBlank()) { |
||||
|
wrapper.eq("error_type", errorType.trim().toUpperCase()); |
||||
|
} |
||||
|
if (startTime != null && !startTime.isBlank()) { |
||||
|
wrapper.ge("create_time", startTime.trim()); |
||||
|
} |
||||
|
if (endTime != null && !endTime.isBlank()) { |
||||
|
wrapper.le("create_time", endTime.trim()); |
||||
|
} |
||||
|
if (keyword != null && !keyword.isBlank()) { |
||||
|
String raw = keyword.trim(); |
||||
|
final String kw = raw.length() > 128 ? raw.substring(0, 128) : raw; |
||||
|
wrapper.and(w -> w.like("user_message", kw).or().like("ai_response", kw)); |
||||
|
} |
||||
|
return wrapper; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* snake_case → camelCase(供 JdbcTemplate 查询结果转驼峰)。 |
||||
|
*/ |
||||
|
private Map<String, Object> snakeToCamel(Map<String, Object> row) { |
||||
|
Map<String, Object> out = new LinkedHashMap<>(); |
||||
|
for (Map.Entry<String, Object> e : row.entrySet()) { |
||||
|
out.put(toCamel(e.getKey()), e.getValue()); |
||||
|
} |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
private String toCamel(String snake) { |
||||
|
StringBuilder sb = new StringBuilder(); |
||||
|
boolean upper = false; |
||||
|
for (char c : snake.toCharArray()) { |
||||
|
if (c == '_') { |
||||
|
upper = true; |
||||
|
} else if (upper) { |
||||
|
sb.append(Character.toUpperCase(c)); |
||||
|
upper = false; |
||||
|
} else { |
||||
|
sb.append(c); |
||||
|
} |
||||
|
} |
||||
|
return sb.toString(); |
||||
|
} |
||||
|
} |
||||
@ -1,123 +0,0 @@ |
|||||
package com.wok.supportbot; |
|
||||
|
|
||||
import com.wok.supportbot.app.ChatContext; |
|
||||
import com.wok.supportbot.rag.CategoryFilter; |
|
||||
import com.wok.supportbot.rag.RagContext; |
|
||||
import com.wok.supportbot.rag.RagPipeline; |
|
||||
import jakarta.annotation.Resource; |
|
||||
import org.junit.jupiter.api.Assertions; |
|
||||
import org.junit.jupiter.api.DisplayName; |
|
||||
import org.junit.jupiter.api.Test; |
|
||||
import org.springframework.boot.test.context.SpringBootTest; |
|
||||
|
|
||||
import java.util.Collections; |
|
||||
import java.util.List; |
|
||||
|
|
||||
/** |
|
||||
* 阶段一公共组件验证测试。 |
|
||||
* 需要运行中的 PostgreSQL(RagPipeline 检索依赖向量库)。 |
|
||||
*/ |
|
||||
@SpringBootTest |
|
||||
class Phase1ComponentTests { |
|
||||
|
|
||||
@Resource |
|
||||
private CategoryFilter categoryFilter; |
|
||||
|
|
||||
@Resource |
|
||||
private RagPipeline ragPipeline; |
|
||||
|
|
||||
// ==================== 1.1 CategoryFilter ==================== |
|
||||
|
|
||||
@Test @DisplayName("CF-01 parse(String) 正常逗号分隔") |
|
||||
void parseStringNormal() { |
|
||||
Assertions.assertEquals(List.of(1L, 2L, 3L), categoryFilter.parse("1,2,3")); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("CF-02 parse(String) 含空格") |
|
||||
void parseStringWithSpaces() { |
|
||||
Assertions.assertEquals(List.of(1L, 2L, 3L), categoryFilter.parse(" 1 , 2 , 3 ")); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("CF-03 parse(String) null/空白") |
|
||||
void parseStringNullOrBlank() { |
|
||||
Assertions.assertEquals(Collections.emptyList(), categoryFilter.parse((String) null)); |
|
||||
Assertions.assertEquals(Collections.emptyList(), categoryFilter.parse("")); |
|
||||
Assertions.assertEquals(Collections.emptyList(), categoryFilter.parse(" ")); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("CF-04/05 parse(Object) List") |
|
||||
void parseObjectList() { |
|
||||
// 数字元素 |
|
||||
Assertions.assertEquals(List.of(1L, 2L, 3L), categoryFilter.parse((Object) List.of(1, 2, 3))); |
|
||||
// 字符串元素 |
|
||||
Assertions.assertEquals(List.of(1L, 2L, 3L), categoryFilter.parse((Object) List.of("1", "2", "3"))); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("CF-06/07 parse(Object) 字符串 / null") |
|
||||
void parseObjectStringOrNull() { |
|
||||
Assertions.assertEquals(List.of(4L, 5L, 6L), categoryFilter.parse((Object) "4,5,6")); |
|
||||
Assertions.assertEquals(Collections.emptyList(), categoryFilter.parse((Object) null)); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("CF-08/09 normalize") |
|
||||
void normalize() { |
|
||||
Assertions.assertEquals(List.of("1", "2", "3"), categoryFilter.normalize(List.of(1L, 2L, 3L))); |
|
||||
Assertions.assertEquals(Collections.emptyList(), categoryFilter.normalize(null)); |
|
||||
Assertions.assertEquals(Collections.emptyList(), categoryFilter.normalize(Collections.emptyList())); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("CF-10/11 buildExpression") |
|
||||
void buildExpression() { |
|
||||
Assertions.assertNotNull(categoryFilter.buildExpression(List.of(1L, 2L))); |
|
||||
Assertions.assertNull(categoryFilter.buildExpression(Collections.emptyList())); |
|
||||
} |
|
||||
|
|
||||
// ==================== 1.2 RagPipeline ==================== |
|
||||
|
|
||||
@Test @DisplayName("RP-08 未指定策略 → 原样查询") |
|
||||
void ragDefaultStrategy() { |
|
||||
ChatContext ctx = new ChatContext("退换货流程是什么", "test-rp-01", "CHAT", |
|
||||
null, null, null, null, true, false); |
|
||||
RagContext result = ragPipeline.retrieve(ctx); |
|
||||
Assertions.assertFalse(result.faqHit(), "不应触发 FAQ(除非该问题恰好命中 FAQ 库)"); |
|
||||
Assertions.assertNotNull(result.documents(), "documents 不应为 null"); |
|
||||
Assertions.assertNotNull(result.rewrittenQuery(), "rewrittenQuery 不应为 null"); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("RP-11/12 分类过滤") |
|
||||
void ragCategoryFilter() { |
|
||||
// 有分类过滤 |
|
||||
ChatContext ctx1 = new ChatContext("平台使用说明", "test-rp-11", "CHAT", |
|
||||
null, null, List.of(1L), null, true, false); |
|
||||
RagContext r1 = ragPipeline.retrieve(ctx1); |
|
||||
Assertions.assertNotNull(r1.documents()); |
|
||||
System.out.println(">>> 分类过滤(1) 命中数: " + r1.documents().size()); |
|
||||
|
|
||||
// 无分类过滤 |
|
||||
ChatContext ctx2 = new ChatContext("平台使用说明", "test-rp-12", "CHAT", |
|
||||
null, null, null, null, true, false); |
|
||||
RagContext r2 = ragPipeline.retrieve(ctx2); |
|
||||
Assertions.assertNotNull(r2.documents()); |
|
||||
System.out.println(">>> 无分类过滤 命中数: " + r2.documents().size()); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("RP-14 retrieveDocuments 跳过 FAQ") |
|
||||
void ragRetrieveDocuments() { |
|
||||
ChatContext ctx = new ChatContext("平台使用说明", "test-rp-14", "CHAT", |
|
||||
null, null, null, null, true, false); |
|
||||
var docs = ragPipeline.retrieveDocuments(ctx); |
|
||||
Assertions.assertNotNull(docs); |
|
||||
System.out.println(">>> retrieveDocuments 命中数: " + docs.size()); |
|
||||
} |
|
||||
|
|
||||
@Test @DisplayName("RP-04 REWRITE 策略") |
|
||||
void ragRewriteStrategy() { |
|
||||
ChatContext ctx = new ChatContext("订单退款流程", "test-rp-04", "CHAT", |
|
||||
null, null, null, "REWRITE", true, false); |
|
||||
RagContext result = ragPipeline.retrieve(ctx); |
|
||||
Assertions.assertNotNull(result.rewrittenQuery()); |
|
||||
System.out.println(">>> REWRITE 重写前: 订单退款流程"); |
|
||||
System.out.println(">>> REWRITE 重写后: " + result.rewrittenQuery()); |
|
||||
Assertions.assertNotNull(result.documents()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,55 +0,0 @@ |
|||||
package com.wok.supportbot; |
|
||||
|
|
||||
import com.wok.supportbot.app.AssistantApp; |
|
||||
import com.wok.supportbot.app.ChatContext; |
|
||||
import jakarta.annotation.Resource; |
|
||||
import org.junit.jupiter.api.Assertions; |
|
||||
import org.junit.jupiter.api.Test; |
|
||||
import org.springframework.boot.test.context.SpringBootTest; |
|
||||
|
|
||||
import java.util.UUID; |
|
||||
|
|
||||
@SpringBootTest |
|
||||
class SupportBotApplicationTests { |
|
||||
@Resource |
|
||||
private AssistantApp assistantApp; |
|
||||
|
|
||||
@Test |
|
||||
void testChat() { |
|
||||
String chatId = UUID.randomUUID().toString(); |
|
||||
// 第一轮:商品咨询 |
|
||||
String message = "你好,我想买一台适合学生用的笔记本电脑,有推荐吗?"; |
|
||||
String answer = assistantApp.chat(ChatContext.of(message, chatId)); |
|
||||
Assertions.assertNotNull(answer); |
|
||||
|
|
||||
// 第二轮:物流问题 |
|
||||
message = "我上周买的那台电脑现在还没到,能查一下物流吗?"; |
|
||||
answer = assistantApp.chat(ChatContext.of(message, chatId)); |
|
||||
Assertions.assertNotNull(answer); |
|
||||
|
|
||||
// 第三轮:售后问题 |
|
||||
message = "电脑到了,但有点问题。你刚刚说的售后流程能再说一遍吗?"; |
|
||||
answer = assistantApp.chat(ChatContext.of(message, chatId)); |
|
||||
Assertions.assertNotNull(answer); |
|
||||
} |
|
||||
|
|
||||
@Test |
|
||||
void doChatWithRag() { |
|
||||
String chatId = "1069b88d-eb85-47ac-bd2e-c393d118a5aa"; |
|
||||
String message = "我之前询问了你什么问题?"; |
|
||||
String answer = assistantApp.chat(new ChatContext(message, chatId, "CHAT", null, null, |
|
||||
null, null, true, false)); |
|
||||
Assertions.assertNotNull(answer); |
|
||||
} |
|
||||
|
|
||||
@Test |
|
||||
void doChatWithRagEnhance() { |
|
||||
String chatId = "1069b88d-eb85-47ac-bd2e-c393d118a5aa"; |
|
||||
String message = "我之前询问了你什么问题?"; |
|
||||
String answer = assistantApp.chat(new ChatContext(message, chatId, "CHAT", null, null, |
|
||||
null, null, true, false)); |
|
||||
Assertions.assertNotNull(answer); |
|
||||
} |
|
||||
|
|
||||
|
|
||||
} |
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue