feat(ai): add retrieve_chat_evidence tool with evidence payload protocol

Introduce a high-level evidence retrieval tool for fact/occurrence/count
questions. It resolves auto mode, runs semantic + keyword paths, de-identifies
keyword snippets via an injected desensitizeMessages callback, applies a
tool-layer millisecond time-range fallback (keyword path can't rely on the CLI
provider's timeFilter), and merges/dedupes candidates into conservative groups.

- Add ChatEvidencePayload / group / source types and EvidenceTimeRangeMs to tools
- Register tool in AGENT_TOOL_REGISTRY only (Agent-only, excluded from MCP)
- Inject desensitizeMessages (preprocessMessages) in desktop + CLI adapters
- TDD: tool + registry unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-06-21 16:18:39 +08:00
1 parent 509f7b4262
commit 6886eb3dc6
7 files changed
+847 -5

No files matched your search

+8 -1
View File
@@ -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)
@@ -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[],
}
}
@@ -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> = {}): 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> = {}): 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<SearchMessagesResult> => {
if (capture) capture.args = [keywords, options]
return { messages, total: messages.length }
},
} as unknown as ToolDataProvider
}
function makeContext(extra: Partial<ToolExecutionContext> = {}): 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')
})
})
@@ -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<string, unknown>,
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<EvidenceRetrievalMode, 'auto'>
warnings: Set<EvidenceWarning>
}
function resolveMode(
requested: EvidenceRetrievalMode,
semanticAvailable: boolean,
hasKeywords: boolean
): ResolveResult {
const warnings = new Set<EvidenceWarning>()
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<number, EvidenceCandidate>()
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<EvidenceStatus, string> = {
included: '含实际发生/到达等证据,建议计入',
excluded: '仅含计划/意向,未见实际发生证据,建议不计入',
uncertain: '证据不足,无法确认是否计入',
}
const GROUP_REASON_EN: Record<EvidenceStatus, string> = {
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<EvidenceWarning>
): 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<string, unknown>, context: ToolExecutionContext): Promise<ToolResult> {
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',
}
+22 -1
View File
@@ -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'])
})
})
+5
View File
@@ -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.
*
+90
View File
@@ -318,6 +318,14 @@ export interface ToolExecutionContext {
segmentText?: (texts: string[], locale: string, options: Record<string, unknown>) => 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[]
}