feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度

- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
This commit is contained in:
Wxw-Gu
2026-09-17 13:11:20 +08:00
parent b8f08d54c8
commit 1337bcb7de
53 changed files with 4985 additions and 624 deletions
@@ -0,0 +1,282 @@
/**
* 覆盖度诚实性:**派生库的统计绝不能替用户宣称"已经建好了"。**
*
* 这一组覆盖四条彼此独立的硬约束:
* 1. 前置依赖缺失时必须**一条记录都不写**(否则会写出一堆假失败);
* 2. 处理过但一条都没成功 = **异常**,不是"已建立",且必须阻断 complete;
* 3. 百分比不许四舍五入到 100(99.5% 不能显示成"全部完成");
* 4. 重置失败记录**不能**动已经成功的记录。
*
* 判据来自 `ImageTextIndexStore.countByState()` 的落盘统计,不依赖任何内存计数器。
*/
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as chat from '../../src/main/services/chat-service'
import { ImageTextIndexService } from '../../src/main/services/image-text-index-service'
import {
ImageTextIndexStore,
getImageTextIndexDatabasePath
} from '../../src/main/services/image-text-index-store'
import {
describeImageTextCoverage,
imageTextCoverageState,
imageTextProcessedPercent,
type ImageTextIndexCoverage
} from '../../src/shared/image-text-index'
const ACCOUNT = 'wxid_coverage_fixture'
const CONVERSATION = 'md5-coverage'
const roots: string[] = []
function makeRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'tm-image-coverage-'))
roots.push(root)
return root
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function imageMessage(localId: number): chat.FormattedMessage {
return {
localId: String(localId),
createTime: 1_700_000_000 + localId,
content: '[图片]',
contentData: { type: 'image', md5: `md5-${localId}`, datName: `dat-${localId}` }
} as unknown as chat.FormattedMessage
}
function coverageOf(overrides: Partial<ImageTextIndexCoverage>): ImageTextIndexCoverage {
return {
totalImageMessages: 0,
processed: 0,
indexed: 0,
empty: 0,
missing: 0,
failed: 0,
runtimeUnavailable: 0,
pending: 0,
established: false,
complete: false,
systemicFailure: false,
countedAt: null,
...overrides
}
}
describe('全失败不能叫"已建立"', () => {
it('indexed/empty/missing 全为 0 而 failed 不为 0 → 异常,且 complete 必为 false', () => {
const coverage = coverageOf({
totalImageMessages: 2000,
processed: 1990,
failed: 1990,
pending: 10,
established: true,
countedAt: 1_789_516_520_246,
complete: false, // 服务侧已经算出 false;这里验证状态与文案
systemicFailure: true
})
expect(imageTextCoverageState(coverage)).toBe('failed')
expect(describeImageTextCoverage(coverage)).toContain('当前异常')
expect(describeImageTextCoverage(coverage)).not.toContain('已覆盖全部')
})
it('处理好绝大多数时不能四舍五入显示成 100%', () => {
// Math.round(1990 / 2000 * 100) === 100 —— 这就是"未完成却显示 100%"的来源。
expect(Math.round((1990 / 2000) * 100)).toBe(100)
// 正确口径:保留 1 位小数,未完成时封顶 99.9。
expect(imageTextProcessedPercent(1990, 2000)).toBe(99.5)
expect(imageTextProcessedPercent(2000, 2000)).toBe(100)
expect(imageTextProcessedPercent(0, 0)).toBe(0)
})
it('服务侧:一条都没成功时不给 complete,并把状态判成 failed', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 100, countedAt: 1, complete: true })
for (let index = 1; index <= 30; index += 1) {
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: `local:${index}`,
createTime: index,
imageIdentity: '',
artifactKey: `unavailable|${index}`,
state: 'decrypt_failed',
updatedAt: index
})
}
store.close()
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
const status = await service.getStatus()
expect(status.coverage.processed).toBe(30)
expect(status.coverage.failed).toBe(30)
expect(status.coverage.systemicFailure).toBe(true)
expect(status.coverage.complete).toBe(false)
expect(imageTextCoverageState(status.coverage)).toBe('failed')
// 收干净句柄:Windows 上没关连接会让临时目录清理 EBUSY。
service.resetAccount()
})
it('运行时不可用不计入 processed,并且阻断 complete', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 10, countedAt: 1, complete: true })
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: 'local:1',
createTime: 1,
imageIdentity: '',
artifactKey: 'unavailable|1',
state: 'decrypt_unavailable',
updatedAt: 1
})
store.close()
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
const status = await service.getStatus()
expect(status.coverage.runtimeUnavailable).toBe(1)
expect(status.coverage.processed).toBe(0)
expect(status.coverage.complete).toBe(false)
service.resetAccount()
})
it('部分成功 + 部分图片缺失 → 仍然是正常的"部分完成"(不误判成异常)', () => {
const coverage = coverageOf({
totalImageMessages: 100,
processed: 100,
indexed: 60,
empty: 20,
missing: 20,
established: true,
countedAt: 1,
complete: true,
systemicFailure: false
})
expect(imageTextCoverageState(coverage)).toBe('complete')
expect(describeImageTextCoverage(coverage)).toContain('已覆盖全部')
})
})
describe('前置依赖缺失时一条记录都不写', () => {
it('解密服务不可用 → pass 直接报错,不写任何 binding', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
resolveAccountId: () => ACCOUNT,
listContacts: async () => [
{ md5: CONVERSATION, m_nsUsrName: 'coverage', type: 'group' as const }
],
listMessages: async () => [imageMessage(1)],
countConversationImages: async () => ({ count: 1, typeColumn: 'local_type' }),
imageWatermark: async () => ({ count: 1, maxLocalId: 1 }),
capability: async () => ({
available: true,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN'
}),
// 关键:解密服务缺失是**运行时**问题,不能落成每张图的"解密失败"
decryptService: () => null
})
await service.startPass()
await vi.waitFor(() => expect(service.isRunning()).toBe(false))
const status = await service.getStatus()
// 判据:宁可一次都不跑,也不要写一堆假失败把派生库和 coverage 一起污染。
expect(status.progress.state).toBe('error')
expect(status.progress.lastError).toContain('解密服务')
expect(status.coverage.processed).toBe(0)
expect(status.coverage.failed).toBe(0)
expect(status.coverage.runtimeUnavailable).toBe(0)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
expect(store.countByState()).toEqual({})
store.close()
service.resetAccount()
})
})
describe('重置失败记录不能动成功记录', () => {
it('只删失败绑定与它们的 checkpoint,indexed 一条不动', async () => {
const databaseRoot = makeRoot()
const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
const store = new ImageTextIndexStore(databasePath, ACCOUNT)
store.writeCountedTotal({ total: 3, countedAt: 1, complete: true })
store.putArtifact({
accountId: ACCOUNT,
artifactKey: 'good|1',
imageIdentity: 'sha256:good',
state: 'indexed',
text: 'TRACE_KEEP_ME',
charCount: 13,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN',
createdAt: 1,
updatedAt: 1
})
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: 'local:1',
createTime: 1,
imageIdentity: 'sha256:good',
artifactKey: 'good|1',
state: 'indexed',
updatedAt: 1
})
for (const id of [2, 3]) {
store.putBinding({
accountId: ACCOUNT,
conversationId: CONVERSATION,
messageId: `local:${id}`,
createTime: id,
imageIdentity: '',
artifactKey: `bad|${id}`,
state: 'decrypt_failed',
updatedAt: id
})
}
store.writeScanState({
conversationId: CONVERSATION,
state: 'done',
imageTotal: 3,
imageProcessed: 3,
maxLocalId: 3
})
store.close()
const service = new ImageTextIndexService()
service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
const result = await service.resetRetriableFailures()
expect(result.reset).toBe(2)
// 成功记录与它的 artifact 必须原封不动。
const after = new ImageTextIndexStore(databasePath, ACCOUNT)
expect(after.countByState()).toEqual({ indexed: 1 })
expect(after.getArtifact('good|1')?.text).toBe('TRACE_KEEP_ME')
// checkpoint 也要清掉,否则下一轮会以"该会话已完成"直接跳过(点了重试却没反应)。
expect(after.readScanState().size).toBe(0)
after.close()
service.resetAccount()
})
})