24 changed files with 1449 additions and 213 deletions
-
10CLAUDE.md
-
1frontend/components.d.ts
-
44frontend/src/api/llm-trace.ts
-
1frontend/src/router/index.ts
-
1frontend/src/stores/navigation.ts
-
420frontend/src/views/PromptTracePanel.vue
-
12frontend/src/views/RoleManager.vue
-
13frontend/src/views/SystemConfigManager.vue
-
117src/main/java/com/wok/supportbot/app/AssistantApp.java
-
20src/main/java/com/wok/supportbot/app/ChatContext.java
-
23src/main/java/com/wok/supportbot/app/ChatPipeline.java
-
14src/main/java/com/wok/supportbot/app/ChatRequest.java
-
36src/main/java/com/wok/supportbot/config/AsyncExecutorConfig.java
-
79src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
10src/main/java/com/wok/supportbot/controller/AiController.java
-
156src/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
-
139src/main/java/com/wok/supportbot/entity/LlmCallTrace.java
-
243src/main/java/com/wok/supportbot/service/LlmCallTraceService.java
-
4src/main/java/com/wok/supportbot/service/RagHitLogService.java
-
127src/main/resources/init-database.sql
-
123src/test/java/com/wok/supportbot/Phase1ComponentTests.java
-
55src/test/java/com/wok/supportbot/SupportBotApplicationTests.java
@ -0,0 +1,44 @@ |
|||||
|
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 |
||||
|
} |
||||
|
|
||||
|
/** 分页查询调用记录 */ |
||||
|
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) |
||||
|
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) |
||||
|
} |
||||
@ -0,0 +1,420 @@ |
|||||
|
<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-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" :disabled="selectedRowKeys.length !== 2" @click="openCompare">对比({{ selectedRowKeys.length }}/2)</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 #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> |
||||
|
</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> |
||||
|
<span class="muted">{{ detail.roleName || '(无角色)' }} · {{ detail.modelName || '未知模型' }} · {{ detail.provider }} · {{ detail.latencyMs }}ms</span> |
||||
|
</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> |
||||
|
<pre class="full-prompt">{{ detail.systemPrompt || '(空)' }}</pre> |
||||
|
<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 回复"><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 资料"> |
||||
|
<pre class="full-prompt">{{ detail.ragContext || '(无 RAG 资料)' }}</pre> |
||||
|
<div class="muted" style="margin-top:8px;">检索模式:{{ detail.searchMode || '-' }} · 命中:{{ detail.hitCount ?? '-' }} 条</div> |
||||
|
</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="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-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 } from 'vue' |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import { listLlmTraces, getLlmTrace, cleanLlmTraces, getLlmTraceStats } from '@/api/llm-trace' |
||||
|
import { getAllRoles } from '@/api/role' |
||||
|
import { toast } from '@/utils/toast' |
||||
|
import { formatDate } from '@/utils/format' |
||||
|
import { useDebounce } from '@/composables/useDebounce' |
||||
|
import { useConfirm } from '@/composables/useConfirm' |
||||
|
|
||||
|
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 roleOptions = 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: '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: 80, 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 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) |
||||
|
|
||||
|
onMounted(async () => { |
||||
|
await loadRoles() |
||||
|
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) } |
||||
|
} |
||||
|
|
||||
|
// ===== 聚合统计 ===== |
||||
|
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, |
||||
|
}) |
||||
|
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() |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
} |
||||
|
</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; } |
||||
|
</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,156 @@ |
|||||
|
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) { |
||||
|
try { |
||||
|
Map<String, Object> result = llmCallTraceService.pageQuery( |
||||
|
page, size, roleId, conversationId, intent, startTime, endTime, keyword); |
||||
|
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() |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前登录用户名(用于审计日志,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,139 @@ |
|||||
|
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; |
||||
|
|
||||
|
/** RAG 资料块(可空) */ |
||||
|
@TableField("rag_context") |
||||
|
private String ragContext; |
||||
|
|
||||
|
/** 是否 FAQ 命中 */ |
||||
|
@TableField("faq_hit") |
||||
|
private Boolean faqHit; |
||||
|
|
||||
|
/** 检索模式:VECTOR / KEYWORD / HYBRID(可空) */ |
||||
|
@TableField("search_mode") |
||||
|
private String searchMode; |
||||
|
|
||||
|
/** 命中文档数(可空) */ |
||||
|
@TableField("hit_count") |
||||
|
private Integer hitCount; |
||||
|
|
||||
|
/** 模型名称 */ |
||||
|
@TableField("model_name") |
||||
|
private String modelName; |
||||
|
|
||||
|
/** 提供商 */ |
||||
|
@TableField("provider") |
||||
|
private String provider; |
||||
|
|
||||
|
/** 温度参数 */ |
||||
|
@TableField("temperature") |
||||
|
private Double temperature; |
||||
|
|
||||
|
/** 最大 Token */ |
||||
|
@TableField("max_tokens") |
||||
|
private Integer maxTokens; |
||||
|
|
||||
|
/** 调用耗时(毫秒) */ |
||||
|
@TableField("latency_ms") |
||||
|
private Integer latencyMs; |
||||
|
|
||||
|
/** 状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS */ |
||||
|
@TableField("status") |
||||
|
private String status; |
||||
|
|
||||
|
/** 创建时间 */ |
||||
|
@TableField(value = "create_time", fill = FieldFill.INSERT) |
||||
|
private Date createTime; |
||||
|
} |
||||
@ -0,0 +1,243 @@ |
|||||
|
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"); |
||||
|
|
||||
|
/** 默认保留天数 */ |
||||
|
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) { |
||||
|
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)); |
||||
|
if (total == null) total = 0L; |
||||
|
|
||||
|
// 列表(排除大字段 + 排序 + 分页) |
||||
|
QueryWrapper<LlmCallTrace> listWrapper = buildWhere( |
||||
|
new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword); |
||||
|
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; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 分批删除 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) { |
||||
|
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 (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