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.
This commit is contained in:
mesalogo
2026-10-04 14:54:39 +08:00
parent 658e7f100f
commit 629e82545b
7 changed files with 89 additions and 13 deletions
@@ -52,7 +52,9 @@
每次任务冻结配置,沿用原文件大小、页数、文本输出与取消边界;HTTP 增加有界响应读取和 每次任务冻结配置,沿用原文件大小、页数、文本输出与取消边界;HTTP 增加有界响应读取和
独立请求超时。多页请求超时不能冒称单页耗时。HTTP 状态、服务 `errorCode`、任务取消及 独立请求超时。多页请求超时不能冒称单页耗时。HTTP 状态、服务 `errorCode`、任务取消及
图片解码错误分别保留,错误正文脱敏后限制长度。没有自动重试或 Provider 轮换。 图片解码错误分别保留。错误展示读取结构化 `errorMsg` 和 `logId`,读取与显示限制见
[生产实现](./implementation.md#解析与设置),不展示原始响应正文。PPTX 错误按实际来源命名并附图片位置。
没有自动重试或 Provider 轮换。
## 高级参数 ## 高级参数
@@ -24,7 +24,9 @@ HTTP 请求固定返回 Markdown 图片、关闭调试可视化。高级布尔
部署模型能力需以用户样例确认,连接检查只执行 health 和 OpenAPI。 部署模型能力需以用户样例确认,连接检查只执行 health 和 OpenAPI。
响应流上限 32 MiB,单张解码图上限 12 MiB、4000 万像素,文档文字上限 500 万字符。 响应流上限 32 MiB,单张解码图上限 12 MiB、4000 万像素,文档文字上限 500 万字符。
请求超时独立于本地单页超时;HTTP 状态和服务错误码保留,凭据和响应正文不出现在错误中。 请求超时独立于本地单页超时;错误保留 HTTP 状态、服务错误码及结构化 `errorMsg` / `logId`。
非成功响应最多读取 8 KiB,消息移除已配置密钥、整理换行后限制为 240 字符,不展示原始响应正文。
PPTX 回退提示按实际 OCR 来源显示,并在图片处理失败时包含幻灯片号与图片序号。
不下载服务返回的图片 URL,不自动重试或切换 Provider。 不下载服务返回的图片 URL,不自动重试或切换 Provider。
图片按来源页和页内键解码,以 UUID 替换 Markdown 与 HTML img 引用。同名键跨页不会覆盖。 图片按来源页和页内键解码,以 UUID 替换 Markdown 与 HTML img 引用。同名键跨页不会覆盖。
@@ -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-空正文与草稿恢复修正)为准。 当前状态以 [2026-09-19 实现与验证](#2026-09-19-实现与验证)、[附件操作布局复验](#2026-09-19-附件操作布局复验)、[全局浮层复验](#2026-09-19-全局浮层复验)及[空正文与草稿恢复修正](#2026-09-19-空正文与草稿恢复修正)为准。
以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。 以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。
+20 -1
View File
@@ -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 { import {
defaultDocumentParsingSettings defaultDocumentParsingSettings
} from './document-parsing-settings-store' } from './document-parsing-settings-store'
import { DocumentParsingService } from './document-parsing-service' import { DocumentParsingService } from './document-parsing-service'
import { createImagePptx } from '../../tests/support/pptx-fixture' import { createImagePptx } from '../../tests/support/pptx-fixture'
afterEach(() => vi.unstubAllGlobals())
function createPdfFixture(...pageTexts: string[]): Buffer { function createPdfFixture(...pageTexts: string[]): Buffer {
const texts = pageTexts.length > 0 ? pageTexts : [''] const texts = pageTexts.length > 0 ? pageTexts : ['']
const fontObjectId = texts.length + 3 const fontObjectId = texts.length + 3
@@ -178,6 +181,22 @@ describe('DocumentParsingService', () => {
expect(recognize).toHaveBeenCalledOnce() 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 () => { it('keeps useful PDF text local without invoking OCR', async () => {
const { recognize, service } = createService() const { recognize, service } = createService()
+5 -2
View File
@@ -553,6 +553,7 @@ export class DocumentParsingService {
let extractedCharacters = native.reduce( let extractedCharacters = native.reduce(
(total, section) => total + section.content.length, 0 (total, section) => total + section.content.length, 0
) )
let activeLocator: string | undefined
if (imagePages.length > 0) { if (imagePages.length > 0) {
try { try {
const http = settings.ocrProvider === 'paddleocr-vl' ? this.httpProvider(operation) : undefined 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()) { for (const [index, image] of page.images.entries()) {
ensureNotAborted(signal) ensureNotAborted(signal)
const locator = `幻灯片 ${page.pageNumber} · 图片 ${index + 1}` const locator = `幻灯片 ${page.pageNumber} · 图片 ${index + 1}`
activeLocator = locator
if (!image.mimeType) { if (!image.mimeType) {
throw new Error(`${locator} 的格式暂不支持 OCR:${extname(image.name)}`) throw new Error(`${locator} 的格式暂不支持 OCR:${extname(image.name)}`)
} }
@@ -611,11 +613,12 @@ export class DocumentParsingService {
purpose === 'knowledge-index' || purpose === 'knowledge-index' ||
!native.some((section) => hasUsefulText(section.content)) !native.some((section) => hasUsefulText(section.content))
) { ) {
if (activeLocator && error instanceof Error) throw new Error(`${activeLocator}:${error.message}`, { cause: error })
throw error throw error
} }
return buildPagedDocument(name, native, pages.length, [ return buildPagedDocument(name, native, pages.length, [
`本地 OCR 不可用,已保留 PPTX 文本内容:${ `${settings.ocrProvider === 'paddleocr-vl' ? 'HTTP OCR' : '本地 OCR'} 未完成,已保留 PPTX 文本内容:${activeLocator ? `${activeLocator}:` : ''}${
error instanceof Error ? error.message : '本地 OCR 识别失败' error instanceof Error ? error.message : 'OCR 识别失败'
}`.slice(0, 500) }`.slice(0, 500)
], '.pptx') ], '.pptx')
} }
+16
View File
@@ -86,4 +86,20 @@ describe('HTTP document OCR', () => {
await expect(provider.recognize(Buffer.from('image'), 1, [1], AbortSignal.abort())).rejects.toThrow() await expect(provider.recognize(Buffer.from('image'), 1, [1], AbortSignal.abort())).rejects.toThrow()
expect(fetch).not.toHaveBeenCalled() 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')
})
}) })
+19 -8
View File
@@ -53,11 +53,8 @@ export class HttpDocumentOcr {
signal: combined, signal: combined,
redirect: 'error' redirect: 'error'
}) })
if (!response.ok) { const httpError = `HTTP OCR ${path}:HTTP ${response.status}`
await response.body?.cancel() if (!response.body) throw new Error(response.ok ? 'HTTP OCR 返回空响应' : httpError)
throw new Error(`HTTP OCR ${path}:HTTP ${response.status}`)
}
if (!response.body) throw new Error('HTTP OCR 返回空响应')
const reader = response.body.getReader() const reader = response.body.getReader()
const chunks: Uint8Array[] = [] const chunks: Uint8Array[] = []
let size = 0 let size = 0
@@ -66,6 +63,7 @@ export class HttpDocumentOcr {
const chunk = await reader.read() const chunk = await reader.read()
if (chunk.done) break if (chunk.done) break
size += chunk.value.byteLength size += chunk.value.byteLength
if (!response.ok && size > 8192) break
if (size > maximumResponseBytes) throw new Error('HTTP OCR 响应超过 32 MiB 限制') if (size > maximumResponseBytes) throw new Error('HTTP OCR 响应超过 32 MiB 限制')
chunks.push(chunk.value) chunks.push(chunk.value)
} }
@@ -74,10 +72,23 @@ export class HttpDocumentOcr {
reader.releaseLock() reader.releaseLock()
} }
combined.throwIfAborted() combined.throwIfAborted()
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) const text = Buffer.concat(chunks).toString('utf8')
const envelope = z.object({ errorCode: z.number().optional() }).passthrough().parse(value) 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) { 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 return value
} }