feat: add document OCR and offline model archives
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getDocument = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
|
||||
getDocument
|
||||
}))
|
||||
|
||||
import { extractPdfTextPages } from './document-parser'
|
||||
|
||||
describe('PDF extraction in Electron main', () => {
|
||||
beforeEach(() => {
|
||||
getDocument.mockReset()
|
||||
})
|
||||
|
||||
it('disables PDF.js DOM factories for headless text extraction', async () => {
|
||||
const cleanup = vi.fn()
|
||||
const destroy = vi.fn(async () => undefined)
|
||||
getDocument.mockReturnValue({
|
||||
promise: Promise.resolve({
|
||||
numPages: 1,
|
||||
getPage: vi.fn(async () => ({
|
||||
getTextContent: vi.fn(async () => ({
|
||||
items: [{ str: 'PDF body text' }]
|
||||
})),
|
||||
cleanup
|
||||
}))
|
||||
}),
|
||||
destroy
|
||||
})
|
||||
|
||||
await expect(
|
||||
extractPdfTextPages(Buffer.from('synthetic PDF'))
|
||||
).resolves.toEqual([
|
||||
{
|
||||
pageNumber: 1,
|
||||
content: 'PDF body text'
|
||||
}
|
||||
])
|
||||
expect(getDocument).toHaveBeenCalledWith({
|
||||
data: expect.any(Uint8Array),
|
||||
disableFontFace: true,
|
||||
isOffscreenCanvasSupported: false,
|
||||
useSystemFonts: false,
|
||||
useWorkerFetch: false
|
||||
})
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,17 @@ import { extname } from 'node:path'
|
||||
export type ParsedSection = {
|
||||
locator: string
|
||||
content: string
|
||||
method?: 'native' | 'ocr' | 'converted' | 'vision'
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export type ParsedDocument = {
|
||||
title: string
|
||||
sourceFormat: string
|
||||
content: string
|
||||
sections: ParsedSection[]
|
||||
warnings: string[]
|
||||
pageCount?: number
|
||||
}
|
||||
|
||||
export type DocumentChunk = {
|
||||
@@ -160,12 +165,43 @@ function parseOfficeArchive(
|
||||
}
|
||||
|
||||
async function parsePdf(buffer: Buffer): Promise<ParsedSection[]> {
|
||||
const pages = await extractPdfTextPages(buffer)
|
||||
return pages
|
||||
.filter((page) => page.content.length > 0)
|
||||
.map((page) => ({
|
||||
locator: `第 ${page.pageNumber} 页`,
|
||||
content: page.content
|
||||
}))
|
||||
}
|
||||
|
||||
export type PdfTextPage = {
|
||||
pageNumber: number
|
||||
content: string
|
||||
}
|
||||
|
||||
export class DocumentTextUnavailableError extends Error {
|
||||
constructor(message = '文档中没有可索引的文本内容') {
|
||||
super(message)
|
||||
this.name = 'DocumentTextUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractPdfTextPages(
|
||||
buffer: Buffer
|
||||
): Promise<PdfTextPage[]> {
|
||||
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
const loadingTask = pdfjs.getDocument({
|
||||
data: new Uint8Array(buffer)
|
||||
data: new Uint8Array(buffer),
|
||||
// Electron's main process identifies itself as process.type ===
|
||||
// "browser", so PDF.js otherwise selects DOM font factories even
|
||||
// though no document exists there.
|
||||
disableFontFace: true,
|
||||
isOffscreenCanvasSupported: false,
|
||||
useSystemFonts: false,
|
||||
useWorkerFetch: false
|
||||
})
|
||||
const document = await loadingTask.promise
|
||||
const sections: ParsedSection[] = []
|
||||
const pages: PdfTextPage[] = []
|
||||
try {
|
||||
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
|
||||
const page = await document.getPage(pageNumber)
|
||||
@@ -175,18 +211,13 @@ async function parsePdf(buffer: Buffer): Promise<ParsedSection[]> {
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (content) {
|
||||
sections.push({
|
||||
locator: `第 ${pageNumber} 页`,
|
||||
content
|
||||
})
|
||||
}
|
||||
pages.push({ pageNumber, content })
|
||||
page.cleanup()
|
||||
}
|
||||
} finally {
|
||||
await loadingTask.destroy()
|
||||
}
|
||||
return sections
|
||||
return pages
|
||||
}
|
||||
|
||||
export async function parseDocument(
|
||||
@@ -227,12 +258,14 @@ export async function parseDocument(
|
||||
.join('\n\n')
|
||||
.slice(0, maximumExtractedCharacters)
|
||||
if (!content) {
|
||||
throw new Error('文档中没有可索引的文本内容')
|
||||
throw new DocumentTextUnavailableError()
|
||||
}
|
||||
return {
|
||||
title: name.replace(/\.[^.]+$/, ''),
|
||||
sourceFormat: extension || 'unknown',
|
||||
content,
|
||||
sections
|
||||
sections,
|
||||
warnings: []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
relative,
|
||||
resolve
|
||||
} from 'node:path'
|
||||
import { chunkDocument, parseDocument, supportedDocumentExtensions } from './document-parser'
|
||||
import {
|
||||
chunkDocument,
|
||||
parseDocument,
|
||||
supportedDocumentExtensions,
|
||||
type ParsedDocument
|
||||
} from './document-parser'
|
||||
import { classifyEmbeddingError } from './embedding-errors'
|
||||
import {
|
||||
extractKnowledgeGraph,
|
||||
@@ -99,6 +104,12 @@ export type KnowledgeServiceOptions = {
|
||||
urlImporter?: UrlImporter
|
||||
embeddingProvider?: EmbeddingProvider
|
||||
embeddingBatchSize?: number
|
||||
parseDocument?: (
|
||||
name: string,
|
||||
buffer: Buffer,
|
||||
purpose: 'knowledge-index',
|
||||
signal?: AbortSignal
|
||||
) => Promise<ParsedDocument>
|
||||
}
|
||||
|
||||
const supportedExtensions = new Set<string>(supportedDocumentExtensions)
|
||||
@@ -118,6 +129,9 @@ export class KnowledgeService {
|
||||
private readonly managedRoot: string
|
||||
private readonly extractStructured?: ExtractStructured
|
||||
private readonly urlImporter: UrlImporter
|
||||
private readonly documentParser: NonNullable<
|
||||
KnowledgeServiceOptions['parseDocument']
|
||||
>
|
||||
private embeddingProvider?: EmbeddingProvider
|
||||
private readonly embeddingBatchSize: number
|
||||
private readonly watchers = new Map<string, FSWatcher>()
|
||||
@@ -131,6 +145,9 @@ export class KnowledgeService {
|
||||
this.managedRoot = resolve(options.managedRoot)
|
||||
this.extractStructured = options.extractStructured
|
||||
this.urlImporter = options.urlImporter ?? new UrlImporter()
|
||||
this.documentParser =
|
||||
options.parseDocument ??
|
||||
((name, buffer) => parseDocument(name, buffer))
|
||||
this.embeddingProvider = options.embeddingProvider
|
||||
const embeddingBatchSize = options.embeddingBatchSize ?? 16
|
||||
if (
|
||||
@@ -839,9 +856,11 @@ export class KnowledgeService {
|
||||
})
|
||||
continue
|
||||
}
|
||||
const parsed = await parseDocument(
|
||||
const parsed = await this.documentParser(
|
||||
basename(file.absolutePath),
|
||||
buffer
|
||||
buffer,
|
||||
'knowledge-index',
|
||||
this.lifecycleController.signal
|
||||
)
|
||||
this.updateKnowledgeTask(parsingTask.id, {
|
||||
progress: 75,
|
||||
|
||||
Reference in New Issue
Block a user