@exulu/backend 3.7.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-T6JVFT7L.js → chunk-QMN6MVHQ.js} +6 -1
- package/dist/{chunk-BNTL6LYY.js → chunk-RBEWHG7I.js} +404 -59
- package/dist/cli/start-whisper.js +1 -1
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-UQSLJDXE.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
- package/dist/index.cjs +957 -474
- package/dist/index.d.cts +3 -6
- package/dist/index.d.ts +3 -6
- package/dist/index.js +258 -182
- package/dist/python-setup-DRJ3QX5F.js +17 -0
- package/ee/LICENSE.md +2 -2
- package/ee/agentic-retrieval/pipeline/config.test.ts +18 -1
- package/ee/agentic-retrieval/pipeline/config.ts +15 -0
- package/ee/agentic-retrieval/pipeline/index.test.ts +73 -0
- package/ee/agentic-retrieval/pipeline/index.ts +67 -13
- package/ee/agentic-retrieval/pipeline/memory.test.ts +59 -0
- package/ee/agentic-retrieval/pipeline/memory.ts +181 -11
- package/ee/agentic-retrieval/pipeline/pin-rerun.test.ts +17 -0
- package/ee/agentic-retrieval/pipeline/pin-rerun.ts +29 -0
- package/ee/agentic-retrieval/pipeline/routing.test.ts +34 -0
- package/ee/agentic-retrieval/pipeline/routing.ts +96 -5
- package/ee/agentic-retrieval/pipeline/search.ts +9 -6
- package/ee/agentic-retrieval/pipeline/timing.test.ts +24 -0
- package/ee/agentic-retrieval/pipeline/timing.ts +26 -0
- package/ee/agentic-retrieval/pipeline/types.ts +2 -0
- package/ee/invoke-skills/artifact-filter.test.ts +49 -0
- package/ee/invoke-skills/artifact-filter.ts +38 -0
- package/ee/invoke-skills/create-sandbox.ts +56 -4
- package/ee/python/documents/processing/README.md +2 -3
- package/ee/python/documents/processing/doc_processor.ts +21 -61
- package/ee/python/documents/processing/split_pdf.py +25 -30
- package/ee/python/documents/processing/tests/__init__.py +0 -0
- package/ee/python/documents/processing/tests/test_split_pdf.py +230 -0
- package/ee/python/requirements.txt +17 -2
- package/ee/python/setup.sh +40 -1
- package/ee/python/transcription/pipeline.py +109 -15
- package/ee/python/transcription/tests/test_align_model_licensing.py +184 -0
- package/ee/workers.ts +2 -7
- package/license.md +2 -2
- package/package.json +3 -4
- package/scripts/postinstall.cjs +52 -1
- package/ee/python/documents/processing/document_to_markdown.py +0 -413
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isIgnoredArtifactPath, capArtifacts, needsDownload } from "./artifact-filter";
|
|
2
|
+
|
|
3
|
+
describe("isIgnoredArtifactPath — dependency and cache trees are not session artifacts", () => {
|
|
4
|
+
it("ignores virtualenvs, node_modules, caches and VCS metadata at any depth", () => {
|
|
5
|
+
for (const p of [
|
|
6
|
+
"venv/pyvenv.cfg",
|
|
7
|
+
"venv/lib/python3.11/site-packages/docx/api.py",
|
|
8
|
+
".venv/bin/python",
|
|
9
|
+
"node_modules/lodash/index.js",
|
|
10
|
+
"scripts/__pycache__/create.cpython-311.pyc",
|
|
11
|
+
".cache/pip/http/x",
|
|
12
|
+
".git/objects/ab/cd",
|
|
13
|
+
"project/env/lib/python3.11/site-packages/x.py",
|
|
14
|
+
]) {
|
|
15
|
+
expect(isIgnoredArtifactPath(p)).toBe(true);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps the files the agent actually produces", () => {
|
|
20
|
+
for (const p of ["Besprechungsprotokoll.docx", "create_minutes.py", "out/report.pdf", "Angebot/Angebot_107426.docx"]) {
|
|
21
|
+
expect(isIgnoredArtifactPath(p)).toBe(false);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("capArtifacts — a bash command that touches thousands of files must not flood the tool result", () => {
|
|
27
|
+
const many = Array.from({ length: 250 }, (_, i) => ({ relativePath: `out/file_${i}.txt`, url: `https://s3/${i}` }));
|
|
28
|
+
|
|
29
|
+
it("returns everything below the cap untouched", () => {
|
|
30
|
+
const { kept, omitted } = capArtifacts(many.slice(0, 10), 50);
|
|
31
|
+
expect(kept).toHaveLength(10);
|
|
32
|
+
expect(omitted).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("keeps only the first N and reports how many were omitted", () => {
|
|
36
|
+
const { kept, omitted } = capArtifacts(many, 50);
|
|
37
|
+
expect(kept).toHaveLength(50);
|
|
38
|
+
expect(kept[0]!.relativePath).toBe("out/file_0.txt");
|
|
39
|
+
expect(omitted).toBe(200);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("needsDownload — resyncing S3 session files into a live sandbox", () => {
|
|
44
|
+
it("downloads when the local file is missing or differs in size, skips identical files", () => {
|
|
45
|
+
expect(needsDownload(undefined, 100)).toBe(true);
|
|
46
|
+
expect(needsDownload(99, 100)).toBe(true);
|
|
47
|
+
expect(needsDownload(100, 100)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sandbox mirrors every file a shell command creates under the session directory to
|
|
3
|
+
* S3 and lists each one (with a presigned URL) in the tool result. A single
|
|
4
|
+
* `python -m venv venv && pip install python-docx` creates thousands of files, which
|
|
5
|
+
* turned one tool result into ~1.8M characters and killed the session's context window.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const IGNORED_SEGMENTS = new Set([
|
|
9
|
+
"venv",
|
|
10
|
+
".venv",
|
|
11
|
+
"env",
|
|
12
|
+
"node_modules",
|
|
13
|
+
"__pycache__",
|
|
14
|
+
"site-packages",
|
|
15
|
+
"dist-packages",
|
|
16
|
+
".cache",
|
|
17
|
+
".git",
|
|
18
|
+
".pytest_cache",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
".ipynb_checkpoints",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/** True when any path segment is a dependency, cache or VCS directory. */
|
|
24
|
+
export function isIgnoredArtifactPath(relativePath: string): boolean {
|
|
25
|
+
return relativePath.split(/[\\/]+/).some((segment) => IGNORED_SEGMENTS.has(segment));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_ARTIFACT_CAP = 50;
|
|
29
|
+
|
|
30
|
+
export function capArtifacts<T>(artifacts: T[], max: number = DEFAULT_ARTIFACT_CAP): { kept: T[]; omitted: number } {
|
|
31
|
+
if (artifacts.length <= max) return { kept: artifacts, omitted: 0 };
|
|
32
|
+
return { kept: artifacts.slice(0, max), omitted: artifacts.length - max };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether an S3 session file must be (re)downloaded into the live sandbox directory. */
|
|
36
|
+
export function needsDownload(localSize: number | undefined, remoteSize: number): boolean {
|
|
37
|
+
return localSize === undefined || localSize !== remoteSize;
|
|
38
|
+
}
|
|
@@ -8,6 +8,7 @@ import { join, dirname, resolve, relative, posix } from 'node:path'
|
|
|
8
8
|
import { exec, spawn } from 'node:child_process'
|
|
9
9
|
import { promisify } from 'node:util'
|
|
10
10
|
import { listS3ObjectsByPrefix, getS3ObjectBytes, uploadFile, getPresignedUrl, type S3FileObject } from '@SRC/uppy/index.ts'
|
|
11
|
+
import { isIgnoredArtifactPath, capArtifacts, needsDownload } from './artifact-filter'
|
|
11
12
|
import { getNpmGlobalRoot } from '@SRC/exulu/system-dependencies.ts'
|
|
12
13
|
import type { ExuluConfig } from '@SRC/exulu/app/index.ts'
|
|
13
14
|
import { createBashTool, type Sandbox } from "bash-tool";
|
|
@@ -270,6 +271,7 @@ async function restoreArtifactsFromS3(
|
|
|
270
271
|
sessionId: string,
|
|
271
272
|
userId: number | string,
|
|
272
273
|
config: ExuluConfig,
|
|
274
|
+
opts: { onlyMissing?: boolean } = {},
|
|
273
275
|
): Promise<void> {
|
|
274
276
|
const userPrefix = `user_${userId}/sessions/${sessionId}/`
|
|
275
277
|
let objects: S3FileObject[]
|
|
@@ -297,8 +299,18 @@ async function restoreArtifactsFromS3(
|
|
|
297
299
|
const idx = obj.key.indexOf(userPrefix)
|
|
298
300
|
const relativePath = idx >= 0 ? obj.key.slice(idx + userPrefix.length) : ''
|
|
299
301
|
if (!relativePath) continue // directory marker or unexpected key shape
|
|
302
|
+
if (isIgnoredArtifactPath(relativePath)) continue // never pull dependency trees back
|
|
300
303
|
|
|
301
304
|
const localPath = join(sessionDir, relativePath)
|
|
305
|
+
if (opts.onlyMissing) {
|
|
306
|
+
let localSize: number | undefined
|
|
307
|
+
try {
|
|
308
|
+
localSize = (await stat(localPath)).size
|
|
309
|
+
} catch {
|
|
310
|
+
localSize = undefined
|
|
311
|
+
}
|
|
312
|
+
if (!needsDownload(localSize, obj.size)) continue
|
|
313
|
+
}
|
|
302
314
|
try {
|
|
303
315
|
// Use binary-safe fetch — session artifacts now include PDFs, .docx
|
|
304
316
|
// and other binary formats (from user uploads as well as agent
|
|
@@ -380,6 +392,20 @@ export async function downloadKeyIntoSandbox(opts: {
|
|
|
380
392
|
* cache AND no session directory on disk), previously persisted artifacts for
|
|
381
393
|
* the session are restored from S3 into the fresh session directory.
|
|
382
394
|
*/
|
|
395
|
+
/**
|
|
396
|
+
* Loaded lazily: python-setup resolves the package root via import.meta.url, which
|
|
397
|
+
* must not be evaluated when this module is merely imported (jest runs CJS).
|
|
398
|
+
*/
|
|
399
|
+
async function resolvePythonVenvPath(): Promise<string | undefined> {
|
|
400
|
+
try {
|
|
401
|
+
const { getPythonVenvPath } = await import('@SRC/utils/python-setup.ts')
|
|
402
|
+
return getPythonVenvPath()
|
|
403
|
+
} catch (err) {
|
|
404
|
+
console.warn('[SKILLS] Could not resolve the Python venv for the session sandbox; skill scripts fall back to the system python.', err)
|
|
405
|
+
return undefined
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
383
409
|
export async function createSessionSandbox(
|
|
384
410
|
sessionId: string,
|
|
385
411
|
skills: SkillRef[],
|
|
@@ -404,6 +430,16 @@ export async function createSessionSandbox(
|
|
|
404
430
|
cached.installedSkills.set(skill.id, skill.current_version)
|
|
405
431
|
}
|
|
406
432
|
|
|
433
|
+
// Files uploaded through the side panel (or by an email routine) after the
|
|
434
|
+
// sandbox was created live only in S3 until re-synced; without this the agent's
|
|
435
|
+
// `ls` never sees them although the system prompt promises it will.
|
|
436
|
+
if (userId && config.fileUploads) {
|
|
437
|
+
try {
|
|
438
|
+
await restoreArtifactsFromS3(cached.handle.sessionDir, sessionId, userId, config, { onlyMissing: true })
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.error(`[SKILLS] Failed to re-sync S3 session files for session ${sessionId}; continuing.`, err)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
407
443
|
return cached.handle
|
|
408
444
|
}
|
|
409
445
|
|
|
@@ -439,8 +475,9 @@ export async function createSessionSandbox(
|
|
|
439
475
|
// Restore artifacts from S3 only on a true cold start. If the dir already
|
|
440
476
|
// existed, the local files are at least as new as S3 and may contain
|
|
441
477
|
// unsaved-to-S3 writes from a prior process.
|
|
442
|
-
if (userId && config.fileUploads
|
|
443
|
-
|
|
478
|
+
if (userId && config.fileUploads) {
|
|
479
|
+
// Cold start restores everything; a surviving on-disk dir only fetches what changed.
|
|
480
|
+
await restoreArtifactsFromS3(sessionDir, sessionId, userId, config, { onlyMissing: dirExisted })
|
|
444
481
|
}
|
|
445
482
|
|
|
446
483
|
// Probe whether bwrap can actually create namespaces on this host. On a
|
|
@@ -492,6 +529,9 @@ export async function createSessionSandbox(
|
|
|
492
529
|
// fail with a clear MODULE_NOT_FOUND, matching what the user would see
|
|
493
530
|
// outside the sandbox).
|
|
494
531
|
const npmGlobalRoot = await getNpmGlobalRoot()
|
|
532
|
+
// The @exulu/backend Python venv carries the skill runtime deps (python-docx …).
|
|
533
|
+
// Put its bin dir first on PATH so `python3` inside the sandbox resolves to it.
|
|
534
|
+
const pythonVenvPath = await resolvePythonVenvPath()
|
|
495
535
|
|
|
496
536
|
// Per-session policy. Passed to every wrapWithSandbox() invocation made
|
|
497
537
|
// from within this closure. customConfig wins over the singleton's
|
|
@@ -510,6 +550,7 @@ export async function createSessionSandbox(
|
|
|
510
550
|
// the sandbox. Without this, `require('docx')` fails with
|
|
511
551
|
// EPERM even when NODE_PATH points the resolver here.
|
|
512
552
|
...(npmGlobalRoot ? [npmGlobalRoot] : []),
|
|
553
|
+
...(pythonVenvPath ? [pythonVenvPath] : []),
|
|
513
554
|
],
|
|
514
555
|
allowWrite: [sessionDir],
|
|
515
556
|
denyWrite: [],
|
|
@@ -563,6 +604,9 @@ export async function createSessionSandbox(
|
|
|
563
604
|
...configuredVariables,
|
|
564
605
|
...process.env,
|
|
565
606
|
...(npmGlobalRoot ? { NODE_PATH: npmGlobalRoot } : {}),
|
|
607
|
+
...(pythonVenvPath
|
|
608
|
+
? { PATH: `${join(pythonVenvPath, 'bin')}:${process.env.PATH ?? ''}`, VIRTUAL_ENV: pythonVenvPath }
|
|
609
|
+
: {}),
|
|
566
610
|
}
|
|
567
611
|
|
|
568
612
|
// Either wrap a command with bwrap/sandbox-exec or return it unchanged when
|
|
@@ -757,6 +801,7 @@ export async function createSessionSandbox(
|
|
|
757
801
|
for (const entry of entries) {
|
|
758
802
|
const full = join(dir, entry.name)
|
|
759
803
|
if (full === skillsDir) continue
|
|
804
|
+
if (isIgnoredArtifactPath(relative(sessionDir, full))) continue
|
|
760
805
|
if (entry.isDirectory()) {
|
|
761
806
|
await walk(full)
|
|
762
807
|
} else if (entry.isFile()) {
|
|
@@ -917,19 +962,26 @@ export async function createSessionSandbox(
|
|
|
917
962
|
// marker block isn't truncated. Only files with a presigned URL
|
|
918
963
|
// appear here; locally-only entries would just confuse the user.
|
|
919
964
|
let stdout = result?.stdout ?? ''
|
|
920
|
-
|
|
965
|
+
// Cap the listing: one command can touch thousands of files, and every
|
|
966
|
+
// entry carries a presigned URL. The full set is still mirrored to S3.
|
|
967
|
+
const { kept, omitted } = capArtifacts(artifacts)
|
|
968
|
+
const withUrls = kept.filter((a) => a.url)
|
|
921
969
|
if (withUrls.length > 0) {
|
|
922
970
|
const lines = ['', '[exulu-artifacts]']
|
|
923
971
|
for (const a of withUrls) {
|
|
924
972
|
lines.push(` ${a.relativePath}: ${a.url}`)
|
|
925
973
|
}
|
|
974
|
+
if (omitted > 0) {
|
|
975
|
+
lines.push(` … ${omitted} more file(s) were created and mirrored but are not listed here.`)
|
|
976
|
+
}
|
|
926
977
|
stdout = `${stdout}\n${lines.join('\n')}`
|
|
927
978
|
}
|
|
928
979
|
|
|
929
980
|
return {
|
|
930
981
|
...result,
|
|
931
982
|
stdout,
|
|
932
|
-
artifacts,
|
|
983
|
+
artifacts: kept,
|
|
984
|
+
...(omitted > 0 ? { artifactsOmitted: omitted } : {}),
|
|
933
985
|
}
|
|
934
986
|
},
|
|
935
987
|
})
|
|
@@ -126,14 +126,13 @@ IMAGE_RESOLUTION_SCALE = 2.0 # Image resolution multiplier
|
|
|
126
126
|
|
|
127
127
|
This script requires the following Python packages (installed via `npm run python:setup`):
|
|
128
128
|
|
|
129
|
-
- `
|
|
130
|
-
- `docling-hierarchical-pdf` - Hierarchical heading processing
|
|
129
|
+
- `pypdf` - PDF page-range splitting for the `mistral` OCR processor
|
|
131
130
|
- `transformers` - ML-based text processing
|
|
132
131
|
- `PIL` - Image handling
|
|
133
132
|
|
|
134
133
|
### Troubleshooting
|
|
135
134
|
|
|
136
|
-
**Issue: ImportError for
|
|
135
|
+
**Issue: ImportError for pypdf**
|
|
137
136
|
```bash
|
|
138
137
|
npm run python:install
|
|
139
138
|
```
|
|
@@ -12,7 +12,6 @@ import WordExtractor from 'word-extractor';
|
|
|
12
12
|
import { parseOfficeAsync } from "officeparser";
|
|
13
13
|
import { checkLicense } from '@EE/entitlements';
|
|
14
14
|
import { executePythonScript } from '@SRC/utils/python-executor';
|
|
15
|
-
import { setupPythonEnvironment, validatePythonEnvironment } from '@SRC/utils/python-setup';
|
|
16
15
|
import { LiteParse } from '@llamaindex/liteparse';
|
|
17
16
|
import { resolveOcr } from '@SRC/exulu/resolve-ocr';
|
|
18
17
|
import type { ResolveOcrInput } from '@SRC/exulu/resolve-ocr';
|
|
@@ -31,7 +30,7 @@ type DocumentProcessorConfig = {
|
|
|
31
30
|
concurrency: number;
|
|
32
31
|
},
|
|
33
32
|
processor: {
|
|
34
|
-
name: "
|
|
33
|
+
name: "liteparse" | "mistral" | "officeparser"
|
|
35
34
|
/**
|
|
36
35
|
* LiteLLM model_name for the "mistral" OCR processor (declared in
|
|
37
36
|
* config.litellm.yaml). Defaults to "mistral-ocr". OCR is routed through
|
|
@@ -534,7 +533,7 @@ async function validateWithVLM(
|
|
|
534
533
|
verbose: boolean = false,
|
|
535
534
|
concurrency: number = 10
|
|
536
535
|
): Promise<ProcessedDocument> {
|
|
537
|
-
console.log(`[EXULU] Starting VLM validation for
|
|
536
|
+
console.log(`[EXULU] Starting VLM validation for processor output, ${document.length} pages...`);
|
|
538
537
|
console.log(`[EXULU] Concurrency limit: ${concurrency}`);
|
|
539
538
|
|
|
540
539
|
// Create a concurrency limiter
|
|
@@ -683,7 +682,7 @@ async function processDocument(
|
|
|
683
682
|
/*
|
|
684
683
|
tempDir/
|
|
685
684
|
uuid/
|
|
686
|
-
|
|
685
|
+
processed.json
|
|
687
686
|
images/
|
|
688
687
|
*/
|
|
689
688
|
const paths: ProcessingPaths = {
|
|
@@ -751,56 +750,7 @@ async function processPdf(
|
|
|
751
750
|
try {
|
|
752
751
|
let json: ProcessedDocument = [];
|
|
753
752
|
// Call the PDF processor script
|
|
754
|
-
if (config?.processor.name === "
|
|
755
|
-
|
|
756
|
-
// Validate Python environment and setup if needed
|
|
757
|
-
console.log(`[EXULU] Validating Python environment...`);
|
|
758
|
-
const validation = await validatePythonEnvironment(undefined, true);
|
|
759
|
-
|
|
760
|
-
if (!validation.valid) {
|
|
761
|
-
console.log(`[EXULU] Python environment not ready, setting up automatically...`);
|
|
762
|
-
console.log(`[EXULU] Reason: ${validation.message}`);
|
|
763
|
-
|
|
764
|
-
const setupResult = await setupPythonEnvironment({
|
|
765
|
-
verbose: true,
|
|
766
|
-
force: false, // Only setup if not already done
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
if (!setupResult.success) {
|
|
770
|
-
throw new Error(`Failed to setup Python environment: ${setupResult.message}\n\n${setupResult.output || ''}`);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
console.log(`[EXULU] Python environment setup completed successfully`);
|
|
774
|
-
} else {
|
|
775
|
-
console.log(`[EXULU] Python environment is valid`);
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
console.log(`[EXULU] Processing document with document_to_markdown.py`);
|
|
779
|
-
|
|
780
|
-
const result = await executePythonScript({
|
|
781
|
-
scriptPath: 'ee/python/documents/processing/document_to_markdown.py',
|
|
782
|
-
args: [
|
|
783
|
-
paths.source,
|
|
784
|
-
'-o', paths.json,
|
|
785
|
-
'--images-dir', paths.images
|
|
786
|
-
],
|
|
787
|
-
timeout: 30 * 60 * 1000, // 30 minutes for large documents
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
// Log processing info from stderr
|
|
791
|
-
if (result.stderr) {
|
|
792
|
-
console.log('Processing info:', result.stderr.trim());
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
if (!result.success) {
|
|
796
|
-
throw new Error(`Document processing failed: ${result.stderr}`);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Read the generated JSON file
|
|
800
|
-
const jsonContent = await fs.promises.readFile(paths.json, 'utf-8');
|
|
801
|
-
json = JSON.parse(jsonContent);
|
|
802
|
-
|
|
803
|
-
} else if (config?.processor.name === "officeparser") {
|
|
753
|
+
if (config?.processor.name === "officeparser") {
|
|
804
754
|
const text = await parseOfficeAsync(buffer, {
|
|
805
755
|
outputErrorToConsole: false,
|
|
806
756
|
newlineDelimiter: "\n",
|
|
@@ -934,16 +884,29 @@ async function processPdf(
|
|
|
934
884
|
}));
|
|
935
885
|
|
|
936
886
|
fs.writeFileSync(paths.json, JSON.stringify(json, null, 2));
|
|
887
|
+
} else {
|
|
888
|
+
// Without this the if/else chain fell through leaving `json` empty, and
|
|
889
|
+
// the document was stored as zero pages with no error anywhere — a silent
|
|
890
|
+
// data-loss path. "docling" in particular used to be a valid value here.
|
|
891
|
+
// Every member of the union is handled above, so TypeScript narrows
|
|
892
|
+
// `processor.name` to `never` here. At runtime a caller can still pass
|
|
893
|
+
// anything, because these configs are routinely built from plain JS or
|
|
894
|
+
// from database rows. String() widens it back for the message.
|
|
895
|
+
const configured = String(config?.processor?.name ?? '');
|
|
896
|
+
throw new Error(
|
|
897
|
+
configured === ''
|
|
898
|
+
? '[EXULU] No document processor configured. Set processor.name to one of: mistral, liteparse, officeparser.'
|
|
899
|
+
: `[EXULU] Unknown document processor "${configured}". Supported processors are: mistral, liteparse, officeparser.` +
|
|
900
|
+
(configured === 'docling'
|
|
901
|
+
? ' The "docling" processor was removed: it depended on PyMuPDF, which is AGPL-licensed. Use "mistral" for PDF OCR.'
|
|
902
|
+
: '')
|
|
903
|
+
);
|
|
937
904
|
}
|
|
938
905
|
|
|
939
906
|
console.log(`[EXULU] \n✓ Document processing completed successfully`);
|
|
940
907
|
console.log(`[EXULU] Total pages: ${json.length}`);
|
|
941
908
|
console.log(`[EXULU] Output file: ${paths.json}`);
|
|
942
909
|
|
|
943
|
-
if (config?.vlm?.model) {
|
|
944
|
-
console.error('[EXULU] VLM validation is only supported when docling is enabled, skipping validation.');
|
|
945
|
-
}
|
|
946
|
-
|
|
947
910
|
// Apply VLM validation if enabled
|
|
948
911
|
const vlmModel = config?.vlm?.model ? await resolveVlmModel(config) : undefined;
|
|
949
912
|
if (vlmModel && json.length > 0) {
|
|
@@ -1121,9 +1084,6 @@ export async function documentProcessor({
|
|
|
1121
1084
|
|
|
1122
1085
|
let supportedTypes: string[] = [];
|
|
1123
1086
|
switch (config?.processor.name) {
|
|
1124
|
-
case "docling":
|
|
1125
|
-
supportedTypes = ['pdf', 'docx', 'doc', 'txt', 'md', 'jpg', 'jpeg', 'png', 'gif', 'webp'];
|
|
1126
|
-
break;
|
|
1127
1087
|
case "officeparser":
|
|
1128
1088
|
supportedTypes = ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'pdf', 'rtf', 'csv', 'md', 'html'];
|
|
1129
1089
|
break;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
PDF Splitter — splits a PDF into fixed-size page chunks using
|
|
3
|
+
PDF Splitter — splits a PDF into fixed-size page chunks using pypdf.
|
|
4
4
|
|
|
5
5
|
Outputs a JSON array to stdout, each element:
|
|
6
6
|
{ "path": "<absolute-path>", "start_page": <int>, "end_page": <int> }
|
|
@@ -22,26 +22,20 @@ import argparse
|
|
|
22
22
|
|
|
23
23
|
# stdout is this script's result channel and the caller does JSON.parse() on it,
|
|
24
24
|
# so nothing else may write there. Our own prints all pass file=sys.stderr, but
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# and
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# keep a private handle for the result, so any library that prints — now or
|
|
32
|
-
# after a future dependency bump — is shunted to the log channel instead of
|
|
33
|
-
# corrupting the payload.
|
|
25
|
+
# a dependency need not: any library is free to emit a deprecation banner or a
|
|
26
|
+
# progress line on stdout, and it would land ahead of the payload and fail the
|
|
27
|
+
# caller with "Unexpected token 'w'". Point sys.stdout at stderr before
|
|
28
|
+
# importing anything and keep a private handle for the result, so a library
|
|
29
|
+
# that prints — now or after a future dependency bump — is shunted to the log
|
|
30
|
+
# channel instead of corrupting the payload.
|
|
34
31
|
_stdout = sys.stdout
|
|
35
32
|
sys.stdout = sys.stderr
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
import pymupdf as fitz # PyMuPDF >= 1.24.3, where the module was renamed
|
|
39
|
-
except ImportError: # older releases only ship the legacy `fitz` module
|
|
40
|
-
import fitz
|
|
34
|
+
from pypdf import PdfReader, PdfWriter
|
|
41
35
|
|
|
42
36
|
|
|
43
37
|
def _write_chunk(
|
|
44
|
-
|
|
38
|
+
reader: PdfReader,
|
|
45
39
|
output_dir: str,
|
|
46
40
|
chunk_start: int,
|
|
47
41
|
chunk_end: int,
|
|
@@ -56,10 +50,11 @@ def _write_chunk(
|
|
|
56
50
|
"""
|
|
57
51
|
chunk_path = os.path.join(output_dir, f"chunk_{chunk_start}_{chunk_end - 1}.pdf")
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
writer = PdfWriter()
|
|
54
|
+
writer.append(reader, pages=(chunk_start, chunk_end))
|
|
55
|
+
with open(chunk_path, "wb") as fh:
|
|
56
|
+
writer.write(fh)
|
|
57
|
+
writer.close()
|
|
63
58
|
|
|
64
59
|
chunk_bytes = os.path.getsize(chunk_path)
|
|
65
60
|
n_pages = chunk_end - chunk_start
|
|
@@ -68,8 +63,8 @@ def _write_chunk(
|
|
|
68
63
|
os.remove(chunk_path)
|
|
69
64
|
mid = chunk_start + n_pages // 2
|
|
70
65
|
return (
|
|
71
|
-
_write_chunk(
|
|
72
|
-
+ _write_chunk(
|
|
66
|
+
_write_chunk(reader, output_dir, chunk_start, mid, max_size_bytes)
|
|
67
|
+
+ _write_chunk(reader, output_dir, mid, chunk_end, max_size_bytes)
|
|
73
68
|
)
|
|
74
69
|
|
|
75
70
|
if max_size_bytes and chunk_bytes > max_size_bytes:
|
|
@@ -88,21 +83,21 @@ def split_pdf(
|
|
|
88
83
|
chunk_size: int,
|
|
89
84
|
max_size_bytes: int | None = None,
|
|
90
85
|
) -> list[dict]:
|
|
91
|
-
|
|
86
|
+
reader = PdfReader(input_path)
|
|
92
87
|
|
|
93
88
|
# Some PDFs are saved with an empty owner/user password by certain writers
|
|
94
89
|
# (e.g. older Adobe Acrobat exports). The OS opens them transparently by
|
|
95
90
|
# trying "" first, but most libraries raise immediately. We replicate that
|
|
96
|
-
# OS-level behaviour here.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if not
|
|
91
|
+
# OS-level behaviour here. pypdf's decrypt() returns a PasswordType enum
|
|
92
|
+
# whose NOT_DECRYPTED member is falsy, so a plain truth test is enough.
|
|
93
|
+
if reader.is_encrypted:
|
|
94
|
+
if not reader.decrypt(""):
|
|
100
95
|
raise ValueError(
|
|
101
96
|
"PDF requires a non-empty password and cannot be opened automatically."
|
|
102
97
|
)
|
|
103
98
|
print("[split_pdf] Authenticated with empty password (phantom-password PDF)", file=sys.stderr)
|
|
104
99
|
|
|
105
|
-
total_pages = len(
|
|
100
|
+
total_pages = len(reader.pages)
|
|
106
101
|
file_size = os.path.getsize(input_path)
|
|
107
102
|
print(
|
|
108
103
|
f"[split_pdf] Total pages: {total_pages}, chunk size: {chunk_size}, "
|
|
@@ -115,7 +110,7 @@ def split_pdf(
|
|
|
115
110
|
|
|
116
111
|
if not needs_split:
|
|
117
112
|
print("[split_pdf] No split needed — returning original path", file=sys.stderr)
|
|
118
|
-
|
|
113
|
+
reader.close()
|
|
119
114
|
return [{
|
|
120
115
|
"path": os.path.abspath(input_path),
|
|
121
116
|
"start_page": 0,
|
|
@@ -127,7 +122,7 @@ def split_pdf(
|
|
|
127
122
|
chunks = []
|
|
128
123
|
for start_page in range(0, total_pages, chunk_size):
|
|
129
124
|
end_page = min(start_page + chunk_size, total_pages)
|
|
130
|
-
sub_chunks = _write_chunk(
|
|
125
|
+
sub_chunks = _write_chunk(reader, output_dir, start_page, end_page, max_size_bytes)
|
|
131
126
|
for c in sub_chunks:
|
|
132
127
|
print(
|
|
133
128
|
f"[split_pdf] Chunk {len(chunks) + 1}: pages {c['start_page']}–{c['end_page'] - 1} "
|
|
@@ -136,7 +131,7 @@ def split_pdf(
|
|
|
136
131
|
)
|
|
137
132
|
chunks.extend(sub_chunks)
|
|
138
133
|
|
|
139
|
-
|
|
134
|
+
reader.close()
|
|
140
135
|
return chunks
|
|
141
136
|
|
|
142
137
|
|
|
File without changes
|