Files
WechatExplorer/tests/unit/local-query-api-service.test.ts
T
Wxw-Gu ab3b3731b7 feat: 重构问问微信并完善知识库增量检索
统一问问微信与 Agent Hub 的查询链路
完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论
基于会话实现真正的增量追新与历史补齐
支持后台同步、取消恢复、重启续传以及同步期间继续查询
优化知识库跨会话检索、同步状态、进度展示和侧栏布局
2026-09-11 17:44:48 +08:00

424 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi, beforeEach } from 'vitest'
const fixture = vi.hoisted(() => ({
contacts: [
{ m_nsUsrName: 'wxid-bobo', m_nsNickName: 'BOBO', md5: 'md5-bobo', type: 'user' as const },
{ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' as const }
],
messages: [
{ id: 'm1', from: 'user', type: '图片', datetime: '2026/8/2 10:00:00', content: '', contentData: { type: 'image', md5: 'image-md5' }, isSender: false, name: 'BOBO', createTime: Math.floor(new Date('2026-08-02T10:00:00+08:00').getTime() / 1000) },
{ id: 'm2', from: 'assistant', type: '文件', datetime: '2026/8/3 10:00:00', content: '', isSender: true, exportMediaType: 'file' as const, exportMediaName: 'a.pdf', createTime: Math.floor(new Date('2026-08-03T10:00:00+08:00').getTime() / 1000) }
]
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContactsAsync: vi.fn(async () => fixture.contacts),
listMessagesAsync: vi.fn(async () => fixture.messages)
}))
import { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
describe('LocalQueryApiService', () => {
const knowledge = {
search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })),
requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })),
waitForIndexingComplete: vi.fn(async () => false),
lastPassDurationMs: vi.fn(() => 0),
beginInteractiveQuery: vi.fn(),
endInteractiveQuery: vi.fn()
} as any
/** 固定时钟:requestedEnd 由它决定。 */
const NOW = new Date('2026-09-09T12:00:00+08:00').getTime()
const DAY = 24 * 60 * 60 * 1000
let service: LocalQueryApiService
beforeEach(() => {
vi.clearAllMocks()
// mockReset 会清掉 once 队列与实现:避免上一个用例残留的队列被下一个用例消费。
knowledge.search.mockReset()
knowledge.search.mockImplementation(async () => ({
state: 'ready',
evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }],
conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true },
voiceCoverage: undefined
}))
knowledge.requestCatchUp.mockReset()
knowledge.requestCatchUp.mockReturnValue({ triggered: false, inProgress: false })
knowledge.waitForIndexingComplete.mockReset()
knowledge.waitForIndexingComplete.mockResolvedValue(false)
knowledge.lastPassDurationMs.mockReset()
knowledge.lastPassDurationMs.mockReturnValue(0)
if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' })
service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00'))
})
it('publishes capability contract and resolves previous month', async () => {
expect(service.capabilities().tools.query_messages.messageTypes).toContain('file')
const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, direction: 'any', order: 'asc', limit: 1 })
expect(result.status).toBe('ambiguous_contact')
fixture.contacts.splice(1)
const resolved = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, direction: 'any', order: 'asc', limit: 1 })
expect(resolved.query?.resolvedTimeRange).toMatchObject({ startTime: expect.any(Number), endTime: expect.any(Number), label: '2026年8月' })
expect(resolved.returnedCount).toBe(1)
})
it('filters direction and message type, and rejects too many variants', async () => {
fixture.contacts.splice(1)
const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, direction: 'to_target', messageTypes: ['file'], limit: 1 })
expect(result.messages?.[0].sourceKind).toBe('file')
await expect(service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: 'x', variants: ['1', '2', '3', '4', '5'] })).resolves.toMatchObject({ status: 'invalid_request' })
})
it('returns canonical type and attachment for the earliest non-text message', async () => {
fixture.contacts.splice(1)
const result = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, order: 'asc', limit: 1, excludeSystem: true })
expect(result.messages?.[0]).toMatchObject({ messageType: 'image', sourceKind: 'image', attachment: { kind: 'image' } })
expect(result.messages?.[0]).not.toHaveProperty('text')
})
it('round-trips opaque refs from messages, search, and overview through context', async () => {
fixture.contacts.splice(1)
const queried = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, order: 'asc', limit: 1 })
const queriedRef = queried.messages?.[0]?.messageRef
expect(queriedRef).toEqual(expect.any(String))
await expect(service.context({ messageRef: queriedRef!, before: 0, after: 0 })).resolves.toMatchObject({
status: 'completed',
anchor: { messageRef: queriedRef }
})
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'local:m1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })
const searched = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '你好' })
const searchedRef = searched.evidence?.[0]?.messageRef
expect(searchedRef).toBe(queriedRef)
await expect(service.context({ messageRef: searchedRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' })
// 概览直读 WCDB,不再消费派生索引 → 这里不再排队 knowledge mock(残留队列会污染后续用例)。
const overview = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } })
const overviewRef = overview.evidence?.[0]?.messageRef
expect(overviewRef).toBe(queriedRef)
await expect(service.context({ messageRef: overviewRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' })
})
it('会话概览以 WCDB 为事实来源,并在派生索引为空时不返回「0 + complete」(P0 回归)', async () => {
fixture.contacts.splice(1)
// 陈旧索引的真实形态:索引对这一会话在范围内 0 行,且自称 complete。
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
conversationRetrieval: { totalMessages: 0, chunkCount: 0, complete: true },
voiceCoverage: undefined
})
const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } })
expect(result).toMatchObject({ status: 'completed', origin: 'wcdb', sourceMessageCount: 2, evidenceCount: 2 })
expect(result.coverage).toEqual({ state: 'complete' })
expect(result.evidence?.map((item) => item.messageRef)).toHaveLength(2)
// 概览不再依赖派生索引(索引可能滞后于 WCDB)。
expect(knowledge.search).not.toHaveBeenCalled()
})
it('会话概览保留时间片代表证据,并如实标注覆盖状态', async () => {
fixture.contacts.splice(1)
const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } })
expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 2 })
expect(result.evidence?.map((item) => item.timestamp)).toEqual(
[...fixture.messages].map((message) => message.createTime * 1000)
)
expect(result.selection?.mode).toBe('temporal_coverage')
})
it('scope=groups 只检索群会话,并把群名与成员写进证据', async () => {
fixture.contacts.splice(1)
fixture.contacts.push({ m_nsUsrName: 'wxid-group@chatroom', m_nsNickName: 'TraceMemo 交流群', md5: 'md5-group', type: 'group' } as never)
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [{ conversationId: 'md5-group', messageId: 'g1', timestamp: 5, sender: '张三', sourceKind: 'text', text: '最近开始健身了' }],
voiceCoverage: undefined
})
const result = await service.search({ timeRange: { kind: 'all' }, query: '健身', scope: { kind: 'groups' } })
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: ['md5-group'] }))
expect(result.scope).toEqual({ kind: 'groups', conversationCount: 1 })
expect(result.evidence?.[0]).toMatchObject({
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三'
})
})
it('scope=all 省略 target 时按全局检索(不传 conversationIds)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ state: 'ready', evidence: [], voiceCoverage: undefined })
const result = await service.search({ timeRange: { kind: 'all' }, query: '健身', scope: { kind: 'all' } })
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: undefined }))
// 跨会话检索不宣称"完整":派生索引不是完整性权威。
expect(result.coverage).toEqual({ state: 'partial' })
})
it('scope 内的 target 越界会被结构化拒绝(而不是悄悄扩大范围)', async () => {
fixture.contacts.splice(1)
const result = await service.search({
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
query: '健身',
scope: { kind: 'groups' }
})
expect(result).toMatchObject({
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_outside_scope',
actual: 'BOBO'
})
expect(knowledge.search).not.toHaveBeenCalled()
})
it('scope=contact 只允许该单聊会话,未知会话会被拒绝', async () => {
fixture.contacts.splice(1)
const allowed = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, scope: { kind: 'contact', conversationId: 'md5-bobo' } })
expect(allowed.status).toBe('completed')
const rejected = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, scope: { kind: 'contact', conversationId: 'md5-unknown' } })
expect(rejected.status).toBe('scope_conversation_not_found')
})
it('会话概览在范围内有多个会话且未指定 target 时要求指定 target', async () => {
fixture.contacts.splice(1)
const result = await service.overview({ timeRange: { kind: 'all' }, scope: { kind: 'groups' } })
expect(result).toMatchObject({ status: 'invalid_tool_arguments', constraint: 'target_required_for_scope' })
})
describe('freshness contract(索引落后时不得出现 stale complete negative)', () => {
it('交互检索全程标记为前台:后台索引必须让路(否则查询会被 pass 拖慢)', async () => {
fixture.contacts.splice(1)
const order: string[] = []
knowledge.beginInteractiveQuery.mockImplementation(() => order.push('begin'))
knowledge.endInteractiveQuery.mockImplementation(() => order.push('end'))
knowledge.search.mockImplementation(async () => {
order.push('search')
return { state: 'ready', evidence: [], indexLatestAt: NOW - 30 * 1000, sourceLatestAt: NOW - 1000, voiceCoverage: undefined }
})
await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(order).toEqual(['begin', 'search', 'end'])
expect(knowledge.beginInteractiveQuery).toHaveBeenCalledTimes(1)
expect(knowledge.endInteractiveQuery).toHaveBeenCalledTimes(1)
})
it('同一次查询的多个 probe 共用 retrieval session(避免重复读群成员快照)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ state: 'ready', indexLatestAt: NOW - 30 * 1000, sourceLatestAt: NOW - 1000, evidence: [], voiceCoverage: undefined })
await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身', variants: ['锻炼', '跑步'] })
expect(knowledge.search).toHaveBeenCalledTimes(3)
const sessions = new Set(knowledge.search.mock.calls.map((call: unknown[]) => (call[0] as { retrievalSessionId?: string }).retrievalSessionId))
expect(sessions.size).toBe(1)
expect([...sessions][0]).toEqual(expect.any(String))
})
const staleIndex = (): Record<string, unknown> => ({
state: 'ready',
evidence: [],
indexLatestAt: NOW - 10 * DAY,
voiceCoverage: undefined
})
const freshIndex = (evidence: unknown[] = []): Record<string, unknown> => ({
state: 'ready',
evidence,
indexLatestAt: NOW - 30 * 1000,
voiceCoverage: undefined
})
it('索引明显落后 → coverage=partial、请求追赶,且 0 结果不被当成“没有”', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true })
knowledge.waitForIndexingComplete.mockResolvedValue(false)
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.coverage).toEqual({ state: 'partial' })
expect(result.evidenceCount).toBe(0)
expect(result.indexLatestAt).toBe(NOW - 10 * DAY)
expect(result.sourceLatestAt).toBe(NOW - 1000)
expect(result.freshness).toEqual({ catchUp: 'pending' })
expect(knowledge.requestCatchUp).toHaveBeenCalledTimes(1)
expect(knowledge.waitForIndexingComplete).toHaveBeenCalledWith(2000)
})
it('追赶在预算内完成 → 用新索引重新检索(不能拿同步前的结果回答)', async () => {
fixture.contacts.splice(1)
knowledge.search
.mockResolvedValueOnce({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
.mockResolvedValueOnce({
state: 'ready',
indexLatestAt: NOW - 30 * 1000,
sourceLatestAt: NOW - 1000,
voiceCoverage: undefined,
evidence: [{ conversationId: 'md5-bobo', messageId: 'new-1', timestamp: NOW - 60_000, sender: 'BOBO', sourceKind: 'text', text: '最近开始健身了' }]
})
knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true })
knowledge.waitForIndexingComplete.mockResolvedValue(true)
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(knowledge.search).toHaveBeenCalledTimes(2)
expect(result.coverage).toEqual({ state: 'complete' })
expect(result.freshness).toEqual({ catchUp: 'completed' })
expect(result.evidenceCount).toBe(1)
expect(result.evidence?.[0]).toMatchObject({ text: '最近开始健身了', sender: 'BOBO' })
})
it('索引已追平 → 不触发同步(fresh path 不增加开销)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ ...freshIndex(), sourceLatestAt: NOW - 1000 })
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.freshness).toEqual({ catchUp: 'none' })
expect(result.coverage).toEqual({ state: 'complete' })
expect(knowledge.requestCatchUp).not.toHaveBeenCalled()
expect(knowledge.waitForIndexingComplete).not.toHaveBeenCalled()
})
it('请求范围落在索引覆盖窗口内 → 不触发同步', async () => {
fixture.contacts.splice(1)
// 索引追到"昨天",而问的是"上个月"(早于索引覆盖边界)→ 完全覆盖。
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
indexLatestAt: NOW - DAY,
sourceLatestAt: NOW - 1000,
voiceCoverage: undefined
})
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, query: '健身' })
expect(knowledge.requestCatchUp).not.toHaveBeenCalled()
expect(result.coverage).toEqual({ state: 'complete' })
expect(result.indexCoverage?.covered).toBe(true)
})
it('有界时间范围落后于索引时必须 partial(resolvedTimeRange 是秒,freshness 口径是毫秒)', async () => {
fixture.contacts.splice(1)
// 回归:`resolvedTimeRange.endTime` 是 epoch 秒,而 indexLatestAt 是 epoch 毫秒。
// 混单位会让请求边界被算成 1970 年,从而把"落后"误判成"已覆盖"。
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
indexLatestAt: NOW - 4 * 60 * 1000,
sourceLatestAt: NOW,
voiceCoverage: undefined
})
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'today' }, query: '健身' })
expect(result.resolvedTimeRange?.endTime).toEqual(expect.any(Number))
// 请求边界(今天)晚于索引覆盖边界 4 分钟 → 只能 partial。
expect(result.coverage).toEqual({ state: 'partial' })
expect(result.indexCoverage?.covered).toBe(false)
expect(result.indexCoverage?.summary).toContain('暂时无法确认')
})
it('已有索引任务在跑 → 复用而不阻塞本次查询', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
knowledge.requestCatchUp.mockReturnValue({ triggered: false, inProgress: true })
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.freshness).toEqual({ catchUp: 'reused' })
expect(knowledge.waitForIndexingComplete).not.toHaveBeenCalled()
expect(result.coverage).toEqual({ state: 'partial' })
})
it('追平后 0 结果才允许 complete negative;落后时同一条件只能是 partial', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
const stale = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '不存在的词' })
expect(stale.coverage).toEqual({ state: 'partial' })
knowledge.search.mockResolvedValue({ ...freshIndex(), sourceLatestAt: NOW - 1000 })
const fresh = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '不存在的词' })
expect(fresh.coverage).toEqual({ state: 'complete' })
expect(fresh.evidenceCount).toBe(0)
})
it('派生索引不可用时保持 unknown/partial,不冒充 complete', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ state: 'unavailable', evidence: [], indexLatestAt: null, sourceLatestAt: null, voiceCoverage: undefined })
const empty = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(empty.coverage).toEqual({ state: 'unknown' })
expect(knowledge.requestCatchUp).not.toHaveBeenCalled()
})
it('落后量低于门槛(max(下限, 上一遍耗时))时不触发追赶,只如实 partial', async () => {
fixture.contacts.splice(1)
// 上一遍索引耗时 15 分钟 → 15 分钟的落后不值得立刻再跑一遍(否则会连续索引)。
knowledge.lastPassDurationMs.mockReturnValue(15 * 60 * 1000)
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
indexLatestAt: NOW - 15 * 60 * 1000,
sourceLatestAt: NOW - 1000,
voiceCoverage: undefined
})
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.freshness).toEqual({ catchUp: 'skipped' })
expect(result.coverage).toEqual({ state: 'partial' })
expect(knowledge.requestCatchUp).not.toHaveBeenCalled()
})
it('落后量超过上一遍耗时 → 值得再追一遍', async () => {
fixture.contacts.splice(1)
knowledge.lastPassDurationMs.mockReturnValue(60 * 1000)
knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true })
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(knowledge.requestCatchUp).toHaveBeenCalledTimes(1)
expect(result.freshness).toEqual({ catchUp: 'pending' })
})
})
})
describe('LocalQueryApiService — indexCoverage 结论句', () => {
const NOW2 = new Date('2026-09-09T12:00:00+08:00').getTime()
let service2: LocalQueryApiService
beforeEach(() => {
vi.clearAllMocks()
if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' })
service2 = new LocalQueryApiService(
{
search: vi.fn(async () => ({
state: 'ready',
evidence: [],
indexLatestAt: NOW2 - 10 * 24 * 60 * 60 * 1000,
sourceLatestAt: NOW2 - 1000,
voiceCoverage: undefined
})),
requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })),
waitForIndexingComplete: vi.fn(async () => false),
lastPassDurationMs: vi.fn(() => 0),
beginInteractiveQuery: vi.fn(),
endInteractiveQuery: vi.fn()
} as never,
() => new Date('2026-09-09T12:00:00+08:00')
)
fixture.contacts.splice(1)
})
it('落后时给出可直接引用的本地时间结论,且明确"暂时无法确认"', async () => {
const result = await service2.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.coverage).toEqual({ state: 'partial' })
// 8/30 12:00 的本地时间标签(固定时钟下可断言)
expect(result.indexCoverage?.covered).toBe(false)
expect(result.indexCoverage?.indexLatestAtLabel).toBe('08-30 12:00')
expect(result.indexCoverage?.summary).toContain('还没进索引')
expect(result.indexCoverage?.summary).toContain('暂时无法确认')
// 不要把 epoch 数字交给模型去猜
expect(result.indexCoverage?.summary).not.toMatch(/\d{12,}/)
})
})