From 629e82545b50fdd4020c3b51bf99601ad366e1ef Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 4 Oct 2026 14:54:39 +0800 Subject: [PATCH] fix(ocr): report the actual provider and PPTX failure location HTTP OCR failures in PPTX parsing were incorrectly labeled as local OCR failures. Report the selected provider and slide/image location, and retain bounded structured server diagnostics with configured-key redaction and log IDs. Preserve existing fallback behavior. Validation: 38 focused tests, typecheck and lint passed. Full test run timed out after 200 seconds. Synthetic service probes reproduced HTTP 500; the server-side failure remains unresolved. Release note: PPTX parsing warnings now identify the actual OCR provider and failed image location, with server diagnostics for troubleshooting. --- .../http-paddleocr-vl-technical-design.md | 4 ++- .../document-processing/implementation.md | 4 ++- docs/features/document-processing/progress.md | 23 ++++++++++++++++ src/main/document-parsing-service.test.ts | 21 ++++++++++++++- src/main/document-parsing-service.ts | 7 +++-- src/main/http-document-ocr.test.ts | 16 +++++++++++ src/main/http-document-ocr.ts | 27 +++++++++++++------ 7 files changed, 89 insertions(+), 13 deletions(-) diff --git a/docs/features/document-processing/http-paddleocr-vl-technical-design.md b/docs/features/document-processing/http-paddleocr-vl-technical-design.md index 22241305..836ed1a2 100644 --- a/docs/features/document-processing/http-paddleocr-vl-technical-design.md +++ b/docs/features/document-processing/http-paddleocr-vl-technical-design.md @@ -52,7 +52,9 @@ 每次任务冻结配置,沿用原文件大小、页数、文本输出与取消边界;HTTP 增加有界响应读取和 独立请求超时。多页请求超时不能冒称单页耗时。HTTP 状态、服务 `errorCode`、任务取消及 -图片解码错误分别保留,错误正文脱敏后限制长度。没有自动重试或 Provider 轮换。 +图片解码错误分别保留。错误展示读取结构化 `errorMsg` 和 `logId`,读取与显示限制见 +[生产实现](./implementation.md#解析与设置),不展示原始响应正文。PPTX 错误按实际来源命名并附图片位置。 +没有自动重试或 Provider 轮换。 ## 高级参数 diff --git a/docs/features/document-processing/implementation.md b/docs/features/document-processing/implementation.md index 9ea68269..f3c4fa72 100644 --- a/docs/features/document-processing/implementation.md +++ b/docs/features/document-processing/implementation.md @@ -24,7 +24,9 @@ HTTP 请求固定返回 Markdown 图片、关闭调试可视化。高级布尔 部署模型能力需以用户样例确认,连接检查只执行 health 和 OpenAPI。 响应流上限 32 MiB,单张解码图上限 12 MiB、4000 万像素,文档文字上限 500 万字符。 -请求超时独立于本地单页超时;HTTP 状态和服务错误码保留,凭据和响应正文不出现在错误中。 +请求超时独立于本地单页超时;错误保留 HTTP 状态、服务错误码及结构化 `errorMsg` / `logId`。 +非成功响应最多读取 8 KiB,消息移除已配置密钥、整理换行后限制为 240 字符,不展示原始响应正文。 +PPTX 回退提示按实际 OCR 来源显示,并在图片处理失败时包含幻灯片号与图片序号。 不下载服务返回的图片 URL,不自动重试或切换 Provider。 图片按来源页和页内键解码,以 UUID 替换 Markdown 与 HTML img 引用。同名键跨页不会覆盖。 diff --git a/docs/features/document-processing/progress.md b/docs/features/document-processing/progress.md index 7baee74c..623c57c7 100644 --- a/docs/features/document-processing/progress.md +++ b/docs/features/document-processing/progress.md @@ -2,6 +2,29 @@ ## 状态 +2026-10-04:HTTP OCR 故障复核见下节。历史成功记录不能代表服务当前可用。 + +## 2026-10-04 HTTP OCR 故障复核 + +PPTX 自动解析的异常分支原来将所有来源显示为“本地 OCR 不可用”,已改为实际来源, +并补充幻灯片与图片位置。HTTP 客户端原来丢弃非成功响应正文,现在保留有界结构化错误 +消息及日志 ID,规则见 [生产实现](./implementation.md#解析与设置)。未切换 Provider, +自动聊天回退和严格索引失败规则保持不变。本次改动位于桌面 Main 文档解析链路,不改变 +Agent 或远端 Runtime 执行协议。 + +使用合成内容对已配置测试服务执行 14 次请求(3 GET、11 POST):health 前后均为 200, +普通文字图 PNG、JPEG、透明文字 logo 和横幅均为 500;空白图和无文字小图返回 200, +但没有文字或图片结果。关闭版面检测、关闭返回图片、保留全部标签均未消除文字图的 500。 +单独开启方向校正或去畸变返回 `the input params for model settings are invalid!`。 +普通文字 PNG 的服务日志 ID 为 `050320f8-d515-4636-99ca-c422af0a61b4`, +JPEG 为 `0dccbb31-2026-42db-95a4-4e333fe73526`,需服务端日志进一步确认内部异常。 + +未读取或发送用户 PPTX,不能据此断言该文件的具体失败图片或服务器内部根因。 +专项测试 38 项、类型检查、lint 及差异空白检查通过。全量 `npm test` 在 200 秒后超时, +未取得完整结果,不计为全量通过。新增说明按 deai-writing 清单人工复核。 + +## 历史状态 + 当前状态以 [2026-09-19 实现与验证](#2026-09-19-实现与验证)、[附件操作布局复验](#2026-09-19-附件操作布局复验)、[全局浮层复验](#2026-09-19-全局浮层复验)及[空正文与草稿恢复修正](#2026-09-19-空正文与草稿恢复修正)为准。 以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。 diff --git a/src/main/document-parsing-service.test.ts b/src/main/document-parsing-service.test.ts index ceb2864b..8b8eaf08 100644 --- a/src/main/document-parsing-service.test.ts +++ b/src/main/document-parsing-service.test.ts @@ -1,10 +1,13 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defaultHttpOcrSettings } from '../shared/document-parsing-contracts' import { defaultDocumentParsingSettings } from './document-parsing-settings-store' import { DocumentParsingService } from './document-parsing-service' import { createImagePptx } from '../../tests/support/pptx-fixture' +afterEach(() => vi.unstubAllGlobals()) + function createPdfFixture(...pageTexts: string[]): Buffer { const texts = pageTexts.length > 0 ? pageTexts : [''] const fontObjectId = texts.length + 3 @@ -178,6 +181,22 @@ describe('DocumentParsingService', () => { expect(recognize).toHaveBeenCalledOnce() }) + it('reports HTTP PPTX failures with the image location without consulting local OCR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ errorCode: 500, errorMsg: 'Internal server error', logId: 'pptx-failure' }, { status: 500 }))) + const { service, modelManager, recognize } = createService({ settings: { + ocrProvider: 'paddleocr-vl', httpOcr: { ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test' } + } }) + const bytes = createImagePptx('Useful native slide body') + const parsed = await service.parse('slides.pptx', bytes, 'chat-attachment') + expect(parsed.content).toContain('Useful native slide body') + expect(parsed.warnings).toEqual([ + 'HTTP OCR 未完成,已保留 PPTX 文本内容:幻灯片 1 · 图片 1:HTTP OCR /layout-parsing:HTTP 500;服务错误 500;Internal server error;logId: pptx-failure' + ]) + await expect(service.parse('slides.pptx', bytes, 'knowledge-index')).rejects.toThrow('幻灯片 1 · 图片 1:HTTP OCR /layout-parsing:HTTP 500') + expect(modelManager.getStatus).not.toHaveBeenCalled() + expect(recognize).not.toHaveBeenCalled() + }) + it('keeps useful PDF text local without invoking OCR', async () => { const { recognize, service } = createService() diff --git a/src/main/document-parsing-service.ts b/src/main/document-parsing-service.ts index 1b766571..333bf131 100644 --- a/src/main/document-parsing-service.ts +++ b/src/main/document-parsing-service.ts @@ -553,6 +553,7 @@ export class DocumentParsingService { let extractedCharacters = native.reduce( (total, section) => total + section.content.length, 0 ) + let activeLocator: string | undefined if (imagePages.length > 0) { try { const http = settings.ocrProvider === 'paddleocr-vl' ? this.httpProvider(operation) : undefined @@ -564,6 +565,7 @@ export class DocumentParsingService { for (const [index, image] of page.images.entries()) { ensureNotAborted(signal) const locator = `幻灯片 ${page.pageNumber} · 图片 ${index + 1}` + activeLocator = locator if (!image.mimeType) { throw new Error(`${locator} 的格式暂不支持 OCR:${extname(image.name)}`) } @@ -611,11 +613,12 @@ export class DocumentParsingService { purpose === 'knowledge-index' || !native.some((section) => hasUsefulText(section.content)) ) { + if (activeLocator && error instanceof Error) throw new Error(`${activeLocator}:${error.message}`, { cause: error }) throw error } return buildPagedDocument(name, native, pages.length, [ - `本地 OCR 不可用,已保留 PPTX 文本内容:${ - error instanceof Error ? error.message : '本地 OCR 识别失败' + `${settings.ocrProvider === 'paddleocr-vl' ? 'HTTP OCR' : '本地 OCR'} 未完成,已保留 PPTX 文本内容:${activeLocator ? `${activeLocator}:` : ''}${ + error instanceof Error ? error.message : 'OCR 识别失败' }`.slice(0, 500) ], '.pptx') } diff --git a/src/main/http-document-ocr.test.ts b/src/main/http-document-ocr.test.ts index a1d3ee44..4c1149d3 100644 --- a/src/main/http-document-ocr.test.ts +++ b/src/main/http-document-ocr.test.ts @@ -86,4 +86,20 @@ describe('HTTP document OCR', () => { await expect(provider.recognize(Buffer.from('image'), 1, [1], AbortSignal.abort())).rejects.toThrow() expect(fetch).not.toHaveBeenCalled() }) + + it.each([200, 500])('retains structured provider diagnostics for status %s and redacts the configured key', async (status) => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + errorCode: 500, errorMsg: 'invalid model settings test-secret', logId: 'probe-log-123' + }, { status }))) + const client = new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test' }, 'test-secret') + await expect(client.recognize(Buffer.from('image'), 1, [1])).rejects.toThrow( + `${status === 500 ? 'HTTP 500;服务错误 500' : '服务错误 500'};invalid model settings [redacted];logId: probe-log-123` + ) + }) + + it('preserves HTTP status for oversized error bodies', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('x'.repeat(9000), { status: 500 }))) + await expect(new HttpDocumentOcr({ ...defaultHttpOcrSettings, baseUrl: 'http://ocr.test' }) + .recognize(Buffer.from('image'), 1, [1])).rejects.toThrow('HTTP 500') + }) }) diff --git a/src/main/http-document-ocr.ts b/src/main/http-document-ocr.ts index c87a8837..6d28c3d0 100644 --- a/src/main/http-document-ocr.ts +++ b/src/main/http-document-ocr.ts @@ -53,11 +53,8 @@ export class HttpDocumentOcr { signal: combined, redirect: 'error' }) - if (!response.ok) { - await response.body?.cancel() - throw new Error(`HTTP OCR ${path}:HTTP ${response.status}`) - } - if (!response.body) throw new Error('HTTP OCR 返回空响应') + const httpError = `HTTP OCR ${path}:HTTP ${response.status}` + if (!response.body) throw new Error(response.ok ? 'HTTP OCR 返回空响应' : httpError) const reader = response.body.getReader() const chunks: Uint8Array[] = [] let size = 0 @@ -66,6 +63,7 @@ export class HttpDocumentOcr { const chunk = await reader.read() if (chunk.done) break size += chunk.value.byteLength + if (!response.ok && size > 8192) break if (size > maximumResponseBytes) throw new Error('HTTP OCR 响应超过 32 MiB 限制') chunks.push(chunk.value) } @@ -74,10 +72,23 @@ export class HttpDocumentOcr { reader.releaseLock() } combined.throwIfAborted() - const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) - const envelope = z.object({ errorCode: z.number().optional() }).passthrough().parse(value) + const text = Buffer.concat(chunks).toString('utf8') + let value: unknown + try { + value = JSON.parse(text) + } catch { + throw new Error(response.ok ? 'HTTP OCR 返回无效 JSON' : httpError) + } + const errorInfo = z.object({ errorCode: z.number().optional(), errorMsg: z.string().optional(), logId: z.string().optional() }).safeParse(value) + const envelope = errorInfo.success ? errorInfo.data : {} + let detail = envelope.errorMsg ?? '' + if (this.apiKey) detail = detail.replaceAll(this.apiKey, '[redacted]') + detail = detail.replace(/[\r\n\t]+/gu, ' ').slice(0, 240) + const logId = envelope.logId && /^[\w-]{1,80}$/u.test(envelope.logId) ? envelope.logId : undefined + const context = `${detail ? `;${detail}` : ''}${logId ? `;logId: ${logId}` : ''}` + if (!response.ok) throw new Error(`${httpError}${envelope.errorCode ? `;服务错误 ${envelope.errorCode}` : ''}${context}`) if (envelope.errorCode && envelope.errorCode !== 0) { - throw new Error(`HTTP OCR ${path}:服务错误 ${envelope.errorCode};请检查部署模型和所选高级选项`) + throw new Error(`HTTP OCR ${path}:服务错误 ${envelope.errorCode}${context};请检查部署模型和所选高级选项`) } return value }