@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
package/ee/workers.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { guardRedisStartup, logRedisErrors } from "@EE/queues/redis-startup.ts";
|
|
|
4
4
|
import { Job, Worker, type JobState } from "bullmq";
|
|
5
5
|
import { bullmq } from "@SRC/validators/bullmq.ts";
|
|
6
6
|
import { serializeError } from "@SRC/utils/serialize-error.ts";
|
|
7
|
+
import { finishTurnMetadata } from "@SRC/exulu/turn-metadata.ts";
|
|
7
8
|
import { getEnabledTools } from "@SRC/utils/enabled-tools.ts";
|
|
8
9
|
import { ExuluStorage } from "@SRC/exulu/storage.ts";
|
|
9
10
|
import type { ExuluAgent } from "@EXULU_TYPES/models/agent.ts";
|
|
@@ -1702,13 +1703,7 @@ export const processUiMessagesFlow = async ({
|
|
|
1702
1703
|
messageMetadata: ({ part }) => {
|
|
1703
1704
|
console.log("[EXULU] part", part.type);
|
|
1704
1705
|
if (part.type === "finish") {
|
|
1705
|
-
return {
|
|
1706
|
-
totalTokens: part.totalUsage.totalTokens,
|
|
1707
|
-
reasoningTokens: part.totalUsage.reasoningTokens,
|
|
1708
|
-
inputTokens: part.totalUsage.inputTokens,
|
|
1709
|
-
outputTokens: part.totalUsage.outputTokens,
|
|
1710
|
-
cachedInputTokens: part.totalUsage.cachedInputTokens,
|
|
1711
|
-
};
|
|
1706
|
+
return finishTurnMetadata({ totalUsage: part.totalUsage, startedAt: startTime });
|
|
1712
1707
|
}
|
|
1713
1708
|
return undefined;
|
|
1714
1709
|
},
|
package/license.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copyright (c) 2023-2026 Qventu B.
|
|
1
|
+
Copyright (c) 2023-2026 Qventu B.V.
|
|
2
2
|
|
|
3
3
|
Certain portions of this software are licensed as described below:
|
|
4
4
|
|
|
@@ -102,4 +102,4 @@ these terms.
|
|
|
102
102
|
|
|
103
103
|
**use** means anything you do with the software requiring one of your licenses.
|
|
104
104
|
|
|
105
|
-
**trademark** means trademarks, service marks, and similar rights.
|
|
105
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exulu/backend",
|
|
3
|
-
"author": "Qventu
|
|
4
|
-
"version": "
|
|
3
|
+
"author": "Qventu B.V.",
|
|
4
|
+
"version": "4.0.0",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"start:whisper": "node ./dist/cli/start-whisper.cjs",
|
|
49
49
|
"python:setup": "cd ee/python && ./setup.sh",
|
|
50
50
|
"python:install": "source ee/python/.venv/bin/activate && pip install -r ee/python/requirements.txt",
|
|
51
|
-
"python:validate": "source ee/python/.venv/bin/activate && python -c 'import
|
|
51
|
+
"python:validate": "source ee/python/.venv/bin/activate && python -c 'import pypdf; import transformers; print(\"✓ Python environment is working correctly\")'",
|
|
52
52
|
"python:clean": "rm -rf ee/python/.venv ee/python/__pycache__ ee/python/**/__pycache__ ee/python/**/*.pyc",
|
|
53
53
|
"python:rebuild": "npm run python:clean && npm run python:setup"
|
|
54
54
|
},
|
|
@@ -143,7 +143,6 @@
|
|
|
143
143
|
"jose": "^6.0.10",
|
|
144
144
|
"json-schema-to-zod": "^2.6.1",
|
|
145
145
|
"jsonwebtoken": "^9.0.2",
|
|
146
|
-
"just-bash": "^2.14.0",
|
|
147
146
|
"knex": "^3.1.0",
|
|
148
147
|
"link": "^2.1.1",
|
|
149
148
|
"mailparser": "^3.9.14",
|
package/scripts/postinstall.cjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const { exec } = require('child_process');
|
|
10
10
|
const { promisify } = require('util');
|
|
11
|
-
const { existsSync } = require('fs');
|
|
11
|
+
const { existsSync, rmSync } = require('fs');
|
|
12
12
|
const { resolve, join } = require('path');
|
|
13
13
|
|
|
14
14
|
const execAsync = promisify(exec);
|
|
@@ -82,6 +82,53 @@ async function setupPythonEnvironment() {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Remove node-liblzma (LGPL-3.0) from the installed tree.
|
|
87
|
+
*
|
|
88
|
+
* It arrives as an OPTIONAL dependency of just-bash, which reaches us through
|
|
89
|
+
* bash-tool. just-bash is the only dependent. It is loaded lazily, by a dynamic
|
|
90
|
+
* import inside a try/catch, and only when a shell command uses xz compression;
|
|
91
|
+
* with it absent that path throws its own "xz compression requires
|
|
92
|
+
* node-liblzma" error and every other command is unaffected.
|
|
93
|
+
*
|
|
94
|
+
* Why not `npm install --omit=optional`: that flag is all-or-nothing, and 24 of
|
|
95
|
+
* the 91 runtime optional packages are the `@img/sharp-*` platform binaries.
|
|
96
|
+
* Omitting them leaves sharp unable to load, which breaks document processing.
|
|
97
|
+
* So the removal has to be targeted at this one package.
|
|
98
|
+
*
|
|
99
|
+
* Set EXULU_KEEP_NODE_LIBLZMA=true to keep it — for example if you want xz
|
|
100
|
+
* support and have satisfied yourself about LGPL-3.0 in your deployment.
|
|
101
|
+
*
|
|
102
|
+
* Best-effort by design: it walks up from this package looking for the hoisted
|
|
103
|
+
* copy, and silently does nothing if the layout differs (pnpm, yarn PnP) or the
|
|
104
|
+
* directory is not writable. Never fails the install.
|
|
105
|
+
*/
|
|
106
|
+
function removeNodeLiblzma() {
|
|
107
|
+
if (String(process.env.EXULU_KEEP_NODE_LIBLZMA).toLowerCase() === 'true') {
|
|
108
|
+
console.log(`${colors.yellow}⊘${colors.reset} Keeping node-liblzma (EXULU_KEEP_NODE_LIBLZMA=true) — LGPL-3.0`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Walk up from this package looking for a hoisted node_modules/node-liblzma.
|
|
113
|
+
let dir = resolve(__dirname, '..');
|
|
114
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
115
|
+
const candidate = join(dir, 'node_modules', 'node-liblzma');
|
|
116
|
+
if (existsSync(candidate)) {
|
|
117
|
+
try {
|
|
118
|
+
rmSync(candidate, { recursive: true, force: true });
|
|
119
|
+
console.log(`${colors.green}✓${colors.reset} Removed node-liblzma (LGPL-3.0; optional, xz compression only)`);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.log(`${colors.yellow}!${colors.reset} Could not remove node-liblzma (LGPL-3.0): ${err.message}`);
|
|
122
|
+
console.log(' Remove it manually, or set EXULU_KEEP_NODE_LIBLZMA=true to keep it deliberately.');
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const parent = resolve(dir, '..');
|
|
127
|
+
if (parent === dir) break;
|
|
128
|
+
dir = parent;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
/**
|
|
86
133
|
* Main postinstall function
|
|
87
134
|
*/
|
|
@@ -92,6 +139,10 @@ async function main() {
|
|
|
92
139
|
console.log(`${colors.blue}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
|
|
93
140
|
console.log('');
|
|
94
141
|
|
|
142
|
+
// Runs before the Python setup and before any early return below, so it
|
|
143
|
+
// happens even when SKIP_PYTHON_SETUP=1 or the venv already exists.
|
|
144
|
+
removeNodeLiblzma();
|
|
145
|
+
|
|
95
146
|
// Check if we should skip setup
|
|
96
147
|
if (shouldSkipSetup()) {
|
|
97
148
|
console.log(`${colors.yellow}⊘${colors.reset} Skipping Python setup (SKIP_PYTHON_SETUP=1)`);
|
|
@@ -1,413 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Document to Markdown Converter using Docling
|
|
4
|
-
Converts a document to JSON with page-separated markdown and images.
|
|
5
|
-
|
|
6
|
-
Usage:
|
|
7
|
-
document_to_markdown.py <document_file_path> [-o OUTPUT_PATH] [--images-dir IMAGES_DIR]
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import sys
|
|
11
|
-
import os
|
|
12
|
-
import warnings
|
|
13
|
-
import argparse
|
|
14
|
-
import json
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from PIL import Image
|
|
17
|
-
|
|
18
|
-
# Suppress warnings
|
|
19
|
-
warnings.filterwarnings('ignore')
|
|
20
|
-
os.environ['PYTHONWARNINGS'] = 'ignore'
|
|
21
|
-
|
|
22
|
-
from docling.document_converter import DocumentConverter, PdfFormatOption
|
|
23
|
-
from docling.datamodel.base_models import InputFormat
|
|
24
|
-
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
25
|
-
from hierarchical.postprocessor import ResultPostprocessor
|
|
26
|
-
|
|
27
|
-
IMAGE_RESOLUTION_SCALE = 2.0
|
|
28
|
-
|
|
29
|
-
def normalize_markdown_content(content: str) -> str:
|
|
30
|
-
"""
|
|
31
|
-
Normalize markdown content by removing excessive whitespace,
|
|
32
|
-
especially in table formatting.
|
|
33
|
-
|
|
34
|
-
Args:
|
|
35
|
-
content: Raw markdown content
|
|
36
|
-
|
|
37
|
-
Returns:
|
|
38
|
-
Normalized markdown content
|
|
39
|
-
"""
|
|
40
|
-
import re
|
|
41
|
-
|
|
42
|
-
lines = content.split('\n')
|
|
43
|
-
normalized_lines = []
|
|
44
|
-
|
|
45
|
-
for line in lines:
|
|
46
|
-
# Check if this is a table row (contains |)
|
|
47
|
-
if '|' in line:
|
|
48
|
-
# Split by | and strip whitespace from each cell
|
|
49
|
-
parts = line.split('|')
|
|
50
|
-
cleaned_parts = [part.strip() for part in parts]
|
|
51
|
-
# Rejoin with single space padding
|
|
52
|
-
normalized_line = ' | '.join(cleaned_parts)
|
|
53
|
-
normalized_lines.append(normalized_line)
|
|
54
|
-
else:
|
|
55
|
-
# For non-table lines, just strip trailing whitespace
|
|
56
|
-
normalized_lines.append(line.rstrip())
|
|
57
|
-
|
|
58
|
-
return '\n'.join(normalized_lines)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def extract_headings_from_markdown(markdown_content: str) -> list:
|
|
62
|
-
"""
|
|
63
|
-
Extract all headings from markdown content as a list of (level, text) tuples.
|
|
64
|
-
|
|
65
|
-
Args:
|
|
66
|
-
markdown_content: Markdown text content
|
|
67
|
-
|
|
68
|
-
Returns:
|
|
69
|
-
List of (level, text) tuples in order of appearance
|
|
70
|
-
"""
|
|
71
|
-
import re
|
|
72
|
-
|
|
73
|
-
headings = []
|
|
74
|
-
lines = markdown_content.split('\n')
|
|
75
|
-
|
|
76
|
-
for line in lines:
|
|
77
|
-
# Match markdown headings (# Header)
|
|
78
|
-
heading_match = re.match(r'^(#{1,6})\s+(.+)$', line.strip())
|
|
79
|
-
if heading_match:
|
|
80
|
-
level = len(heading_match.group(1)) # Number of # symbols
|
|
81
|
-
text = heading_match.group(2).strip()
|
|
82
|
-
headings.append((level, text))
|
|
83
|
-
|
|
84
|
-
return headings
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def build_hierarchy_from_stack(heading_stack: list) -> dict:
|
|
88
|
-
"""
|
|
89
|
-
Build a nested hierarchy dictionary from a heading stack.
|
|
90
|
-
|
|
91
|
-
Args:
|
|
92
|
-
heading_stack: List of (level, text) tuples representing the current path
|
|
93
|
-
|
|
94
|
-
Returns:
|
|
95
|
-
Nested dictionary representing the hierarchy
|
|
96
|
-
"""
|
|
97
|
-
hierarchy = {}
|
|
98
|
-
current = hierarchy
|
|
99
|
-
|
|
100
|
-
for i, (level, heading_text) in enumerate(heading_stack):
|
|
101
|
-
if i == len(heading_stack) - 1:
|
|
102
|
-
# Last item in stack - set to null
|
|
103
|
-
current[heading_text] = None
|
|
104
|
-
else:
|
|
105
|
-
# Not last item - create dict for children
|
|
106
|
-
if heading_text not in current:
|
|
107
|
-
current[heading_text] = {}
|
|
108
|
-
current = current[heading_text]
|
|
109
|
-
|
|
110
|
-
return hierarchy
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def merge_hierarchies(h1: dict, h2: dict) -> dict:
|
|
114
|
-
"""
|
|
115
|
-
Deep merge two hierarchy dictionaries, combining their structures.
|
|
116
|
-
"""
|
|
117
|
-
if not h1:
|
|
118
|
-
return h2.copy() if h2 else {}
|
|
119
|
-
if not h2:
|
|
120
|
-
return h1.copy()
|
|
121
|
-
|
|
122
|
-
result = {}
|
|
123
|
-
all_keys = set(h1.keys()) | set(h2.keys())
|
|
124
|
-
|
|
125
|
-
for key in all_keys:
|
|
126
|
-
if key in h1 and key in h2:
|
|
127
|
-
# Both have this key
|
|
128
|
-
if isinstance(h1[key], dict) and isinstance(h2[key], dict):
|
|
129
|
-
result[key] = merge_hierarchies(h1[key], h2[key])
|
|
130
|
-
elif h2[key] is not None:
|
|
131
|
-
result[key] = h2[key]
|
|
132
|
-
else:
|
|
133
|
-
result[key] = h1[key]
|
|
134
|
-
elif key in h1:
|
|
135
|
-
result[key] = h1[key]
|
|
136
|
-
else:
|
|
137
|
-
result[key] = h2[key]
|
|
138
|
-
|
|
139
|
-
return result
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def parse_heading_hierarchy(markdown_content: str) -> dict:
|
|
143
|
-
"""
|
|
144
|
-
Parse markdown content and build a nested heading hierarchy.
|
|
145
|
-
Headings at the same level are siblings in the hierarchy.
|
|
146
|
-
|
|
147
|
-
Args:
|
|
148
|
-
markdown_content: Markdown text content
|
|
149
|
-
|
|
150
|
-
Returns:
|
|
151
|
-
Nested dictionary representing heading hierarchy
|
|
152
|
-
"""
|
|
153
|
-
import re
|
|
154
|
-
|
|
155
|
-
lines = markdown_content.split('\n')
|
|
156
|
-
heading_stack = [] # Stack of (level, text) tuples
|
|
157
|
-
hierarchy = {}
|
|
158
|
-
|
|
159
|
-
for line in lines:
|
|
160
|
-
# Match markdown headings (# Header)
|
|
161
|
-
heading_match = re.match(r'^(#{1,6})\s+(.+)$', line.strip())
|
|
162
|
-
if heading_match:
|
|
163
|
-
level = len(heading_match.group(1)) # Number of # symbols
|
|
164
|
-
text = heading_match.group(2).strip()
|
|
165
|
-
|
|
166
|
-
# Pop headings from stack that are deeper than current level
|
|
167
|
-
# (removes children when moving back up the hierarchy)
|
|
168
|
-
while heading_stack and heading_stack[-1][0] >= level:
|
|
169
|
-
heading_stack.pop()
|
|
170
|
-
|
|
171
|
-
# Add this heading to stack
|
|
172
|
-
heading_stack.append((level, text))
|
|
173
|
-
|
|
174
|
-
# Build nested structure for current heading path
|
|
175
|
-
current = hierarchy
|
|
176
|
-
for i, (lvl, heading_text) in enumerate(heading_stack):
|
|
177
|
-
if heading_text not in current:
|
|
178
|
-
# If this is the last heading in the stack, set to null
|
|
179
|
-
# Otherwise, set to empty dict for children
|
|
180
|
-
if i == len(heading_stack) - 1:
|
|
181
|
-
current[heading_text] = None
|
|
182
|
-
else:
|
|
183
|
-
current[heading_text] = {}
|
|
184
|
-
|
|
185
|
-
# Navigate to the next level if not at the end
|
|
186
|
-
if i < len(heading_stack) - 1:
|
|
187
|
-
if current[heading_text] is None:
|
|
188
|
-
current[heading_text] = {}
|
|
189
|
-
current = current[heading_text]
|
|
190
|
-
|
|
191
|
-
return hierarchy
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def process_pdf_to_json(pdf_path: str, output_path: str = None, images_dir: str = None) -> list:
|
|
195
|
-
"""
|
|
196
|
-
Process a PDF file using Docling and return JSON with page-separated markdown and images.
|
|
197
|
-
|
|
198
|
-
Args:
|
|
199
|
-
pdf_path: Path to the PDF file
|
|
200
|
-
output_path: Optional output path for JSON file
|
|
201
|
-
images_dir: Directory to save page images (should be passed from main)
|
|
202
|
-
|
|
203
|
-
Returns:
|
|
204
|
-
List of page objects with content and image references
|
|
205
|
-
"""
|
|
206
|
-
# Configure PDF pipeline with image generation
|
|
207
|
-
pipeline_options = PdfPipelineOptions()
|
|
208
|
-
pipeline_options.images_scale = IMAGE_RESOLUTION_SCALE
|
|
209
|
-
pipeline_options.generate_page_images = True # Generate page images
|
|
210
|
-
|
|
211
|
-
# Convert the PDF document
|
|
212
|
-
converter = DocumentConverter(
|
|
213
|
-
format_options={
|
|
214
|
-
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)
|
|
215
|
-
}
|
|
216
|
-
)
|
|
217
|
-
result = converter.convert(source=pdf_path)
|
|
218
|
-
|
|
219
|
-
# Apply hierarchical post-processing to fix heading hierarchy
|
|
220
|
-
print(f"Applying hierarchical post-processing...", file=sys.stderr)
|
|
221
|
-
ResultPostprocessor(result, source=pdf_path).process()
|
|
222
|
-
|
|
223
|
-
doc = result.document
|
|
224
|
-
|
|
225
|
-
# Export full markdown with page markers
|
|
226
|
-
full_markdown = doc.export_to_markdown(page_break_placeholder="<!-- END_OF_PAGE -->")
|
|
227
|
-
|
|
228
|
-
# Split by page markers
|
|
229
|
-
pages = full_markdown.split("<!-- END_OF_PAGE -->")
|
|
230
|
-
|
|
231
|
-
# Ensure images_dir is a Path object
|
|
232
|
-
images_dir = Path(images_dir)
|
|
233
|
-
images_dir.mkdir(exist_ok=True)
|
|
234
|
-
|
|
235
|
-
# Extract and save page images from the conversion result
|
|
236
|
-
page_images = {}
|
|
237
|
-
|
|
238
|
-
# Check if page images are in the result object
|
|
239
|
-
if hasattr(result, 'pages') and result.pages:
|
|
240
|
-
# Create images directory if it doesn't exist
|
|
241
|
-
images_dir.mkdir(exist_ok=True)
|
|
242
|
-
|
|
243
|
-
for page_data in result.pages:
|
|
244
|
-
# Get page number
|
|
245
|
-
page_no = getattr(page_data, 'page_no', None) or getattr(page_data, 'page_number', None)
|
|
246
|
-
|
|
247
|
-
# Check for image attribute
|
|
248
|
-
if hasattr(page_data, 'image') and page_data.image:
|
|
249
|
-
# Save the PIL image to disk
|
|
250
|
-
image_filename = f"page_{page_no}.png"
|
|
251
|
-
image_path = images_dir / image_filename
|
|
252
|
-
|
|
253
|
-
# Save the image
|
|
254
|
-
if isinstance(page_data.image, Image.Image):
|
|
255
|
-
page_data.image.save(str(image_path), 'PNG')
|
|
256
|
-
page_images[page_no] = str(image_path)
|
|
257
|
-
print(f"Saved page {page_no} image to: {image_path}", file=sys.stderr)
|
|
258
|
-
|
|
259
|
-
# Build page objects with cumulative heading hierarchy
|
|
260
|
-
page_objects = []
|
|
261
|
-
cumulative_markdown = "" # Track all markdown up to and including current page
|
|
262
|
-
heading_stack = [] # Current heading context (stack of (level, text) tuples)
|
|
263
|
-
|
|
264
|
-
# Build JSON structure with page-separated content
|
|
265
|
-
for page_num, page_content in enumerate(pages, start=1):
|
|
266
|
-
# Skip empty pages
|
|
267
|
-
if not page_content.strip():
|
|
268
|
-
continue
|
|
269
|
-
|
|
270
|
-
# Add current page to cumulative markdown
|
|
271
|
-
cumulative_markdown += page_content + "\n"
|
|
272
|
-
|
|
273
|
-
# Extract headings from current page only
|
|
274
|
-
page_headings = extract_headings_from_markdown(page_content)
|
|
275
|
-
|
|
276
|
-
# Track all heading contexts that appear on this page
|
|
277
|
-
page_hierarchy = {}
|
|
278
|
-
|
|
279
|
-
# If no headings on this page, use the current stack context
|
|
280
|
-
if not page_headings:
|
|
281
|
-
if heading_stack:
|
|
282
|
-
page_hierarchy = build_hierarchy_from_stack(heading_stack)
|
|
283
|
-
else:
|
|
284
|
-
# Process each heading on the current page
|
|
285
|
-
for level, text in page_headings:
|
|
286
|
-
# Pop headings from stack that are at same or deeper level
|
|
287
|
-
while heading_stack and heading_stack[-1][0] >= level:
|
|
288
|
-
heading_stack.pop()
|
|
289
|
-
|
|
290
|
-
# Add this heading to stack
|
|
291
|
-
heading_stack.append((level, text))
|
|
292
|
-
|
|
293
|
-
# Build hierarchy for this context and merge it
|
|
294
|
-
context_hierarchy = build_hierarchy_from_stack(heading_stack)
|
|
295
|
-
page_hierarchy = merge_hierarchies(page_hierarchy, context_hierarchy)
|
|
296
|
-
|
|
297
|
-
# Get image path if available
|
|
298
|
-
page_image_path = page_images.get(page_num)
|
|
299
|
-
|
|
300
|
-
# Normalize the content to remove excessive whitespace
|
|
301
|
-
normalized_content = normalize_markdown_content(page_content.strip())
|
|
302
|
-
|
|
303
|
-
page_objects.append({
|
|
304
|
-
"page": page_num,
|
|
305
|
-
"content": normalized_content,
|
|
306
|
-
"image": page_image_path,
|
|
307
|
-
"headings": page_hierarchy
|
|
308
|
-
})
|
|
309
|
-
|
|
310
|
-
# Save to JSON file if output path provided
|
|
311
|
-
if output_path:
|
|
312
|
-
with open(output_path, 'w', encoding='utf-8') as f:
|
|
313
|
-
json.dump(page_objects, f, indent=2, ensure_ascii=False)
|
|
314
|
-
f.flush()
|
|
315
|
-
print(f"Successfully saved JSON to: {output_path}", file=sys.stderr)
|
|
316
|
-
print(f"Images saved to: {images_dir}", file=sys.stderr)
|
|
317
|
-
|
|
318
|
-
return page_objects
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def main():
|
|
322
|
-
"""Main entry point for the script."""
|
|
323
|
-
# Set up argument parser
|
|
324
|
-
parser = argparse.ArgumentParser(
|
|
325
|
-
description='Convert PDF to Markdown using Docling with hierarchical headings and page markers',
|
|
326
|
-
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
parser.add_argument(
|
|
330
|
-
'pdf_path',
|
|
331
|
-
type=str,
|
|
332
|
-
help='Path to the PDF file to convert'
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
parser.add_argument(
|
|
336
|
-
'-o', '--output',
|
|
337
|
-
type=str,
|
|
338
|
-
dest='output_path',
|
|
339
|
-
help='Output path for the JSON file (default: same name as PDF with .json extension)'
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
parser.add_argument(
|
|
343
|
-
'--images-dir',
|
|
344
|
-
type=str,
|
|
345
|
-
dest='images_dir',
|
|
346
|
-
help='Directory to save page images (default: <pdf_name>_images/)'
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
# Parse arguments
|
|
350
|
-
args = parser.parse_args()
|
|
351
|
-
|
|
352
|
-
pdf_path = args.pdf_path
|
|
353
|
-
output_path = args.output_path
|
|
354
|
-
images_dir = args.images_dir
|
|
355
|
-
|
|
356
|
-
# Validate the file exists
|
|
357
|
-
if not Path(pdf_path).exists():
|
|
358
|
-
print(f"Error: File not found: {pdf_path}", file=sys.stderr)
|
|
359
|
-
sys.exit(1)
|
|
360
|
-
|
|
361
|
-
# Create a shared folder named after the source file
|
|
362
|
-
pdf_file = Path(pdf_path)
|
|
363
|
-
shared_folder = pdf_file.parent / pdf_file.stem
|
|
364
|
-
shared_folder.mkdir(exist_ok=True)
|
|
365
|
-
|
|
366
|
-
# Default: JSON file inside the shared folder
|
|
367
|
-
if not output_path:
|
|
368
|
-
output_path = str(shared_folder / "processed.json")
|
|
369
|
-
else:
|
|
370
|
-
# If output_path is a directory, append docling.json
|
|
371
|
-
output_path_obj = Path(output_path)
|
|
372
|
-
if output_path_obj.is_dir():
|
|
373
|
-
output_path = str(output_path_obj / "processed.json")
|
|
374
|
-
elif not output_path_obj.suffix:
|
|
375
|
-
# If no extension provided, treat as directory
|
|
376
|
-
output_path_obj.mkdir(exist_ok=True)
|
|
377
|
-
output_path = str(output_path_obj / "processed.json")
|
|
378
|
-
|
|
379
|
-
# Default: images directory inside the shared folder
|
|
380
|
-
if not images_dir:
|
|
381
|
-
# If output_path was provided and is in a custom location, use that location's parent
|
|
382
|
-
output_parent = Path(output_path).parent
|
|
383
|
-
images_dir = str(output_parent / "images")
|
|
384
|
-
|
|
385
|
-
try:
|
|
386
|
-
# Process the PDF
|
|
387
|
-
print(f"Processing PDF: {pdf_path}", file=sys.stderr)
|
|
388
|
-
page_objects = process_pdf_to_json(pdf_path, output_path, images_dir)
|
|
389
|
-
|
|
390
|
-
# Print stats
|
|
391
|
-
total_content_length = sum(len(page['content']) for page in page_objects)
|
|
392
|
-
images_with_content = sum(1 for page in page_objects if page.get('image'))
|
|
393
|
-
|
|
394
|
-
print(f"\nJSON output stats:", file=sys.stderr)
|
|
395
|
-
print(f" Total pages: {len(page_objects)}", file=sys.stderr)
|
|
396
|
-
print(f" Pages with images: {images_with_content}", file=sys.stderr)
|
|
397
|
-
print(f" Total content characters: {total_content_length}", file=sys.stderr)
|
|
398
|
-
|
|
399
|
-
# Exit cleanly
|
|
400
|
-
sys.stderr.flush()
|
|
401
|
-
sys.stdout.flush()
|
|
402
|
-
os._exit(0)
|
|
403
|
-
|
|
404
|
-
except Exception as e:
|
|
405
|
-
print(f"Error processing PDF: {str(e)}", file=sys.stderr)
|
|
406
|
-
import traceback
|
|
407
|
-
traceback.print_exc(file=sys.stderr)
|
|
408
|
-
sys.stderr.flush()
|
|
409
|
-
os._exit(1)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if __name__ == "__main__":
|
|
413
|
-
main()
|