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 增加有界响应读取和
|
||||||
独立请求超时。多页请求超时不能冒称单页耗时。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-空正文与草稿恢复修正)为准。
|
||||||
以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。
|
以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user