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:
@@ -52,7 +52,9 @@
|
||||
|
||||
每次任务冻结配置,沿用原文件大小、页数、文本输出与取消边界;HTTP 增加有界响应读取和
|
||||
独立请求超时。多页请求超时不能冒称单页耗时。HTTP 状态、服务 `errorCode`、任务取消及
|
||||
图片解码错误分别保留,错误正文脱敏后限制长度。没有自动重试或 Provider 轮换。
|
||||
图片解码错误分别保留。错误展示读取结构化 `errorMsg` 和 `logId`,读取与显示限制见
|
||||
[生产实现](./implementation.md#解析与设置),不展示原始响应正文。PPTX 错误按实际来源命名并附图片位置。
|
||||
没有自动重试或 Provider 轮换。
|
||||
|
||||
## 高级参数
|
||||
|
||||
|
||||
@@ -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 引用。同名键跨页不会覆盖。
|
||||
|
||||
@@ -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-空正文与草稿恢复修正)为准。
|
||||
以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user