mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 完善问问微信查询理解与检索链路
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 暂时无法生成回答']
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user