@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.
Files changed (41) hide show
  1. package/dist/{chunk-T6JVFT7L.js → chunk-QMN6MVHQ.js} +6 -1
  2. package/dist/{chunk-BNTL6LYY.js → chunk-RBEWHG7I.js} +404 -59
  3. package/dist/cli/start-whisper.js +1 -1
  4. package/dist/{convert-exulu-tools-to-ai-sdk-tools-UQSLJDXE.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
  5. package/dist/index.cjs +957 -474
  6. package/dist/index.d.cts +3 -6
  7. package/dist/index.d.ts +3 -6
  8. package/dist/index.js +258 -182
  9. package/dist/python-setup-DRJ3QX5F.js +17 -0
  10. package/ee/LICENSE.md +2 -2
  11. package/ee/agentic-retrieval/pipeline/config.test.ts +18 -1
  12. package/ee/agentic-retrieval/pipeline/config.ts +15 -0
  13. package/ee/agentic-retrieval/pipeline/index.test.ts +73 -0
  14. package/ee/agentic-retrieval/pipeline/index.ts +67 -13
  15. package/ee/agentic-retrieval/pipeline/memory.test.ts +59 -0
  16. package/ee/agentic-retrieval/pipeline/memory.ts +181 -11
  17. package/ee/agentic-retrieval/pipeline/pin-rerun.test.ts +17 -0
  18. package/ee/agentic-retrieval/pipeline/pin-rerun.ts +29 -0
  19. package/ee/agentic-retrieval/pipeline/routing.test.ts +34 -0
  20. package/ee/agentic-retrieval/pipeline/routing.ts +96 -5
  21. package/ee/agentic-retrieval/pipeline/search.ts +9 -6
  22. package/ee/agentic-retrieval/pipeline/timing.test.ts +24 -0
  23. package/ee/agentic-retrieval/pipeline/timing.ts +26 -0
  24. package/ee/agentic-retrieval/pipeline/types.ts +2 -0
  25. package/ee/invoke-skills/artifact-filter.test.ts +49 -0
  26. package/ee/invoke-skills/artifact-filter.ts +38 -0
  27. package/ee/invoke-skills/create-sandbox.ts +56 -4
  28. package/ee/python/documents/processing/README.md +2 -3
  29. package/ee/python/documents/processing/doc_processor.ts +21 -61
  30. package/ee/python/documents/processing/split_pdf.py +25 -30
  31. package/ee/python/documents/processing/tests/__init__.py +0 -0
  32. package/ee/python/documents/processing/tests/test_split_pdf.py +230 -0
  33. package/ee/python/requirements.txt +17 -2
  34. package/ee/python/setup.sh +40 -1
  35. package/ee/python/transcription/pipeline.py +109 -15
  36. package/ee/python/transcription/tests/test_align_model_licensing.py +184 -0
  37. package/ee/workers.ts +2 -7
  38. package/license.md +2 -2
  39. package/package.json +3 -4
  40. package/scripts/postinstall.cjs +52 -1
  41. 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 && !dirExisted) {
443
- await restoreArtifactsFromS3(sessionDir, sessionId, userId, config)
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
- const withUrls = artifacts.filter((a) => a.url)
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
- - `docling` - Document conversion
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 docling**
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: "docling" | "liteparse" | "mistral" | "officeparser"
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 docling output, ${document.length} pages...`);
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
- docling.json
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 === "docling") {
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 PyMuPDF.
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
- # dependencies do not honour that: PyMuPDF sends its messages to sys.stdout by
26
- # default, and importing the legacy `fitz` alias emits
27
- # "warning: The `fitz` API is deprecated ..." which lands ahead of the payload
28
- # and fails the caller with "Unexpected token 'w'". PyMuPDF arrives unpinned as
29
- # a docling transitive dependency, so a routine rebuild is enough to introduce a
30
- # banner like that. Point sys.stdout at stderr before importing anything and
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
- try:
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
- doc: fitz.Document,
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
- sub = fitz.open()
60
- sub.insert_pdf(doc, from_page=chunk_start, to_page=chunk_end - 1)
61
- sub.save(chunk_path)
62
- sub.close()
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(doc, output_dir, chunk_start, mid, max_size_bytes)
72
- + _write_chunk(doc, output_dir, mid, chunk_end, max_size_bytes)
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
- doc = fitz.open(input_path)
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
- if doc.needs_pass:
98
- authenticated = doc.authenticate("")
99
- if not authenticated:
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(doc)
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
- doc.close()
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(doc, output_dir, start_page, end_page, max_size_bytes)
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
- doc.close()
134
+ reader.close()
140
135
  return chunks
141
136
 
142
137