/** * OCR 分层处理协调器(plan.md「3.1 分层处理架构」+「3.9 JS 层分层处理」)。 * * Layer 1: 规则匹配(快速,无成本)→ 直接出账单 * Layer 2: OCR + 正则解析(中等精度,无成本)→ 出账单 * Layer 3: AI Vision(高精度,有成本)→ 可选,需用户启用 * * 每层失败自动降级到下一层。OCR 引擎与 AI Provider 通过注入抽象。 */ import { DEDUP_CACHE_MAX, IMAGE_HASH_TRUNCATE_LEN } from './constants'; import { matchOcrRule, parseOcrBill, checkIsDetailPage } from './ocr'; import { hash } from './ledger'; import type { ImportedEvent } from './types'; /** OCR 引擎抽象(生产用原生 PP-OCRv5 Module,测试用 mock)。 */ export interface OcrEngine { /** 识别图片文本,返回纯文本。 */ recognizeText(imageBase64: string): Promise; } /** AI Vision 提供者抽象(Layer 3,可选)。 */ export interface AiVisionProvider { /** 识别图片中的账单,返回账单事件或 null。 */ recognizeBill(imageBase64: string): Promise; } /** 分层处理结果。 */ export interface OcrProcessResult { /** 最终识别出的事件(可能为 null 表示三层都未识别)。 */ event: ImportedEvent | null; /** 命中的层级。 */ layer: 'layer1-rule' | 'layer2-ocr' | 'layer3-ai' | 'none'; /** OCR 原始文本(Layer 2 命中时有值)。 */ ocrText?: string; /** 跳过原因(去重/免打扰等)。 */ skipped?: 'dedup' | 'busy' | 'landscape-dnd' | 'non-bill'; } export interface OcrProcessorConfig { /** 是否启用 Layer 3 AI(默认 false,需用户启用)。 */ aiVisionEnabled: boolean; /** 横屏免打扰模式。 */ landscapeDnd: boolean; } /** 队列中等待处理的请求。 */ interface QueuedRequest { imageBase64: string; packageName?: string; isLandscape: boolean; resolve: (result: OcrProcessResult) => void; } /** * OCR 处理器:协调三层降级 + 去重守卫。 * * 去重守卫参考 AutoAccounting 的 ocrDoing:防止同一截图重复触发。 * 使用 Promise 队列替代布尔标志,避免快速连续截屏时丢弃合法请求。 */ export class OcrProcessor { private processedHashes = new Map(); // hash -> timestamp for LRU private processing = false; private queue: QueuedRequest[] = []; constructor( private readonly ocrEngine: OcrEngine, private readonly aiProvider?: AiVisionProvider, private readonly config: OcrProcessorConfig = { aiVisionEnabled: false, landscapeDnd: false }, ) {} /** * 处理一张截图/图片,返回账单事件。 * 串行化:同时只处理一张(队列机制)。 */ process( imageBase64: string, packageName?: string, isLandscape = false, ): Promise { return new Promise(resolve => { this.queue.push({ imageBase64, packageName, isLandscape, resolve }); this.processQueue(); }); } private async processQueue(): Promise { if (this.processing || this.queue.length === 0) return; this.processing = true; const request = this.queue.shift()!; try { const result = await this.doProcess(request.imageBase64, request.packageName, request.isLandscape); request.resolve(result); } catch { request.resolve({ event: null, layer: 'none', skipped: 'non-bill' }); } finally { this.processing = false; this.processQueue(); } } private async doProcess( imageBase64: string, packageName?: string, isLandscape = false, ): Promise { // 横屏免打扰 if (this.config.landscapeDnd && isLandscape) { return { event: null, layer: 'none', skipped: 'landscape-dnd' }; } // 去重守卫:同一图片不重复处理 const imageHash = hash(imageBase64.slice(0, IMAGE_HASH_TRUNCATE_LEN)); if (this.processedHashes.has(imageHash)) { // LRU: 更新时间戳 this.processedHashes.set(imageHash, Date.now()); return { event: null, layer: 'none', skipped: 'dedup' }; } try { // Layer 2: OCR 识别(Layer 1 基于文本,需先 OCR) const ocrText = await this.ocrEngine.recognizeText(imageBase64); // OCR 成功后才标记已处理,避免异常时永久阻断重试 this.processedHashes.set(imageHash, Date.now()); // LRU 淘汰:超出阈值 20% 时批量清理最旧条目 if (this.processedHashes.size > DEDUP_CACHE_MAX * 1.2) { const entries = [...this.processedHashes.entries()] .sort((a, b) => a[1] - b[1]); const toDelete = entries.slice(0, entries.length - DEDUP_CACHE_MAX); for (const [key] of toDelete) { this.processedHashes.delete(key); } } if (!ocrText || !ocrText.trim()) { // 无文本,尝试 Layer 3 return await this.tryLayer3(imageBase64, undefined); } // 针对账单详情页进行特殊路由:若判断是结构化详情页,直接绕过宽松的 Layer 1 规则匹配, // 以避免"银行卡"等规则在详情页上误提取(例如误将"消费1次"提取为"-1元")。 const isDetailPage = checkIsDetailPage(ocrText); if (!isDetailPage) { // Layer 1: 规则匹配(基于 OCR 文本) const ruleMatch = matchOcrRule(ocrText, packageName); if (ruleMatch.matched && ruleMatch.event) { return { event: ruleMatch.event, layer: 'layer1-rule', ocrText }; } } // Layer 2: 退化解析 const fallback = parseOcrBill(ocrText, packageName); if (fallback) { return { event: fallback, layer: 'layer2-ocr', ocrText }; } // 非账单内容(billGuard),尝试 Layer 3 return await this.tryLayer3(imageBase64, ocrText); } catch { return { event: null, layer: 'none', skipped: 'non-bill' }; } } private async tryLayer3(imageBase64: string, ocrText?: string): Promise { if (!this.config.aiVisionEnabled || !this.aiProvider) { return { event: null, layer: 'none', ocrText, skipped: 'non-bill' }; } try { const event = await this.aiProvider.recognizeBill(imageBase64); if (event) return { event, layer: 'layer3-ai', ocrText }; return { event: null, layer: 'none', ocrText, skipped: 'non-bill' }; } catch { return { event: null, layer: 'none', ocrText, skipped: 'non-bill' }; } } /** 重置去重状态(切换页面/应用重启时)。 */ reset(): void { this.processedHashes.clear(); this.processing = false; this.queue = []; } } /** 模拟 OCR 引擎(测试用,返回预设文本)。 */ export class MockOcrEngine implements OcrEngine { constructor(private responses: Map = new Map(), public defaultResponse = '') {} async recognizeText(imageBase64: string): Promise { // 简单匹配:按图片 hash 前缀找预设 const h = hash(imageBase64.slice(0, 100)); return this.responses.get(h) ?? this.defaultResponse; } /** 测试辅助:注入预设响应。 */ setResponse(imageBase64: string, text: string): void { this.responses.set(hash(imageBase64.slice(0, 100)), text); } } /** 模拟 AI Vision Provider(测试用)。 */ export class MockAiVisionProvider implements AiVisionProvider { constructor(private response: ImportedEvent | null = null) {} async recognizeBill(): Promise { return this.response; } }