feat: 重构问问微信并完善知识库增量检索

统一问问微信与 Agent Hub 的查询链路
完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论
基于会话实现真正的增量追新与历史补齐
支持后台同步、取消恢复、重启续传以及同步期间继续查询
优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
Wxw-Gu
2026-09-11 17:44:48 +08:00
parent 0c4932d740
commit ab3b3731b7
69 changed files with 8317 additions and 898 deletions
+490
View File
@@ -0,0 +1,490 @@
import { describe, expect, it, vi } from 'vitest'
import { AskWechatService, type AskWechatLogRecord } from '../../src/main/services/ask-wechat-service'
import { QueryAgentService, type QueryAgentProvider } from '../../src/main/services/query-agent-service'
import type { AiSearchPipelineResult } from '../../src/shared/ai-search'
import type { AskWechatQueryRequest, AskWechatQueryResult } from '../../src/shared/query-agent'
type ChatResponse = Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>
type ToolExecutor = (name: string, input: Record<string, unknown>) => Promise<Record<string, unknown>>
const request = (
text: string,
legacy?: AskWechatQueryRequest['legacy']
): AskWechatQueryRequest => ({ requestId: 'req-1', text, legacy })
function providerFactory(responses: ChatResponse[], configured = true): {
provider: QueryAgentProvider
calls: Array<{ messages: Array<Record<string, unknown>>; tools: string[] }>
} {
const calls: Array<{ messages: Array<Record<string, unknown>>; tools: string[] }> = []
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({
configured,
providerName: 'Fixture Provider',
model: 'fixture-model',
modelName: 'Fixture Model'
}),
chatWithTools: vi.fn(async (messages, tools) => {
calls.push({ messages, tools: tools.map((tool) => tool.function.name) })
return responses.shift() || { success: true, data: 'done' }
})
}
return { provider, calls }
}
const toolCall = (name: string, args: Record<string, unknown>): ChatResponse => ({
success: true,
data: '',
toolCalls: [{ id: `call-${name}`, name, arguments: JSON.stringify(args) }]
})
const answer = (data: string): ChatResponse => ({ success: true, data })
const toolMessages = (messages: Array<Record<string, unknown>>): string =>
JSON.stringify(messages.filter((message) => message.role === 'tool'))
const legacyResult = (patch: Record<string, unknown> = {}): AiSearchPipelineResult =>
({
requestId: 'req-1',
status: 'completed',
answer: 'legacy answer',
...patch
}) as unknown as AiSearchPipelineResult
const answered = (result: AskWechatQueryResult): Extract<AskWechatQueryResult, { status: 'answered' }> => {
if (result.status !== 'answered') throw new Error(`expected answered, got ${result.status}`)
return result
}
describe('AskWechatService — 桌面问问微信主路径', () => {
it('A. 「我和 BOBO 第一次聊了什么」走 query_messages 并返回回答', async () => {
const execute = vi.fn(async () => ({
status: 'completed',
returnedCount: 1,
messages: [{ messageRef: 'ref-1', text: '你好' }]
}))
const { provider } = providerFactory([
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' },
order: 'asc',
limit: 1
}),
answer('你们的第一次聊天是一条问候。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(await service.ask(request('我和 BOBO 第一次聊了什么')))
expect(result.engine).toBe('query-agent')
expect(result.answer).toBe('你们的第一次聊天是一条问候。')
expect(result.diagnostics).toMatchObject({
entry: 'desktop',
provider: 'Fixture Provider',
model: 'Fixture Model',
modelCallCount: 2,
toolCallCount: 1,
tools: ['query_messages'],
outcome: 'answered'
})
expect(execute).toHaveBeenCalledTimes(1)
// temporalBasis 是 LLM-facing 元数据,必须在到达 Query API 前被剥离。
expect(execute.mock.calls[0][1]).not.toHaveProperty('temporalBasis')
expect(execute.mock.calls[0][1].timeRange).toEqual({ kind: 'all' })
})
it('B. 明确时间(constraint)不会被 retry 扩大,ISO-8601 被换算成 epoch seconds', async () => {
const execute = vi.fn(async () => ({
status: 'completed',
returnedCount: 0,
coverage: { state: 'complete' }
}))
const constraintBasis = { kind: 'constraint', sourceText: '八月份' }
const { provider, calls } = providerFactory([
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: {
kind: 'absolute',
startTime: '2026-08-01T00:00:00+08:00',
endTime: '2026-09-01T00:00:00+08:00'
},
temporalBasis: constraintBasis
}),
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: constraintBasis
}),
answer('八月份没有找到相关记录。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
answered(await service.ask(request('八月份 BOBO 有没有给我发过文件')))
// 第二次改时间范围被 Host 结构性拒绝,没有到达 Query API。
expect(execute).toHaveBeenCalledTimes(1)
expect(execute.mock.calls[0][1].timeRange).toEqual({
kind: 'absolute',
startTime: Math.floor(Date.parse('2026-08-01T00:00:00+08:00') / 1000),
endTime: Math.floor(Date.parse('2026-09-01T00:00:00+08:00') / 1000)
})
expect(toolMessages(calls[2].messages)).toContain('constraint_time_range_immutable')
})
it('C. 模糊时间(recall_hint)首次 0 条时由 Host 自动补查 all,且不占 toolCallCount', async () => {
const execute = vi
.fn<ToolExecutor>()
.mockResolvedValueOnce({ status: 'completed', returnedCount: 0, coverage: { state: 'complete' } })
.mockResolvedValueOnce({
status: 'completed',
returnedCount: 2,
coverage: { state: 'complete' },
messages: [{ messageRef: 'ref-2' }]
})
const { provider } = providerFactory([
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'last_7_days' },
temporalBasis: { kind: 'recall_hint', sourceText: '前阵子' }
}),
answer('原范围没有,全部历史里有两条。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(await service.ask(request('前阵子 BOBO 好像发过东西给我')))
expect(execute).toHaveBeenCalledTimes(2)
expect(execute.mock.calls[1][1].timeRange).toEqual({ kind: 'all' })
expect(result.diagnostics.toolCallCount).toBe(1)
expect(result.diagnostics.modelCallCount).toBe(2)
})
it('D. 语义检索走 search_messages,queries 映射为 query + variants', async () => {
const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 2, evidence: [] }))
const { provider } = providerFactory([
toolCall('search_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
queries: ['房租', '押金']
}),
answer('找到两条相关记录。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(await service.ask(request('BOBO 提过房租或者押金吗')))
expect(result.diagnostics.tools).toEqual(['search_messages'])
expect(execute.mock.calls[0][1]).toMatchObject({ query: '房租', variants: ['押金'] })
})
it('E. 宽泛总结走 conversation_overview', async () => {
const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 12, evidence: [] }))
const { provider } = providerFactory([
toolCall('conversation_overview', {
target: { query: 'TraceMemo交流群' },
timeRange: { kind: 'last_7_days' }
}),
answer('最近一周主要讨论了两件事。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(await service.ask(request('TraceMemo交流群最近聊了什么')))
expect(result.diagnostics.tools).toEqual(['conversation_overview'])
})
it('F. 模型追问(clarification)按普通回答返回,并进入下一轮上下文', async () => {
const { provider, calls } = providerFactory([
answer('你想问的是哪位联系人?'),
answer('好的,是 BOBO。')
])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' })
const first = answered(await service.ask(request('我们第一次聊了什么')))
expect(first.answer).toBe('你想问的是哪位联系人?')
await service.ask(request('BOBO'))
expect(calls[1].messages.map((message) => message.role)).toEqual([
'system',
'user',
'assistant',
'user'
])
expect(calls[1].messages[1]).toMatchObject({ content: '我们第一次聊了什么' })
expect(calls[1].messages[2]).toMatchObject({ content: '你想问的是哪位联系人?' })
})
it('G. Provider 失败 → 安全文案,且不回退 Legacy', async () => {
const runLegacy = vi.fn(async () => legacyResult())
const { provider } = providerFactory([{ success: false, error: 'AI 请求超时', timedOut: true }])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
entry: 'desktop',
runLegacy
})
const result = await service.ask(request('BOBO 说过什么'))
expect(result.status).toBe('provider_unavailable')
if (result.status !== 'provider_unavailable') throw new Error('unreachable')
expect(result.message).toBe('当前 AI 查询服务暂时不可用,请稍后再试。')
expect(result.diagnostics.outcome).toBe('provider_failure')
expect(runLegacy).not.toHaveBeenCalled()
})
it('G2. Provider 未配置 → 安全文案,且不触发任何模型 / 工具调用', async () => {
const execute = vi.fn(async () => ({ status: 'completed' }))
const { provider } = providerFactory([], false)
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = await service.ask(request('BOBO 说过什么'))
expect(result.status).toBe('provider_unavailable')
expect(execute).not.toHaveBeenCalled()
expect(provider.chatWithTools).not.toHaveBeenCalled()
})
it('H. Query Agent 查 0 条 → 仍然是它回答,不调用 Legacy', async () => {
const execute = vi.fn(async () => ({
status: 'completed',
returnedCount: 0,
coverage: { state: 'complete' }
}))
const runLegacy = vi.fn(async () => legacyResult())
const { provider } = providerFactory([
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' }
}),
answer('当前可读取的完整范围里没有找到相关记录。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), {
entry: 'desktop',
runLegacy
})
const result = answered(await service.ask(request('BOBO 给我发过文件吗')))
expect(result.answer).toContain('没有找到')
expect(result.diagnostics.outcome).toBe('answered')
expect(runLegacy).not.toHaveBeenCalled()
})
it('Runtime 抛出未分类异常 → 允许回退 Legacy,并沿用 UI 范围', async () => {
const runLegacy = vi.fn(async () => legacyResult({ status: 'completed' }))
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({
configured: true,
providerName: 'P',
model: 'm',
modelName: 'M'
}),
chatWithTools: vi.fn(async () => {
throw new Error('unexpected internal failure')
})
}
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
entry: 'desktop',
runLegacy
})
const result = await service.ask(
request('BOBO 说过什么', {
scope: 'conversation',
range: '30d',
conversationId: 'conversation-1'
})
)
expect(result.engine).toBe('legacy')
if (result.engine !== 'legacy') throw new Error('unreachable')
expect(result.reason).toBe('runtime_error')
expect(runLegacy).toHaveBeenCalledTimes(1)
expect(runLegacy.mock.calls[0][0]).toMatchObject({
requestId: 'req-1',
text: 'BOBO 说过什么',
scope: 'conversation',
range: '30d',
conversationId: 'conversation-1'
})
})
it('Runtime 异常且没有 Legacy 通道(Agent Hub)→ 明确文案,不抛异常', async () => {
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({ configured: true, providerName: 'P', model: 'm', modelName: 'M' }),
chatWithTools: vi.fn(async () => {
throw new Error('unexpected internal failure')
})
}
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
entry: 'agent-hub'
})
const result = await service.ask(request('BOBO 说过什么'))
expect(result.status).toBe('error')
if (result.status !== 'error') throw new Error('unreachable')
expect(result.message).toBe('本次查询没有完成,请稍后再试或换一种问法。')
expect(result.diagnostics.entry).toBe('agent-hub')
})
it('超过工具调用上限 → 判为 Runtime 失败(可回退),不伪装成正常回答', async () => {
let counter = 0
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0 }))
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({ configured: true, providerName: 'P', model: 'm', modelName: 'M' }),
chatWithTools: vi.fn(async () => {
counter += 1
return {
success: true,
data: '',
toolCalls: [
{
id: `call-${counter}`,
name: 'query_messages',
arguments: JSON.stringify({
target: { query: `BOBO${counter}` },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' }
})
}
]
}
})
}
const service = new AskWechatService(new QueryAgentService(provider, execute), {
entry: 'agent-hub'
})
const result = await service.ask(request('一直查不完的问题'))
expect(result.status).toBe('error')
if (result.status !== 'error') throw new Error('unreachable')
expect(result.diagnostics.outcome).toBe('tool_limit')
})
it('空问题 → 明确的用户文案', async () => {
const { provider } = providerFactory([])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' })
const result = await service.ask(request(' '))
expect(result.status).toBe('error')
if (result.status !== 'error') throw new Error('unreachable')
expect(result.message).toBe('请先输入想了解的问题。')
expect(result.diagnostics.outcome).toBe('invalid_question')
})
it('生产日志只记录形态字段,不含回答内容', async () => {
const logs: AskWechatLogRecord[] = []
const { provider } = providerFactory([answer('BOBO 最近在准备搬家,提到了房租和押金。')])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
entry: 'desktop',
log: (record) => logs.push(record)
})
await service.ask(request('BOBO 最近在忙什么'))
expect(logs).toHaveLength(1)
expect(Object.keys(logs[0].details ?? {}).sort()).toEqual([
'entry',
'model',
'modelCallCount',
'outcome',
'provider',
'toolCallCount',
'tools',
'totalMs'
])
expect(JSON.stringify(logs)).not.toContain('准备搬家')
})
it('forgetConversation 清掉指定会话的澄清上下文', async () => {
const { provider, calls } = providerFactory([
answer('哪一位?'),
answer('好的。'),
answer('好的。')
])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' })
await service.ask(request('我们第一次聊了什么'))
service.forgetConversation()
await service.ask(request('BOBO'))
expect(calls[1].messages.map((message) => message.role)).toEqual(['system', 'user'])
})
})
describe('AskWechatService — 搜索范围(conversationScope)', () => {
it('把 UI 的 scope 透传到 Tool 执行上下文,并让范围说明进入模型上下文', async () => {
const seen: Array<Record<string, unknown> | undefined> = []
const execute = vi.fn(async (_name: string, _input: Record<string, unknown>, context?: { conversationScope?: unknown }) => {
seen.push(context?.conversationScope as Record<string, unknown> | undefined)
return { status: 'completed', returnedCount: 0 }
})
const { provider, calls } = providerFactory([
toolCall('search_messages', { timeRange: { kind: 'all' }, queries: ['健身'] }),
answer('范围内没有找到。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
await service.ask({
requestId: 'req-1',
text: '最近谁聊过健身',
scope: { scope: { kind: 'groups' }, label: '群聊专属' }
})
expect(seen[0]).toEqual({ kind: 'groups' })
// 范围说明只描述边界;强制由 Engine 完成(越界 target 会被结构化拒绝)。
expect(String(calls[0].messages[1]?.content)).toContain('群聊专属')
expect(String(calls[0].messages[1]?.content)).toContain('群成员实际发送的消息')
})
it('没有 scope 时不注入范围说明(保持毕业版本的 messages 形状)', async () => {
const { provider, calls } = providerFactory([answer('好的')])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'agent-hub' })
await service.ask(request('你好'))
expect(calls[0].messages.map((message) => message.role)).toEqual(['system', 'user'])
})
it('回答结果带真实统计与证据(供 UI 顶部与右侧面板使用)', async () => {
const execute = vi.fn(async () => ({
status: 'completed',
evidenceCount: 2,
evidence: [
{
messageRef: 'ref-1',
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三',
timestamp: 1_787_650_302_000,
sourceKind: 'text',
text: '最近开始健身了'
}
]
}))
const { provider } = providerFactory([
toolCall('search_messages', { timeRange: { kind: 'all' }, queries: ['健身'] }),
answer('张三提过。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(
await service.ask({ requestId: 'req-1', text: '谁聊过健身', scope: { scope: { kind: 'groups' } } })
)
expect(result.evidence).toHaveLength(1)
expect(result.evidence[0]).toMatchObject({
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三',
source: 'search_messages'
})
expect(result.stats.tools).toEqual(['search_messages'])
expect(result.stats.reads.evidenceCount).toBe(1)
expect(result.stats.scope).toEqual({ kind: 'groups' })
})
})