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