feat: 完善问问微信查询理解与检索链路

This commit is contained in:
Wxw-Gu
2026-09-09 16:51:31 +08:00
parent 7554de7d72
commit facc292443
14 changed files with 1107 additions and 66 deletions
+327 -1
View File
@@ -10,7 +10,8 @@ vi.mock('../../src/main/services/chat-service', () => ({
listContactsAsync
}))
import { AiSearchPipelineService } from '../../src/main/services/ai-search-pipeline-service'
import { AiSearchPipelineService, isSuspiciousLocalPlan } from '../../src/main/services/ai-search-pipeline-service'
import { buildLocalAiSearchPlan } from '../../src/shared/ai-search'
import type { KnowledgeEvidence } from '../../src/shared/knowledge'
const makeCandidate = (index: number): KnowledgeEvidence => ({
@@ -143,6 +144,331 @@ describe('AiSearchPipelineService', () => {
expect(result.agent).toMatchObject({ mode: 'agent', toolCalls: 1 })
})
it('uses the AI query-understanding fallback for the real 小史 boundary expression', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'xiaoshi', m_nsUsrName: 'wxid_xiaoshi', m_nsNickName: '小史', type: 'user' }
])
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValueOnce({
success: true,
data: JSON.stringify({
intent: 'conversation_boundary', contactQuery: '小史', topicQuery: null,
boundary: 'first', confidence: 0.98, requiresClarification: false
})
})
knowledge.search.mockResolvedValue({
source: 'knowledge', state: 'ready', indexedMessageCount: 2,
indexedChunkCount: 1, totalMessages: 2,
evidence: [{
chunkId: 'boundary', conversationId: 'xiaoshi', startTime: 1, endTime: 1,
messageId: 'message-1', sender: '小史', senderId: 'wxid_xiaoshi', timestamp: 1,
messageIds: ['message-1'], sourceKind: 'text', text: '你好'
}],
conversationRetrieval: {
conversationId: 'xiaoshi', totalMessages: 2, chunkCount: 1,
candidateMessages: 1, systemMessagesDeprioritized: 0, complete: true
}
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const localPlan = buildLocalAiSearchPlan('我和小史第一次聊天是在什么时候')
expect(localPlan).toMatchObject({ intent: 'global_topic_search' })
expect(localPlan.contactQuery).toBeUndefined()
expect(localPlan.boundary).toBeUndefined()
expect(isSuspiciousLocalPlan('我和小史第一次聊天是在什么时候', localPlan as never)).toBe(true)
const result = await service.run(
{ requestId: 'ai-boundary', text: '我和小史第一次聊天是在什么时候', scope: 'global', range: 'all' },
() => undefined
)
expect(aiProvider.chat).toHaveBeenCalledTimes(1)
const parserMessages = aiProvider.chat.mock.calls[0][0] as Array<{ role: string; content: string }>
expect(parserMessages[1].content).toContain('我和小史第一次聊天是在什么时候')
expect(parserMessages[1].content).toContain('当前界面范围:global')
expect(parserMessages[1].content).not.toMatch(/Evidence|conversationId|wxid_|knowledge|contacts|数据库|路径/)
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({
conversationIds: ['xiaoshi'], conversationBoundary: 'first', terms: []
}))
expect(result).toMatchObject({ status: 'completed', plan: {
intent: 'conversation_boundary', contactQuery: '小史', boundary: 'first'
}, retrieval: { identityResolution: 'resolved', conversationId: 'xiaoshi' }, answer: expect.stringContaining('[E1]'), evidence: [expect.any(Object)] })
expect(result.agent).toMatchObject({ toolCalls: 0 })
})
it('supports content projection through the real BOBO boundary pipeline', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'bobo', m_nsUsrName: 'wxid_bobo', m_nsNickName: 'BOBO', type: 'user' }
])
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValueOnce({
success: true,
data: JSON.stringify({
intent: 'conversation_boundary', contactQuery: 'BOBO', topicQuery: null,
boundary: 'first', projection: 'content', confidence: 0.98, requiresClarification: false
})
})
knowledge.search.mockResolvedValue({
source: 'knowledge', state: 'ready', indexedMessageCount: 3,
indexedChunkCount: 1, totalMessages: 3,
evidence: [{
chunkId: 'bobo-boundary', conversationId: 'bobo', startTime: 2, endTime: 2,
messageId: 'bobo-1', sender: 'BOBO', senderId: 'wxid_bobo', timestamp: 2,
messageIds: ['bobo-1'], sourceKind: 'voice', text: '语音转写:你好'
}],
conversationRetrieval: {
conversationId: 'bobo', totalMessages: 3, chunkCount: 1,
candidateMessages: 1, systemMessagesDeprioritized: 0, complete: true
}
})
const result = await new AiSearchPipelineService(knowledge as never, aiProvider as never).run(
{ requestId: 'bobo-content', text: '我和BOBO第一次讲话说的什么', scope: 'global', range: 'all' },
() => undefined
)
expect(aiProvider.chat).toHaveBeenCalledTimes(1)
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({
conversationIds: ['bobo'], conversationBoundary: 'first', terms: []
}))
expect(result).toMatchObject({
status: 'completed',
plan: { intent: 'conversation_boundary', contactQuery: 'BOBO', boundary: 'first', projection: 'content' },
answer: expect.stringContaining('语音转写:你好'),
evidence: [expect.any(Object)]
})
expect(result.answer).not.toContain('正在生成')
})
it('executes a semantic retrieval plan with bounded probes and one answer AI call', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'bobo', m_nsUsrName: 'wxid_bobo', m_nsNickName: 'BOBO', type: 'user' }
])
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: JSON.stringify({
mode: 'semantic', intent: 'general', targetQuery: 'BOBO',
semanticQuery: '双方关系开始明显变熟、互动增加或开始更深入交流',
queryVariants: ['开始熟起来', '关系变熟', '聊天明显增多', '开始聊更深入的话题'],
answerMode: 'synthesis', confidence: 0.97, requiresClarification: false
})
})
.mockResolvedValueOnce({ success: true, data: 'BOBO后来逐渐和我熟悉起来。[E1]' })
knowledge.search.mockImplementation(async (query: { terms: string[] }) => ({
source: 'knowledge', state: 'ready', indexedMessageCount: 10,
indexedChunkCount: 4, totalMessages: 10,
evidence: [{
chunkId: `semantic-${query.terms[0]}`, conversationId: 'bobo', startTime: 2, endTime: 2,
messageId: `message-${query.terms[0]}`, sender: 'BOBO', senderId: 'wxid_bobo', timestamp: 2,
messageIds: [`message-${query.terms[0]}`], sourceKind: 'text', text: `关于${query.terms[0]}的聊天`
}]
}))
const result = await new AiSearchPipelineService(knowledge as never, aiProvider as never).run(
{ requestId: 'semantic-bobo', text: 'BOBO什么时候开始跟我熟起来的', scope: 'global', range: 'all' },
() => undefined
)
expect(result).toMatchObject({
status: 'completed',
plan: {
mode: 'semantic', targetQuery: 'BOBO', contactQuery: 'BOBO',
semanticQuery: '双方关系开始明显变熟、互动增加或开始更深入交流',
queryVariants: ['开始熟起来', '关系变熟', '聊天明显增多', '开始聊更深入的话题'],
answerMode: 'synthesis'
},
retrieval: { identityResolution: 'resolved', conversationId: 'bobo' },
answer: expect.stringContaining('BOBO后来逐渐和我熟悉起来。[E1]')
})
expect(knowledge.search).toHaveBeenCalledTimes(5)
expect(knowledge.search.mock.calls.map(([query]) => query.terms)).toEqual([
['双方关系开始明显变熟、互动增加或开始更深入交流'], ['开始熟起来'], ['关系变熟'],
['聊天明显增多'], ['开始聊更深入的话题']
])
expect(aiProvider.chat).toHaveBeenCalledTimes(2)
})
it('keeps promise-like natural language in semantic retrieval instead of understanding_failed', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'laowang', m_nsUsrName: 'wxid_laowang', m_nsNickName: '老王', type: 'user' }
])
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: JSON.stringify({
mode: 'semantic', intent: 'general', targetQuery: '老王',
semanticQuery: '承诺之后提供、发送或完成某件事情', queryVariants: ['我给你', '我发你', '答应'],
answerMode: 'synthesis', confidence: 0.94, requiresClarification: false
})
})
.mockResolvedValueOnce({ success: true, data: '老王答应过随后把东西发给我。[E1]' })
knowledge.search.mockResolvedValue({
source: 'knowledge', state: 'ready', indexedMessageCount: 4,
indexedChunkCount: 1, totalMessages: 4,
evidence: [{
chunkId: 'promise', conversationId: 'laowang', startTime: 3, endTime: 3,
messageId: 'promise-1', sender: '老王', senderId: 'wxid_laowang', timestamp: 3,
messageIds: ['promise-1'], sourceKind: 'text', text: '我发你,答应'
}]
})
const result = await new AiSearchPipelineService(knowledge as never, aiProvider as never).run(
{ requestId: 'semantic-laowang', text: '老王之前答应我的东西是什么', scope: 'global', range: 'all' },
() => undefined
)
expect(result.status).toBe('completed')
expect(result.plan).toMatchObject({ mode: 'semantic', targetQuery: '老王', contactQuery: '老王' })
expect(result.retrieval).toMatchObject({ identityResolution: 'resolved', conversationId: 'laowang' })
expect(aiProvider.chat).toHaveBeenCalledTimes(2)
})
it.each([
['我和张三最近聊了什么', 'conversation_recall'],
['我和张三第一次聊天是什么时候', 'conversation_boundary'],
['我和张三第一次聊天是在什么时候', 'conversation_boundary'],
['我和张三第一次说话是哪天', 'conversation_boundary'],
['我第一次跟张三聊天是什么时候', 'conversation_boundary'],
['我最早什么时候和张三聊过', 'conversation_boundary'],
['我和张三是从什么时候开始聊天的', 'conversation_boundary'],
['我和张三什么时候开始聊天的', 'conversation_boundary'],
['我和张三最后一次聊天是什么时候', 'conversation_boundary'],
['我和张三最后一次聊天是在什么时候', 'conversation_boundary'],
['我最近一次跟张三说话是什么时候', 'conversation_boundary'],
['我上一次和张三聊天是哪天', 'conversation_boundary'],
['张三最后一次和我聊天是在什么时候', 'conversation_boundary'],
['我和张三最近聊得怎么样', 'not_boundary'],
['我和张三聊过装修吗', 'not_boundary'],
['最近张三说了什么', 'not_boundary']
])('keeps the boundary synonym/negative matrix out of accidental intent: %s', (query, expected) => {
const plan = buildLocalAiSearchPlan(query)
if (expected === 'conversation_recall') expect(plan.intent).toBe('conversation_recall')
else if (expected === 'conversation_boundary') {
expect(plan.intent === 'conversation_boundary' || plan.intent === 'global_topic_search').toBe(true)
} else expect(plan.intent).not.toBe('conversation_boundary')
})
it.each([
'BOBO什么时候开始跟我熟起来的',
'我跟BOBO刚认识的时候聊了什么',
'老王之前答应我的东西是什么',
'我之前是不是跟谁提过买房'
])('routes interpretive natural language to the Query Compiler: %s', (query) => {
const plan = buildLocalAiSearchPlan(query)
expect(isSuspiciousLocalPlan(query, plan as never)).toBe(true)
})
it.each([
['我和张三第一次聊天是什么时候', 'first'],
['我和张三第一次聊天是在什么时候', 'first'],
['我和张三第一次说话是哪天', 'first'],
['我第一次跟张三聊天是什么时候', 'first'],
['我最早什么时候和张三聊过', 'first'],
['我和张三是从什么时候开始聊天的', 'first'],
['我和张三什么时候开始聊天的', 'first'],
['我和张三最后一次聊天是什么时候', 'last'],
['我和张三最后一次聊天是在什么时候', 'last'],
['我最近一次跟张三说话是什么时候', 'last'],
['我上一次和张三聊天是哪天', 'last'],
['张三最后一次和我聊天是在什么时候', 'last']
])('normalizes the full boundary synonym matrix through the Pipeline: %s', async (query, boundary) => {
listContactsAsync.mockResolvedValue([
{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' }
])
aiProvider.getRuntimeConfig.mockReturnValue({ configured: true, providerId: 'fixture-provider', model: 'fixture-model' })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: true, requiresConsent: false, providerId: 'fixture-provider', recipient: 'fixture' })
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValueOnce({
success: true,
data: JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', topicQuery: null, boundary, confidence: 0.98, requiresClarification: false })
})
knowledge.search.mockResolvedValue({
source: 'knowledge', state: 'ready', indexedMessageCount: 1, indexedChunkCount: 1, totalMessages: 1,
evidence: [{ chunkId: 'boundary', conversationId: 'zhangsan', startTime: 1, endTime: 1, messageId: 'message-1', sender: '张三', senderId: 'wxid_zhangsan', timestamp: 1, messageIds: ['message-1'], sourceKind: 'text', text: '你好' }],
conversationRetrieval: { conversationId: 'zhangsan', totalMessages: 1, chunkCount: 1, candidateMessages: 1, systemMessagesDeprioritized: 0, complete: true }
})
const result = await new AiSearchPipelineService(knowledge as never, aiProvider as never).run(
{ requestId: `matrix-${boundary}-${query}`, text: query, scope: 'global', range: 'all' },
() => undefined
)
const local = buildLocalAiSearchPlan(query)
const expectedParserCalls = local.intent === 'conversation_boundary' ? 0 : 1
expect(result).toMatchObject({ status: 'completed', plan: { intent: 'conversation_boundary', contactQuery: '张三', boundary }, retrieval: { identityResolution: 'resolved' }, evidence: [expect.any(Object)] })
expect(aiProvider.chat).toHaveBeenCalledTimes(expectedParserCalls)
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: ['zhangsan'], terms: [], conversationBoundary: boundary }))
})
it('returns understanding_failed for unavailable and throwing Query Parser fallback', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' }
])
aiProvider.getRuntimeConfig.mockReturnValue({ configured: false })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: false })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const unavailable = await service.run(
{ requestId: 'understanding-unavailable', text: '我和张三第一次聊天是在什么时候', scope: 'global', range: 'all' },
() => undefined
)
expect(unavailable).toMatchObject({ status: 'understanding_failed' })
expect(knowledge.search).not.toHaveBeenCalled()
aiProvider.getRuntimeConfig.mockReturnValue({ configured: true, providerId: 'fixture-provider', model: 'fixture-model' })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: true, requiresConsent: false, providerId: 'fixture-provider', recipient: 'fixture' })
aiProvider.chat.mockReset()
aiProvider.chat.mockRejectedValueOnce(new Error('Query Parser timeout'))
const throwing = await service.run(
{ requestId: 'understanding-timeout', text: '我和张三第一次聊天是在什么时候', scope: 'global', range: 'all' },
() => undefined
)
expect(throwing).toMatchObject({ status: 'understanding_failed', error: expect.stringContaining('Query Parser timeout') })
expect(knowledge.search).not.toHaveBeenCalled()
})
it('keeps local recall and local boundary at zero Query Parser calls when AI is unavailable', async () => {
listContactsAsync.mockResolvedValue([
{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' }
])
aiProvider.getRuntimeConfig.mockReturnValue({ configured: false })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: false })
knowledge.search.mockResolvedValue({ source: 'knowledge', state: 'ready', indexedMessageCount: 1, indexedChunkCount: 1, totalMessages: 1, evidence: [] })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const recall = await service.run({ requestId: 'local-recall-no-ai', text: '我和张三最近聊了什么', scope: 'global', range: 'all' }, () => undefined)
expect(recall.plan.intent).toBe('conversation_recall')
expect(aiProvider.chat).toHaveBeenCalledTimes(0)
aiProvider.chat.mockReset()
const boundary = await service.run({ requestId: 'local-boundary-no-ai', text: '我和张三第一次聊天是什么时候', scope: 'global', range: 'all' }, () => undefined)
expect(boundary.plan.intent).toBe('conversation_boundary')
expect(aiProvider.chat).toHaveBeenCalledTimes(0)
})
it('stops before Knowledge when contact resolution is ambiguous or not found', async () => {
const parser = JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', topicQuery: null, boundary: 'first', confidence: 0.98, requiresClarification: false })
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValue({ success: true, data: parser })
aiProvider.getRuntimeConfig.mockReturnValue({ configured: true, providerId: 'fixture-provider', model: 'fixture-model' })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: true, requiresConsent: false, providerId: 'fixture-provider', recipient: 'fixture' })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
listContactsAsync.mockResolvedValue([
{ md5: 'zhangsan-a', m_nsUsrName: 'wxid_a', m_nsNickName: '张三', type: 'user' },
{ md5: 'zhangsan-b', m_nsUsrName: 'wxid_b', m_nsNickName: '张三', type: 'user' }
])
const ambiguous = await service.run({ requestId: 'ambiguous-boundary', text: '我和张三第一次聊天是在什么时候', scope: 'global', range: 'all' }, () => undefined)
expect(ambiguous).toMatchObject({ status: 'ambiguous_contact' })
expect(knowledge.search).not.toHaveBeenCalled()
aiProvider.chat.mockReset()
aiProvider.chat.mockResolvedValueOnce({ success: true, data: parser.replace('张三', '不存在的人') })
listContactsAsync.mockResolvedValue([{ md5: 'other', m_nsUsrName: 'wxid_other', m_nsNickName: '其他人', type: 'user' }])
const missing = await service.run({ requestId: 'missing-boundary', text: '我和不存在的人第一次聊天是在什么时候', scope: 'global', range: 'all' }, () => undefined)
expect(missing).toMatchObject({ status: 'contact_not_found' })
expect(knowledge.search).not.toHaveBeenCalled()
})
it('distinguishes a confirmed contact with no readable messages', async () => {
listContactsAsync.mockResolvedValue([{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' }])
knowledge.search.mockResolvedValue({ source: 'knowledge', state: 'ready', indexedMessageCount: 1, indexedChunkCount: 1, totalMessages: 1, evidence: [], conversationRetrieval: { conversationId: 'zhangsan', totalMessages: 1, chunkCount: 1, candidateMessages: 0, systemMessagesDeprioritized: 1, complete: true } })
aiProvider.getRuntimeConfig.mockReturnValue({ configured: false })
aiProvider.getAiSearchProviderStatus.mockReturnValue({ configured: false })
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run({ requestId: 'no-boundary-messages', text: '我和张三第一次聊天是什么时候', scope: 'global', range: 'all' }, () => undefined)
expect(result).toMatchObject({ status: 'no_messages' })
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationBoundary: 'first' }))
})
it('cancels an active Agent request and aborts the AI call before local retrieval continues', async () => {
let observedSignal: AbortSignal | undefined
let markStarted: (() => void) | undefined
+21
View File
@@ -27,6 +27,27 @@ describe('AI search natural-language time ranges', () => {
})
})
it.each([
['我和BOBO上个月聊了什么', '2026-08-01T00:00:00+08:00', '2026-08-31T23:59:59+08:00'],
['我和BOBO这个月聊了什么', '2026-09-01T00:00:00+08:00', undefined],
['我和BOBO昨天聊了什么', '2026-09-08T00:00:00+08:00', '2026-09-08T23:59:59+08:00'],
['我和BOBO前天聊了什么', '2026-09-07T00:00:00+08:00', '2026-09-07T23:59:59+08:00'],
['我和BOBO去年聊了什么', '2025-01-01T00:00:00+08:00', '2025-12-31T23:59:59+08:00']
])('%s resolves against the injected clock', (query, start, end) => {
const range = inferAiSearchTimeRange(query, 'all', new Date('2026-09-09T15:00:00+08:00'))
expect(range.startTime).toBe(Math.floor(new Date(start).getTime() / 1000))
expect(range.endTime).toBe(end ? Math.floor(new Date(end).getTime() / 1000) : Math.floor(new Date('2026-09-09T15:00:00+08:00').getTime() / 1000))
})
it.each([
['2026-01-10T12:00:00+08:00', '2025-12-01T00:00:00+08:00', '2025-12-31T23:59:59+08:00'],
['2026-03-01T12:00:00+08:00', '2026-02-01T00:00:00+08:00', '2026-02-28T23:59:59+08:00']
])('handles previous-month year and month boundaries from %s', (now, start, end) => {
const range = inferAiSearchTimeRange('我和BOBO上个月聊了什么', 'all', new Date(now))
expect(range.startTime).toBe(Math.floor(new Date(start).getTime() / 1000))
expect(range.endTime).toBe(Math.floor(new Date(end).getTime() / 1000))
})
it('keeps an explicit user retry override above the word 最近 in the original question', () => {
expect(
inferAiSearchTimeRange('我和张三最近聊了什么?', 'all', NOW, {
@@ -99,6 +99,28 @@ const makeTrace = (overrides: Partial<SearchTrace> = {}): SearchTrace => ({
})
describe('AI Search request context', () => {
it('includes the resolved absolute range in relative-time cache keys', () => {
const query = '我和BOBO上个月聊了什么'
const first = createSearchRequestContext({
query, scope: 'global', range: 'all', now: new Date('2025-09-09T12:00:00+08:00')
})
const second = createSearchRequestContext({
query, scope: 'global', range: 'all', now: new Date('2026-09-09T12:00:00+08:00')
})
const repeat = createSearchRequestContext({
query, scope: 'global', range: 'all', now: new Date('2026-09-09T12:00:00+08:00')
})
const refreshed = createSearchRequestContext({
query, scope: 'global', range: 'all', now: new Date('2026-09-09T12:00:00+08:00'),
knowledgeGeneration: 'ready:200:20:200'
})
expect(first.resolvedTimeRange.label).toBe('2025年8月')
expect(second.resolvedTimeRange.label).toBe('2026年8月')
expect(second.cacheKey).not.toBe(first.cacheKey)
expect(second.cacheKey).toBe(repeat.cacheKey)
expect(refreshed.cacheKey).not.toBe(second.cacheKey)
})
it('trims only the submitted query while keeping cache query normalization unchanged', () => {
const context = createSearchRequestContext({
query: ' Mixed Case 问题 ',
@@ -107,7 +129,7 @@ describe('AI Search request context', () => {
})
expect(context.normalizedQuery).toBe('Mixed Case 问题')
expect(context.cacheKey).toBe(JSON.stringify(['global', '', '30d', 'mixed case 问题']))
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(['global', '', '30d', 'mixed case 问题'])
})
it.each(['global', 'groups', 'contacts'] as const)(
@@ -121,7 +143,7 @@ describe('AI Search request context', () => {
})
expect(context.conversationId).toBeUndefined()
expect(context.cacheKey).toBe(JSON.stringify([scope, '', '7d', '范围问题']))
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual([scope, '', '7d', '范围问题'])
}
)
@@ -134,8 +156,8 @@ describe('AI Search request context', () => {
})
expect(context.conversationId).toBe(aiSearchContact.md5)
expect(context.cacheKey).toBe(
JSON.stringify(['conversation', aiSearchContact.md5, 'today', '会话问题'])
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(
['conversation', aiSearchContact.md5, 'today', '会话问题']
)
})
@@ -147,7 +169,7 @@ describe('AI Search request context', () => {
})
expect(context.conversationId).toBeUndefined()
expect(context.cacheKey).toBe(JSON.stringify(['conversation', '', 'all', '未选择会话']))
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(['conversation', '', 'all', '未选择会话'])
})
it('uses retry range and retry time override when both are provided', () => {
@@ -171,7 +193,7 @@ describe('AI Search request context', () => {
expect(context.effectiveRange).toBe('all')
expect(context.effectiveTimeRangeOverride).toBe(retryOverride)
expect(context.cacheKey).toBe(JSON.stringify(['global', '', 'all', '重试问题']))
expect(JSON.parse(context.cacheKey).slice(0, 4)).toEqual(['global', '', 'all', '重试问题'])
})
it('falls back to the current time override when retry does not provide one', () => {
@@ -386,6 +408,23 @@ describe('AI Search trace and cache mapping', () => {
})
})
it('reports conversation recall coverage instead of keyword-hit count', () => {
const result = makeSearchResult()
result.candidateEvidenceCount = 8
result.retrieval = {
intent: 'conversation_recall',
sourceMessageCount: 31,
sourceCoverage: 'complete',
isComplete: true,
candidateCount: 8,
uniqueCandidateCount: 8,
fallbackUsed: false,
suspicious: false,
timeRange: { label: '2026年8月', reason: 'test', source: 'query' }
}
expect(mapSearchResultToTrace(result, 8).retrievedEvidence).toBe(31)
})
it('maps AI token, citation, and voice coverage details without transforming them', () => {
const result: AiSearchPipelineResult = makeSearchResult()
result.ai = {
@@ -560,6 +599,10 @@ describe('AI Search result view transition', () => {
})
it.each([
['understanding_failed', 'insufficient', '我没有完全理解你想怎么查,可以换一种说法。'],
['contact_not_found', 'insufficient', '我理解你在问这个联系人,但没有在当前通讯录中确认到对应联系人。'],
['ambiguous_contact', 'insufficient', '找到多个可能的联系人,暂时无法确定你指的是哪一个。'],
['no_messages', 'insufficient', '已经确认联系人,但当前可读取记录里没有对应聊天消息。'],
['retrieval_incomplete', 'partial', '当前检索未完整覆盖聊天记录,未生成总结。'],
['failed', 'insufficient', '本地搜索暂时无法完成'],
['ai_failed', 'partial', '证据已找到,但 AI 暂时无法生成回答']
+119
View File
@@ -0,0 +1,119 @@
import { mkdtempSync } from 'fs'
import { rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { buildLocalAiSearchPlan, parseAiQueryUnderstanding } from '../../src/shared/ai-search'
import { DEFAULT_KNOWLEDGE_CHUNKER, type KnowledgeFtsConfig } from '../../src/shared/knowledge'
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
const roots: string[] = []
const fts: KnowledgeFtsConfig = {
profileId: 'boundary-test', tokenizer: 'trigram', contentMode: 'external', detail: 'full', columnsize: 1
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('conversation boundary query planning', () => {
it('validates the AI query understanding contract without accepting unsafe fields', () => {
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_boundary', contactQuery: '小史', topicQuery: null,
boundary: 'first', confidence: 0.98, requiresClarification: false
}))).toMatchObject({ intent: 'conversation_boundary', contactQuery: '小史', boundary: 'first' })
expect(parseAiQueryUnderstanding('```json\n{"intent":"conversation_boundary"}\n```')).toBeNull()
expect(parseAiQueryUnderstanding('{"intent":"conversation_boundary"}{"intent":"general"}')).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'deep_research', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', boundary: 'middle', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三'.repeat(33), boundary: 'first', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_boundary', contactQuery: '张三', topicQuery: '装修', boundary: 'first', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({ intent: 'conversation_recall', contactQuery: '张三', boundary: 'first', confidence: 1, requiresClarification: false }))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_boundary', contactQuery: '张三', boundary: 'first', projection: 'content',
confidence: 1, requiresClarification: false
}))).toMatchObject({ projection: 'content' })
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_boundary', contactQuery: '张三', boundary: 'first', projection: 'middle',
confidence: 1, requiresClarification: false
}))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_recall', contactQuery: '张三', projection: 'content',
confidence: 1, requiresClarification: false
}))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({
mode: 'semantic', intent: 'general', targetQuery: 'BOBO',
semanticQuery: '双方关系开始明显变熟、互动增加或开始更深入交流',
queryVariants: ['开始熟起来', '关系变熟'], answerMode: 'synthesis',
confidence: 0.95, requiresClarification: false
}))).toMatchObject({ mode: 'semantic', targetQuery: 'BOBO', answerMode: 'synthesis', queryVariants: ['开始熟起来', '关系变熟'] })
expect(parseAiQueryUnderstanding(JSON.stringify({
mode: 'semantic', intent: 'general', semanticQuery: '关系变熟',
queryVariants: ['一', '二', '三', '四', '五'], confidence: 1, requiresClarification: false
}))).toBeNull()
expect(parseAiQueryUnderstanding(JSON.stringify({
mode: 'semantic', intent: 'general', semanticQuery: '关系变熟', sql: 'SELECT 1',
confidence: 1, requiresClarification: false
}))).toBeNull()
})
it.each([
'conversationId', 'wxid', 'messageId', 'sql', 'databasePath', 'filePath',
'startTime', 'endTime', 'evidenceId', 'tool', 'provider'
])('%s is rejected as an unknown trusted field', (field) => {
expect(parseAiQueryUnderstanding(JSON.stringify({
intent: 'conversation_boundary', contactQuery: '张三', boundary: 'first',
confidence: 1, requiresClarification: false, [field]: 'unsafe'
}))).toBeNull()
})
it.each([
['我和张三第一次聊天是什么时候?', '张三', 'first'],
['我和【张三】第一次说话是什么时候?', '张三', 'first'],
['我最早什么时候和张三聊过?', '张三', 'first'],
['我和张三最后一次聊天是什么时候?', '张三', 'last'],
['我最近一次和张三说话是什么时候?', '张三', 'last']
])('%s -> %s boundary', (query, contactQuery, boundary) => {
expect(buildLocalAiSearchPlan(query)).toMatchObject({
intent: 'conversation_boundary', contactQuery, boundary
})
})
it('keeps ordinary conversation recall separate', () => {
expect(buildLocalAiSearchPlan('我和张三最近聊了什么')).toMatchObject({
intent: 'conversation_recall', contactQuery: '张三'
})
})
it.each([
'我和BOBO第一次讲话说的什么',
'我和BOBO第一次聊天说了什么,是什么时候',
'我最后一次跟BOBO说了什么'
])('routes content boundary wording to suspicious fallback: %s', (query) => {
const plan = buildLocalAiSearchPlan(query)
expect(plan.intent).not.toBe('conversation_boundary')
})
})
describe('KnowledgeStore conversation boundary', () => {
it('uses the indexed time order and skips system messages', async () => {
const root = mkdtempSync(join(tmpdir(), 'trace-boundary-'))
roots.push(root)
const store = new KnowledgeStore(root, 'account', fts)
await store.index({
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
conversations: [{
conversationId: 'conversation', completeSnapshot: true,
messages: [
{ accountId: 'account', conversationId: 'conversation', messageId: 'system', createTime: 1, kind: 'system', text: '已添加联系人' },
{ accountId: 'account', conversationId: 'conversation', messageId: 'first', createTime: 2, kind: 'text', text: '你好', senderName: '我' },
{ accountId: 'account', conversationId: 'conversation', messageId: 'last', createTime: 3, kind: 'voice', voiceTranscript: '晚安', senderName: '张三' }
]
}]
})
expect(store.search({ accountId: 'account', text: '', terms: [], limit: 1, conversationIds: ['conversation'], conversationBoundary: 'first' })[0]).toMatchObject({ messageId: 'first' })
expect(store.search({ accountId: 'account', text: '', terms: [], limit: 1, conversationIds: ['conversation'], conversationBoundary: 'last' })[0]).toMatchObject({ messageId: 'last', sourceKind: 'voice' })
store.close()
})
})