fix: 修复 AI 检索 Evidence 覆盖与加载更多问题

修复问一问微信查询遗漏群聊的问题
区分会话覆盖与发送者覆盖策略
新增问一问微信加载更多功能
This commit is contained in:
Wxw-Gu
2026-08-18 14:19:28 +08:00
parent 15811c820c
commit f93dc539e4
14 changed files with 484 additions and 65 deletions
@@ -242,6 +242,17 @@ describe('AISearchWorkspace cache privacy boundary', () => {
timestamp: 1_785_900_000_000 + index,
text: `证据 ${index + 1}`
})),
evidenceCollection: Array.from({ length: 16 }, (_, index) => ({
id: `E${index + 1}`,
conversationId: 'fixture-contact',
conversationName: '测试会话',
conversationType: 'user',
messageId: `collection-message-${index + 1}`,
sender: `发送者 ${index + 1}`,
senderId: `collection-sender-${index + 1}`,
timestamp: 1_785_900_000_000 + index,
text: `扩展证据 ${index + 1}`
})),
aggregation: {
messageCount: 8,
peopleCount: 1,
@@ -279,6 +290,11 @@ describe('AISearchWorkspace cache privacy boundary', () => {
const card = screen.getByText('E7 · 发送者 7').closest('article')
await waitFor(() => expect(card).toHaveClass('focus-flash'))
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' })
const loadMore = screen.getByRole('button', { name: '加载更多证据' })
await userEvent.click(loadMore)
expect(screen.getByText('E9 · 发送者 9')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '加载更多证据' })).not.toBeInTheDocument()
})
it('keeps the submitted result title stable while drafting a new question and clears it from 新问题', async () => {
+35
View File
@@ -34,6 +34,7 @@ describe('Final Evidence builder', () => {
expect(result.candidateCount).toBe(16)
expect(result.evidence).toHaveLength(8)
expect(result.collection).toHaveLength(16)
expect(result.evidence.map((item) => item.id)).toEqual([
'E1',
'E2',
@@ -71,6 +72,40 @@ describe('Final Evidence builder', () => {
expect(result.evidence[0]).toMatchObject({ id: 'E1', sourceKind: 'voice' })
})
it('covers different conversations before filling remaining relevance slots', () => {
const candidates = Array.from({ length: 24 }, (_, index) =>
candidate(index + 1, {
conversationId: `conversation-${Math.floor(index / 2) + 1}`,
conversationName: `群聊 ${Math.floor(index / 2) + 1}`,
score: -(index + 1)
})
)
const result = buildFinalEvidence(candidates, 8, { strategy: 'conversation_coverage' })
expect(result.evidence).toHaveLength(8)
expect(new Set(result.evidence.map((item) => item.conversationId)).size).toBe(8)
expect(new Set(result.evidence.map((item) => item.conversationId))).toEqual(
new Set(Array.from({ length: 8 }, (_, index) => `conversation-${12 - index}`))
)
})
it('keeps sender coverage separate from conversation coverage', () => {
const candidates = Array.from({ length: 12 }, (_, index) =>
candidate(index + 1, {
conversationId: `conversation-${Math.floor(index / 3) + 1}`,
senderId: `sender-${Math.floor(index / 2) + 1}`,
sender: `成员 ${Math.floor(index / 2) + 1}`,
score: -(index + 1)
})
)
const result = buildFinalEvidence(candidates, 8, { strategy: 'sender_coverage' })
expect(result.evidence).toHaveLength(8)
expect(new Set(result.evidence.map((item) => item.senderId)).size).toBe(6)
})
it('removes citations which do not resolve to Final Evidence', () => {
const evidence = buildFinalEvidence([candidate(1), candidate(2)], 8).evidence
const result = sanitizeAnswerCitations('杨伟提到健身。[E1] 另有无效来源。[E10][E23]', evidence)
+102 -3
View File
@@ -277,6 +277,90 @@ describe('AiSearchPipelineService', () => {
])
})
it('keeps single-chat matches out of a global group lookup and covers groups in Final Evidence', async () => {
const groups = Array.from({ length: 10 }, (_, index) => ({
md5: `group-${index + 1}`,
m_nsUsrName: `group-${index + 1}@chatroom`,
m_nsNickName: `测试群 ${index + 1}`,
type: 'group' as const
}))
listContactsAsync.mockResolvedValue([
...groups,
{
md5: 'direct-contact',
m_nsUsrName: 'wxid_direct',
m_nsNickName: '单聊联系人',
type: 'user' as const
}
])
knowledge.search.mockResolvedValue({
source: 'knowledge',
state: 'ready',
indexedMessageCount: 2_000,
indexedChunkCount: 300,
totalMessages: 2_000,
evidence: [
{
chunkId: 'direct-chunk',
conversationId: 'direct-contact',
startTime: 1785900000000,
endTime: 1785900000000,
messageId: 'direct-message',
sender: '单聊联系人',
senderId: 'direct-sender',
timestamp: 1785900000000,
messageIds: ['direct-message'],
text: 'WechatExplorer',
score: -1
},
...groups.map((group, index) => ({
chunkId: `group-chunk-${index + 1}`,
conversationId: group.md5,
startTime: 1785899000000 - index,
endTime: 1785899000000 - index,
messageId: `group-message-${index + 1}`,
sender: `群成员 ${index + 1}`,
senderId: `group-sender-${index + 1}`,
timestamp: 1785899000000 - index,
messageIds: [`group-message-${index + 1}`],
text: 'WechatExplorer',
score: -(index + 2)
}))
]
})
aiProvider.chat.mockReset()
aiProvider.chat
.mockResolvedValueOnce({
success: true,
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"WechatExplorer"}}'
})
.mockResolvedValueOnce({
success: true,
data: '{"action":"finalize","reason":"已覆盖多个群聊"}'
})
.mockResolvedValueOnce({
success: true,
data: '多个群聊提到过 WechatExplorer。[E1]'
})
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
const result = await service.run(
{
requestId: 'global-group-coverage',
text: '哪个群说过 WechatExplorer',
scope: 'global',
range: '30d'
},
() => undefined
)
expect(result.plan.intent).toBe('global_group_topic_search')
expect(result.evidence).toHaveLength(8)
expect(result.evidence.every((item) => item.conversationType === 'group')).toBe(true)
expect(new Set(result.evidence.map((item) => item.conversationId)).size).toBe(8)
expect(result.evidence.some((item) => item.conversationId === 'direct-contact')).toBe(false)
})
it('keeps real evidence when the answer model fails', async () => {
aiProvider.chat.mockReset()
aiProvider.chat
@@ -530,7 +614,11 @@ describe('AiSearchPipelineService', () => {
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({
terms: [],
conversationIds: ['zhongtian-contact'],
conversationIds: expect.arrayContaining([
'zhongtian-contact',
'wxid_zhongtian',
'Chat_zhongtian-contact'
]),
startTime: expect.any(Number)
})
)
@@ -640,7 +728,11 @@ describe('AiSearchPipelineService', () => {
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({
terms: ['健身'],
conversationIds: ['zhongtian-contact'],
conversationIds: expect.arrayContaining([
'zhongtian-contact',
'wxid_zhongtian',
'Chat_zhongtian-contact'
]),
startTime: expect.any(Number)
})
)
@@ -1119,7 +1211,14 @@ describe('AiSearchPipelineService', () => {
retrieval: { conversationId: 'selected-contact' }
})
expect(knowledge.search).toHaveBeenCalledWith(
expect.objectContaining({ conversationIds: ['selected-contact'], terms: [] })
expect.objectContaining({
conversationIds: expect.arrayContaining([
'selected-contact',
'wxid_selected',
'Chat_selected-contact'
]),
terms: []
})
)
expect(aiProvider.chat.mock.calls[0]?.[0][1].content).toContain('conversation-1')
expect(result.agent.trace).not.toContainEqual(
+21 -1
View File
@@ -74,7 +74,7 @@ describe('AI search natural-language time ranges', () => {
it('classifies global topics and bare conversation names without turning names into FTS terms', () => {
expect(buildLocalAiSearchPlan('最近谁聊过 MCP')).toMatchObject({
intent: 'global_topic_search',
intent: 'global_sender_topic_search',
topicQuery: 'MCP',
keywords: ['MCP']
})
@@ -95,6 +95,26 @@ describe('AI search natural-language time ranges', () => {
})
})
it('classifies group conversation questions separately from sender questions', () => {
for (const question of [
'哪个群聊过 WechatExplorer',
'哪些群聊过 WechatExplorer',
'哪些群讨论过 WechatExplorer',
'哪个群说过 WechatExplorer'
]) {
expect(buildLocalAiSearchPlan(question)).toMatchObject({
intent: 'global_group_topic_search',
topicQuery: 'WechatExplorer',
keywords: ['WechatExplorer']
})
}
expect(buildLocalAiSearchPlan('谁聊过 WechatExplorer')).toMatchObject({
intent: 'global_sender_topic_search',
topicQuery: 'WechatExplorer',
keywords: ['WechatExplorer']
})
})
it('matches an explicitly mentioned nickname when the user omits punctuation', () => {
expect(includesExplicitAiSearchAlias('我和中田健身弘毅最近聊了什么?', '中田健身-弘毅')).toBe(
true
@@ -114,6 +114,20 @@ describe('KnowledgeSearchService legacy fallback', () => {
await service.dispose()
})
it('accepts username and Chat_<md5> aliases in the legacy fallback scope', async () => {
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
const result = await service.search({
text: 'Knowledge Worker fallback',
terms: ['Knowledge Worker', 'fallback'],
conversationIds: ['fixture-contact', 'Chat_fixture-conversation'],
limit: 10
})
expect(listMessagesAsync).toHaveBeenCalledWith('fixture-conversation', undefined, undefined)
expect(result.evidence).toHaveLength(1)
await service.dispose()
})
it('hydrates a cached voice transcript and incrementally indexes only its conversation', async () => {
chatState.ready = true
chatState.accountId = 'C:/fixtures/account-a'