@exulu/backend 1.68.0 → 1.69.1
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-VPSLTGZF.js → chunk-PJSDLFXL.js} +167 -48
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-CHQF36XW.js → convert-exulu-tools-to-ai-sdk-tools-MXLGIOCT.js} +1 -1
- package/dist/index.cjs +592 -113
- package/dist/index.d.cts +60 -2
- package/dist/index.d.ts +60 -2
- package/dist/index.js +394 -52
- package/ee/python/documents/processing/doc_processor.ts +61 -10
- package/ee/python/documents/processing/split_pdf.py +97 -0
- package/ee/queues/queues.ts +10 -0
- package/ee/queues/redis-startup.ts +121 -0
- package/ee/workers.ts +6 -0
- package/package.json +1 -1
|
@@ -40,6 +40,13 @@ type DocumentProcessorConfig = {
|
|
|
40
40
|
* vertex_ai) can be switched without code changes.
|
|
41
41
|
*/
|
|
42
42
|
model?: string
|
|
43
|
+
/**
|
|
44
|
+
* Maximum pages per OCR request for the "mistral" processor.
|
|
45
|
+
* Vertex AI OCR rejects documents over 30 pages; the PDF is split into
|
|
46
|
+
* chunks of this size and each chunk is OCR'd independently.
|
|
47
|
+
* Defaults to 25 (safely under the Vertex AI 30-page limit).
|
|
48
|
+
*/
|
|
49
|
+
maxPagesPerChunk?: number
|
|
43
50
|
}
|
|
44
51
|
/**
|
|
45
52
|
* Optional cost-attribution context, forwarded to LiteLLM as spend tags
|
|
@@ -816,17 +823,61 @@ async function processPdf(
|
|
|
816
823
|
...config.attribution,
|
|
817
824
|
});
|
|
818
825
|
|
|
819
|
-
//
|
|
820
|
-
|
|
826
|
+
// Split the PDF into ≤ N-page chunks before sending to OCR.
|
|
827
|
+
// Vertex AI (and some other providers) reject documents over 30 pages.
|
|
828
|
+
// We use PyMuPDF via a Python helper because it handles edge cases that
|
|
829
|
+
// trip up JS PDF libraries — in particular "phantom password" PDFs that
|
|
830
|
+
// are technically encrypted with an empty string (the OS opens them
|
|
831
|
+
// transparently, but libraries throw without the empty-string fallback).
|
|
832
|
+
const maxPagesPerChunk = config.processor.maxPagesPerChunk ?? 25;
|
|
833
|
+
const chunksDir = path.join(path.dirname(paths.json), 'ocr_chunks');
|
|
834
|
+
|
|
835
|
+
const splitResult = await executePythonScript({
|
|
836
|
+
scriptPath: 'ee/python/documents/processing/split_pdf.py',
|
|
837
|
+
args: [paths.source, chunksDir, '--chunk-size', String(maxPagesPerChunk)],
|
|
838
|
+
timeout: 5 * 60 * 1000,
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const pdfChunks: Array<{ path: string; start_page: number; end_page: number }> =
|
|
842
|
+
JSON.parse(splitResult.stdout);
|
|
843
|
+
|
|
844
|
+
console.log(`[EXULU] PDF split into ${pdfChunks.length} chunk(s) for OCR (max ${maxPagesPerChunk} pages each)`);
|
|
845
|
+
|
|
846
|
+
// Process chunks in parallel with a concurrency cap to respect rate limits.
|
|
847
|
+
// Each chunk gets a small random jitter to spread out requests.
|
|
848
|
+
const chunkLimit = pLimit(3);
|
|
821
849
|
|
|
822
|
-
const
|
|
850
|
+
const chunkResults = await Promise.all(
|
|
851
|
+
pdfChunks.map((chunk, i) =>
|
|
852
|
+
chunkLimit(async () => {
|
|
853
|
+
// Yield to the event loop so BullMQ can renew job locks during long runs
|
|
854
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
855
|
+
await new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * 1000) + 200));
|
|
823
856
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
857
|
+
console.log(`[EXULU] OCR chunk ${i + 1}/${pdfChunks.length}: pages ${chunk.start_page}–${chunk.end_page - 1}`);
|
|
858
|
+
|
|
859
|
+
const chunkBuffer = await fs.promises.readFile(chunk.path);
|
|
860
|
+
const chunkBase64 = chunkBuffer.toString('base64');
|
|
861
|
+
|
|
862
|
+
const chunkResponse = await withRetry(async () => {
|
|
863
|
+
return await resolved.ocr({
|
|
864
|
+
type: "document_url",
|
|
865
|
+
document_url: "data:application/pdf;base64," + chunkBase64,
|
|
866
|
+
}, { includeImageBase64: false });
|
|
867
|
+
}, 10);
|
|
868
|
+
|
|
869
|
+
return { pages: chunkResponse.pages, offset: chunk.start_page };
|
|
870
|
+
})
|
|
871
|
+
)
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
// Merge all chunk pages in document order, offsetting indices to their
|
|
875
|
+
// original positions in the full document.
|
|
876
|
+
const mergedPages = chunkResults
|
|
877
|
+
.sort((a, b) => a.offset - b.offset)
|
|
878
|
+
.flatMap(({ pages, offset }) =>
|
|
879
|
+
pages.map(p => ({ ...p, index: p.index + offset }))
|
|
880
|
+
);
|
|
830
881
|
|
|
831
882
|
const parser = new LiteParse();
|
|
832
883
|
const screenshots = await parser.screenshot(paths.source, undefined);
|
|
@@ -841,7 +892,7 @@ async function processPdf(
|
|
|
841
892
|
screenshot.imagePath = path.join(paths.images, `${screenshot.pageNum}.png`);
|
|
842
893
|
}
|
|
843
894
|
|
|
844
|
-
json =
|
|
895
|
+
json = mergedPages.map(page => ({
|
|
845
896
|
page: page.index + 1,
|
|
846
897
|
content: page.markdown,
|
|
847
898
|
image: screenshots.find(s => s.pageNum === page.index + 1)?.imagePath,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PDF Splitter — splits a PDF into fixed-size page chunks using PyMuPDF.
|
|
4
|
+
|
|
5
|
+
Outputs a JSON array to stdout, each element:
|
|
6
|
+
{ "path": "<absolute-path>", "start_page": <int>, "end_page": <int> }
|
|
7
|
+
|
|
8
|
+
start_page is 0-indexed, end_page is exclusive (Python-slice convention).
|
|
9
|
+
If the document fits within chunk_size, a single entry pointing to the
|
|
10
|
+
original file is returned (no copy made).
|
|
11
|
+
|
|
12
|
+
Progress and diagnostics go to stderr so stdout stays clean JSON.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
split_pdf.py <input_pdf> <output_dir> [--chunk-size N]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
import os
|
|
20
|
+
import json
|
|
21
|
+
import argparse
|
|
22
|
+
|
|
23
|
+
import fitz # PyMuPDF — installed as a docling transitive dependency
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def split_pdf(input_path: str, output_dir: str, chunk_size: int) -> list[dict]:
|
|
27
|
+
doc = fitz.open(input_path)
|
|
28
|
+
|
|
29
|
+
# Some PDFs are saved with an empty owner/user password by certain writers
|
|
30
|
+
# (e.g. older Adobe Acrobat exports). The OS opens them transparently by
|
|
31
|
+
# trying "" first, but most libraries raise immediately. We replicate that
|
|
32
|
+
# OS-level behaviour here.
|
|
33
|
+
if doc.needs_pass:
|
|
34
|
+
authenticated = doc.authenticate("")
|
|
35
|
+
if not authenticated:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"PDF requires a non-empty password and cannot be opened automatically."
|
|
38
|
+
)
|
|
39
|
+
print("[split_pdf] Authenticated with empty password (phantom-password PDF)", file=sys.stderr)
|
|
40
|
+
|
|
41
|
+
total_pages = len(doc)
|
|
42
|
+
print(f"[split_pdf] Total pages: {total_pages}, chunk size: {chunk_size}", file=sys.stderr)
|
|
43
|
+
|
|
44
|
+
if total_pages <= chunk_size:
|
|
45
|
+
print("[split_pdf] No split needed — returning original path", file=sys.stderr)
|
|
46
|
+
doc.close()
|
|
47
|
+
return [{
|
|
48
|
+
"path": os.path.abspath(input_path),
|
|
49
|
+
"start_page": 0,
|
|
50
|
+
"end_page": total_pages,
|
|
51
|
+
}]
|
|
52
|
+
|
|
53
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
chunks = []
|
|
56
|
+
for start_page in range(0, total_pages, chunk_size):
|
|
57
|
+
end_page = min(start_page + chunk_size, total_pages)
|
|
58
|
+
chunk_filename = f"chunk_{start_page}_{end_page - 1}.pdf"
|
|
59
|
+
chunk_path = os.path.join(output_dir, chunk_filename)
|
|
60
|
+
|
|
61
|
+
chunk_doc = fitz.open()
|
|
62
|
+
chunk_doc.insert_pdf(doc, from_page=start_page, to_page=end_page - 1)
|
|
63
|
+
chunk_doc.save(chunk_path)
|
|
64
|
+
chunk_doc.close()
|
|
65
|
+
|
|
66
|
+
chunks.append({
|
|
67
|
+
"path": os.path.abspath(chunk_path),
|
|
68
|
+
"start_page": start_page,
|
|
69
|
+
"end_page": end_page,
|
|
70
|
+
})
|
|
71
|
+
print(
|
|
72
|
+
f"[split_pdf] Chunk {len(chunks)}: pages {start_page}–{end_page - 1} → {chunk_filename}",
|
|
73
|
+
file=sys.stderr,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
doc.close()
|
|
77
|
+
return chunks
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
parser = argparse.ArgumentParser(description="Split a PDF into fixed-size page chunks.")
|
|
82
|
+
parser.add_argument("input_pdf", help="Path to the input PDF")
|
|
83
|
+
parser.add_argument("output_dir", help="Directory to write chunk PDFs into")
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--chunk-size",
|
|
86
|
+
type=int,
|
|
87
|
+
default=25,
|
|
88
|
+
help="Maximum pages per chunk (default: 25)",
|
|
89
|
+
)
|
|
90
|
+
args = parser.parse_args()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
chunks = split_pdf(args.input_pdf, args.output_dir, args.chunk_size)
|
|
94
|
+
print(json.dumps(chunks))
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f"[split_pdf] ERROR: {e}", file=sys.stderr)
|
|
97
|
+
sys.exit(1)
|
package/ee/queues/queues.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Queue } from "bullmq";
|
|
2
2
|
import { redisServer } from "./server";
|
|
3
|
+
import { guardRedisStartup, logRedisErrors } from "./redis-startup";
|
|
3
4
|
import { BullMQOtel } from "bullmq-otel";
|
|
4
5
|
import type { ExuluQueueConfig } from "@EXULU_TYPES/queue-config";
|
|
5
6
|
import { checkLicense } from "@EE/entitlements";
|
|
@@ -115,6 +116,15 @@ class ExuluQueues {
|
|
|
115
116
|
},
|
|
116
117
|
telemetry: new BullMQOtel("simple-guide"),
|
|
117
118
|
});
|
|
119
|
+
// Surface connection errors and FAIL FAST instead of hanging silently when Redis is down:
|
|
120
|
+
// wait for the connection to be ready (bounded by REDIS_STARTUP_TIMEOUT_MS) before using it.
|
|
121
|
+
logRedisErrors(newQueue, `queue "${name}"`);
|
|
122
|
+
try {
|
|
123
|
+
await guardRedisStartup(`queue "${name}"`, () => newQueue.waitUntilReady(), newQueue);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
void newQueue.close().catch(() => { /* best-effort cleanup; we are aborting startup anyway */ });
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
118
128
|
await newQueue.setGlobalConcurrency(queueConcurrency);
|
|
119
129
|
this.queues.push({
|
|
120
130
|
queue: newQueue,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { redisServer } from "./server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loud, bounded Redis startup helpers.
|
|
5
|
+
*
|
|
6
|
+
* Without these, a Redis-down boot hangs SILENTLY: the BullMQ queue/worker connections retry
|
|
7
|
+
* forever with no error listener and no timeout, so `exulu()` never returns and nothing is logged
|
|
8
|
+
* (you just see repeated `connect ETIMEDOUT 127.0.0.1:6379` from the socket layer, if anything).
|
|
9
|
+
*
|
|
10
|
+
* These helpers make a Redis-dependent startup step:
|
|
11
|
+
* 1. announce the target host:port it is connecting to,
|
|
12
|
+
* 2. surface the (otherwise swallowed) connection errors with address + code,
|
|
13
|
+
* 3. warn every few seconds while it is still blocked, and
|
|
14
|
+
* 4. FAIL FAST with a clear error after REDIS_STARTUP_TIMEOUT_MS instead of hanging forever.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Hard ceiling on a single Redis-dependent startup step before we abort instead of hanging. */
|
|
18
|
+
export const REDIS_STARTUP_TIMEOUT_MS = 60_000;
|
|
19
|
+
/** How often, while still blocked, to remind the operator that startup is stuck on Redis. */
|
|
20
|
+
const WATCHDOG_INTERVAL_MS = 10_000;
|
|
21
|
+
/** Throttle for the permanent per-connection error logger so a retry storm can't flood the log. */
|
|
22
|
+
const ERROR_LOG_THROTTLE_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
const log = (line: string): void => console.log(`[EXULU-REDIS] ${line}`);
|
|
25
|
+
const warn = (line: string): void => console.warn(`[EXULU-REDIS] ${line}`);
|
|
26
|
+
const errorLog = (line: string): void => console.error(`[EXULU-REDIS] ${line}`);
|
|
27
|
+
|
|
28
|
+
/** The configured Redis target as `host:port` (with `(unset)` placeholders) for log/error messages. */
|
|
29
|
+
export const redisAddress = (): string =>
|
|
30
|
+
`${redisServer.host || "(unset)"}:${redisServer.port || "(unset)"}`;
|
|
31
|
+
|
|
32
|
+
/** One-line, human-readable description of a Redis/socket error (code first, message head, no stack). */
|
|
33
|
+
const describeError = (e: unknown): string => {
|
|
34
|
+
const any = e as any;
|
|
35
|
+
const head = any?.message ? String(any.message).split("\n")[0] : undefined;
|
|
36
|
+
if (any?.code) return head && head !== any.code ? `${any.code} (${head})` : `${any.code}`;
|
|
37
|
+
return head ?? String(e);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Minimal structural shape shared by ioredis, node-redis clients, and BullMQ Queue/Worker. */
|
|
41
|
+
type RedisErrorSource = {
|
|
42
|
+
on(event: "error", cb: (err: unknown) => void): unknown;
|
|
43
|
+
off?(event: "error", cb: (err: unknown) => void): unknown;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Attach a PERMANENT `error` listener that logs connection errors with the target address (the first
|
|
48
|
+
* immediately, then at most once per throttle window). Also prevents node-redis/ioredis from treating
|
|
49
|
+
* an `error` event as unhandled. Safe to call once per long-lived connection.
|
|
50
|
+
*/
|
|
51
|
+
export function logRedisErrors(source: RedisErrorSource, label: string): void {
|
|
52
|
+
let count = 0;
|
|
53
|
+
let lastLoggedAt = 0;
|
|
54
|
+
source.on("error", (err) => {
|
|
55
|
+
count += 1;
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
if (count === 1 || now - lastLoggedAt >= ERROR_LOG_THROTTLE_MS) {
|
|
58
|
+
errorLog(`${label} connection error (${redisAddress()}): ${describeError(err)}${count > 1 ? ` (x${count})` : ""}`);
|
|
59
|
+
lastLoggedAt = now;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run a Redis-dependent startup step with loud logging + a hard timeout. Transparent on success
|
|
66
|
+
* (returns `run()`'s value). While `run()` is pending it warns every WATCHDOG_INTERVAL_MS that startup
|
|
67
|
+
* is blocked; if it does not settle within REDIS_STARTUP_TIMEOUT_MS it REJECTS with a clear error
|
|
68
|
+
* (citing the address + last surfaced connection error) so the caller can fail the boot instead of
|
|
69
|
+
* hanging forever. `source`, if given, is observed only to capture the latest error for that message.
|
|
70
|
+
*/
|
|
71
|
+
export async function guardRedisStartup<T>(
|
|
72
|
+
label: string,
|
|
73
|
+
run: () => Promise<T>,
|
|
74
|
+
source?: RedisErrorSource,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const addr = redisAddress();
|
|
77
|
+
log(`Connecting to Redis (${addr}) for ${label}…`);
|
|
78
|
+
const startedAt = Date.now();
|
|
79
|
+
|
|
80
|
+
let lastError: unknown;
|
|
81
|
+
const onError = (err: unknown): void => { lastError = err; };
|
|
82
|
+
source?.on("error", onError);
|
|
83
|
+
|
|
84
|
+
const watchdog = setInterval(() => {
|
|
85
|
+
const secs = Math.round((Date.now() - startedAt) / 1000);
|
|
86
|
+
warn(
|
|
87
|
+
`⚠ Still waiting for Redis at ${addr} after ${secs}s — ${label} startup is blocked. ` +
|
|
88
|
+
`Is Redis running? (aborting at ${REDIS_STARTUP_TIMEOUT_MS / 1000}s)`,
|
|
89
|
+
);
|
|
90
|
+
}, WATCHDOG_INTERVAL_MS);
|
|
91
|
+
// Don't let the watchdog timer keep the event loop alive on its own; the timeout below holds it.
|
|
92
|
+
(watchdog as { unref?: () => void }).unref?.();
|
|
93
|
+
|
|
94
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
95
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
96
|
+
timer = setTimeout(() => {
|
|
97
|
+
reject(
|
|
98
|
+
new Error(
|
|
99
|
+
`[EXULU-REDIS] Redis unreachable at ${addr} after ${REDIS_STARTUP_TIMEOUT_MS / 1000}s — aborting ${label} startup. ` +
|
|
100
|
+
`Last error: ${lastError ? describeError(lastError) : "none surfaced"}. ` +
|
|
101
|
+
`Check REDIS_HOST/REDIS_PORT and that a Redis server is reachable at ${addr}.`,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}, REDIS_STARTUP_TIMEOUT_MS);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Mark the work promise handled so a rejection that lands AFTER the timeout already won the race
|
|
108
|
+
// does not surface as an unhandledRejection.
|
|
109
|
+
const runPromise = Promise.resolve().then(run);
|
|
110
|
+
runPromise.catch(() => { /* handled via the race below or intentionally ignored post-timeout */ });
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const result = await Promise.race([runPromise, timeout]);
|
|
114
|
+
log(`Redis ready; ${label} initialized (${addr}, ${((Date.now() - startedAt) / 1000).toFixed(1)}s).`);
|
|
115
|
+
return result as T;
|
|
116
|
+
} finally {
|
|
117
|
+
clearInterval(watchdog);
|
|
118
|
+
if (timer) clearTimeout(timer);
|
|
119
|
+
source?.off?.("error", onError);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/ee/workers.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import IORedis from "ioredis";
|
|
2
2
|
import { redisServer } from "@EE/queues/server.ts";
|
|
3
|
+
import { guardRedisStartup, logRedisErrors } from "@EE/queues/redis-startup.ts";
|
|
3
4
|
import { Job, Worker, type JobState } from "bullmq";
|
|
4
5
|
import { bullmq } from "@SRC/validators/bullmq.ts";
|
|
5
6
|
import { getEnabledTools } from "@SRC/utils/enabled-tools.ts";
|
|
@@ -158,6 +159,11 @@ export const createWorkers = async (
|
|
|
158
159
|
},
|
|
159
160
|
maxRetriesPerRequest: null,
|
|
160
161
|
});
|
|
162
|
+
// Surface connection errors and FAIL FAST instead of hanging silently when Redis is down:
|
|
163
|
+
// confirm the connection is actually reachable (bounded by REDIS_STARTUP_TIMEOUT_MS) before
|
|
164
|
+
// handing it to the workers, which would otherwise block on their first operation forever.
|
|
165
|
+
logRedisErrors(redisConnection, "worker");
|
|
166
|
+
await guardRedisStartup("workers", () => redisConnection.ping().then(() => undefined), redisConnection);
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
const workers = queues.map((queue) => {
|