Files
goodbuddy/src/main/document-parsing-service.test.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

484 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
const firstContentObjectId = fontObjectId + 1
const objects = [
'<< /Type /Catalog /Pages 2 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 >>',
...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]
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(content))
content += `${index + 1} 0 obj\n${object}\nendobj\n`
}
const xrefOffset = Buffer.byteLength(content)
content += `xref\n0 ${objects.length + 1}\n`
content += '0000000000 65535 f \n'
content += offsets
.slice(1)
.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`)
.join('')
content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`
content += `startxref\n${xrefOffset}\n%%EOF\n`
return Buffer.from(content)
}
function createService(overrides?: {
settings?: Partial<typeof defaultDocumentParsingSettings>
modelStatus?: {
available: boolean
verified: boolean
detail: string
}
recognize?: () => Promise<{
requestId: string
sections: Array<{
locator: string
pageNumber?: number
content: string
confidence: number
}>
pageCount: number
warnings: string[]
}>
}) {
const settings = {
...defaultDocumentParsingSettings,
...overrides?.settings
}
const recognize = vi.fn(
overrides?.recognize ??
(async () => ({
requestId: crypto.randomUUID(),
sections: [
{
locator: '第 1 页',
pageNumber: 1,
content: '扫描件识别正文',
confidence: 0.93
}
],
pageCount: 1,
warnings: []
}))
)
const settingsStore = {
forOperation: vi.fn(async () => ({ settings, apiKey: () => undefined })),
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: overrides?.modelStatus?.available ?? true,
verified: overrides?.modelStatus?.verified ?? true,
runtime: 'onnxruntime-web-wasm',
detail: overrides?.modelStatus?.detail ?? '可用'
})),
getSnapshot: vi.fn()
}
const service = new DocumentParsingService(
settingsStore as never,
modelManager as never,
{ recognize } as never
)
return { modelManager, recognize, service, settingsStore }
}
describe('DocumentParsingService', () => {
it('waits for an unnamed diagnostic to settle before closing shared result access', async () => {
const { settingsStore, modelManager } = createService()
const close = vi.fn(async () => undefined)
const service = new DocumentParsingService(settingsStore as never, modelManager as never, {} as never, { close } as never)
let finish!: () => void
const pending = new Promise<void>(resolve => { finish = resolve })
vi.spyOn(service, 'parse').mockImplementation(async (_name, _bytes, _purpose, signal) => {
await pending
signal?.throwIfAborted()
throw new Error('Diagnostic should have been cancelled')
})
const diagnostic = service.diagnose('source.pdf', Buffer.from('source'))
const rejected = expect(diagnostic).rejects.toThrow()
const disposing = service.dispose()
expect(close).not.toHaveBeenCalled()
finish()
await rejected
await disposing
expect(close).toHaveBeenCalledOnce()
await expect(service.diagnose('source.pdf', Buffer.from('source'))).rejects.toThrow('closed')
})
it.each(['chat-attachment', 'knowledge-index', 'artifact-import', 'diagnostic'] as const)(
'recognizes embedded PPTX images for %s',
async (purpose) => {
const { recognize, service } = createService()
const parsed = await service.parse('slides.pptx', createImagePptx('', 2), purpose)
expect(parsed.sourceFormat).toBe('.pptx')
expect(parsed.pageCount).toBe(2)
expect(parsed.sections.map(({ locator, method, pageNumber }) => ({
locator, method, pageNumber
}))).toEqual([
{ locator: '幻灯片 1 · 图片 1', method: 'ocr', pageNumber: 1 },
{ locator: '幻灯片 2 · 图片 1', method: 'ocr', pageNumber: 2 }
])
expect(recognize).toHaveBeenCalledTimes(2)
expect(recognize).toHaveBeenCalledWith(expect.objectContaining({
mimeType: 'image/png', maximumPages: 1, modelId: 'pp-ocrv6-tiny'
}))
}
)
it('preserves PPTX native text while recognizing image content on the same slide', async () => {
const { service } = createService()
const parsed = await service.parse('slides.pptx', createImagePptx('Native slide title'), 'chat-attachment')
expect(parsed.content).toContain('Native slide title')
expect(parsed.content).toContain('扫描件识别正文')
const diagnostic = await service.diagnose('slides.pptx', createImagePptx('Native slide title'))
expect(diagnostic).toMatchObject({ method: 'mixed', pageCount: 1, ocrPageCount: 1 })
})
it('reports disabled OCR for image-only PPTX in fast-text mode', async () => {
const { service, recognize } = createService({ settings: { chatWorkflow: 'fast-text' } })
await expect(service.parse('slides.pptx', createImagePptx(), 'chat-attachment')).rejects.toThrow('当前工作流未启用 OCR')
expect(recognize).not.toHaveBeenCalled()
})
it('enforces PPTX OCR page limits before recognizing images', async () => {
const { service, recognize } = createService({ settings: { maximumPages: 1 } })
await expect(service.parse('slides.pptx', createImagePptx('', 2), 'knowledge-index')).rejects.toThrow('超过 1 页限制')
expect(recognize).not.toHaveBeenCalled()
})
it('reports the model error for image-only PPTX, not empty document text', async () => {
const { service } = createService({ modelStatus: {
available: false, verified: false, detail: '模型尚未安装'
} })
await expect(service.parse('slides.pptx', createImagePptx(), 'chat-attachment')).rejects.toThrow('模型尚未安装')
})
it('only falls back to PPTX native text for automatic non-index workflows', async () => {
const { service } = createService({ recognize: async () => { throw new Error('OCR runtime failed') } })
const bytes = createImagePptx('Useful native slide body')
expect((await service.parse('slides.pptx', bytes, 'chat-attachment')).warnings).toEqual([
expect.stringContaining('OCR runtime failed')
])
await expect(service.parse('slides.pptx', bytes, 'knowledge-index')).rejects.toThrow('OCR runtime failed')
})
it('stops PPTX OCR between images on cancellation instead of returning native text', async () => {
const controller = new AbortController()
const { service, recognize } = createService({ recognize: async () => {
controller.abort(new Error('Cancelled PPTX'))
throw new Error('OCR stopped')
} })
await expect(service.parse('slides.pptx', createImagePptx('Useful native text', 2), 'chat-attachment', controller.signal)).rejects.toThrow('Cancelled PPTX')
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()
const parsed = await service.parse(
'native.pdf',
createPdfFixture('Native PDF body text'),
'knowledge-index'
)
expect(parsed.content).toContain('Native PDF body text')
expect(parsed.sections[0]?.method).toBe('native')
expect(recognize).not.toHaveBeenCalled()
})
it('uses OCR for a PDF without useful text', async () => {
const { recognize, service } = createService()
const parsed = await service.parse(
'scan.pdf',
createPdfFixture(''),
'chat-attachment'
)
expect(parsed.content).toBe('扫描件识别正文')
expect(parsed.sections).toEqual([
{
locator: '第 1 页',
content: '扫描件识别正文',
method: 'ocr',
confidence: 0.93,
pageNumber: 1,
blockKind: 'text'
}
])
expect(recognize).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'scan.pdf',
modelId: 'pp-ocrv6-tiny',
mimeType: 'application/pdf',
pageNumbers: [1]
})
)
})
it('does not use OCR in a fast-text workflow', async () => {
const { recognize, service } = createService({
settings: { chatWorkflow: 'fast-text' }
})
await expect(
service.parse(
'scan.pdf',
createPdfFixture(''),
'chat-attachment'
)
).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('does not silently index a partial mixed PDF when OCR fails', async () => {
const { service } = createService({
recognize: async () => {
throw new Error('OCR runtime failed')
}
})
await expect(
service.parse(
'mixed.pdf',
createPdfFixture('Native PDF body text', ''),
'knowledge-index'
)
).rejects.toThrow('OCR runtime failed')
})
it('does not silently index a mixed PDF when OCR returns no text', async () => {
const { service } = createService({
recognize: async () => ({
requestId: crypto.randomUUID(),
sections: [],
pageCount: 2,
warnings: ['第 2 页未识别到文字']
})
})
await expect(
service.parse(
'mixed.pdf',
createPdfFixture('Native PDF body text', ''),
'knowledge-index'
)
).rejects.toThrow('第 2 页未识别到可索引文本')
})
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()
})
it('rejects oversized non-PDF input through the unified service', async () => {
const { service } = createService()
await expect(
service.parse(
'large.txt',
Buffer.alloc(20 * 1024 * 1024 + 1),
'knowledge-index'
)
).rejects.toThrow('20MB')
})
it('bounds OCR output before returning parsed sections', async () => {
const { service } = createService({
recognize: async () => ({
requestId: crypto.randomUUID(),
sections: [
{
locator: '第 1 页',
pageNumber: 1,
content: 'x'.repeat(1_000_000),
confidence: 0.9
},
{
locator: '第 2 页',
pageNumber: 2,
content: 'y'.repeat(1_000_000),
confidence: 0.9
},
{
locator: '第 3 页',
pageNumber: 3,
content: 'z'.repeat(1_000_000),
confidence: 0.9
},
{
locator: '第 4 页',
pageNumber: 4,
content: 'a'.repeat(1_000_000),
confidence: 0.9
},
{
locator: '第 5 页',
pageNumber: 5,
content: 'b'.repeat(1_000_000),
confidence: 0.9
}
],
pageCount: 5,
warnings: []
}),
settings: {
chatWorkflow: 'high-fidelity',
maximumPages: 5
}
})
const parsed = await service.parse(
'large-ocr.pdf',
createPdfFixture('', '', '', '', ''),
'chat-attachment'
)
expect(parsed.content.length).toBeLessThanOrEqual(5_000_000)
expect(
parsed.sections.map((section) => section.content).join('\n\n')
).toBe(parsed.content)
expect(parsed.warnings).toContain(
'文档提取文本超过 5,000,000 字符,已截断'
)
})
})