Files
WechatExplorer/tests/integration/image-text-index-synthetic-e2e.test.ts
T
Wxw-Gu 1337bcb7de feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度
- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
2026-09-17 13:11:20 +08:00

390 lines
14 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.
/**
* 图片 OCR 来源语义的 **deterministic synthetic E2E**。
*
* 硬要求是"不依赖真实线上 AI 模型也能 PASS",所以这里把两个外部边界**确定性**地固定住:
* - WCDB(chat-service)→ 用合成联系人 / 合成消息;
* - Knowledge 检索 → 用 fake 直接返回合成证据(形状与真实 `KnowledgeEvidence` 一致,
* 包括**未清理**的 `searchable_text`,用来验证内部前缀确实被剥掉)。
*
* 链路上真正的被测代码仍然是生产实现:
* LocalQueryApiService.search() ← 真实 scope 解析 / 证据映射 / 前缀剥离
* createLocalQueryToolExecutor() ← 真实 Tool 执行
* QueryAgentService.run() ← 真实 Agent 循环 / tool result 组装
*
* 断言的 10 项对应需求:FOUND=YES / sourceKind=image / derived source=image_ocr /
* conversation scope=技术交流群 / Evidence messageRef=原始图片消息 /
* Evidence UI=图片文字 / jump target=原始图片消息 / 不产生虚构 OCR 消息。
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { decodeMessageRef } from '../../src/shared/local-query-api'
process.env.TZ = 'Asia/Shanghai'
const GROUP_MD5 = 'md5-tech-group'
const GROUP_NAME = '技术交流群'
const IMAGE_MESSAGE_ID = 'local:9001'
const TEXT_MESSAGE_ID = 'local:9002'
const OCR_TEXT = 'OpenAI ChatGPT Plus $20 Pro $200'
/** Knowledge 侧的原始 searchable_text:带内部标签,绝不该出现在 Evidence 里。 */
const RAW_SEARCHABLE = `图片文字:${OCR_TEXT}`
const fixture = vi.hoisted(() => {
const imageTimestamp = Date.parse('2026-09-03T14:32:00+08:00')
const textTimestamp = Date.parse('2026-09-03T14:30:00+08:00')
return {
imageTimestamp,
textTimestamp,
contacts: [
{
m_nsUsrName: 'wxid-tech-group',
m_nsNickName: '技术交流群',
md5: 'md5-tech-group',
type: 'group' as const
}
],
messages: [
{
id: '9002',
localId: '9002',
from: 'user',
type: '文本',
datetime: '2026/9/3 14:30:00',
content: '今天正常讨论一下 API',
isSender: false,
name: '张三',
createTime: Math.floor(textTimestamp / 1000)
},
{
id: '9001',
localId: '9001',
from: 'user',
type: '图片',
datetime: '2026/9/3 14:32:00',
content: '',
contentData: { type: 'image', md5: 'image-md5-fixture', datName: 'dat-fixture' },
isSender: false,
name: '张三',
createTime: Math.floor(imageTimestamp / 1000)
}
]
}
})
const IMAGE_TIMESTAMP = fixture.imageTimestamp
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'
import { createLocalQueryToolExecutor } from '../../src/main/services/local-query-tool-executor'
import {
QueryAgentService,
type QueryAgentProvider
} from '../../src/main/services/query-agent-service'
/** 与真实 Knowledge 检索返回的证据形状一致(含原始未清理文本)。 */
function syntheticKnowledgeEvidence() {
return [
{
chunkId: 'chunk-1',
conversationId: GROUP_MD5,
startTime: IMAGE_TIMESTAMP,
endTime: IMAGE_TIMESTAMP,
messageId: IMAGE_MESSAGE_ID,
senderId: 'fixture-member',
sender: '张三',
timestamp: IMAGE_TIMESTAMP,
messageIds: [IMAGE_MESSAGE_ID],
sourceKind: 'image' as const,
text: RAW_SEARCHABLE,
imageOcrText: OCR_TEXT,
derivedSource: 'image_ocr' as const
}
]
}
function makeKnowledge() {
return {
search: vi.fn(async () => ({
state: 'ready',
evidence: syntheticKnowledgeEvidence(),
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 never
}
const NOW = new Date('2026-09-16T09:00:00+08:00')
describe('图片文字索引 synthetic E2E(确定性,不依赖真模型)', () => {
let knowledge: ReturnType<typeof makeKnowledge>
let service: LocalQueryApiService
beforeEach(() => {
knowledge = makeKnowledge()
service = new LocalQueryApiService(knowledge, () => NOW)
})
it('Question Tool 链路:命中图片文字的 Evidence 指向原始图片消息,且不泄露内部前缀', async () => {
const result = await service.search({
target: { query: GROUP_NAME },
timeRange: { kind: 'all' },
query: 'ChatGPT 价格',
variants: ['ChatGPT']
})
expect(result.status).toBe('completed')
// FOUND = YES
expect(result.evidenceCount).toBe(1)
expect(result.evidence).toHaveLength(1)
const evidence = result.evidence![0]
// sourceKind = image(原始消息是什么)
expect(evidence.sourceKind).toBe('image')
// derived source = image_ocr(靠什么搜到的)
expect(evidence.derivedSource).toBe('image_ocr')
// OCR 片段只作命中解释
expect(evidence.imageOcrText).toBe(OCR_TEXT)
// conversation scope = 技术交流群:target 把检索范围真正收敛到这一个会话
expect(result.target).toEqual({ displayName: GROUP_NAME, type: 'group' })
expect(evidence.conversationName).toBe(GROUP_NAME)
expect(evidence.conversationType).toBe('group')
expect(knowledge.search).toHaveBeenCalledTimes(2)
for (const call of knowledge.search.mock.calls) {
expect((call[0] as { conversationIds?: string[] }).conversationIds).toEqual([GROUP_MD5])
}
// sender / createTime 来自原始消息
expect(evidence.sender).toBe('张三')
expect(evidence.timestamp).toBe(IMAGE_TIMESTAMP)
// Evidence messageRef = 原始 image message(jump target 就是它)。
// 注意 `local:` 只是 WCDB 侧的本地 id 装饰,不属于身份本身,所以还原后是裸 id。
const identity = decodeMessageRef(evidence.messageRef)
expect(identity).toEqual({ conversationId: GROUP_MD5, messageId: '9001' })
// 不能产生"OCR 消息":证据集合里不存在任何非原始消息的身份
expect(result.evidence!.every((item) => decodeMessageRef(item.messageRef)?.messageId === '9001')).toBe(true)
expect(result.evidence!.some((item) => decodeMessageRef(item.messageRef)?.messageId === '9002')).toBe(false)
// 内部前缀绝不泄露给用户(模型侧与 UI 侧都不允许)
expect(evidence.text).not.toContain('图片文字:')
expect(evidence.text).not.toContain('OCR:')
expect(evidence.text).not.toContain('system-ocr')
expect(evidence.text).toContain(OCR_TEXT)
})
it('Query Agent 链路:来源语义进入 tool result,OCR 片段不进模型上下文', async () => {
const executor = createLocalQueryToolExecutor(service)
const responses: Array<Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>> = [
{
success: true,
toolCalls: [
{
id: 'call-1',
name: 'search_messages',
arguments: JSON.stringify({
target: { query: GROUP_NAME },
timeRange: { kind: 'all' },
queries: ['ChatGPT 价格']
})
}
]
},
{ success: true, data: '找到了:技术交流群发过一张 ChatGPT 价格的图片。' }
]
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({
configured: true,
providerName: 'Fixture Provider',
model: 'fixture-model',
modelName: 'Fixture Model'
}),
chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' })
}
const agentResult = await new QueryAgentService(provider, executor).run(
'技术交流群之前是不是发过 ChatGPT 价格的图片?'
)
// 模型实际看到的 tool result
const toolMessage = vi
.mocked(provider.chatWithTools)
.mock.calls[1]?.[0].find((message) => message.role === 'tool')
const presented = JSON.parse(String(toolMessage?.content)) as Record<string, any>
const presentedEvidence = presented.evidence?.[0]
expect(presentedEvidence.sourceKind).toBe('image')
expect(presentedEvidence.derivedSource).toBe('image_ocr')
// 片段的内容已经在 text 里,不再重复塞进上下文(避免无谓 token)。
expect(presentedEvidence.imageOcrText).toBeUndefined()
expect(presentedEvidence.text).not.toContain('图片文字:')
// messageRef 指向原始图片消息(模型只拿到 opaque ref,看不到会话身份)。
expect(decodeMessageRef(presentedEvidence.messageRef)).toEqual({
conversationId: GROUP_MD5,
messageId: '9001'
})
// 暴露给 UI 的证据保留来源语义与片段
const uiEvidence = agentResult.evidence.find((item) => item.messageRef === presentedEvidence.messageRef)
expect(uiEvidence?.messageType).toBe('image')
expect(uiEvidence?.derivedSource).toBe('image_ocr')
expect(uiEvidence?.imageOcrText).toBe(OCR_TEXT)
expect(uiEvidence?.text).not.toContain('图片文字:')
expect(uiEvidence?.conversationName).toBe(GROUP_NAME)
})
})
describe('partial coverage honesty(确定性,不依赖真模型)', () => {
const NOT_INDEXED_KEYWORD = 'TRACE_NOT_YET_INDEXED_IMAGE'
function partialImageCoverage() {
return {
totalImageMessages: 100,
processed: 30,
indexed: 28,
empty: 2,
missing: 0,
failed: 0,
pending: 70,
established: true,
complete: false,
countedAt: Date.parse('2026-09-16T08:00:00+08:00')
}
}
beforeEach(() => {
vi.clearAllMocks()
})
it('已处理的 30 张里搜不到关键词时,覆盖度必须带上"不能断言没有"的语义', async () => {
const knowledge = makeKnowledge()
// 关键:已建立的 30 张里确实没有这个关键词 → 检索结果为空。
knowledge.search.mockImplementation(async () => ({
state: 'ready',
evidence: [],
// 文字索引这一维是**完整**的(噪音):证明图片维度不会被文字维度"带过"。
indexLatestAt: NOW.getTime(),
sourceLatestAt: NOW.getTime(),
conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true },
voiceCoverage: undefined
}))
const service = new LocalQueryApiService(knowledge, () => NOW)
// 图片文字索引建立过,但只完成 30 / 100。
service.setImageTextCoverageProvider(() => partialImageCoverage())
const result = await service.search({
target: { query: GROUP_NAME },
timeRange: { kind: 'all' },
query: NOT_INDEXED_KEYWORD
})
expect(result.status).toBe('completed')
expect(result.evidenceCount).toBe(0)
// 文字索引这一维是完整的(噪音),图片这一维才是缺口。
expect(result.coverage).toEqual({ state: 'complete' })
expect(result.imageOcrCoverage).toMatchObject({
state: 'partial',
totalImageMessages: 100,
processed: 30,
pending: 70
})
const summary = result.imageOcrCoverage!.summary
expect(summary).toContain('30')
expect(summary).toContain('100')
expect(summary).toContain('不能因为没搜到就回答')
// 覆盖度必须真的进入 Query Agent 的上下文,而不是只留在 Engine 里。
const executor = createLocalQueryToolExecutor(service)
const responses: Array<Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>> = [
{
success: true,
toolCalls: [
{
id: 'call-1',
name: 'search_messages',
arguments: JSON.stringify({
target: { query: GROUP_NAME },
timeRange: { kind: 'all' },
queries: [NOT_INDEXED_KEYWORD]
})
}
]
},
{
success: true,
data: '图片文字索引目前只处理 30 / 100 条图片消息,当前结果不完整,无法确认全部历史图片。'
}
]
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({
configured: true,
providerName: 'Fixture Provider',
model: 'fixture-model',
modelName: 'Fixture Model'
}),
chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' })
}
const agentResult = await new QueryAgentService(provider, executor).run(
`之前是不是有张图片写着 ${NOT_INDEXED_KEYWORD}?`
)
const calls = vi.mocked(provider.chatWithTools).mock.calls
// 提示词里写死了零结果诚实性规则(不能指望模型自己想到)。
expect(String(calls[0]?.[0]?.[0]?.content)).toContain('imageOcrCoverage')
const presented = JSON.parse(
String(calls[1]?.[0].find((message) => message.role === 'tool')?.content)
) as Record<string, any>
expect(presented.evidenceCount).toBe(0)
expect(presented.imageOcrCoverage).toMatchObject({
state: 'partial',
totalImageMessages: 100,
processed: 30,
pending: 70
})
expect(presented.imageOcrCoverage.summary).toContain('不能因为没搜到就回答')
// 最终回答本身必须是"覆盖不完整",不是"没有"。
expect(agentResult.answer).toContain('30')
expect(agentResult.answer).toContain('100')
expect(agentResult.answer).not.toBe('没有')
})
it('图片索引完整时不下发零结果约束(避免模型机械附加警告)', async () => {
const knowledge = makeKnowledge()
knowledge.search.mockImplementation(async () => ({
state: 'ready',
evidence: [],
conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true },
voiceCoverage: undefined
}))
const service = new LocalQueryApiService(knowledge, () => NOW)
service.setImageTextCoverageProvider(() => ({
...partialImageCoverage(),
processed: 100,
indexed: 98,
empty: 2,
pending: 0,
complete: true
}))
const result = await service.search({
target: { query: GROUP_NAME },
timeRange: { kind: 'all' },
query: NOT_INDEXED_KEYWORD
})
expect(result.imageOcrCoverage?.state).toBe('complete')
expect(result.imageOcrCoverage?.summary).not.toContain('不能因为没搜到就回答')
})
})