@exulu/backend 1.67.0 → 1.69.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-ZPZKOT6I.js → chunk-IVC2M56U.js} +1562 -163
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-4B7BQ5G2.js → convert-exulu-tools-to-ai-sdk-tools-ET6UI7YG.js} +1 -1
- package/dist/index.cjs +24847 -22818
- package/dist/index.d.cts +315 -101
- package/dist/index.d.ts +315 -101
- package/dist/index.js +2766 -2232
- package/ee/agentic-retrieval/v3/agent-loop.ts +4 -4
- package/ee/agentic-retrieval/v3/index.ts +20 -6
- package/ee/python/documents/processing/doc_processor.ts +136 -40
- 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 +9 -17
- package/package.json +1 -1
- package/ee/agentic-retrieval/v4/agent-loop.ts +0 -208
- package/ee/agentic-retrieval/v4/context-sampler.ts +0 -79
- package/ee/agentic-retrieval/v4/index.ts +0 -690
- package/ee/agentic-retrieval/v4/types.ts +0 -58
|
@@ -2,7 +2,7 @@ import { generateText, stepCountIs, tool } from "ai";
|
|
|
2
2
|
import type { LanguageModel, Tool as AITool, ModelMessage } from "ai";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { withRetry } from "@SRC/utils/with-retry";
|
|
5
|
-
import type {
|
|
5
|
+
import type { ResolvedReranker } from "@SRC/exulu/resolve-reranker";
|
|
6
6
|
import type { AgenticRetrievalOutput, ChunkResult, ClassificationResult } from "./types";
|
|
7
7
|
import type { StrategyConfig } from "./strategies";
|
|
8
8
|
import { createDynamicTools } from "./dynamic-tools";
|
|
@@ -69,7 +69,7 @@ export async function* runAgentLoop(params: {
|
|
|
69
69
|
strategy: StrategyConfig;
|
|
70
70
|
tools: Record<string, AITool>;
|
|
71
71
|
model: LanguageModel;
|
|
72
|
-
reranker?:
|
|
72
|
+
reranker?: ResolvedReranker;
|
|
73
73
|
contextGuidance?: string;
|
|
74
74
|
customInstructions?: string;
|
|
75
75
|
classification: ClassificationResult;
|
|
@@ -171,8 +171,8 @@ export async function* runAgentLoop(params: {
|
|
|
171
171
|
|
|
172
172
|
// Rerank if reranker is available
|
|
173
173
|
if (reranker && stepChunks.length > 0) {
|
|
174
|
-
console.log(`[EXULU] v3 reranking ${stepChunks.length} chunks with ${reranker.
|
|
175
|
-
stepChunks = await reranker.
|
|
174
|
+
console.log(`[EXULU] v3 reranking ${stepChunks.length} chunks with ${reranker.model}`);
|
|
175
|
+
stepChunks = await reranker.rerank(query, stepChunks);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// Create dynamic tools (browse adjacent pages, load specific pages)
|
|
@@ -2,7 +2,8 @@ import { z } from "zod";
|
|
|
2
2
|
import { createBashTool } from "bash-tool";
|
|
3
3
|
import type { LanguageModel, Tool } from "ai";
|
|
4
4
|
import type { ExuluContext } from "@SRC/exulu/context";
|
|
5
|
-
import
|
|
5
|
+
import { resolveReranker } from "@SRC/exulu/resolve-reranker";
|
|
6
|
+
import type { ResolvedReranker } from "@SRC/exulu/resolve-reranker";
|
|
6
7
|
import { ExuluTool } from "@SRC/exulu/tool";
|
|
7
8
|
import type { User } from "@EXULU_TYPES/models/user";
|
|
8
9
|
import { checkLicense } from "@EE/entitlements";
|
|
@@ -34,7 +35,7 @@ async function* executeV3({
|
|
|
34
35
|
}: {
|
|
35
36
|
query: string;
|
|
36
37
|
contexts: ExuluContext[];
|
|
37
|
-
reranker?:
|
|
38
|
+
reranker?: ResolvedReranker;
|
|
38
39
|
toolVariablesConfig?: Record<string, any>;
|
|
39
40
|
model: LanguageModel;
|
|
40
41
|
user?: User;
|
|
@@ -189,7 +190,6 @@ async function* executeV3({
|
|
|
189
190
|
export function createAgenticRetrievalToolV3({
|
|
190
191
|
contexts,
|
|
191
192
|
instructions: adminInstructions,
|
|
192
|
-
rerankers,
|
|
193
193
|
user,
|
|
194
194
|
role,
|
|
195
195
|
model,
|
|
@@ -197,7 +197,6 @@ export function createAgenticRetrievalToolV3({
|
|
|
197
197
|
memoryItems
|
|
198
198
|
}: {
|
|
199
199
|
contexts: ExuluContext[];
|
|
200
|
-
rerankers: ExuluReranker[];
|
|
201
200
|
user?: User;
|
|
202
201
|
role?: string;
|
|
203
202
|
model?: LanguageModel;
|
|
@@ -355,7 +354,7 @@ export function createAgenticRetrievalToolV3({
|
|
|
355
354
|
}
|
|
356
355
|
|
|
357
356
|
let activeContexts = contexts;
|
|
358
|
-
let configuredReranker:
|
|
357
|
+
let configuredReranker: ResolvedReranker | undefined;
|
|
359
358
|
let configInstructions = "";
|
|
360
359
|
let logTrajectory = false;
|
|
361
360
|
let requiresPreselectedContexts = false;
|
|
@@ -382,7 +381,22 @@ export function createAgenticRetrievalToolV3({
|
|
|
382
381
|
const rerankerId = toolVariablesConfig["reranker"];
|
|
383
382
|
|
|
384
383
|
if (rerankerId && rerankerId !== "none") {
|
|
385
|
-
|
|
384
|
+
// rerankerId is a LiteLLM model_name from config.litellm.yaml
|
|
385
|
+
// (model_info.type: reranker). Resolution is best-effort: a
|
|
386
|
+
// misconfigured model or an unready proxy must not break retrieval —
|
|
387
|
+
// it just runs unreranked, matching the old find()→undefined path.
|
|
388
|
+
try {
|
|
389
|
+
configuredReranker = await resolveReranker({
|
|
390
|
+
model: rerankerId,
|
|
391
|
+
user,
|
|
392
|
+
roleId: role,
|
|
393
|
+
});
|
|
394
|
+
} catch (err) {
|
|
395
|
+
console.warn(
|
|
396
|
+
`[EXULU] v3 — could not resolve reranker "${rerankerId}", continuing without reranking:`,
|
|
397
|
+
err,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
386
400
|
}
|
|
387
401
|
}
|
|
388
402
|
|
|
@@ -14,17 +14,48 @@ import { checkLicense } from '@EE/entitlements';
|
|
|
14
14
|
import { executePythonScript } from '@SRC/utils/python-executor';
|
|
15
15
|
import { setupPythonEnvironment, validatePythonEnvironment } from '@SRC/utils/python-setup';
|
|
16
16
|
import { LiteParse } from '@llamaindex/liteparse';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { resolveOcr } from '@SRC/exulu/resolve-ocr';
|
|
18
|
+
import type { ResolveOcrInput } from '@SRC/exulu/resolve-ocr';
|
|
19
|
+
import { resolveModel } from '@SRC/exulu/resolve-model';
|
|
19
20
|
|
|
20
21
|
type DocumentProcessorConfig = {
|
|
21
22
|
vlm?: {
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* LiteLLM model_name for the VLM page-validation pass (declared in
|
|
25
|
+
* config.litellm.yaml, e.g. "vertex-gemini-2.5-flash"). Resolved via
|
|
26
|
+
* resolveModel() so the VLM pass shares the same tag-based cost controls
|
|
27
|
+
* and provider-switching as chat / embeddings / OCR, and the underlying
|
|
28
|
+
* provider can be swapped without code changes.
|
|
29
|
+
*/
|
|
30
|
+
model: string;
|
|
23
31
|
concurrency: number;
|
|
24
32
|
},
|
|
25
33
|
processor: {
|
|
26
34
|
name: "docling" | "liteparse" | "mistral" | "officeparser"
|
|
35
|
+
/**
|
|
36
|
+
* LiteLLM model_name for the "mistral" OCR processor (declared in
|
|
37
|
+
* config.litellm.yaml). Defaults to "mistral-ocr". OCR is routed through
|
|
38
|
+
* the LiteLLM proxy so it shares the same tag-based cost controls as chat
|
|
39
|
+
* and embeddings, and the underlying provider (mistral / azure_ai /
|
|
40
|
+
* vertex_ai) can be switched without code changes.
|
|
41
|
+
*/
|
|
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
|
|
27
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Optional cost-attribution context, forwarded to LiteLLM as spend tags
|
|
53
|
+
* (user / role / project / context) for both the OCR pass (resolveOcr) and
|
|
54
|
+
* the VLM page-validation pass (resolveModel). Not yet populated by callers;
|
|
55
|
+
* the wiring is in place so per-user/per-context budgets work the moment
|
|
56
|
+
* attribution is threaded through.
|
|
57
|
+
*/
|
|
58
|
+
attribution?: Omit<ResolveOcrInput, "model">
|
|
28
59
|
debugging?: {
|
|
29
60
|
deleteTempFiles?: boolean;
|
|
30
61
|
}
|
|
@@ -94,6 +125,38 @@ async function processWord(file: Buffer): Promise<ProcessorOutput> {
|
|
|
94
125
|
}
|
|
95
126
|
}
|
|
96
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the dev-supplied VLM `model` string (a LiteLLM model_name from
|
|
130
|
+
* config.litellm.yaml, e.g. "vertex-gemini-2.5-flash") into an `ai` SDK
|
|
131
|
+
* LanguageModel via resolveModel. This routes the VLM page-validation pass
|
|
132
|
+
* through the LiteLLM proxy — same tag-based cost controls and provider
|
|
133
|
+
* switching as chat / embeddings / OCR — and keeps the internal VLM helpers
|
|
134
|
+
* (validateWithVLM / validatePageWithVLM) working with a LanguageModel.
|
|
135
|
+
*
|
|
136
|
+
* Returns undefined when no VLM model is configured. Attribution (user /
|
|
137
|
+
* project / agent / routine) is forwarded for spend tagging when callers
|
|
138
|
+
* populate config.attribution; rbacBypass is set because this is a background
|
|
139
|
+
* package call where model-level access control is delegated to LiteLLM.
|
|
140
|
+
*/
|
|
141
|
+
async function resolveVlmModel(
|
|
142
|
+
config?: DocumentProcessorConfig,
|
|
143
|
+
): Promise<LanguageModel | undefined> {
|
|
144
|
+
const modelId = config?.vlm?.model;
|
|
145
|
+
if (!modelId) return undefined;
|
|
146
|
+
|
|
147
|
+
const { languageModel } = await resolveModel({
|
|
148
|
+
modelId,
|
|
149
|
+
providers: [], // unused in LiteLLM mode; resolveModel ignores it there
|
|
150
|
+
user: config?.attribution?.user,
|
|
151
|
+
project: config?.attribution?.project,
|
|
152
|
+
agent: config?.attribution?.agent,
|
|
153
|
+
routine: config?.attribution?.routine,
|
|
154
|
+
rbacBypass: true,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return languageModel;
|
|
158
|
+
}
|
|
159
|
+
|
|
97
160
|
/**
|
|
98
161
|
* Processes a standalone image file by optionally extracting content using VLM
|
|
99
162
|
*/
|
|
@@ -122,14 +185,15 @@ async function processImage(
|
|
|
122
185
|
}];
|
|
123
186
|
|
|
124
187
|
// If VLM is enabled, use it to extract content from the image
|
|
125
|
-
|
|
188
|
+
const vlmModel = await resolveVlmModel(config);
|
|
189
|
+
if (vlmModel) {
|
|
126
190
|
console.log('[EXULU] Extracting content from image using VLM...');
|
|
127
191
|
|
|
128
192
|
json = await validateWithVLM(
|
|
129
193
|
json,
|
|
130
|
-
|
|
194
|
+
vlmModel,
|
|
131
195
|
verbose,
|
|
132
|
-
config
|
|
196
|
+
config!.vlm!.concurrency
|
|
133
197
|
);
|
|
134
198
|
|
|
135
199
|
// Save the processed result
|
|
@@ -679,15 +743,6 @@ async function processDocument(
|
|
|
679
743
|
};
|
|
680
744
|
}
|
|
681
745
|
|
|
682
|
-
const getMistralApiKey = async () => {
|
|
683
|
-
if (process.env.MISTRAL_API_KEY) {
|
|
684
|
-
return process.env.MISTRAL_API_KEY;
|
|
685
|
-
} else {
|
|
686
|
-
const variable = await ExuluVariables.get("MISTRAL_API_KEY");
|
|
687
|
-
return variable;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
746
|
async function processPdf(
|
|
692
747
|
buffer: Buffer,
|
|
693
748
|
paths: ProcessingPaths,
|
|
@@ -759,29 +814,70 @@ async function processPdf(
|
|
|
759
814
|
|
|
760
815
|
} else if (config?.processor.name === "mistral") {
|
|
761
816
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
817
|
+
// OCR is routed through the LiteLLM proxy's Mistral-compatible /v1/ocr
|
|
818
|
+
// endpoint (see resolveOcr) rather than the Mistral SDK directly. This
|
|
819
|
+
// gives us tag-based cost control and lets us switch the OCR provider
|
|
820
|
+
// (mistral / azure_ai / vertex_ai) from config.litellm.yaml.
|
|
821
|
+
const resolved = await resolveOcr({
|
|
822
|
+
model: config.processor.model ?? "mistral-ocr",
|
|
823
|
+
...config.attribution,
|
|
824
|
+
});
|
|
766
825
|
|
|
767
|
-
//
|
|
768
|
-
|
|
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
|
+
});
|
|
769
840
|
|
|
770
|
-
const
|
|
771
|
-
|
|
841
|
+
const pdfChunks: Array<{ path: string; start_page: number; end_page: number }> =
|
|
842
|
+
JSON.parse(splitResult.stdout);
|
|
772
843
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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);
|
|
849
|
+
|
|
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));
|
|
856
|
+
|
|
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
|
+
);
|
|
785
881
|
|
|
786
882
|
const parser = new LiteParse();
|
|
787
883
|
const screenshots = await parser.screenshot(paths.source, undefined);
|
|
@@ -796,7 +892,7 @@ async function processPdf(
|
|
|
796
892
|
screenshot.imagePath = path.join(paths.images, `${screenshot.pageNum}.png`);
|
|
797
893
|
}
|
|
798
894
|
|
|
799
|
-
json =
|
|
895
|
+
json = mergedPages.map(page => ({
|
|
800
896
|
page: page.index + 1,
|
|
801
897
|
content: page.markdown,
|
|
802
898
|
image: screenshots.find(s => s.pageNum === page.index + 1)?.imagePath,
|
|
@@ -838,13 +934,14 @@ async function processPdf(
|
|
|
838
934
|
}
|
|
839
935
|
|
|
840
936
|
// Apply VLM validation if enabled
|
|
841
|
-
|
|
937
|
+
const vlmModel = config?.vlm?.model ? await resolveVlmModel(config) : undefined;
|
|
938
|
+
if (vlmModel && json.length > 0) {
|
|
842
939
|
|
|
843
940
|
json = await validateWithVLM(
|
|
844
941
|
json,
|
|
845
|
-
|
|
942
|
+
vlmModel,
|
|
846
943
|
verbose,
|
|
847
|
-
config
|
|
944
|
+
config!.vlm!.concurrency
|
|
848
945
|
);
|
|
849
946
|
|
|
850
947
|
console.log('[EXULU] \n📊 Processing Summary:');
|
|
@@ -1046,7 +1143,6 @@ export async function documentProcessor({
|
|
|
1046
1143
|
} catch (error) {
|
|
1047
1144
|
console.error('Error during chunking:', error);
|
|
1048
1145
|
throw error;
|
|
1049
|
-
|
|
1050
1146
|
} finally {
|
|
1051
1147
|
if (config?.debugging?.deleteTempFiles !== false) {
|
|
1052
1148
|
// Delete the temp directory using the local array to avoid race conditions
|
|
@@ -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
|
+
}
|