mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
509f7b4262
commit
6886eb3dc6
7 files changed
+847
-5
No files matched your search
@@ -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',
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
Reference in new issue
Block a user