diff --git a/CLAUDE.md b/CLAUDE.md index 0e33c00..68ad9c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,6 +214,7 @@ catch (e) { toast('操作失败', 'error') } - FAQ 管理: `/faq/*`(`FaqController`) - SDK 认证: `/open-api/auth/*`(`AuthController`,Token 换取) - API Key 角色绑定: `/api-key/{id}/roles`(`ApiKeyController`,admin 角色) +- LLM 调用追踪: `/llm-trace/*`(`LlmCallTraceController`,admin 角色) ### Filter 优先级 @@ -263,6 +264,15 @@ catch (e) { toast('操作失败', 'error') } - **vector_store 全文检索**: 新增 `content_tsvector` 列 + GIN 索引 + PostgreSQL 触发器自动维护 - **前端**: `DocSearch.js` 增加检索模式下拉选择(向量/关键词/混合),结果标注来源模式 +## LLM 调用追踪(提示词调试) + +- **llm_call_trace 表**:append-only,无逻辑删除。记录每次 LLM 调用的完整现场——最终 `system_prompt` + `global_prompt`/`role_prompt`/`rag_context` 分段快照、用户消息、AI 回复(截断)、模型参数(model_name/provider/temperature/max_tokens)、耗时、意图(CHAT/CHITCHAT/FAQ/RAG)、状态(COMPLETE/ERROR/CANCEL/FAQ/BYPASS)。 +- **埋点位置**:`AssistantApp.chatWithEvents`/`chatStream` 顶部埋点,覆盖 FAQ 命中、熔断降级、流式断连/异常等路径;流式用 `doOnNext` 聚合分片 + `doFinally` 按终止信号落库(绝不在方法返回处计时)。 +- **异步写入**:`LlmCallTraceService.recordAsync` 走 `@Async("traceExecutor")`(有界线程池见 `AsyncExecutorConfig`,替代默认 `SimpleAsyncTaskExecutor` 线程爆炸隐患;`RagHitLogService` 已一并切换)。 +- **查询面板**:前端「系统设置 → 提示词追踪」(`PromptTracePanel.vue`,admin),支持筛选/搜索/详情(来源标注)/聚合统计/并排对比/跳转编辑(deep-link `?roleId=`、`?key=ai_system_prompt`)。 +- **安全声明**:`system_prompt`/`user_message`/`ai_response` 属敏感数据(提示词为商业秘密),当前**明文存储**、仅 admin 可查,落库前做敏感词脱敏(`ContentSafetyService.mask`)+ AI 回复截断;保留期限由 `system_config.llm_trace_retention_days` 控制(默认 30 天,`@Scheduled` 每日 2 点自动清理)。字段级加密留二期。 +- **预留**:`account_id`/`api_key_id` 列预留租户隔离与个人信息删除权,本期不写入;旁路 LLM 调用(意图识别/查询重写/建议生成)追踪留二期(表加 `trace_type`/`source` 字段)。 + ## 已知 TODO - `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续 diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 426aa5e..30b2be5 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -21,6 +21,7 @@ declare module 'vue' { TDatePicker: typeof import('tdesign-vue-next')['DatePicker'] TDialog: typeof import('tdesign-vue-next')['Dialog'] TDivider: typeof import('tdesign-vue-next')['Divider'] + TDrawer: typeof import('tdesign-vue-next')['Drawer'] TEmpty: typeof import('tdesign-vue-next')['Empty'] TForm: typeof import('tdesign-vue-next')['Form'] TFormItem: typeof import('tdesign-vue-next')['FormItem'] diff --git a/frontend/src/api/llm-trace.ts b/frontend/src/api/llm-trace.ts new file mode 100644 index 0000000..0f3dcda --- /dev/null +++ b/frontend/src/api/llm-trace.ts @@ -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 { + 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 { + 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 { + return request.post('/llm-trace/clean', { keepDays }).then(r => r.data) +} + +/** 聚合统计(按角色或模型分组) */ +export function getLlmTraceStats(groupBy: 'role' | 'model' = 'role'): Promise { + return request.get(`/llm-trace/stats?groupBy=${groupBy}`).then(r => r.data) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 73243ce..11d1be7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -36,6 +36,7 @@ const routes: RouteRecordRaw[] = [ { path: '/settings/pipeline-flow', name: 'PipelineFlow', component: () => import('@/views/PipelineFlow.vue') }, { path: '/settings/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfigManager.vue') }, { path: '/settings/log-viewer', name: 'LogViewer', component: () => import('@/views/LogViewer.vue') }, + { path: '/settings/prompt-trace', name: 'PromptTrace', component: () => import('@/views/PromptTracePanel.vue') }, // ==================== 兜底 ==================== { path: '/:pathMatch(.*)*', redirect: '/chat' }, ] diff --git a/frontend/src/stores/navigation.ts b/frontend/src/stores/navigation.ts index a04b807..8a6ff36 100644 --- a/frontend/src/stores/navigation.ts +++ b/frontend/src/stores/navigation.ts @@ -33,6 +33,7 @@ export const MENU_ITEMS = [ { id: 'pipeline-flow', label: 'AI 执行链', icon: '🔀', path: '/settings/pipeline-flow' }, { id: 'system-config', label: '系统配置', icon: '🔧', path: '/settings/system-config', roles: ['admin'] }, { id: 'log-viewer', label: '系统日志', icon: '📜', path: '/settings/log-viewer', roles: ['admin'] }, + { id: 'prompt-trace', label: '提示词追踪', icon: '🔬', path: '/settings/prompt-trace', roles: ['admin'] }, ], }, ] diff --git a/frontend/src/views/PromptTracePanel.vue b/frontend/src/views/PromptTracePanel.vue new file mode 100644 index 0000000..3bde341 --- /dev/null +++ b/frontend/src/views/PromptTracePanel.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/frontend/src/views/RoleManager.vue b/frontend/src/views/RoleManager.vue index cb87163..54df263 100644 --- a/frontend/src/views/RoleManager.vue +++ b/frontend/src/views/RoleManager.vue @@ -184,6 +184,7 @@