diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts index 0bca2da7..cff90566 100644 --- a/apps/cli/src/ai/tool-adapter.ts +++ b/apps/cli/src/ai/tool-adapter.ts @@ -5,15 +5,17 @@ * 消息类工具返回 rawMessages 时自动执行预处理管道(清洗、去噪、脱敏、截断、格式化)。 */ -import type { ToolDefinition, ToolExecutionContext, SemanticSearchToolService } from '@openchatlab/tools' +import type { ToolDefinition, ToolExecutionContext, SemanticSearchToolService, RawMessage } from '@openchatlab/tools' import { CoreDataProvider } from '@openchatlab/tools' import type { DatabaseAdapter } from '@openchatlab/core' import { applyPreprocessingPipeline, batchSegmentWithFrequency, + preprocessMessages, type AgentTool, type AgentToolResult, type PreprocessableMessage, + type PreprocessConfig, type TruncationStrategy, createChartSchemaGateState, wrapWithChartSchemaGate, @@ -87,6 +89,11 @@ export function adaptToolsForAgent( ownerPlatformId: ctx.ownerPlatformId, maxToolResultTokens: tokenBudget, segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any), + desensitizeMessages: (messages: RawMessage[]): RawMessage[] => + preprocessMessages( + messages as PreprocessableMessage[], + ctx.preprocessConfig as PreprocessConfig | undefined + ) as RawMessage[], } try { const result = await tool.handler(toolParams, execCtx) diff --git a/apps/desktop/main/ai/tools/shared-tool-adapter.ts b/apps/desktop/main/ai/tools/shared-tool-adapter.ts index d8b4f88c..e45b834b 100644 --- a/apps/desktop/main/ai/tools/shared-tool-adapter.ts +++ b/apps/desktop/main/ai/tools/shared-tool-adapter.ts @@ -5,9 +5,9 @@ * Electron 端使用 WorkerDataProvider 替代 Server 端的 CoreDataProvider。 */ -import type { ToolDefinition, ToolExecutionContext } from '@openchatlab/tools' -import type { AgentTool, AgentToolResult } from '@openchatlab/node-runtime' -import { batchSegmentWithFrequency } from '@openchatlab/node-runtime' +import type { ToolDefinition, ToolExecutionContext, RawMessage } from '@openchatlab/tools' +import type { AgentTool, AgentToolResult, PreprocessableMessage, PreprocessConfig } from '@openchatlab/node-runtime' +import { batchSegmentWithFrequency, preprocessMessages } from '@openchatlab/node-runtime' import type { ToolContext, ToolRegistryEntry, ToolCategory } from './types' import { WorkerDataProvider } from './worker-data-provider' import { t as i18nT } from '../../i18n' @@ -36,6 +36,11 @@ function buildExecutionContext(ctx: ToolContext): ToolExecutionContext { const translated = i18nT(key) return translated !== key ? translated : undefined }, + desensitizeMessages: (messages: RawMessage[]): RawMessage[] => + preprocessMessages( + messages as PreprocessableMessage[], + ctx.preprocessConfig as PreprocessConfig | undefined + ) as RawMessage[], } } diff --git a/packages/tools/src/definitions/retrieve-chat-evidence.test.ts b/packages/tools/src/definitions/retrieve-chat-evidence.test.ts new file mode 100644 index 00000000..ff62621e --- /dev/null +++ b/packages/tools/src/definitions/retrieve-chat-evidence.test.ts @@ -0,0 +1,274 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { retrieveChatEvidenceTool } from './retrieve-chat-evidence' +import type { + ChatEvidencePayload, + RawMessage, + SearchMessagesResult, + SemanticSearchToolResult, + SemanticSearchToolService, + ToolDataProvider, + ToolExecutionContext, +} from '../types' + +function semanticResult(over: Partial = {}): SemanticSearchToolResult { + return { + available: true, + text: 'U1: 到了乐山大佛', + returned: 1, + hitCount: 1, + partial: false, + coverage: 1, + truncated: false, + timeRange: { earliest: '2024-05-01T00:00:00.000Z', latest: '2024-05-01T01:00:00.000Z' }, + sources: [ + { + startMessageId: 100, + endMessageId: 110, + score: 0.9, + chunkIds: ['c1'], + snippet: '到了乐山大佛,住了一晚', + startTime: '2024-05-01T00:00:00.000Z', + endTime: '2024-05-01T01:00:00.000Z', + }, + ], + ...over, + } +} + +function makeService(over: Partial = {}): SemanticSearchToolService { + return { + canSearch: () => true, + searchForTool: async () => semanticResult(), + ...over, + } +} + +/** 秒级 RawMessage,模拟聊天库返回 */ +function rawMsg(id: number, tsSeconds: number, content: string, senderName = '甲'): RawMessage { + return { id, senderName, content, timestamp: tsSeconds } +} + +function makeDataProvider(messages: RawMessage[], capture?: { args?: unknown[] }): ToolDataProvider { + return { + searchMessages: async (keywords, options): Promise => { + if (capture) capture.args = [keywords, options] + return { messages, total: messages.length } + }, + } as unknown as ToolDataProvider +} + +function makeContext(extra: Partial = {}): ToolExecutionContext { + return { sessionId: 's1', locale: 'zh-CN', ...extra } +} + +function getPayload(data: unknown): ChatEvidencePayload { + const evidence = (data as { evidence?: ChatEvidencePayload })?.evidence + assert.ok(evidence, 'data.evidence should exist') + return evidence +} + +describe('retrieveChatEvidenceTool schema', () => { + it('requires query and exposes evidence params', () => { + const props = retrieveChatEvidenceTool.inputSchema.properties + const keys = Object.keys(props).sort() + assert.deepEqual(retrieveChatEvidenceTool.inputSchema.required, ['query']) + for (const k of ['query', 'criteria', 'keywords', 'mode', 'max_results', 'start_time', 'end_time']) { + assert.ok(keys.includes(k), `schema should include ${k}`) + } + }) + + it('is registered as a core tool', () => { + assert.equal(retrieveChatEvidenceTool.name, 'retrieve_chat_evidence') + }) +}) + +describe('retrieveChatEvidenceTool mode resolution', () => { + it('auto resolves to hybrid and calls both semantic + keyword when available with keywords', async () => { + let semanticCalled = false + const capture: { args?: unknown[] } = {} + const service = makeService({ + searchForTool: async () => { + semanticCalled = true + return semanticResult() + }, + }) + const dp = makeDataProvider([rawMsg(200, 1714521600, '我们去乐山玩了')], capture) + const res = await retrieveChatEvidenceTool.handler( + { query: '去过乐山几次', criteria: '实际出行', keywords: ['乐山'] }, + makeContext({ semanticIndexService: service, dataProvider: dp }) + ) + assert.equal(semanticCalled, true) + assert.ok(capture.args, 'keyword search should be called') + const payload = getPayload(res.data) + assert.equal(payload.mode, 'hybrid') + }) + + it('semantic mode does not call keyword search', async () => { + const capture: { args?: unknown[] } = {} + const dp = makeDataProvider([rawMsg(1, 1714521600, 'x')], capture) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', mode: 'semantic', keywords: ['乐山'] }, + makeContext({ semanticIndexService: makeService(), dataProvider: dp }) + ) + assert.equal(capture.args, undefined) + assert.equal(getPayload(res.data).mode, 'semantic') + }) + + it('keyword mode without keywords returns warning + empty', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', mode: 'keyword' }, + makeContext({ dataProvider: makeDataProvider([]) }) + ) + const payload = getPayload(res.data) + assert.equal(payload.mode, 'keyword') + assert.ok(payload.warnings?.includes('keywords_required_for_keyword_mode')) + assert.deepEqual(payload.groups, []) + }) + + it('auto resolves to semantic when index available but no keywords', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q' }, + makeContext({ semanticIndexService: makeService(), dataProvider: makeDataProvider([]) }) + ) + const payload = getPayload(res.data) + assert.equal(payload.mode, 'semantic') + assert.ok(payload.warnings?.includes('keywords_missing_for_hybrid')) + }) + + it('auto resolves to keyword + semantic_unavailable when index missing', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider([rawMsg(1, 1714521600, '乐山')]) }) + ) + const payload = getPayload(res.data) + assert.equal(payload.mode, 'keyword') + assert.ok(payload.warnings?.includes('semantic_unavailable')) + }) +}) + +describe('retrieveChatEvidenceTool warnings', () => { + it('warns when criteria is missing', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', keywords: ['乐山'] }, + makeContext({ semanticIndexService: makeService(), dataProvider: makeDataProvider([]) }) + ) + assert.ok(getPayload(res.data).warnings?.includes('criteria_missing')) + }) + + it('warns keyword_unavailable when dataProvider missing but continues with semantic', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'hybrid', keywords: ['乐山'] }, + makeContext({ semanticIndexService: makeService() }) + ) + const payload = getPayload(res.data) + assert.ok(payload.warnings?.includes('keyword_unavailable')) + assert.ok(payload.groups.length > 0, 'semantic results should still produce groups') + }) + + it('warns semantic_unavailable but keyword continues', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'hybrid', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山')]) }) + ) + const payload = getPayload(res.data) + assert.ok(payload.warnings?.includes('semantic_unavailable')) + assert.ok(payload.groups.length > 0) + }) + + it('warns semantic_partial when semantic result is partial', async () => { + const service = makeService({ searchForTool: async () => semanticResult({ partial: true }) }) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'semantic' }, + makeContext({ semanticIndexService: service }) + ) + assert.ok(getPayload(res.data).warnings?.includes('semantic_partial')) + }) +}) + +describe('retrieveChatEvidenceTool candidates & grouping', () => { + it('converts keyword RawMessage seconds timestamp to milliseconds', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'keyword', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山')]) }) + ) + const payload = getPayload(res.data) + const source = payload.groups[0].sources[0] + assert.equal(source.timestamp, 1714521600 * 1000) + }) + + it('desensitizes keyword snippets and never persists raw secret content', async () => { + const desensitize = (messages: RawMessage[]): RawMessage[] => + messages.map((m) => ({ ...m, content: (m.content ?? '').replace('13800000000', '***') })) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'keyword', keywords: ['乐山'] }, + makeContext({ + dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山 13800000000')]), + desensitizeMessages: desensitize, + }) + ) + const payload = getPayload(res.data) + const blob = JSON.stringify(payload) + assert.equal(blob.includes('13800000000'), false) + assert.ok(blob.includes('***')) + }) + + it('dedupes keyword hit that falls inside a semantic range', async () => { + // semantic range [100,110]; keyword hit id=105 within → should not double-count + const dp = makeDataProvider([rawMsg(105, 1714521600, '到了乐山大佛')]) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'hybrid', keywords: ['乐山'] }, + makeContext({ semanticIndexService: makeService(), dataProvider: dp }) + ) + const payload = getPayload(res.data) + const allSources = payload.groups.flatMap((g) => g.sources) + const ids = allSources.map((s) => s.messageId) + // only the semantic source (anchor 100) should remain, keyword 105 deduped into range + assert.ok(!ids.includes(105), 'keyword hit inside semantic range should be deduped') + }) + + it('splits candidates into separate groups when time gap exceeds threshold', async () => { + const base = 1714521600 + const messages = [ + rawMsg(1, base, '到了乐山'), + rawMsg(2, base + 30 * 60, '在乐山吃饭'), // +30min same group + rawMsg(3, base + 5 * 24 * 60 * 60, '又去乐山'), // +5 days new group + ] + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'keyword', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider(messages) }) + ) + const payload = getPayload(res.data) + assert.equal(payload.groups.length, 2) + }) +}) + +describe('retrieveChatEvidenceTool output safety', () => { + it('never returns rawMessages and produces non-empty content', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'keyword', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山')]) }) + ) + assert.equal(res.rawMessages, undefined) + assert.ok(typeof res.content === 'string' && res.content.length > 0) + }) + + it('applies time filter fallback on keyword path (seconds compare)', async () => { + const base = 1714521600 // 2024-05-01 + const messages = [rawMsg(1, base, '到了乐山'), rawMsg(2, base + 365 * 24 * 60 * 60, '一年后又去乐山')] + const res = await retrieveChatEvidenceTool.handler( + { + query: 'q', + criteria: 'c', + mode: 'keyword', + keywords: ['乐山'], + start_time: '2024-04-01', + end_time: '2024-06-01', + }, + makeContext({ dataProvider: makeDataProvider(messages) }) + ) + const payload = getPayload(res.data) + const ids = payload.groups.flatMap((g) => g.sources).map((s) => s.messageId) + assert.deepEqual(ids, [1], 'out-of-range message should be filtered out') + }) +}) diff --git a/packages/tools/src/definitions/retrieve-chat-evidence.ts b/packages/tools/src/definitions/retrieve-chat-evidence.ts new file mode 100644 index 00000000..d68ad18d --- /dev/null +++ b/packages/tools/src/definitions/retrieve-chat-evidence.ts @@ -0,0 +1,440 @@ +/** + * 证据检索工具 retrieve_chat_evidence + * + * 面向“多少次 / 有没有 / 是否曾经 / 给证据链”这类历史事实判断问题: + * 同时(或按 mode)跑语义召回与关键词召回,合并去重并保守分组, + * 产出可持久化、已脱敏的结构化证据 payload(data.evidence)和给 LLM 的安全摘要文本。 + * + * 单位约定:聊天库 / RawMessage.timestamp 为秒;evidence payload 统一毫秒。 + * 关键词路径必须在工具层按时间范围兜底过滤(CLI provider 不保证时间过滤生效)。 + */ + +import type { + ChatEvidenceGroup, + ChatEvidencePayload, + ChatEvidenceSource, + EvidencePayloadStatus, + EvidenceRetrievalMode, + EvidenceStatus, + EvidenceTimeRangeMs, + EvidenceWarning, + JsonSchema, + RawMessage, + ToolDefinition, + ToolExecutionContext, + ToolResult, +} from '../types' +import { SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP } from '../types' +import { timeParamProperties } from '../utils/schemas' + +const EVIDENCE_GROUP_GAP_SECONDS = 2 * 60 * 60 +const EVIDENCE_GROUP_GAP_MS = EVIDENCE_GROUP_GAP_SECONDS * 1000 +const EVIDENCE_GROUP_MAX_SOURCES = 5 +const EVIDENCE_SNIPPET_MAX_CHARS = 160 +const KEYWORD_DEFAULT_LIMIT = 80 + +/** 计划/意向词:仅出现这些且无实际证据词,倾向判为“不计入” */ +const PLANNING_WORDS = ['准备', '打算', '攻略', '想去', '计划', '要不要去', '考虑去', '安排一下'] +/** 实际发生证据词:出现则倾向判为“计入” */ +const EVIDENCE_WORDS = ['到了', '到达', '出发', '返程', '回来', '入住', '住了', '门票', '合影', '打卡', '玩了'] + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + query: { + type: 'string', + description: '需要用聊天证据确认的问题/检索目标,例如“我们去乐山旅行过多少次”。', + }, + criteria: { + type: 'string', + description: + '判定标准:什么算计入、什么不计入。统计/判断类问题应填写,例如“计入:有实际出行/到达/住宿证据;不计入:仅计划、攻略、别人经历、泛聊”。', + }, + keywords: { + type: 'array', + items: { type: 'string' }, + description: '关键词列表,由你显式给出(工具不做同义词扩展)。hybrid/keyword 模式用于精确召回。', + }, + mode: { + type: 'string', + enum: ['auto', 'hybrid', 'semantic', 'keyword'], + description: 'auto(默认)/hybrid(语义+关键词)/semantic(仅语义)/keyword(仅关键词)。历史事实统计通常用 hybrid。', + }, + max_results: { + type: 'number', + description: `语义路径期望返回片段数,硬上限 ${SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP}。`, + minimum: 1, + maximum: SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP, + }, + ...timeParamProperties, + }, + required: ['query'], +} + +/** 内部统一候选 */ +interface EvidenceCandidate { + messageId: number + startMessageId?: number + endMessageId?: number + timestamp: number // 毫秒 + rangeStartMs: number // 毫秒,用于时间交集 + rangeEndMs: number // 毫秒 + snippet: string + senderName?: string + sourceKind: 'semantic' | 'keyword' + score?: number +} + +function isChinese(locale?: string): boolean { + return !locale || locale.toLowerCase().startsWith('zh') +} + +function truncateSnippet(text: string): string { + const t = text.trim() + return t.length > EVIDENCE_SNIPPET_MAX_CHARS ? `${t.slice(0, EVIDENCE_SNIPPET_MAX_CHARS)}…` : t +} + +function parseDateMs(s?: unknown): number | undefined { + if (typeof s !== 'string' || !s.trim()) return undefined + const d = new Date(s.trim().replace(' ', 'T')) + const t = d.getTime() + return Number.isNaN(t) ? undefined : t +} + +function parseIsoMs(s?: string): number | undefined { + if (!s) return undefined + const t = Date.parse(s) + return Number.isNaN(t) ? undefined : t +} + +/** 解析时间范围为毫秒(可单边);显式参数优先,其次继承 context.timeFilter(秒→毫秒) */ +function parseEvidenceTimeRange( + params: Record, + context: ToolExecutionContext +): EvidenceTimeRangeMs | undefined { + const startMs = parseDateMs(params.start_time) + const endMs = parseDateMs(params.end_time) + if (startMs != null || endMs != null) { + return { startTs: startMs, endTs: endMs } + } + if (context.timeFilter) { + return { startTs: context.timeFilter.startTs * 1000, endTs: context.timeFilter.endTs * 1000 } + } + return undefined +} + +function overlapsTimeRangeMs(startMs: number, endMs: number, filter?: EvidenceTimeRangeMs): boolean { + if (!filter) return true + if (filter.startTs != null && endMs < filter.startTs) return false + if (filter.endTs != null && startMs > filter.endTs) return false + return true +} + +function clampMaxResults(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(1, Math.min(SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP, Math.floor(value))) +} + +interface ResolveResult { + mode: Exclude + warnings: Set +} + +function resolveMode( + requested: EvidenceRetrievalMode, + semanticAvailable: boolean, + hasKeywords: boolean +): ResolveResult { + const warnings = new Set() + if (requested !== 'auto') return { mode: requested, warnings } + + if (semanticAvailable && hasKeywords) return { mode: 'hybrid', warnings } + if (semanticAvailable && !hasKeywords) { + warnings.add('keywords_missing_for_hybrid') + return { mode: 'semantic', warnings } + } + warnings.add('semantic_unavailable') + if (!hasKeywords) warnings.add('keywords_required_for_keyword_mode') + return { mode: 'keyword', warnings } +} + +/** 关键词消息 → 候选(脱敏 + 时间兜底) */ +function keywordMessagesToCandidates( + messages: RawMessage[], + rangeMs: EvidenceTimeRangeMs | undefined, + desensitize: ToolExecutionContext['desensitizeMessages'] +): EvidenceCandidate[] { + const safe = desensitize ? desensitize(messages) : messages + const candidates: EvidenceCandidate[] = [] + for (const m of safe) { + if (m.id == null || !m.content) continue + const tsMs = m.timestamp * 1000 + if (!overlapsTimeRangeMs(tsMs, tsMs, rangeMs)) continue + candidates.push({ + messageId: m.id, + timestamp: tsMs, + rangeStartMs: tsMs, + rangeEndMs: tsMs, + snippet: truncateSnippet(m.content), + senderName: m.senderName, + sourceKind: 'keyword', + }) + } + return candidates +} + +/** 合并去重:关键词命中落在语义范围内则丢弃;按 messageId 去重;按时间排序 */ +function mergeCandidates(semantic: EvidenceCandidate[], keyword: EvidenceCandidate[]): EvidenceCandidate[] { + const semanticRanges = semantic.map((c) => [c.startMessageId ?? c.messageId, c.endMessageId ?? c.messageId] as const) + const keptKeyword = keyword.filter((kc) => !semanticRanges.some(([s, e]) => kc.messageId >= s && kc.messageId <= e)) + + const byId = new Map() + for (const c of [...semantic, ...keptKeyword]) { + if (!byId.has(c.messageId)) byId.set(c.messageId, c) + } + return [...byId.values()].sort((a, b) => a.timestamp - b.timestamp) +} + +function classifyGroup(snippets: string[], hasCriteria: boolean): EvidenceStatus { + if (!hasCriteria) return 'uncertain' + const joined = snippets.join('\n') + const hasEvidence = EVIDENCE_WORDS.some((w) => joined.includes(w)) + const hasPlanning = PLANNING_WORDS.some((w) => joined.includes(w)) + if (hasEvidence) return 'included' + if (hasPlanning) return 'excluded' + return 'uncertain' +} + +function formatDate(ms: number, locale?: string): string { + const d = new Date(ms) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return isChinese(locale) ? `${y}年${m}月${day}日` : `${y}-${m}-${day}` +} + +function buildGroups(candidates: EvidenceCandidate[], hasCriteria: boolean, locale?: string): ChatEvidenceGroup[] { + const groups: ChatEvidenceGroup[] = [] + let current: ChatEvidenceGroup | null = null + let lastTs = 0 + + for (const c of candidates) { + const source: ChatEvidenceSource = { + messageId: c.messageId, + startMessageId: c.startMessageId, + endMessageId: c.endMessageId, + timestamp: c.timestamp, + senderName: c.senderName, + snippet: c.snippet, + sourceKind: c.sourceKind, + } + const continues = + current && c.timestamp - lastTs <= EVIDENCE_GROUP_GAP_MS && current.sources.length < EVIDENCE_GROUP_MAX_SOURCES + if (continues && current) { + current.sources.push(source) + current.timeRange = { startTs: current.timeRange!.startTs, endTs: c.timestamp } + } else { + current = { + id: `g${groups.length + 1}`, + status: 'uncertain', + title: '', + reason: '', + timeRange: { startTs: c.timestamp, endTs: c.timestamp }, + sources: [source], + } + groups.push(current) + } + lastTs = c.timestamp + } + + const cn = isChinese(locale) + for (const g of groups) { + const snippets = g.sources.map((s) => s.snippet) + g.status = classifyGroup(snippets, hasCriteria) + const start = formatDate(g.timeRange!.startTs, locale) + const end = formatDate(g.timeRange!.endTs, locale) + g.title = start === end ? start : cn ? `${start} 至 ${end}` : `${start} – ${end}` + g.reason = cn ? GROUP_REASON_CN[g.status] : GROUP_REASON_EN[g.status] + } + return groups +} + +const GROUP_REASON_CN: Record = { + included: '含实际发生/到达等证据,建议计入', + excluded: '仅含计划/意向,未见实际发生证据,建议不计入', + uncertain: '证据不足,无法确认是否计入', +} +const GROUP_REASON_EN: Record = { + included: 'Contains concrete evidence of an actual event; suggest counting it.', + excluded: 'Only plans/intentions without evidence of occurrence; suggest excluding.', + uncertain: 'Insufficient evidence to confirm.', +} + +function buildContent(payload: ChatEvidencePayload, locale: string | undefined): string { + const cn = isChinese(locale) + const lines: string[] = [] + lines.push( + cn + ? `证据检索结果(mode=${payload.mode},status=${payload.status})` + : `Evidence (mode=${payload.mode}, status=${payload.status})` + ) + if (payload.warnings && payload.warnings.length > 0) { + lines.push((cn ? '告警: ' : 'Warnings: ') + payload.warnings.join(', ')) + } + if (payload.groups.length === 0) { + lines.push(cn ? '未找到可计入的明确证据。' : 'No conclusive evidence found.') + } + for (const g of payload.groups) { + lines.push(`\n[${g.status}] ${g.title} — ${g.reason}`) + for (const s of g.sources) { + const who = s.senderName ? `${s.senderName}: ` : '' + lines.push(` · ${who}${s.snippet}`) + } + } + lines.push( + cn + ? '\n请基于以上证据保守作答:只把 included 计入确定结论,excluded/uncertain 不计入;证据不足时回答“无法确认”。' + : '\nAnswer conservatively: count only `included` toward the conclusion; do not count `excluded`/`uncertain`.' + ) + return lines.join('\n') +} + +function computeStatus( + anyPathRan: boolean, + hasResults: boolean, + warnings: Set +): EvidencePayloadStatus { + if (!anyPathRan) return 'unavailable' + if (!hasResults) return 'empty' + const degraded = + warnings.has('semantic_partial') || warnings.has('semantic_unavailable') || warnings.has('keyword_unavailable') + return degraded ? 'partial' : 'complete' +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const locale = context.locale + const query = typeof params.query === 'string' ? params.query.trim() : '' + const criteria = typeof params.criteria === 'string' && params.criteria.trim() ? params.criteria.trim() : undefined + const keywords = Array.isArray(params.keywords) + ? (params.keywords.filter((k) => typeof k === 'string' && k.trim()) as string[]) + : [] + const requestedMode: EvidenceRetrievalMode = + params.mode === 'hybrid' || params.mode === 'semantic' || params.mode === 'keyword' || params.mode === 'auto' + ? params.mode + : 'auto' + + const service = context.semanticIndexService + const semanticAvailable = !!service && service.canSearch(context.sessionId) + const rangeMs = parseEvidenceTimeRange(params, context) + + const { mode, warnings } = resolveMode(requestedMode, semanticAvailable, keywords.length > 0) + if (!criteria) warnings.add('criteria_missing') + + const runSemantic = mode === 'semantic' || mode === 'hybrid' + const runKeyword = mode === 'keyword' || mode === 'hybrid' + + let semanticRan = false + let keywordRan = false + const semanticCandidates: EvidenceCandidate[] = [] + let keywordCandidates: EvidenceCandidate[] = [] + + // 语义路径(Phase 1 暂不传 timeFilter,工具层按 startTime/endTime 兜底过滤) + if (runSemantic) { + if (!service || !semanticAvailable) { + warnings.add('semantic_unavailable') + } else { + const result = await service.searchForTool(context.sessionId, query, { + maxResults: clampMaxResults(params.max_results), + preprocessConfig: context.preprocessConfig, + ownerPlatformId: context.ownerPlatformId, + locale, + maxResultTokens: context.maxToolResultTokens, + }) + semanticRan = true + if (!result.available) { + warnings.add('semantic_unavailable') + } else { + if (result.partial) warnings.add('semantic_partial') + for (const s of result.sources) { + const startMs = parseIsoMs(s.startTime) + const endMs = parseIsoMs(s.endTime) ?? startMs + if (rangeMs && startMs != null && endMs != null && !overlapsTimeRangeMs(startMs, endMs, rangeMs)) continue + semanticCandidates.push({ + messageId: s.startMessageId, + startMessageId: s.startMessageId, + endMessageId: s.endMessageId, + timestamp: startMs ?? 0, + rangeStartMs: startMs ?? 0, + rangeEndMs: endMs ?? startMs ?? 0, + snippet: truncateSnippet(s.snippet), + sourceKind: 'semantic', + score: s.score, + }) + } + } + } + } + + // 关键词路径 + if (runKeyword) { + if (keywords.length === 0) { + warnings.add(mode === 'keyword' ? 'keywords_required_for_keyword_mode' : 'keywords_missing_for_hybrid') + } else if (!context.dataProvider) { + warnings.add('keyword_unavailable') + } else { + const secFilter = + rangeMs?.startTs != null && rangeMs?.endTs != null + ? { startTs: Math.floor(rangeMs.startTs / 1000), endTs: Math.floor(rangeMs.endTs / 1000) } + : undefined + const searchResult = await context.dataProvider.searchMessages(keywords, { + timeFilter: secFilter, + limit: context.maxMessagesLimit || KEYWORD_DEFAULT_LIMIT, + }) + keywordRan = true + keywordCandidates = keywordMessagesToCandidates(searchResult.messages, rangeMs, context.desensitizeMessages) + } + } + + const merged = mergeCandidates(semanticCandidates, keywordCandidates) + const groups = buildGroups(merged, !!criteria, locale) + + const status = computeStatus(semanticRan || keywordRan, groups.length > 0, warnings) + + const payload: ChatEvidencePayload = { + version: 1, + query, + criteria, + mode, + status, + warnings: warnings.size > 0 ? [...warnings] : undefined, + groups, + } + if (rangeMs) { + payload.appliedTimeFilter = { + startTs: rangeMs.startTs, + endTs: rangeMs.endTs, + } + } + + const cn = isChinese(locale) + payload.summary = + groups.length === 0 + ? cn + ? '未找到明确可计入的证据。' + : 'No conclusive evidence found.' + : cn + ? `共 ${groups.length} 组候选证据。` + : `${groups.length} candidate evidence group(s).` + + return { content: buildContent(payload, locale), data: { evidence: payload } } +} + +export const retrieveChatEvidenceTool: ToolDefinition = { + name: 'retrieve_chat_evidence', + description: + 'Retrieve and assemble conservative, de-identified evidence from the CURRENT conversation for fact/occurrence/count questions (e.g. "how many times did we...", "did we ever...", "prove from chat history..."). Runs semantic + keyword retrieval, groups results, and returns a structured evidence payload. Prefer this over search_messages/semantic_search_current_chat for evidence-chain or counting questions.', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_first', +} diff --git a/packages/tools/src/registry.test.ts b/packages/tools/src/registry.test.ts index e5b1e645..de3dcefe 100644 --- a/packages/tools/src/registry.test.ts +++ b/packages/tools/src/registry.test.ts @@ -1,6 +1,12 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { AGENT_TOOL_REGISTRY, MCP_TOOL_REGISTRY, SEMANTIC_SEARCH_TOOL_NAME, getToolByName } from './registry' +import { + AGENT_TOOL_REGISTRY, + MCP_TOOL_REGISTRY, + SEMANTIC_SEARCH_TOOL_NAME, + RETRIEVE_CHAT_EVIDENCE_TOOL_NAME, + getToolByName, +} from './registry' describe('semantic_search_current_chat registry placement', () => { it('is registered in AGENT registry only, not MCP (privacy: no external semantic access in Phase 1)', () => { @@ -16,3 +22,18 @@ describe('semantic_search_current_chat registry placement', () => { assert.deepEqual(Object.keys(tool!.inputSchema.properties).sort(), ['max_results', 'query']) }) }) + +describe('retrieve_chat_evidence registry placement', () => { + it('is registered in AGENT registry only, not MCP', () => { + const inAgent = AGENT_TOOL_REGISTRY.some((t) => t.name === RETRIEVE_CHAT_EVIDENCE_TOOL_NAME) + const inMcp = MCP_TOOL_REGISTRY.some((t) => t.name === RETRIEVE_CHAT_EVIDENCE_TOOL_NAME) + assert.equal(inAgent, true) + assert.equal(inMcp, false) + }) + + it('is resolvable by name and requires query', () => { + const tool = getToolByName(RETRIEVE_CHAT_EVIDENCE_TOOL_NAME) + assert.ok(tool) + assert.deepEqual(tool!.inputSchema.required, ['query']) + }) +}) diff --git a/packages/tools/src/registry.ts b/packages/tools/src/registry.ts index 1859bd34..a85fe516 100644 --- a/packages/tools/src/registry.ts +++ b/packages/tools/src/registry.ts @@ -30,6 +30,7 @@ import { getSegmentSummariesTool } from './definitions/get-segment-summaries' import { responseTimeAnalysisTool } from './definitions/response-time-analysis' import { keywordFrequencyTool } from './definitions/keyword-frequency' import { semanticSearchCurrentChatTool } from './definitions/semantic-search-current-chat' +import { retrieveChatEvidenceTool } from './definitions/retrieve-chat-evidence' import { SQL_TOOL_DEFS, createAllSqlToolDefinitions } from './sql' /** @@ -72,12 +73,16 @@ const SHARED_TOOLS: ToolDefinition[] = [ export const AGENT_TOOL_REGISTRY: ToolDefinition[] = [ ...SHARED_TOOLS, semanticSearchCurrentChatTool, + retrieveChatEvidenceTool, ...createAllSqlToolDefinitions(SQL_TOOL_DEFS), ] /** 语义检索工具名(runner 动态过滤用) */ export const SEMANTIC_SEARCH_TOOL_NAME = semanticSearchCurrentChatTool.name +/** 证据检索工具名(planner / runner 判断用) */ +export const RETRIEVE_CHAT_EVIDENCE_TOOL_NAME = retrieveChatEvidenceTool.name + /** * MCP Server toolset — slim registry optimised for external AI agents. * diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts index 01a0ff8e..9ae5ad66 100644 --- a/packages/tools/src/types.ts +++ b/packages/tools/src/types.ts @@ -318,6 +318,14 @@ export interface ToolExecutionContext { segmentText?: (texts: string[], locale: string, options: Record) => SegmentResult /** i18n 模板翻译回调(用于 SQL 工具模板国际化) */ translateTemplate?: (key: string) => string | undefined + /** + * 消息脱敏回调:对一组原始消息执行清洗 + 脱敏,返回安全消息(含 id/发送者/时间/脱敏后内容)。 + * + * 供需要在工具内部生成可持久化安全 snippet 的工具使用(如证据检索的关键词路径)。 + * 由平台 adapter 注入 applyPreprocessingPipeline 等价实现;清洗可能删除/合并消息, + * 返回条数可能少于输入。未注入时调用方应回退到不脱敏或跳过相关能力。 + */ + desensitizeMessages?: (messages: RawMessage[], options?: { anonymizeNames?: boolean }) => RawMessage[] } // ==================== Raw Message ==================== @@ -366,3 +374,85 @@ export interface ToolDefinition { category?: ToolCategory truncationStrategy?: TruncationStrategy } + +// ==================== Chat Evidence ==================== + +/** 证据检索模式 */ +export type EvidenceRetrievalMode = 'auto' | 'hybrid' | 'semantic' | 'keyword' + +/** 单组证据状态:计入 / 不计入 / 不确定 */ +export type EvidenceStatus = 'included' | 'excluded' | 'uncertain' + +/** 整个证据 payload 状态 */ +export type EvidencePayloadStatus = 'complete' | 'partial' | 'empty' | 'unavailable' + +/** 证据检索告警 */ +export type EvidenceWarning = + | 'criteria_missing' + | 'keywords_missing_for_hybrid' + | 'keywords_required_for_keyword_mode' + | 'semantic_unavailable' + | 'keyword_unavailable' + | 'semantic_partial' + +/** + * 毫秒级、可单边的时间区间。 + * + * evidence payload 与语义路径统一使用毫秒口径(聊天库 / core RawMessage.timestamp 是秒, + * 语义 chunk 是毫秒);单边区间用于“今年以来 / 截至某天”等查询。 + */ +export interface EvidenceTimeRangeMs { + startTs?: number + endTs?: number +} + +/** 单条证据来源(已脱敏,timestamp 为毫秒) */ +export interface ChatEvidenceSource { + /** 点击追溯锚点;语义 range source 默认用 startMessageId,关键词 source 用命中消息 id */ + messageId: number + /** 范围来源起点;关键词 source 可与 messageId 相同 */ + startMessageId?: number + /** 范围来源终点;关键词 source 可与 messageId 相同 */ + endMessageId?: number + /** 毫秒时间戳 */ + timestamp: number + senderName?: string + snippet: string + role?: 'primary' | 'supporting' + sourceKind?: 'semantic' | 'keyword' +} + +/** 一组证据 */ +export interface ChatEvidenceGroup { + id: string + status: EvidenceStatus + title: string + reason: string + /** 毫秒时间范围 */ + timeRange?: { startTs: number; endTs: number } + sources: ChatEvidenceSource[] +} + +/** + * 证据 payload(持久化到 AI 对话历史,时间戳统一毫秒)。 + * + * 只保存脱敏后的 snippet / 时间戳 / messageId / 分组元数据 / 查询上下文, + * 不保存原始 rawMessages、整段上下文全文或 embedding 原文。 + */ +export interface ChatEvidencePayload { + version: 1 + query: string + criteria?: string + /** 已 resolve 的实际检索模式(不会是 auto) */ + mode: EvidenceRetrievalMode + status: EvidencePayloadStatus + summary?: string + /** 实际生效的时间过滤(毫秒,可单边) */ + appliedTimeFilter?: { + startTs?: number + endTs?: number + label?: string + } + warnings?: EvidenceWarning[] + groups: ChatEvidenceGroup[] +}