fix: streamline document parsing settings
This commit is contained in:
@@ -50,4 +50,93 @@ describe('DocumentOcrBroker', () => {
|
||||
).toThrow('OCR 解析已取消')
|
||||
broker.dispose()
|
||||
})
|
||||
|
||||
it('rejects requests whose selected OCR pages exceed the limit', () => {
|
||||
const broker = new DocumentOcrBroker({
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: { send: vi.fn() }
|
||||
} as never)
|
||||
|
||||
expect(() =>
|
||||
broker.recognize({
|
||||
...request(),
|
||||
maximumPages: 1,
|
||||
pageNumbers: [1, 2]
|
||||
})
|
||||
).toThrow('OCR 页数超过当前文档限制')
|
||||
broker.dispose()
|
||||
})
|
||||
|
||||
it('queues OCR requests and starts timeout accounting on dispatch', async () => {
|
||||
const send = vi.fn()
|
||||
const broker = new DocumentOcrBroker({
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: { send }
|
||||
} as never)
|
||||
const first = broker.recognize(request())
|
||||
const second = broker.recognize({
|
||||
...request(),
|
||||
fileName: 'second.pdf'
|
||||
})
|
||||
const requests = send.mock.calls.filter(
|
||||
([channel]) => channel === ipcChannels.documentParsingOcrRequest
|
||||
)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
const firstRequest = requests[0]?.[1] as {
|
||||
requestId: string
|
||||
}
|
||||
broker.respond({
|
||||
requestId: firstRequest.requestId,
|
||||
sections: [],
|
||||
pageCount: 1,
|
||||
warnings: []
|
||||
})
|
||||
await expect(first).resolves.toEqual(
|
||||
expect.objectContaining({ requestId: firstRequest.requestId })
|
||||
)
|
||||
|
||||
const dispatched = send.mock.calls.filter(
|
||||
([channel]) => channel === ipcChannels.documentParsingOcrRequest
|
||||
)
|
||||
expect(dispatched).toHaveLength(2)
|
||||
const secondRequest = dispatched[1]?.[1] as {
|
||||
requestId: string
|
||||
}
|
||||
broker.respond({
|
||||
requestId: secondRequest.requestId,
|
||||
sections: [],
|
||||
pageCount: 1,
|
||||
warnings: []
|
||||
})
|
||||
await expect(second).resolves.toEqual(
|
||||
expect.objectContaining({ requestId: secondRequest.requestId })
|
||||
)
|
||||
broker.dispose()
|
||||
})
|
||||
|
||||
it('cancels a queued request without interrupting the active request', async () => {
|
||||
const send = vi.fn()
|
||||
const broker = new DocumentOcrBroker({
|
||||
isDestroyed: vi.fn(() => false),
|
||||
webContents: { send }
|
||||
} as never)
|
||||
const active = broker.recognize(request())
|
||||
const controller = new AbortController()
|
||||
const queued = broker.recognize(
|
||||
{ ...request(), fileName: 'queued.pdf' },
|
||||
controller.signal
|
||||
)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(queued).rejects.toThrow('OCR 解析已取消')
|
||||
expect(
|
||||
send.mock.calls.filter(
|
||||
([channel]) => channel === ipcChannels.documentParsingOcrCancel
|
||||
)
|
||||
).toHaveLength(0)
|
||||
broker.dispose()
|
||||
await expect(active).rejects.toThrow('OCR 解析已取消')
|
||||
})
|
||||
})
|
||||
|
||||
+111
-37
@@ -9,17 +9,23 @@ import {
|
||||
} from '../shared/document-parsing-contracts'
|
||||
|
||||
type PendingRequest = {
|
||||
request: DocumentOcrRequest
|
||||
resolve: (result: DocumentOcrResult) => void
|
||||
reject: (error: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
timeoutMs: number
|
||||
detachAbort: () => void
|
||||
dispatched: boolean
|
||||
}
|
||||
|
||||
const maximumPendingRequests = 4
|
||||
const maximumTotalTimeoutMs = 10 * 60 * 1_000
|
||||
const workerStartupTimeoutMs = 60 * 1_000
|
||||
|
||||
export class DocumentOcrBroker {
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
private readonly queue: string[] = []
|
||||
private activeRequestId?: string
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly window: BrowserWindow) {}
|
||||
@@ -41,50 +47,37 @@ export class DocumentOcrBroker {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('OCR 解析已取消')
|
||||
}
|
||||
const pageCount =
|
||||
request.pageNumbers?.length ?? request.maximumPages
|
||||
const timeoutMs = Math.min(
|
||||
maximumTotalTimeoutMs,
|
||||
Math.max(
|
||||
request.pageTimeoutSeconds * 1_000,
|
||||
request.pageTimeoutSeconds *
|
||||
request.maximumPages *
|
||||
1_000
|
||||
workerStartupTimeoutMs +
|
||||
request.pageTimeoutSeconds *
|
||||
pageCount *
|
||||
1_000
|
||||
)
|
||||
)
|
||||
return new Promise<DocumentOcrResult>((resolve, reject) => {
|
||||
const cancel = (message: string): void => {
|
||||
const pending = this.pending.get(request.requestId)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
clearTimeout(pending.timer)
|
||||
pending.detachAbort()
|
||||
this.pending.delete(request.requestId)
|
||||
this.window.webContents.send(
|
||||
ipcChannels.documentParsingOcrCancel,
|
||||
request.requestId
|
||||
)
|
||||
reject(new Error(message))
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cancel('OCR 解析超时')
|
||||
}, timeoutMs)
|
||||
const onAbort = (): void => cancel('OCR 解析已取消')
|
||||
const onAbort = (): void =>
|
||||
this.cancelRequest(request.requestId, 'OCR 解析已取消')
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
this.pending.set(request.requestId, {
|
||||
request,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
timeoutMs,
|
||||
detachAbort: () =>
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
signal?.removeEventListener('abort', onAbort),
|
||||
dispatched: false
|
||||
})
|
||||
this.queue.push(request.requestId)
|
||||
if (signal?.aborted) {
|
||||
cancel('OCR 解析已取消')
|
||||
this.cancelRequest(request.requestId, 'OCR 解析已取消')
|
||||
return
|
||||
}
|
||||
this.window.webContents.send(
|
||||
ipcChannels.documentParsingOcrRequest,
|
||||
request
|
||||
)
|
||||
this.dispatchNext()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -102,30 +95,111 @@ export class DocumentOcrBroker {
|
||||
throw new Error('OCR 响应无效')
|
||||
}
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) {
|
||||
if (!pending || !pending.dispatched) {
|
||||
return
|
||||
}
|
||||
clearTimeout(pending.timer)
|
||||
pending.detachAbort()
|
||||
this.pending.delete(requestId)
|
||||
if (result.success) {
|
||||
pending.resolve(result.data)
|
||||
this.finishRequest(requestId, () =>
|
||||
pending.resolve(result.data)
|
||||
)
|
||||
} else {
|
||||
if (!failure?.success) {
|
||||
pending.reject(new Error('OCR 响应无效'))
|
||||
this.finishRequest(requestId, () =>
|
||||
pending.reject(new Error('OCR 响应无效'))
|
||||
)
|
||||
return
|
||||
}
|
||||
pending.reject(new Error(failure.data.error))
|
||||
this.finishRequest(requestId, () =>
|
||||
pending.reject(new Error(failure.data.error))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer)
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer)
|
||||
}
|
||||
pending.detachAbort()
|
||||
pending.reject(new Error('OCR 解析已取消'))
|
||||
}
|
||||
this.pending.clear()
|
||||
this.queue.length = 0
|
||||
this.activeRequestId = undefined
|
||||
}
|
||||
|
||||
private dispatchNext(): void {
|
||||
if (
|
||||
this.disposed ||
|
||||
this.activeRequestId ||
|
||||
this.window.isDestroyed()
|
||||
) {
|
||||
return
|
||||
}
|
||||
let requestId = this.queue.shift()
|
||||
while (requestId && !this.pending.has(requestId)) {
|
||||
requestId = this.queue.shift()
|
||||
}
|
||||
if (!requestId) {
|
||||
return
|
||||
}
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
this.activeRequestId = requestId
|
||||
pending.dispatched = true
|
||||
pending.timer = setTimeout(() => {
|
||||
this.cancelRequest(requestId, 'OCR 解析超时')
|
||||
}, pending.timeoutMs)
|
||||
try {
|
||||
this.window.webContents.send(
|
||||
ipcChannels.documentParsingOcrRequest,
|
||||
pending.request
|
||||
)
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof Error ? error.message : 'OCR 渲染服务不可用'
|
||||
this.finishRequest(requestId, () =>
|
||||
pending.reject(new Error(detail))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private cancelRequest(requestId: string, message: string): void {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
if (pending.dispatched && !this.window.isDestroyed()) {
|
||||
this.window.webContents.send(
|
||||
ipcChannels.documentParsingOcrCancel,
|
||||
requestId
|
||||
)
|
||||
}
|
||||
this.finishRequest(requestId, () =>
|
||||
pending.reject(new Error(message))
|
||||
)
|
||||
}
|
||||
|
||||
private finishRequest(
|
||||
requestId: string,
|
||||
settle: () => void
|
||||
): void {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer)
|
||||
}
|
||||
pending.detachAbort()
|
||||
this.pending.delete(requestId)
|
||||
if (this.activeRequestId === requestId) {
|
||||
this.activeRequestId = undefined
|
||||
}
|
||||
settle()
|
||||
this.dispatchNext()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,17 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('DocumentOcrModelManager', () => {
|
||||
it('reports a removed catalog model as unavailable', async () => {
|
||||
const { manager } = await createManager()
|
||||
|
||||
await expect(manager.getStatus('retired-model')).resolves.toMatchObject({
|
||||
id: 'retired-model',
|
||||
available: false,
|
||||
verified: false,
|
||||
detail: expect.stringContaining('不再提供')
|
||||
})
|
||||
})
|
||||
|
||||
it('uses immutable SHA-256 verified ModelScope catalog files', () => {
|
||||
expect(DOCUMENT_OCR_MODEL_CATALOG).toHaveLength(3)
|
||||
expect(
|
||||
|
||||
@@ -205,7 +205,18 @@ export class DocumentOcrModelManager {
|
||||
async getStatus(
|
||||
modelId: string
|
||||
): Promise<ReturnType<typeof documentParsingModelStatusSchema.parse>> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
const id = localOcrModelIdSchema.parse(modelId)
|
||||
const entry = this.catalog.find((candidate) => candidate.id === id)
|
||||
if (!entry) {
|
||||
return documentParsingModelStatusSchema.parse({
|
||||
id,
|
||||
displayName: id,
|
||||
available: false,
|
||||
verified: false,
|
||||
runtime: 'onnxruntime-web-wasm',
|
||||
detail: '当前版本不再提供此 OCR 模型,请选择其他模型'
|
||||
})
|
||||
}
|
||||
try {
|
||||
await this.getVerifiedStatus(entry)
|
||||
return documentParsingModelStatusSchema.parse({
|
||||
|
||||
@@ -4,14 +4,29 @@ import {
|
||||
} from './document-parsing-settings-store'
|
||||
import { DocumentParsingService } from './document-parsing-service'
|
||||
|
||||
function createPdfFixture(text: string): Buffer {
|
||||
const stream = `BT /F1 18 Tf 50 100 Td (${text}) Tj ET`
|
||||
function createPdfFixture(...pageTexts: string[]): Buffer {
|
||||
const texts = pageTexts.length > 0 ? pageTexts : ['']
|
||||
const fontObjectId = texts.length + 3
|
||||
const firstContentObjectId = fontObjectId + 1
|
||||
const objects = [
|
||||
'<< /Type /Catalog /Pages 2 0 R >>',
|
||||
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
|
||||
`<< /Type /Pages /Kids [${texts
|
||||
.map((_, index) => `${index + 3} 0 R`)
|
||||
.join(' ')}] /Count ${texts.length} >>`,
|
||||
...texts.map(
|
||||
(_, index) =>
|
||||
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] ' +
|
||||
`/Resources << /Font << /F1 ${fontObjectId} 0 R >> >> ` +
|
||||
`/Contents ${firstContentObjectId + index} 0 R >>`
|
||||
),
|
||||
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
||||
`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`
|
||||
...texts.map((text) => {
|
||||
const stream = `BT /F1 18 Tf 50 100 Td (${text}) Tj ET`
|
||||
return (
|
||||
`<< /Length ${Buffer.byteLength(stream)} >>\n` +
|
||||
`stream\n${stream}\nendstream`
|
||||
)
|
||||
})
|
||||
]
|
||||
let content = '%PDF-1.4\n'
|
||||
const offsets = [0]
|
||||
@@ -33,41 +48,62 @@ function createPdfFixture(text: string): Buffer {
|
||||
|
||||
function createService(overrides?: {
|
||||
settings?: Partial<typeof defaultDocumentParsingSettings>
|
||||
modelStatus?: {
|
||||
available: boolean
|
||||
verified: boolean
|
||||
detail: string
|
||||
}
|
||||
recognize?: () => Promise<{
|
||||
requestId: string
|
||||
sections: Array<{
|
||||
locator: string
|
||||
content: string
|
||||
confidence: number
|
||||
}>
|
||||
pageCount: number
|
||||
warnings: string[]
|
||||
}>
|
||||
}) {
|
||||
const settings = {
|
||||
...defaultDocumentParsingSettings,
|
||||
...overrides?.settings
|
||||
}
|
||||
const recognize = vi.fn(async () => ({
|
||||
requestId: crypto.randomUUID(),
|
||||
sections: [
|
||||
{
|
||||
locator: '第 1 页',
|
||||
content: '扫描件识别正文',
|
||||
confidence: 0.93
|
||||
}
|
||||
],
|
||||
pageCount: 1,
|
||||
warnings: []
|
||||
}))
|
||||
const service = new DocumentParsingService(
|
||||
{
|
||||
get: vi.fn(async () => settings),
|
||||
update: vi.fn(async () => settings)
|
||||
} as never,
|
||||
{
|
||||
getStatus: vi.fn(async () => ({
|
||||
const recognize = vi.fn(
|
||||
overrides?.recognize ??
|
||||
(async () => ({
|
||||
requestId: crypto.randomUUID(),
|
||||
sections: [
|
||||
{
|
||||
locator: '第 1 页',
|
||||
content: '扫描件识别正文',
|
||||
confidence: 0.93
|
||||
}
|
||||
],
|
||||
pageCount: 1,
|
||||
warnings: []
|
||||
}))
|
||||
)
|
||||
const settingsStore = {
|
||||
get: vi.fn(async () => settings),
|
||||
update: vi.fn(async () => settings)
|
||||
}
|
||||
const modelManager = {
|
||||
getStatus: vi.fn(async () => ({
|
||||
id: 'pp-ocrv6-tiny',
|
||||
displayName: 'PP-OCRv6 Tiny',
|
||||
available: true,
|
||||
verified: true,
|
||||
available: overrides?.modelStatus?.available ?? true,
|
||||
verified: overrides?.modelStatus?.verified ?? true,
|
||||
runtime: 'onnxruntime-web-wasm',
|
||||
detail: '可用'
|
||||
}))
|
||||
} as never,
|
||||
detail: overrides?.modelStatus?.detail ?? '可用'
|
||||
})),
|
||||
getSnapshot: vi.fn()
|
||||
}
|
||||
const service = new DocumentParsingService(
|
||||
settingsStore as never,
|
||||
modelManager as never,
|
||||
{ recognize } as never
|
||||
)
|
||||
return { recognize, service }
|
||||
return { modelManager, recognize, service, settingsStore }
|
||||
}
|
||||
|
||||
describe('DocumentParsingService', () => {
|
||||
@@ -127,4 +163,100 @@ describe('DocumentParsingService', () => {
|
||||
).rejects.toThrow('未启用 OCR')
|
||||
expect(recognize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to useful native text when automatic OCR is unavailable', async () => {
|
||||
const { recognize, service } = createService({
|
||||
modelStatus: {
|
||||
available: false,
|
||||
verified: false,
|
||||
detail: '模型尚未安装'
|
||||
}
|
||||
})
|
||||
const pdf = createPdfFixture('Native PDF body text', '')
|
||||
const originalExtract = await service.parse(
|
||||
'native.pdf',
|
||||
pdf,
|
||||
'chat-attachment'
|
||||
)
|
||||
expect(originalExtract.content).toContain('Native PDF body text')
|
||||
expect(originalExtract.warnings).toEqual([
|
||||
expect.stringContaining('模型尚未安装')
|
||||
])
|
||||
expect(recognize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to native text when automatic OCR fails', async () => {
|
||||
const { service } = createService({
|
||||
recognize: async () => {
|
||||
throw new Error('OCR runtime failed')
|
||||
}
|
||||
})
|
||||
const parsed = await service.parse(
|
||||
'native.pdf',
|
||||
createPdfFixture('Native PDF body text', ''),
|
||||
'chat-attachment'
|
||||
)
|
||||
|
||||
expect(parsed.content).toContain('Native PDF body text')
|
||||
expect(parsed.warnings).toEqual([
|
||||
expect.stringContaining('OCR runtime failed')
|
||||
])
|
||||
})
|
||||
|
||||
it('limits the number of pages sent to OCR rather than total PDF pages', async () => {
|
||||
const { recognize, service } = createService({
|
||||
settings: { maximumPages: 1 }
|
||||
})
|
||||
|
||||
await service.parse(
|
||||
'mixed.pdf',
|
||||
createPdfFixture('Native PDF body text', ''),
|
||||
'chat-attachment'
|
||||
)
|
||||
|
||||
expect(recognize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maximumPages: 1,
|
||||
pageNumbers: [2]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects high-fidelity parsing when OCR pages exceed the limit', async () => {
|
||||
const { recognize, service } = createService({
|
||||
settings: {
|
||||
chatWorkflow: 'high-fidelity',
|
||||
maximumPages: 1
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.parse(
|
||||
'two-pages.pdf',
|
||||
createPdfFixture('First page text', 'Second page text'),
|
||||
'chat-attachment'
|
||||
)
|
||||
).rejects.toThrow('有 2 页需要 OCR')
|
||||
expect(recognize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects selecting an OCR model that is not installed', async () => {
|
||||
const { modelManager, service, settingsStore } = createService()
|
||||
modelManager.getStatus.mockResolvedValueOnce({
|
||||
id: 'pp-ocrv6-medium',
|
||||
displayName: 'PP-OCRv6 Medium',
|
||||
available: false,
|
||||
verified: false,
|
||||
runtime: 'onnxruntime-web-wasm',
|
||||
detail: '模型尚未安装'
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.update({
|
||||
...defaultDocumentParsingSettings,
|
||||
localOcrModelId: 'pp-ocrv6-medium'
|
||||
})
|
||||
).rejects.toThrow('请先安装并校验')
|
||||
expect(settingsStore.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { extname } from 'node:path'
|
||||
import {
|
||||
documentParsingDiagnosticSchema,
|
||||
documentParsingSettingsUpdateSchema,
|
||||
documentParsingSnapshotSchema,
|
||||
type DocumentParsingDiagnostic,
|
||||
type DocumentParsingPurpose,
|
||||
@@ -52,9 +53,10 @@ function hasUsefulText(content: string): boolean {
|
||||
function effectiveOcrMode(
|
||||
settings: DocumentParsingSettings,
|
||||
purpose: DocumentParsingPurpose
|
||||
): DocumentParsingSettings['pdfOcrMode'] {
|
||||
): 'auto' | 'always' | 'disabled' {
|
||||
if (
|
||||
(purpose === 'chat-attachment' &&
|
||||
((purpose === 'chat-attachment' ||
|
||||
purpose === 'artifact-import') &&
|
||||
settings.chatWorkflow === 'fast-text') ||
|
||||
(purpose === 'knowledge-index' &&
|
||||
settings.knowledgeWorkflow === 'fast-index')
|
||||
@@ -62,14 +64,15 @@ function effectiveOcrMode(
|
||||
return 'disabled'
|
||||
}
|
||||
if (
|
||||
(purpose === 'chat-attachment' &&
|
||||
((purpose === 'chat-attachment' ||
|
||||
purpose === 'artifact-import') &&
|
||||
settings.chatWorkflow === 'high-fidelity') ||
|
||||
(purpose === 'knowledge-index' &&
|
||||
settings.knowledgeWorkflow === 'high-fidelity')
|
||||
) {
|
||||
return 'always'
|
||||
}
|
||||
return settings.pdfOcrMode
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function buildPdfDocument(
|
||||
@@ -130,7 +133,21 @@ export class DocumentParsingService {
|
||||
}
|
||||
|
||||
async update(input: unknown): Promise<DocumentParsingSnapshot> {
|
||||
await this.settingsStore.update(input)
|
||||
const nextSettings =
|
||||
documentParsingSettingsUpdateSchema.parse(input)
|
||||
const currentSettings = await this.settingsStore.get()
|
||||
if (
|
||||
nextSettings.localOcrModelId !==
|
||||
currentSettings.localOcrModelId
|
||||
) {
|
||||
const status = await this.modelManager.getStatus(
|
||||
nextSettings.localOcrModelId
|
||||
)
|
||||
if (!status.available || !status.verified) {
|
||||
throw new Error('请先安装并校验所选 OCR 模型')
|
||||
}
|
||||
}
|
||||
await this.settingsStore.update(nextSettings)
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
@@ -159,7 +176,7 @@ export class DocumentParsingService {
|
||||
? pagesWithoutUsefulText
|
||||
: []
|
||||
|
||||
if (mode === 'disabled' || !settings.localOcrEnabled) {
|
||||
if (mode === 'disabled') {
|
||||
const native = nativePdfSections(pages)
|
||||
if (native.length > 0) {
|
||||
return buildPdfDocument(
|
||||
@@ -182,15 +199,24 @@ export class DocumentParsingService {
|
||||
pages.length
|
||||
)
|
||||
}
|
||||
if (pages.length > settings.maximumPages) {
|
||||
if (ocrPageNumbers.length > settings.maximumPages) {
|
||||
throw new Error(
|
||||
`PDF 共 ${pages.length} 页,超过本地 OCR 的 ${settings.maximumPages} 页限制`
|
||||
`PDF 有 ${ocrPageNumbers.length} 页需要 OCR,超过 ${settings.maximumPages} 页限制`
|
||||
)
|
||||
}
|
||||
const native = nativePdfSections(pages)
|
||||
const modelStatus = await this.modelManager.getStatus(
|
||||
settings.localOcrModelId
|
||||
)
|
||||
if (!modelStatus.available || !modelStatus.verified) {
|
||||
if (
|
||||
mode === 'auto' &&
|
||||
native.some((section) => hasUsefulText(section.content))
|
||||
) {
|
||||
return buildPdfDocument(name, native, pages.length, [
|
||||
`本地 OCR 不可用,已保留 PDF 文本层内容:${modelStatus.detail}`
|
||||
])
|
||||
}
|
||||
throw new Error(modelStatus.detail)
|
||||
}
|
||||
|
||||
@@ -203,9 +229,25 @@ export class DocumentParsingService {
|
||||
pageNumbers: ocrPageNumbers,
|
||||
pageTimeoutSeconds: settings.pageTimeoutSeconds
|
||||
}
|
||||
const ocr = await (signal
|
||||
? this.ocrBroker.recognize(ocrRequest, signal)
|
||||
: this.ocrBroker.recognize(ocrRequest))
|
||||
let ocr
|
||||
try {
|
||||
ocr = await (signal
|
||||
? this.ocrBroker.recognize(ocrRequest, signal)
|
||||
: this.ocrBroker.recognize(ocrRequest))
|
||||
} catch (error) {
|
||||
ensureNotAborted(signal)
|
||||
if (
|
||||
mode === 'auto' &&
|
||||
native.some((section) => hasUsefulText(section.content))
|
||||
) {
|
||||
const detail =
|
||||
error instanceof Error ? error.message : '本地 OCR 识别失败'
|
||||
return buildPdfDocument(name, native, pages.length, [
|
||||
`本地 OCR 失败,已保留 PDF 文本层内容:${detail}`
|
||||
])
|
||||
}
|
||||
throw error
|
||||
}
|
||||
ensureNotAborted(signal)
|
||||
const ocrByLocator = new Map(
|
||||
ocr.sections.map((section) => [section.locator, section])
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('DocumentParsingSettingsStore', () => {
|
||||
|
||||
await expect(store.update(settings)).resolves.toEqual(settings)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 2,
|
||||
version: 3,
|
||||
...settings
|
||||
})
|
||||
await expect(
|
||||
@@ -68,27 +68,59 @@ describe('DocumentParsingSettingsStore', () => {
|
||||
).resolves.toEqual(settings)
|
||||
})
|
||||
|
||||
it('migrates legacy cloud permissions to the local OCR provider', async () => {
|
||||
it('migrates version 2 OCR switches into scenario modes', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
chatWorkflow: 'auto',
|
||||
knowledgeWorkflow: 'complete-index',
|
||||
pdfOcrMode: 'always',
|
||||
ocrProvider: 'local',
|
||||
localOcrEnabled: true,
|
||||
localOcrModelId: 'pp-ocrv6-small',
|
||||
maximumPages: 42,
|
||||
ocrConcurrency: 4,
|
||||
pageTimeoutSeconds: 90
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
chatWorkflow: 'high-fidelity',
|
||||
knowledgeWorkflow: 'high-fidelity',
|
||||
localOcrModelId: 'pp-ocrv6-small',
|
||||
maximumPages: 42,
|
||||
pageTimeoutSeconds: 90
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates version 1 cloud settings into local scenario modes', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const {
|
||||
ocrProvider: _ocrProvider,
|
||||
...legacySettings
|
||||
} = defaultDocumentParsingSettings
|
||||
void _ocrProvider
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
...legacySettings,
|
||||
chatWorkflow: 'auto',
|
||||
knowledgeWorkflow: 'complete-index',
|
||||
pdfOcrMode: 'auto',
|
||||
localOcrEnabled: false,
|
||||
localOcrModelId: 'pp-ocrv6-tiny',
|
||||
maximumPages: 100,
|
||||
ocrConcurrency: 1,
|
||||
pageTimeoutSeconds: 60,
|
||||
chatCloudPermission: 'always',
|
||||
knowledgeCloudPermission: 'never'
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual(
|
||||
defaultDocumentParsingSettings
|
||||
)
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultDocumentParsingSettings,
|
||||
chatWorkflow: 'fast-text',
|
||||
knowledgeWorkflow: 'fast-index'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects incomplete or out-of-range settings', async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type DocumentParsingSettings
|
||||
} from '../shared/document-parsing-contracts'
|
||||
|
||||
const CURRENT_SETTINGS_VERSION = 2
|
||||
const CURRENT_SETTINGS_VERSION = 3
|
||||
|
||||
const storedDocumentParsingSettingsSchema =
|
||||
documentParsingSettingsSchema
|
||||
@@ -27,9 +27,32 @@ type StoredDocumentParsingSettings = z.infer<
|
||||
typeof storedDocumentParsingSettingsSchema
|
||||
>
|
||||
|
||||
const legacyDocumentParsingSettingsSchema =
|
||||
documentParsingSettingsSchema
|
||||
.omit({ ocrProvider: true })
|
||||
const legacyVersionTwoSettingsSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
chatWorkflow: z.enum(['auto', 'fast-text', 'high-fidelity']),
|
||||
knowledgeWorkflow: z.enum([
|
||||
'complete-index',
|
||||
'fast-index',
|
||||
'high-fidelity'
|
||||
]),
|
||||
pdfOcrMode: z.enum(['auto', 'always', 'disabled']),
|
||||
ocrProvider: z.literal('local'),
|
||||
localOcrEnabled: z.boolean(),
|
||||
localOcrModelId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(96)
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u),
|
||||
maximumPages: z.number().int().min(1).max(500),
|
||||
ocrConcurrency: z.number().int().min(1).max(4),
|
||||
pageTimeoutSeconds: z.number().int().min(10).max(300)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const legacyVersionOneSettingsSchema =
|
||||
legacyVersionTwoSettingsSchema
|
||||
.omit({ version: true, ocrProvider: true })
|
||||
.extend({
|
||||
version: z.literal(1),
|
||||
chatCloudPermission: z.enum(['ask', 'always', 'never']),
|
||||
@@ -40,15 +63,39 @@ const legacyDocumentParsingSettingsSchema =
|
||||
export const defaultDocumentParsingSettings: DocumentParsingSettings = {
|
||||
chatWorkflow: 'auto',
|
||||
knowledgeWorkflow: 'complete-index',
|
||||
pdfOcrMode: 'auto',
|
||||
ocrProvider: 'local',
|
||||
localOcrEnabled: true,
|
||||
localOcrModelId: 'pp-ocrv6-tiny',
|
||||
maximumPages: 100,
|
||||
ocrConcurrency: 1,
|
||||
pageTimeoutSeconds: 60
|
||||
}
|
||||
|
||||
type LegacySettings = z.infer<
|
||||
typeof legacyVersionTwoSettingsSchema
|
||||
>
|
||||
|
||||
function migrateLegacySettings(
|
||||
legacy: LegacySettings
|
||||
): StoredDocumentParsingSettings {
|
||||
const ocrDisabled =
|
||||
!legacy.localOcrEnabled || legacy.pdfOcrMode === 'disabled'
|
||||
const ocrAlways = legacy.pdfOcrMode === 'always'
|
||||
return {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
chatWorkflow: ocrDisabled
|
||||
? 'fast-text'
|
||||
: legacy.chatWorkflow === 'auto' && ocrAlways
|
||||
? 'high-fidelity'
|
||||
: legacy.chatWorkflow,
|
||||
knowledgeWorkflow: ocrDisabled
|
||||
? 'fast-index'
|
||||
: legacy.knowledgeWorkflow === 'complete-index' && ocrAlways
|
||||
? 'high-fidelity'
|
||||
: legacy.knowledgeWorkflow,
|
||||
localOcrModelId: legacy.localOcrModelId,
|
||||
maximumPages: legacy.maximumPages,
|
||||
pageTimeoutSeconds: legacy.pageTimeoutSeconds
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return (
|
||||
error !== null &&
|
||||
@@ -99,23 +146,27 @@ export class DocumentParsingSettingsStore {
|
||||
const result =
|
||||
storedDocumentParsingSettingsSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const legacy =
|
||||
legacyDocumentParsingSettingsSchema.safeParse(parsed)
|
||||
if (legacy.success) {
|
||||
const versionTwo =
|
||||
legacyVersionTwoSettingsSchema.safeParse(parsed)
|
||||
if (versionTwo.success) {
|
||||
this.settings = migrateLegacySettings(versionTwo.data)
|
||||
return this.settings
|
||||
}
|
||||
const versionOne =
|
||||
legacyVersionOneSettingsSchema.safeParse(parsed)
|
||||
if (versionOne.success) {
|
||||
const {
|
||||
version: _version,
|
||||
chatCloudPermission: _chatCloudPermission,
|
||||
knowledgeCloudPermission: _knowledgeCloudPermission,
|
||||
...settings
|
||||
} = legacy.data
|
||||
void _version
|
||||
...legacy
|
||||
} = versionOne.data
|
||||
void _chatCloudPermission
|
||||
void _knowledgeCloudPermission
|
||||
this.settings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
this.settings = migrateLegacySettings({
|
||||
...legacy,
|
||||
version: 2,
|
||||
ocrProvider: 'local',
|
||||
...settings
|
||||
}
|
||||
})
|
||||
return this.settings
|
||||
}
|
||||
await this.isolateCorruptFile()
|
||||
|
||||
@@ -784,6 +784,149 @@ describe('registerIpcHandlers model ZIP dialogs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerIpcHandlers document parsing', () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
electronMocks.handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('tests an explicit scenario and parses artifact PDFs through the shared service', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-ipc-document-parsing-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const diagnosticPath = join(directory, 'diagnostic.pdf')
|
||||
const artifactPath = join(directory, 'artifact.pdf')
|
||||
await writeFile(diagnosticPath, 'diagnostic')
|
||||
await writeFile(artifactPath, 'artifact')
|
||||
const diagnostic = {
|
||||
fileName: 'diagnostic.pdf',
|
||||
sourceFormat: 'PDF',
|
||||
pageCount: 1,
|
||||
ocrPageCount: 0,
|
||||
characterCount: 4,
|
||||
method: 'native',
|
||||
durationMs: 1,
|
||||
preview: 'text',
|
||||
warnings: []
|
||||
}
|
||||
const documentParsingService = {
|
||||
diagnose: vi.fn(async () => diagnostic),
|
||||
parse: vi.fn(async () => ({
|
||||
title: 'artifact',
|
||||
sourceFormat: '.pdf',
|
||||
content: 'parsed artifact',
|
||||
sections: [
|
||||
{
|
||||
locator: '第 1 页',
|
||||
content: 'parsed artifact',
|
||||
method: 'native'
|
||||
}
|
||||
],
|
||||
pageCount: 1,
|
||||
warnings: []
|
||||
}))
|
||||
}
|
||||
const createInlineArtifact = vi.fn((input) => input)
|
||||
const assistantDatabase = {
|
||||
claimDueSchedules: vi.fn(() => []),
|
||||
createInlineArtifact
|
||||
}
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => false),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
const event = {
|
||||
sender: webContents,
|
||||
senderFrame: webContents.mainFrame
|
||||
}
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never,
|
||||
{ capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space',
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
assistantDatabase as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => undefined),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
documentParsingService as never
|
||||
)
|
||||
|
||||
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
filePaths: [diagnosticPath]
|
||||
})
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.documentParsingTest
|
||||
)?.(event, { purpose: 'knowledge-index' })
|
||||
).resolves.toBe(diagnostic)
|
||||
expect(documentParsingService.diagnose).toHaveBeenCalledWith(
|
||||
'diagnostic.pdf',
|
||||
expect.any(Buffer),
|
||||
'knowledge-index'
|
||||
)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.documentParsingTest
|
||||
)?.(event, { purpose: 'diagnostic' })
|
||||
).rejects.toThrow()
|
||||
|
||||
electronMocks.showOpenDialog.mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
filePaths: [artifactPath]
|
||||
})
|
||||
await expect(
|
||||
electronMocks.handlers.get(
|
||||
ipcChannels.artifactsImportFiles
|
||||
)?.(event)
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'artifact.pdf',
|
||||
content: '## 第 1 页\n\nparsed artifact'
|
||||
})
|
||||
])
|
||||
expect(documentParsingService.parse).toHaveBeenCalledWith(
|
||||
'artifact.pdf',
|
||||
expect.any(Buffer),
|
||||
'artifact-import'
|
||||
)
|
||||
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerIpcHandlers connection tests', () => {
|
||||
afterEach(() => {
|
||||
electronMocks.handlers.clear()
|
||||
|
||||
+14
-4
@@ -102,7 +102,8 @@ import {
|
||||
documentOcrModelActionInputSchema,
|
||||
documentOcrFailureSchema,
|
||||
documentOcrResultSchema,
|
||||
documentParsingSettingsUpdateSchema
|
||||
documentParsingSettingsUpdateSchema,
|
||||
documentParsingTestInputSchema
|
||||
} from '../shared/document-parsing-contracts'
|
||||
import {
|
||||
agentRuntimeSelectionSchema,
|
||||
@@ -2785,11 +2786,13 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
ipcChannels.documentParsingTest,
|
||||
async (event) => {
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!documentParsingService) {
|
||||
throw new Error('文档解析设置服务不可用')
|
||||
}
|
||||
const { purpose } =
|
||||
documentParsingTestInputSchema.parse(input)
|
||||
const result = await dialog.showOpenDialog(window, {
|
||||
title: '选择测试文档',
|
||||
properties: ['openFile'],
|
||||
@@ -2814,7 +2817,8 @@ export function registerIpcHandlers(
|
||||
}
|
||||
return documentParsingService.diagnose(
|
||||
basename(canonicalPath),
|
||||
await readFile(canonicalPath)
|
||||
await readFile(canonicalPath),
|
||||
purpose
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !('code' in error)) {
|
||||
@@ -3436,7 +3440,13 @@ export function registerIpcHandlers(
|
||||
)
|
||||
continue
|
||||
}
|
||||
const parsed = await parseDocument(name, file)
|
||||
const parsed = documentParsingService
|
||||
? await documentParsingService.parse(
|
||||
name,
|
||||
file,
|
||||
'artifact-import'
|
||||
)
|
||||
: await parseDocument(name, file)
|
||||
artifacts.push(
|
||||
assistantDatabase.createInlineArtifact({
|
||||
projectId,
|
||||
|
||||
Reference in New Issue
Block a user