@xpert-ai/plugin-opendataloader 0.0.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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "@xpert-ai/plugin-opendataloader",
3
+ "version": "0.0.1",
4
+ "sandboxActions": "./dist/sandbox-actions/convert/action.json"
5
+ }
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # OpenDataLoader PDF
2
+
3
+ Knowledge-base document parser `@xpert-ai/plugin-opendataloader`. Install as a **system plugin**; it registers the `opendataloader` transformer and `opendataloader.convert` Sandbox Action (1.2.1).
4
+
5
+ ## Execution
6
+
7
+ - Uses platform Sandbox Jobs and scoped Workspace Files. API processes do not load native modules or start Java themselves.
8
+ - Runtime profile: `document/java-17/v1`. Conversion dependencies are installed, pinned and verified by the host Runtime Suite.
9
+ - No URL, token, executable-path or dependency-installation settings in the parser form.
10
+ - Input limit 100 MiB; output/decoded assets limit 128 MiB; 1,000 assets maximum; job timeout 300 seconds.
11
+ - Each conversion uses tenant/document/content/runtime-scoped identity and unique persisted asset paths. Cancelling processing cancels the same Job, including retries.
12
+ - Conversion failures are failed Jobs and retain their parser error code and affected pages. Retrying after recovery executes again; successful results are reused only for the same conversion identity, including the Runtime dependency fingerprint.
13
+
14
+ ## Formats and limits
15
+
16
+ Supports **PDF only**, using Java 17 and the official OpenDataLoader PDF 2.5.8 CLI JAR. Native text pages retain their Java extraction results. Pages without verified text use the official Hybrid OCR backend through a job-local Python process with Docling/EasyOCR, simplified Chinese and English models, and CPU execution. The managed Runtime preinstalls the Python dependencies and model weights; jobs do not download models or call an external OCR service. The local backend is stopped when the Job ends or fails.
17
+
18
+ The parser form exposes an **OCR minimum confidence** slider (`ocrConfidenceThreshold`, 0–1, default 0.5). Lower values retain more uncertain recognized text; the setting applies to OCR pages and participates in the conversion identity.
19
+
20
+ Markdown page separators and JSON page evidence must agree. OCR that cannot verify all requested pages fails explicitly instead of returning partial success; blank pages are not guessed from missing text. OCR uses the same 300-second Job budget, so large or slow scanned documents can time out.
21
+
22
+ Text, tables, verified page numbers, external PNG images and the original JSON result are preserved. Extracted images are ordinary image assets; they are not labelled as full-page scans. Image paths must remain within the conversion directory; symlinks and traversal are rejected.
23
+
24
+ ## Local development
25
+
26
+ The host must include this Runtime profile and support knowledge-document `fileScope`. Use the platform-managed installer from the host checkout with Node 20.20.2:
27
+
28
+ ```sh
29
+ corepack pnpm --filter @xpert-ai/sandbox-runtime install:document-java
30
+ corepack pnpm --filter @xpert-ai/sandbox-runtime verify:local-document-java
31
+ ```
32
+
33
+ Local process execution is development/test only. Production needs a published `document-java` OCI image bound through the existing Runtime infrastructure; jobs use non-root, read-only filesystem and network isolation. Installing this plugin does not publish/bind a Runtime image.
34
+
35
+ From `xpertai/`:
36
+
37
+ ```sh
38
+ corepack pnpm exec nx run @xpert-ai/plugin-opendataloader:build
39
+ corepack pnpm exec nx run @xpert-ai/plugin-opendataloader:test
40
+ ```
41
+
42
+ From the repository root:
43
+
44
+ ```sh
45
+ node plugin-dev-harness/dist/index.js --workspace ./xpertai --plugin @xpert-ai/plugin-opendataloader
46
+ ```
47
+
48
+ The installable package includes `.xpertai-plugin/plugin.json`, which declares `dist/sandbox-actions/convert/action.json`, and its hashed bundle. Official artwork is bundled under `dist/_assets` and shared by the plugin card and parser metadata; displaying it does not fetch external URLs. After installing from this workspace into the local host, restart the API explicitly, then select OpenDataLoader PDF under the relevant file type. Existing documents need reprocessing to use a new parser. Browser/knowledge-base indexing acceptance is separate from converter and lifecycle tests.
@@ -0,0 +1,8 @@
1
+ # Official branding asset
2
+
3
+ - Source: https://opendataloader.org/logo-icon.webp
4
+ - Retrieved: 2026-09-15
5
+ - SHA-256: `50b7dc9964a73bad4e3eae26f03ee58cccca1b1a5ae087fee0bb624b3b9e40e5`
6
+ - Kept unmodified and bundled locally for plugin and parser identification.
7
+
8
+ The original project retains its branding rights.
Binary file
@@ -0,0 +1,4 @@
1
+ import type { XpertPlugin } from '@xpert-ai/plugin-sdk';
2
+ declare const plugin: XpertPlugin;
3
+ export default plugin;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAIvD,QAAA,MAAM,MAAM,EAAE,WAuBb,CAAA;AACD,eAAe,MAAM,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { OpenDataLoaderPluginModule } from './lib/plugin.module.js';
3
+ import { ConfigSchema, Icon } from './lib/types.js';
4
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
5
+ const plugin = {
6
+ meta: {
7
+ name: pkg.name,
8
+ version: pkg.version,
9
+ level: 'system',
10
+ artifactNamespace: 'opendataloader',
11
+ category: 'integration',
12
+ displayName: 'OpenDataLoader PDF',
13
+ description: 'Offline knowledge document parsing through platform-managed Sandbox Jobs.',
14
+ icon: Icon,
15
+ keywords: ['document', 'parser', 'pdf', 'opendataloader'],
16
+ author: 'XpertAI Team'
17
+ },
18
+ config: { schema: ConfigSchema, formSchema: { type: 'object', properties: {} } },
19
+ register() {
20
+ return { module: OpenDataLoaderPluginModule, global: true };
21
+ },
22
+ onStart(ctx) {
23
+ ctx.logger.log('OpenDataLoader PDF parser started');
24
+ },
25
+ onStop(ctx) {
26
+ ctx.logger.log('OpenDataLoader PDF parser stopped');
27
+ }
28
+ };
29
+ export default plugin;
@@ -0,0 +1,35 @@
1
+ import { type AgentMiddlewareRuntimeCapabilityRegistry, type WorkspaceFileScope } from '@xpert-ai/plugin-sdk';
2
+ import { type OpenDataLoaderParserConfig } from './types.js';
3
+ export type OpenDataLoaderFileScope = WorkspaceFileScope & {
4
+ organizationId?: string | null;
5
+ };
6
+ export declare class OpenDataLoaderSandboxConverter {
7
+ private readonly capabilities?;
8
+ constructor(capabilities?: Pick<AgentMiddlewareRuntimeCapabilityRegistry, 'get'>);
9
+ checkHealth(): Promise<import("@xpert-ai/plugin-sdk").SandboxJobActionHealth>;
10
+ convert(filePath: string, extension: string, options: OpenDataLoaderParserConfig & {
11
+ fileScope?: OpenDataLoaderFileScope;
12
+ documentId?: string;
13
+ stage: 'test' | 'prod';
14
+ signal?: AbortSignal;
15
+ }): Promise<{
16
+ sandboxJobId: string;
17
+ runtimeProfile: string;
18
+ ok?: true;
19
+ pages?: {
20
+ markdown?: string;
21
+ page?: number;
22
+ }[];
23
+ markdown?: string;
24
+ assets?: {
25
+ page?: number;
26
+ name?: string;
27
+ mimeType?: string;
28
+ data?: string;
29
+ size?: number;
30
+ sha256?: string;
31
+ }[];
32
+ }>;
33
+ private runtime;
34
+ }
35
+ //# sourceMappingURL=convert.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"convert.d.ts","sourceRoot":"","sources":["../../src/lib/convert.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,KAAK,wCAAwC,EAC7C,KAAK,kBAAkB,EACxB,MAAM,sBAAsB,CAAA;AAE7B,OAAO,EAML,KAAK,0BAA0B,EAChC,MAAM,YAAY,CAAA;AAEnB,MAAM,MAAM,uBAAuB,GAAG,kBAAkB,GAAG;IAAE,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAA;AAgD7F,qBACa,8BAA8B;IAIvC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;gBAAb,YAAY,CAAC,EAAE,IAAI,CAAC,wCAAwC,EAAE,KAAK,CAAC;IAGjF,WAAW;IAkBX,OAAO,CACX,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,0BAA0B,GAAG;QACpC,SAAS,CAAC,EAAE,uBAAuB,CAAA;QACnC,UAAU,CAAC,EAAE,MAAM,CAAA;QACnB,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;QACtB,MAAM,CAAC,EAAE,WAAW,CAAA;KACrB;;;;;;;;;;;;;;;;;;IAuHH,OAAO,CAAC,OAAO;CAShB"}
@@ -0,0 +1,232 @@
1
+ import { __decorate, __metadata, __param } from "tslib";
2
+ import { Inject, Injectable, Optional, ServiceUnavailableException } from '@nestjs/common';
3
+ import { createHash } from 'node:crypto';
4
+ import { isSandboxJobRuntimeError, SandboxJobsRuntimeCapability, WorkspaceFilesRuntimeCapability, XPERT_RUNTIME_CAPABILITIES_TOKEN } from '@xpert-ai/plugin-sdk';
5
+ import { z } from 'zod';
6
+ import { PACKAGE_NAME, ACTION, ACTION_VERSION, FILE_TYPES, ParserConfigSchema } from './types.js';
7
+ const failureSchema = z.object({
8
+ ok: z.literal(false),
9
+ code: z.enum([
10
+ 'EMPTY_FILE',
11
+ 'EMPTY_TEXT',
12
+ 'INPUT_TOO_LARGE',
13
+ 'OUTPUT_TOO_LARGE',
14
+ 'INVALID_DOCUMENT',
15
+ 'ENCRYPTED',
16
+ 'UNSUPPORTED_FORMAT',
17
+ 'NEEDS_OCR',
18
+ 'OCR_FAILED',
19
+ 'INCOMPLETE_PAGES',
20
+ 'RESOURCE_LIMIT',
21
+ 'RUNTIME_INVALID',
22
+ 'INVALID_CONFIG'
23
+ ]),
24
+ pages: z.array(z.number().int().positive().max(10000)).max(100).optional()
25
+ });
26
+ const resultSchema = z.discriminatedUnion('ok', [
27
+ failureSchema,
28
+ z.object({
29
+ ok: z.literal(true),
30
+ markdown: z.string().min(1),
31
+ pages: z
32
+ .array(z.object({ page: z.number().int().positive(), markdown: z.string() }))
33
+ .max(10000)
34
+ .optional(),
35
+ assets: z
36
+ .array(z.object({
37
+ name: z.string().regex(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9]+$/),
38
+ mimeType: z.string().max(100),
39
+ data: z.string(),
40
+ size: z
41
+ .number()
42
+ .int()
43
+ .nonnegative()
44
+ .max(128 * 1024 * 1024),
45
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
46
+ page: z.number().int().positive().optional()
47
+ }))
48
+ .max(1000)
49
+ })
50
+ ]);
51
+ let OpenDataLoaderSandboxConverter = class OpenDataLoaderSandboxConverter {
52
+ constructor(capabilities) {
53
+ this.capabilities = capabilities;
54
+ }
55
+ async checkHealth() {
56
+ const { jobs } = this.runtime();
57
+ const health = await jobs.getActionHealth({
58
+ pluginName: PACKAGE_NAME,
59
+ action: ACTION,
60
+ actionVersion: ACTION_VERSION
61
+ });
62
+ if (!health.available)
63
+ throw new ServiceUnavailableException(health.reason === 'ACTION_MISSING'
64
+ ? 'OpenDataLoader Sandbox Action is missing (ACTION_MISSING). Install OpenDataLoader as a system plugin, then restart the API.'
65
+ : `OpenDataLoader Sandbox Runtime is unavailable (${health.reason ?? 'RUNTIME_UNBOUND'}). ${health.message ?? 'Check the platform Sandbox Runtime configuration.'}`);
66
+ return health;
67
+ }
68
+ async convert(filePath, extension, options) {
69
+ const parserConfig = ParserConfigSchema.parse(options);
70
+ if (!FILE_TYPES.includes(extension))
71
+ throw new Error(`OpenDataLoader does not support this file type: ${extension}`);
72
+ const scope = options.fileScope;
73
+ if (!scope?.tenantId || scope.catalog !== 'knowledges' || !scope.scopeId) {
74
+ throw new Error('OpenDataLoader requires the host knowledge-base file scope');
75
+ }
76
+ options.signal?.throwIfAborted();
77
+ const health = await this.checkHealth();
78
+ const { files, jobs } = this.runtime();
79
+ const reference = await files.resolveRuntimeReference({
80
+ ...scope,
81
+ source: 'platform.workspace.files',
82
+ filePath,
83
+ workspacePath: filePath
84
+ });
85
+ const source = await files.readBuffer(reference);
86
+ if (!source.buffer.length)
87
+ throw new Error('OPENDATALOADER_EMPTY_FILE');
88
+ if (source.buffer.length > 100 * 1024 * 1024)
89
+ throw new Error('OPENDATALOADER_INPUT_TOO_LARGE');
90
+ const checksum = sha256(source.buffer);
91
+ const identity = sha256(JSON.stringify([
92
+ scope.tenantId,
93
+ scope.organizationId,
94
+ scope.userId,
95
+ scope.scopeId,
96
+ options.documentId,
97
+ reference.filePath,
98
+ extension,
99
+ checksum,
100
+ options.stage,
101
+ ACTION_VERSION,
102
+ health.sandboxRuntimeVersion,
103
+ health.artifactDigest,
104
+ health.manifest?.dependenciesSha256,
105
+ parserConfig
106
+ ]));
107
+ // A retry reattaches Core's existing Job; cancellation must target that same ID.
108
+ const jobId = jobIdFromIdentity(identity);
109
+ const folder = `opendataloader/jobs/${identity}`;
110
+ options.signal?.throwIfAborted();
111
+ let cancelRetry;
112
+ const cancel = () => {
113
+ // Abort can arrive before Core persists the Job. Retry until it exists or run() settles.
114
+ const attempt = () => void jobs
115
+ .cancel({ jobId })
116
+ .then(() => clearInterval(cancelRetry))
117
+ .catch(() => undefined);
118
+ cancelRetry = setInterval(attempt, 100);
119
+ cancelRetry.unref();
120
+ attempt();
121
+ };
122
+ options.signal?.addEventListener('abort', cancel, { once: true });
123
+ try {
124
+ const result = await jobs
125
+ .run({
126
+ jobId,
127
+ action: ACTION,
128
+ actionVersion: ACTION_VERSION,
129
+ idempotencyKey: `opendataloader:${identity}`,
130
+ scope: {
131
+ tenantId: scope.tenantId,
132
+ organizationId: scope.organizationId,
133
+ userId: scope.userId,
134
+ pluginName: PACKAGE_NAME,
135
+ businessResourceType: 'knowledge-document',
136
+ businessResourceId: options.documentId ?? identity
137
+ },
138
+ payload: { extension, ...parserConfig },
139
+ files: [{ reference, targetPath: 'source.bin', size: source.buffer.length, sha256: checksum }],
140
+ outputs: [
141
+ {
142
+ path: 'result.json',
143
+ originalName: 'result.json',
144
+ mimeType: 'application/json',
145
+ destination: { ...scope, folder }
146
+ }
147
+ ],
148
+ timeoutMs: 300000
149
+ })
150
+ .catch(rethrowConversionFailure);
151
+ options.signal?.throwIfAborted();
152
+ const output = result.outputs.find((item) => item.path === 'result.json');
153
+ if (!output || output.size > 128 * 1024 * 1024)
154
+ throw new Error('OpenDataLoader Sandbox output is missing or exceeds its limit');
155
+ const content = await files.readBuffer(output.reference);
156
+ if (content.buffer.length > 128 * 1024 * 1024)
157
+ throw new Error('OPENDATALOADER_OUTPUT_TOO_LARGE');
158
+ if (content.buffer.length !== output.size || sha256(content.buffer) !== output.sha256) {
159
+ throw new Error('OpenDataLoader Sandbox output integrity check failed');
160
+ }
161
+ const converted = resultSchema.parse(JSON.parse(content.buffer.toString('utf8')));
162
+ if (converted.ok === false)
163
+ throw Object.assign(new Error(`OPENDATALOADER_${converted.code}`), { pages: converted.pages });
164
+ let decodedSize = Buffer.byteLength(converted.markdown);
165
+ const names = new Set();
166
+ for (const asset of converted.assets) {
167
+ const data = Buffer.from(asset.data, 'base64');
168
+ if (names.has(asset.name) ||
169
+ data.toString('base64') !== asset.data ||
170
+ data.length !== asset.size ||
171
+ sha256(data) !== asset.sha256)
172
+ throw new Error('OPENDATALOADER_INVALID_DOCUMENT');
173
+ names.add(asset.name);
174
+ decodedSize += data.length;
175
+ if (decodedSize > 128 * 1024 * 1024)
176
+ throw new Error('OPENDATALOADER_OUTPUT_TOO_LARGE');
177
+ }
178
+ if (!converted.markdown.trim())
179
+ throw new Error('OPENDATALOADER_EMPTY_TEXT');
180
+ return { ...converted, sandboxJobId: result.id, runtimeProfile: result.runtimeProfile };
181
+ }
182
+ finally {
183
+ options.signal?.removeEventListener('abort', cancel);
184
+ clearInterval(cancelRetry);
185
+ }
186
+ }
187
+ runtime() {
188
+ const jobs = this.capabilities?.get(SandboxJobsRuntimeCapability);
189
+ const files = this.capabilities?.get(WorkspaceFilesRuntimeCapability);
190
+ if (!jobs || !files)
191
+ throw new ServiceUnavailableException('OpenDataLoader requires platform Sandbox Jobs and Workspace Files. Update and restart the API.');
192
+ return { jobs, files };
193
+ }
194
+ };
195
+ OpenDataLoaderSandboxConverter = __decorate([
196
+ Injectable(),
197
+ __param(0, Optional()),
198
+ __param(0, Inject(XPERT_RUNTIME_CAPABILITIES_TOKEN)),
199
+ __metadata("design:paramtypes", [Object])
200
+ ], OpenDataLoaderSandboxConverter);
201
+ export { OpenDataLoaderSandboxConverter };
202
+ /** Restore only the bounded error envelope emitted by this Action, retaining other Runtime failures. */
203
+ function rethrowConversionFailure(error) {
204
+ if (isSandboxJobRuntimeError(error)) {
205
+ const match = error.message.match(/^OPENDATALOADER_CONVERSION_ERROR: (\{[^\r\n]{1,2048}\})$/m);
206
+ let failure;
207
+ if (match) {
208
+ try {
209
+ const parsed = failureSchema.safeParse(JSON.parse(match[1]));
210
+ if (parsed.success)
211
+ failure = parsed.data;
212
+ }
213
+ catch {
214
+ // Malformed or truncated output remains the original Runtime error.
215
+ }
216
+ }
217
+ if (failure)
218
+ throw Object.assign(new Error(`OPENDATALOADER_${failure.code}`), { pages: failure.pages });
219
+ }
220
+ throw error;
221
+ }
222
+ function sha256(value) {
223
+ return createHash('sha256').update(value).digest('hex');
224
+ }
225
+ /** UUIDv8 derived from the tenant-scoped conversion identity. */
226
+ function jobIdFromIdentity(identity) {
227
+ const bytes = Buffer.from(identity.slice(0, 32), 'hex');
228
+ bytes[6] = (bytes[6] & 0x0f) | 0x80;
229
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
230
+ const hex = bytes.toString('hex');
231
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
232
+ }
@@ -0,0 +1,2 @@
1
+ export declare function pluginOpendataloader(): string;
2
+ //# sourceMappingURL=plugin-opendataloader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-opendataloader.d.ts","sourceRoot":"","sources":["../../src/lib/plugin-opendataloader.ts"],"names":[],"mappings":"AAAA,wBAAgB,oBAAoB,IAAI,MAAM,CAE7C"}
@@ -0,0 +1,3 @@
1
+ export function pluginOpendataloader() {
2
+ return 'plugin-opendataloader';
3
+ }
@@ -0,0 +1,3 @@
1
+ export declare class OpenDataLoaderPluginModule {
2
+ }
3
+ //# sourceMappingURL=plugin.module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.module.d.ts","sourceRoot":"","sources":["../../src/lib/plugin.module.ts"],"names":[],"mappings":"AAGA,qBACa,0BAA0B;CAAG"}
@@ -0,0 +1,10 @@
1
+ import { __decorate } from "tslib";
2
+ import { XpertServerPlugin } from '@xpert-ai/plugin-sdk';
3
+ import { OpenDataLoaderSandboxConverter } from './convert.js';
4
+ import { OpenDataLoaderTransformerStrategy } from './transformer.strategy.js';
5
+ let OpenDataLoaderPluginModule = class OpenDataLoaderPluginModule {
6
+ };
7
+ OpenDataLoaderPluginModule = __decorate([
8
+ XpertServerPlugin({ providers: [OpenDataLoaderSandboxConverter, OpenDataLoaderTransformerStrategy] })
9
+ ], OpenDataLoaderPluginModule);
10
+ export { OpenDataLoaderPluginModule };
@@ -0,0 +1,62 @@
1
+ import { type IDocumentTransformerStrategy, type Permissions, type TDocumentTransformerConfig, type WorkspaceFileScope } from '@xpert-ai/plugin-sdk';
2
+ import { OpenDataLoaderSandboxConverter } from './convert.js';
3
+ import { type OpenDataLoaderParserConfig } from './types.js';
4
+ type Config = TDocumentTransformerConfig & OpenDataLoaderParserConfig & {
5
+ signal?: AbortSignal;
6
+ fileScope?: WorkspaceFileScope;
7
+ };
8
+ type Documents = Parameters<IDocumentTransformerStrategy['transformDocuments']>[0];
9
+ type Results = Awaited<ReturnType<IDocumentTransformerStrategy['transformDocuments']>>;
10
+ export declare class OpenDataLoaderTransformerStrategy implements IDocumentTransformerStrategy<Config> {
11
+ private readonly converter;
12
+ constructor(converter: OpenDataLoaderSandboxConverter);
13
+ readonly permissions: Permissions;
14
+ readonly meta: {
15
+ name: string;
16
+ label: {
17
+ en_US: string;
18
+ zh_Hans: string;
19
+ };
20
+ description: {
21
+ en_US: string;
22
+ zh_Hans: string;
23
+ };
24
+ icon: {
25
+ type: "image";
26
+ value: string;
27
+ };
28
+ supportedFileTypes: string[];
29
+ providesImageText: boolean;
30
+ configSchema: {
31
+ type: string;
32
+ properties: {
33
+ ocrConfidenceThreshold: {
34
+ type: string;
35
+ title: {
36
+ en_US: string;
37
+ zh_Hans: string;
38
+ };
39
+ description: {
40
+ en_US: string;
41
+ zh_Hans: string;
42
+ };
43
+ default: number;
44
+ minimum: number;
45
+ maximum: number;
46
+ 'x-ui': {
47
+ component: string;
48
+ inputs: {
49
+ min: number;
50
+ max: number;
51
+ step: number;
52
+ };
53
+ };
54
+ };
55
+ };
56
+ };
57
+ };
58
+ validateConfig(config: Config): Promise<void>;
59
+ transformDocuments(documents: Documents, config: Config): Promise<Results>;
60
+ }
61
+ export {};
62
+ //# sourceMappingURL=transformer.strategy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transformer.strategy.d.ts","sourceRoot":"","sources":["../../src/lib/transformer.strategy.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,4BAA4B,EACjC,KAAK,WAAW,EAEhB,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,EACxB,MAAM,sBAAsB,CAAA;AAE7B,OAAO,EAAE,8BAA8B,EAAE,MAAM,cAAc,CAAA;AAC7D,OAAO,EAAwC,KAAK,0BAA0B,EAAqB,MAAM,YAAY,CAAA;AAErH,KAAK,MAAM,GAAG,0BAA0B,GACtC,0BAA0B,GAAG;IAAE,MAAM,CAAC,EAAE,WAAW,CAAC;IAAC,SAAS,CAAC,EAAE,kBAAkB,CAAA;CAAE,CAAA;AACvF,KAAK,SAAS,GAAG,UAAU,CAAC,4BAA4B,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAClF,KAAK,OAAO,GAAG,OAAO,CAAC,UAAU,CAAC,4BAA4B,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAA;AACtF,qBAEa,iCAAkC,YAAW,4BAA4B,CAAC,MAAM,CAAC;IAChF,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,8BAA8B;IACtE,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAqE;IACtG,QAAQ,CAAC,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MA+BZ;IACK,cAAc,CAAC,MAAM,EAAE,MAAM;IAK7B,kBAAkB,CAAC,SAAS,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAiEjF"}
@@ -0,0 +1,120 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import { Injectable } from '@nestjs/common';
3
+ import { DocumentTransformerStrategy } from '@xpert-ai/plugin-sdk';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { OpenDataLoaderSandboxConverter } from './convert.js';
6
+ import { FILE_TYPES, Icon, ParserConfigSchema, documentExtension } from './types.js';
7
+ let OpenDataLoaderTransformerStrategy = class OpenDataLoaderTransformerStrategy {
8
+ constructor(converter) {
9
+ this.converter = converter;
10
+ this.permissions = [{ type: 'filesystem', operations: ['read', 'write'], scope: [] }];
11
+ this.meta = {
12
+ name: 'opendataloader',
13
+ label: { en_US: 'OpenDataLoader PDF', zh_Hans: 'OpenDataLoader PDF' },
14
+ description: {
15
+ en_US: 'Offline PDF text extraction with local OCR for scanned pages, using platform Sandbox Jobs.',
16
+ zh_Hans: '通过平台沙箱离线解析 PDF;扫描页自动使用本地 OCR 识别中文和英文。'
17
+ },
18
+ icon: Icon,
19
+ supportedFileTypes: FILE_TYPES,
20
+ providesImageText: false,
21
+ configSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ ocrConfidenceThreshold: {
25
+ type: 'number',
26
+ title: { en_US: 'Minimum OCR confidence', zh_Hans: 'OCR 最低置信度' },
27
+ description: {
28
+ en_US: 'Text below this confidence is discarded. Lower values retain more text but may include recognition errors. Applies to scanned pages.',
29
+ zh_Hans: '低于此置信度的文字会被丢弃。降低数值可保留更多文字,也可能保留误识别内容。仅影响扫描页 OCR。'
30
+ },
31
+ default: 0.5,
32
+ minimum: 0,
33
+ maximum: 1,
34
+ 'x-ui': {
35
+ component: 'slider',
36
+ inputs: { min: 0, max: 1, step: 0.01 }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ };
42
+ }
43
+ async validateConfig(config) {
44
+ config.signal?.throwIfAborted();
45
+ ParserConfigSchema.parse(config);
46
+ await this.converter.checkHealth();
47
+ }
48
+ async transformDocuments(documents, config) {
49
+ const fs = config.permissions?.fileSystem;
50
+ if (!fs)
51
+ throw new Error('OpenDataLoader PDF requires the scoped knowledge-base filesystem');
52
+ const output = [];
53
+ for (const document of documents) {
54
+ config.signal?.throwIfAborted();
55
+ const extension = documentExtension(document.type, document.mimeType);
56
+ if (!document.filePath || !FILE_TYPES.includes(extension))
57
+ throw new Error('OPENDATALOADER_UNSUPPORTED_FORMAT');
58
+ const result = await this.converter.convert(document.filePath, extension, {
59
+ signal: config.signal,
60
+ fileScope: config.fileScope,
61
+ documentId: document.id,
62
+ stage: config.stage,
63
+ ocrConfidenceThreshold: config.ocrConfidenceThreshold
64
+ });
65
+ const folder = 'opendataloader/' + randomUUID();
66
+ const assets = [];
67
+ const references = new Map();
68
+ for (const asset of result.assets) {
69
+ config.signal?.throwIfAborted();
70
+ const filePath = `${folder}/${asset.name}`;
71
+ const url = await fs.writeFile(filePath, Buffer.from(asset.data, 'base64'));
72
+ references.set(`xpert-asset://${asset.name}`, url);
73
+ assets.push({
74
+ type: /^(image\/(png|jpeg|gif|webp))$/.test(asset.mimeType) ? 'image' : 'file',
75
+ filePath,
76
+ url,
77
+ order: assets.length,
78
+ ...(asset.page ? { page: asset.page } : {})
79
+ });
80
+ }
81
+ const replaceReferences = (text) => text.replace(/xpert-asset:\/\/[a-zA-Z0-9_-]+\.[a-zA-Z0-9]+/g, (match) => references.get(match) ?? match);
82
+ const markdown = replaceReferences(result.markdown);
83
+ const originalPath = `${folder}/result.md`;
84
+ assets.push({ type: 'file', filePath: originalPath, url: await fs.writeFile(originalPath, markdown) });
85
+ const pages = result.pages ?? [{ page: undefined, markdown: result.markdown }];
86
+ const chunks = pages
87
+ .filter((page) => page.markdown.trim())
88
+ .map((page, index) => ({
89
+ pageContent: replaceReferences(page.markdown),
90
+ metadata: {
91
+ chunkId: randomUUID(),
92
+ chunkIndex: index,
93
+ parser: 'opendataloader',
94
+ mediaType: 'text',
95
+ contentFormat: 'markdown',
96
+ ...(page.page ? { page: page.page } : {}),
97
+ assets: assets.filter((asset) => asset.type === 'image' && (!page.page || asset.page === page.page))
98
+ }
99
+ }));
100
+ output.push({
101
+ id: document.id,
102
+ chunks,
103
+ metadata: {
104
+ chunkId: randomUUID(),
105
+ parser: 'opendataloader',
106
+ sandboxJobId: result.sandboxJobId,
107
+ runtimeProfile: result.runtimeProfile,
108
+ assets
109
+ }
110
+ });
111
+ }
112
+ return output;
113
+ }
114
+ };
115
+ OpenDataLoaderTransformerStrategy = __decorate([
116
+ Injectable(),
117
+ DocumentTransformerStrategy('opendataloader'),
118
+ __metadata("design:paramtypes", [OpenDataLoaderSandboxConverter])
119
+ ], OpenDataLoaderTransformerStrategy);
120
+ export { OpenDataLoaderTransformerStrategy };
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ export declare const PACKAGE_NAME = "@xpert-ai/plugin-opendataloader";
3
+ export declare const PARSER_NAME = "opendataloader";
4
+ export declare const FILE_TYPES: string[];
5
+ export declare const ACTION = "opendataloader.convert";
6
+ export declare const ACTION_VERSION = "1.2.1";
7
+ export declare const PROFILE = "document/java-17/v1";
8
+ export declare const ConfigSchema: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
9
+ export declare const ParserConfigSchema: z.ZodObject<{
10
+ ocrConfidenceThreshold: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodNumber>>, number, number>;
11
+ }, "strip", z.ZodTypeAny, {
12
+ ocrConfidenceThreshold?: number;
13
+ }, {
14
+ ocrConfidenceThreshold?: number;
15
+ }>;
16
+ export type OpenDataLoaderParserConfig = z.input<typeof ParserConfigSchema>;
17
+ export declare const Icon: {
18
+ type: "image";
19
+ value: string;
20
+ };
21
+ /** Host uploads usually supply an extension; MIME-only callers use the same upstream format. */
22
+ export declare function documentExtension(type?: string, mimeType?: string): string;
23
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,YAAY,oCAAoC,CAAA;AAC7D,eAAO,MAAM,WAAW,mBAAmB,CAAA;AAC3C,eAAO,MAAM,UAAU,UAAU,CAAA;AACjC,eAAO,MAAM,MAAM,2BAA2B,CAAA;AAC9C,eAAO,MAAM,cAAc,UAAU,CAAA;AACrC,eAAO,MAAM,OAAO,wBAAwB,CAAA;AAC5C,eAAO,MAAM,YAAY,iDAAwB,CAAA;AACjD,eAAO,MAAM,kBAAkB;;;;;;EAQ7B,CAAA;AACF,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAA;AAC3E,eAAO,MAAM,IAAI;;;CAGhB,CAAA;AAED,gGAAgG;AAChG,wBAAgB,iBAAiB,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAsB1E"}
@@ -0,0 +1,45 @@
1
+ import { z } from 'zod';
2
+ import { readFileSync } from 'node:fs';
3
+ export const PACKAGE_NAME = '@xpert-ai/plugin-opendataloader';
4
+ export const PARSER_NAME = 'opendataloader';
5
+ export const FILE_TYPES = ['pdf'];
6
+ export const ACTION = 'opendataloader.convert';
7
+ export const ACTION_VERSION = '1.2.1';
8
+ export const PROFILE = 'document/java-17/v1';
9
+ export const ConfigSchema = z.object({}).strict();
10
+ export const ParserConfigSchema = z.object({
11
+ ocrConfidenceThreshold: z
12
+ .number()
13
+ .finite()
14
+ .min(0)
15
+ .max(1)
16
+ .nullish()
17
+ .transform((value) => value ?? 0.5)
18
+ });
19
+ export const Icon = {
20
+ type: 'image',
21
+ value: `data:image/webp;base64,${readFileSync(new URL('../_assets/icon.webp', import.meta.url)).toString('base64')}`
22
+ };
23
+ /** Host uploads usually supply an extension; MIME-only callers use the same upstream format. */
24
+ export function documentExtension(type, mimeType) {
25
+ const value = (type && type !== 'unknown' ? type : mimeType)
26
+ ?.split(';')[0]
27
+ .trim()
28
+ .toLowerCase()
29
+ .replace(/^.*\//, '')
30
+ .replace(/^\./, '') ?? '';
31
+ const aliases = {
32
+ msword: 'doc',
33
+ 'vnd.ms-powerpoint': 'ppt',
34
+ 'vnd.ms-excel': 'xls',
35
+ 'vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
36
+ 'vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
37
+ 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
38
+ 'vnd.oasis.opendocument.text': 'odt',
39
+ 'vnd.oasis.opendocument.spreadsheet': 'ods',
40
+ 'vnd.oasis.opendocument.presentation': 'odp',
41
+ 'epub+zip': 'epub',
42
+ 'x-rtf': 'rtf'
43
+ };
44
+ return Object.prototype.hasOwnProperty.call(aliases, value) ? aliases[value] : value;
45
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "opendataloader.convert",
3
+ "version": "1.2.1",
4
+ "runtimeProfile": "document/java-17/v1",
5
+ "runtimeContractVersion": "1",
6
+ "bundle": "./bundle",
7
+ "entrypoint": "runner.mjs",
8
+ "bundleSha256": "a268595f7ae831799e4d7b3e5d4e4d93e7ccf0e8349ea4ea39d241a18c70a8e6"
9
+ }
@@ -0,0 +1,209 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { copyFile, mkdir, readFile, unlink } from 'node:fs/promises'
4
+ import { randomUUID } from 'node:crypto'
5
+ import path from 'node:path'
6
+ import { INPUT_LIMIT, ASSET_LIMIT, asset, fail, finish, readBounded, safeOutput, checkOutputTree } from './result.mjs'
7
+ import { normalizeOcrConfidenceThreshold, withOcrBackend } from './ocr.mjs'
8
+
9
+ export async function convert(file, extension, output, ocrConfidenceThreshold) {
10
+ const threshold = normalizeOcrConfidenceThreshold(ocrConfidenceThreshold)
11
+ if (extension !== 'pdf') fail('UNSUPPORTED_FORMAT')
12
+ let bytes
13
+ try {
14
+ bytes = await readBounded(file, INPUT_LIMIT)
15
+ } catch (error) {
16
+ if (error.code === 'OUTPUT_TOO_LARGE') fail('INPUT_TOO_LARGE')
17
+ throw error
18
+ }
19
+ if (!bytes.length) fail('EMPTY_FILE')
20
+ if (!bytes.subarray(0, 1024).includes(Buffer.from('%PDF-'))) fail('INVALID_DOCUMENT')
21
+ const root = process.env.XPERT_SANDBOX_DOCUMENT_DEPENDENCY_ROOT
22
+ if (!root || !path.isAbsolute(root)) fail('RUNTIME_INVALID')
23
+ let lock
24
+ try {
25
+ lock = JSON.parse(await readFile(path.join(root, 'dependencies.lock.json'), 'utf8'))
26
+ } catch {
27
+ fail('RUNTIME_INVALID')
28
+ }
29
+ if (lock.cli?.jar !== 'opendataloader-pdf-cli-2.5.8.jar') fail('RUNTIME_INVALID')
30
+ return finish(
31
+ await convertPages(async (pages) => {
32
+ const work = path.join(output, `convert-${randomUUID()}`)
33
+ await mkdir(work, { recursive: true })
34
+ if (pages) return withOcrBackend(root, work, (url) => extractPdf(file, root, lock, work, pages, url), threshold)
35
+ return extractPdf(file, root, lock, work)
36
+ })
37
+ )
38
+ }
39
+
40
+ async function extractPdf(file, root, lock, work, pages, hybridUrl) {
41
+ await mkdir(work, { recursive: true })
42
+ const input = path.join(work, 'document.pdf')
43
+ await copyFile(file, input)
44
+ const separator = `XPERT-${randomUUID()}-PAGE-`
45
+ try {
46
+ await promisify(execFile)(
47
+ path.join(root, 'jre/bin/java'),
48
+ [
49
+ '-Djava.awt.headless=true',
50
+ `-Djava.io.tmpdir=${work}`,
51
+ '-Xmx2g',
52
+ '-jar',
53
+ path.join(root, 'cli', lock.cli.jar),
54
+ '--format',
55
+ 'markdown,json',
56
+ '--output-dir',
57
+ work,
58
+ '--image-output',
59
+ 'external',
60
+ '--image-format',
61
+ 'png',
62
+ '--image-dir',
63
+ path.join(work, 'images'),
64
+ '--markdown-page-separator',
65
+ `${separator}%page-number%-END`,
66
+ '--reading-order',
67
+ 'xycut',
68
+ '--threads',
69
+ '1',
70
+ ...(hybridUrl
71
+ ? [
72
+ '--pages',
73
+ pages.join(','),
74
+ '--hybrid',
75
+ 'docling-fast',
76
+ '--hybrid-mode',
77
+ 'full',
78
+ '--hybrid-url',
79
+ hybridUrl,
80
+ '--hybrid-timeout',
81
+ '240000'
82
+ ]
83
+ : ['--hybrid', 'off']),
84
+ '--quiet',
85
+ input
86
+ ],
87
+ { timeout: 290000, maxBuffer: 256 * 1024, killSignal: 'SIGKILL' }
88
+ )
89
+ } catch (error) {
90
+ if (error.killed || error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') fail('RESOURCE_LIMIT')
91
+ if (/password|encrypt/i.test(String(error.stderr ?? ''))) fail('ENCRYPTED')
92
+ if (hybridUrl && error.stderr)
93
+ process.stderr.write(`OpenDataLoader OCR conversion: ${String(error.stderr).slice(-8192)}\n`)
94
+ fail(hybridUrl ? 'OCR_FAILED' : 'INVALID_DOCUMENT', pages)
95
+ }
96
+ await unlink(input)
97
+ await checkOutputTree(work)
98
+ const raw = await readBounded(path.join(work, 'document.json'))
99
+ const markdown = (await readBounded(path.join(work, 'document.md'))).toString('utf8')
100
+ return { json: JSON.parse(raw), markdown, separator, root: work }
101
+ }
102
+
103
+ /** Preserve Java text verbatim; OCR receives only pages whose text coverage is missing. */
104
+ export async function convertPages(extract) {
105
+ const native = await extract()
106
+ const result = await mapResult(native.json, native.markdown, native.separator, native.root, { allowUncovered: true })
107
+ const assets = [
108
+ ...result.assets,
109
+ asset('result-raw.json', 'application/json', Buffer.from(JSON.stringify(native.json)))
110
+ ]
111
+ let pages = result.pages
112
+ if (result.uncovered.length) {
113
+ const ocr = await extract(result.uncovered)
114
+ if (ocr.json['number of pages'] !== native.json['number of pages']) fail('INCOMPLETE_PAGES', result.uncovered)
115
+ const recognized = await mapResult(ocr.json, ocr.markdown, ocr.separator, ocr.root, {
116
+ selectedPages: result.uncovered,
117
+ assetPrefix: 'ocr-'
118
+ })
119
+ const replacements = new Map(recognized.pages.map((p) => [p.page, p]))
120
+ pages = pages.map((p) => replacements.get(p.page) ?? p)
121
+ assets.push(
122
+ ...recognized.assets,
123
+ asset('result-ocr.json', 'application/json', Buffer.from(JSON.stringify(ocr.json)))
124
+ )
125
+ }
126
+ return { markdown: pages.map((p) => p.markdown).join('\n\n'), pages, assets }
127
+ }
128
+
129
+ /** Coverage is established from page markers plus JSON text, never from filenames or missing text alone. */
130
+ export async function mapResult(json, markdown, separator, root, options = {}) {
131
+ const count = json?.['number of pages']
132
+ if (!Number.isSafeInteger(count) || count <= 0 || count > 10000 || !Array.isArray(json.kids)) fail('INCOMPLETE_PAGES')
133
+ const pages = splitPages(markdown, separator, count, options.selectedPages)
134
+ const textPages = new Set(),
135
+ imagePages = new Set(),
136
+ images = []
137
+ let nodes = 0
138
+ function walk(value, inheritedPage, depth = 0) {
139
+ if (++nodes > 200000 || depth > 100) fail('RESOURCE_LIMIT')
140
+ if (!value || typeof value !== 'object') return
141
+ const page = value['page number'] ?? inheritedPage
142
+ if (value['page number'] != null && (!Number.isSafeInteger(page) || page < 1 || page > count))
143
+ fail('INCOMPLETE_PAGES')
144
+ if (page && typeof value.content === 'string' && value.content.trim()) textPages.add(page)
145
+ if (value.type === 'image') {
146
+ if (!page || typeof value.source !== 'string') fail('INCOMPLETE_PAGES')
147
+ imagePages.add(page)
148
+ images.push({ source: value.source, page })
149
+ if (images.length > ASSET_LIMIT) fail('OUTPUT_TOO_LARGE')
150
+ }
151
+ for (const child of Object.values(value))
152
+ if (child && typeof child === 'object') {
153
+ if (Array.isArray(child)) child.forEach((item) => walk(item, page, depth + 1))
154
+ else walk(child, page, depth + 1)
155
+ }
156
+ }
157
+ walk(json.kids)
158
+ const uncovered = pages.filter((p) => !textPages.has(p.page)).map((p) => p.page)
159
+ if (uncovered.length && !options.allowUncovered)
160
+ fail(
161
+ options.selectedPages
162
+ ? 'OCR_FAILED'
163
+ : uncovered.some((p) => imagePages.has(p))
164
+ ? 'NEEDS_OCR'
165
+ : 'INCOMPLETE_PAGES',
166
+ uncovered
167
+ )
168
+ const assets = []
169
+ const seenImages = new Set()
170
+ for (const image of images) {
171
+ // Full scan images on uncovered pages are replaced by OCR text, not sent to VLM again.
172
+ if (uncovered.includes(image.page)) continue
173
+ const source = image.source
174
+ const key = `${image.page}\0${source}`
175
+ if (seenImages.has(key)) continue
176
+ seenImages.add(key)
177
+ const file = await safeOutput(root, source)
178
+ const data = await readBounded(file)
179
+ if (!data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) fail('INVALID_DOCUMENT')
180
+ const name = `${options.assetPrefix ?? ''}image-${assets.length + 1}.png`
181
+ assets.push(asset(name, 'image/png', data, image.page))
182
+ const page = pages.find((page) => page.page === image.page)
183
+ if (!page) fail('INCOMPLETE_PAGES')
184
+ // Only generated image destinations are rewritten. Text, links and repeated basenames remain intact.
185
+ const escapedSource = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
186
+ const reference = new RegExp(`(!\\[(?:\\\\.|[^\\]\\\\])*\\]\\()<${escapedSource}>(\\))`, 'g')
187
+ let matched = false
188
+ page.markdown = page.markdown.replace(reference, (_match, before, after) => {
189
+ matched = true
190
+ return `${before}xpert-asset://${name}${after}`
191
+ })
192
+ if (!matched) fail('INCOMPLETE_PAGES')
193
+ }
194
+ return { markdown: pages.map((p) => p.markdown).join('\n\n'), pages, assets, uncovered }
195
+ }
196
+
197
+ export function splitPages(markdown, separator, count, selectedPages) {
198
+ const expected = selectedPages ?? Array.from({ length: count }, (_, i) => i + 1)
199
+ const escaped = separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
200
+ const matches = [...markdown.matchAll(new RegExp(`^${escaped}(\\d+)-END\\s*$`, 'gm'))]
201
+ if (matches.length !== expected.length || markdown.slice(0, matches[0]?.index).trim()) fail('INCOMPLETE_PAGES')
202
+ return matches.map((match, index) => {
203
+ if (Number(match[1]) !== expected[index]) fail('INCOMPLETE_PAGES')
204
+ return {
205
+ page: expected[index],
206
+ markdown: markdown.slice(match.index + match[0].length, matches[index + 1]?.index ?? markdown.length).trim()
207
+ }
208
+ })
209
+ }
@@ -0,0 +1,88 @@
1
+ // The backend belongs to this Job only. stdin closure also stops it when the Action is killed.
2
+ import { spawn } from 'node:child_process'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { once } from 'node:events'
5
+ import path from 'node:path'
6
+ import { fail } from './result.mjs'
7
+
8
+ export function normalizeOcrConfidenceThreshold(value = 0.5) {
9
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) fail('INVALID_CONFIG')
10
+ return value
11
+ }
12
+
13
+ export async function withOcrBackend(root, work, convert, ocrConfidenceThreshold = 0.5) {
14
+ const threshold = normalizeOcrConfidenceThreshold(ocrConfidenceThreshold)
15
+ const ready = path.join(work, 'ocr-ready.json')
16
+ const child = spawn(
17
+ path.join(root, 'python/bin/python3'),
18
+ [
19
+ '-I',
20
+ '-X',
21
+ 'faulthandler',
22
+ path.join(root, 'hybrid-backend.py'),
23
+ '--root',
24
+ root,
25
+ '--ready',
26
+ ready,
27
+ '--ocr-confidence-threshold',
28
+ String(threshold)
29
+ ],
30
+ {
31
+ stdio: ['pipe', 'ignore', 'pipe'],
32
+ env: {
33
+ ...process.env,
34
+ TMPDIR: work,
35
+ HOME: work,
36
+ XDG_CACHE_HOME: path.join(work, 'cache'),
37
+ HF_HUB_OFFLINE: '1',
38
+ TRANSFORMERS_OFFLINE: '1',
39
+ HF_HUB_DISABLE_TELEMETRY: '1',
40
+ DOCLING_ARTIFACTS_PATH: path.join(root, 'models'),
41
+ PYTHONDONTWRITEBYTECODE: '1',
42
+ OMP_NUM_THREADS: '2',
43
+ MKL_NUM_THREADS: '2',
44
+ OPENBLAS_NUM_THREADS: '2'
45
+ }
46
+ }
47
+ )
48
+ let spawnError,
49
+ diagnostics = ''
50
+ child.on('error', (error) => {
51
+ spawnError = error
52
+ })
53
+ child.stderr.on('data', (bytes) => {
54
+ diagnostics = (diagnostics + bytes.toString()).slice(-8192)
55
+ })
56
+ const exited = once(child, 'exit').catch(() => undefined)
57
+ try {
58
+ const deadline = Date.now() + 90000
59
+ while (Date.now() < deadline) {
60
+ if (spawnError || child.exitCode !== null || child.signalCode) fail('RUNTIME_INVALID')
61
+ let port
62
+ try {
63
+ port = JSON.parse(await readFile(ready, 'utf8')).port
64
+ } catch (error) {
65
+ if (error.code !== 'ENOENT') throw error
66
+ }
67
+ if (Number.isSafeInteger(port) && port > 0 && port <= 65535) {
68
+ const url = `http://127.0.0.1:${port}`
69
+ const health = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) }).catch(() => undefined)
70
+ if (health?.ok) return await convert(url)
71
+ }
72
+ await new Promise((resolve) => setTimeout(resolve, 100))
73
+ }
74
+ fail('RESOURCE_LIMIT')
75
+ } catch (error) {
76
+ process.stderr.write(`OpenDataLoader OCR backend exit: code=${child.exitCode}, signal=${child.signalCode}\n`)
77
+ if (diagnostics) process.stderr.write(`OpenDataLoader OCR backend: ${diagnostics}\n`)
78
+ if (child.signalCode === 'SIGKILL') fail('RESOURCE_LIMIT')
79
+ throw error
80
+ } finally {
81
+ child.stdin.destroy()
82
+ child.kill('SIGTERM')
83
+ const kill = setTimeout(() => child.kill('SIGKILL'), 3000)
84
+ kill.unref()
85
+ if (!spawnError) await exited
86
+ clearTimeout(kill)
87
+ }
88
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFile, lstat, realpath, readdir } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ export const INPUT_LIMIT = 100 * 1024 * 1024
5
+ export const OUTPUT_LIMIT = 128 * 1024 * 1024
6
+ export const ASSET_LIMIT = 1000
7
+ export function fail(code, pages) {
8
+ throw Object.assign(new Error(code), { code, pages })
9
+ }
10
+ export function asset(name, mimeType, data, page) {
11
+ if (data.length > OUTPUT_LIMIT) fail('OUTPUT_TOO_LARGE')
12
+ return {
13
+ name,
14
+ mimeType,
15
+ size: data.length,
16
+ sha256: createHash('sha256').update(data).digest('hex'),
17
+ data: data.toString('base64'),
18
+ ...(page ? { page } : {})
19
+ }
20
+ }
21
+ export async function readBounded(file, limit = OUTPUT_LIMIT) {
22
+ const stat = await lstat(file)
23
+ if (!stat.isFile() || stat.isSymbolicLink()) fail('INVALID_DOCUMENT')
24
+ if (stat.size > limit) fail('OUTPUT_TOO_LARGE')
25
+ const data = await readFile(file)
26
+ if (data.length > limit) fail('OUTPUT_TOO_LARGE')
27
+ return data
28
+ }
29
+ export async function safeOutput(root, relative) {
30
+ if (
31
+ !relative ||
32
+ path.isAbsolute(relative) ||
33
+ /[\\\0]/.test(relative) ||
34
+ relative.split('/').some((p) => !p || p === '..' || p === '.')
35
+ )
36
+ fail('INVALID_DOCUMENT')
37
+ const target = path.join(root, relative)
38
+ let current = root
39
+ for (const part of relative.split('/')) {
40
+ current = path.join(current, part)
41
+ if ((await lstat(current)).isSymbolicLink()) fail('INVALID_DOCUMENT')
42
+ }
43
+ const resolved = await realpath(target)
44
+ if (!resolved.startsWith((await realpath(root)) + path.sep)) fail('INVALID_DOCUMENT')
45
+ return target
46
+ }
47
+ export async function checkOutputTree(root) {
48
+ let total = 0,
49
+ count = 0
50
+ async function visit(dir) {
51
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
52
+ if (++count > 5000 || entry.isSymbolicLink()) fail('OUTPUT_TOO_LARGE')
53
+ const file = path.join(dir, entry.name)
54
+ if (entry.isDirectory()) await visit(file)
55
+ else {
56
+ const stat = await lstat(file)
57
+ if (!stat.isFile()) fail('INVALID_DOCUMENT')
58
+ total += stat.size
59
+ if (total > OUTPUT_LIMIT) fail('OUTPUT_TOO_LARGE')
60
+ }
61
+ }
62
+ }
63
+ await visit(root)
64
+ }
65
+ export function finish(result) {
66
+ if (!result.markdown.trim()) fail('EMPTY_TEXT')
67
+ if (result.assets.length > ASSET_LIMIT) fail('OUTPUT_TOO_LARGE')
68
+ const decoded = Buffer.byteLength(result.markdown) + result.assets.reduce((n, a) => n + a.size, 0)
69
+ if (decoded > OUTPUT_LIMIT || Buffer.byteLength(JSON.stringify(result)) > OUTPUT_LIMIT) fail('OUTPUT_TOO_LARGE')
70
+ return { ok: true, ...result }
71
+ }
@@ -0,0 +1,61 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { convert } from './convert.mjs'
4
+ const argument = (name) => {
5
+ const index = process.argv.indexOf(name)
6
+ if (index < 0 || !process.argv[index + 1]) throw new Error('Invalid Action arguments')
7
+ return path.resolve(process.argv[index + 1])
8
+ }
9
+ try {
10
+ const requestPath = argument('--request'),
11
+ output = argument('--output')
12
+ const request = JSON.parse(await readFile(requestPath, 'utf8'))
13
+ if (
14
+ request.contractVersion !== '1' ||
15
+ request.action !== 'opendataloader.convert' ||
16
+ request.actionVersion !== '1.2.1'
17
+ )
18
+ throw new Error('Invalid Action contract')
19
+ await mkdir(output, { recursive: true })
20
+ let result
21
+ try {
22
+ result = await convert(
23
+ path.join(path.dirname(requestPath), 'source.bin'),
24
+ request.payload?.extension,
25
+ output,
26
+ request.payload?.ocrConfidenceThreshold
27
+ )
28
+ } catch (error) {
29
+ const codes = [
30
+ 'EMPTY_FILE',
31
+ 'EMPTY_TEXT',
32
+ 'INPUT_TOO_LARGE',
33
+ 'OUTPUT_TOO_LARGE',
34
+ 'INVALID_DOCUMENT',
35
+ 'ENCRYPTED',
36
+ 'UNSUPPORTED_FORMAT',
37
+ 'NEEDS_OCR',
38
+ 'OCR_FAILED',
39
+ 'INCOMPLETE_PAGES',
40
+ 'RESOURCE_LIMIT',
41
+ 'RUNTIME_INVALID',
42
+ 'INVALID_CONFIG'
43
+ ]
44
+ result = {
45
+ ok: false,
46
+ code: codes.includes(error?.code) ? error.code : 'INVALID_DOCUMENT',
47
+ ...(Array.isArray(error?.pages)
48
+ ? { pages: error.pages.filter((n) => Number.isSafeInteger(n) && n > 0 && n <= 10000).slice(0, 100) }
49
+ : {})
50
+ }
51
+ }
52
+ await writeFile(path.join(output, 'result.json'), JSON.stringify(result))
53
+ if (!result.ok) {
54
+ // Core must persist a failed Job so a later attempt can run after recovery.
55
+ process.stderr.write(`OPENDATALOADER_CONVERSION_ERROR: ${JSON.stringify(result)}\n`)
56
+ process.exitCode = 1
57
+ }
58
+ } catch {
59
+ process.stderr.write('EXPORT_OUTPUT_INVALID: Document conversion Action failed.\n')
60
+ process.exitCode = 1
61
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@xpert-ai/plugin-opendataloader",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ "./package.json": "./package.json",
10
+ ".": {
11
+ "@xpert-plugins-starter/source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ ".xpertai-plugin",
20
+ "!**/*.tsbuildinfo"
21
+ ],
22
+ "dependencies": {
23
+ "tslib": "^2.3.0"
24
+ },
25
+ "license": "AGPL-3.0",
26
+ "author": "XpertAI",
27
+ "description": "OpenDataLoader PDF knowledge document parser using platform-managed Sandbox Jobs.",
28
+ "peerDependencies": {
29
+ "@nestjs/common": "^11.1.6",
30
+ "@xpert-ai/plugin-sdk": "^3.17.3",
31
+ "zod": "3.25.67"
32
+ },
33
+ "xpert": {
34
+ "plugin": {
35
+ "level": "system",
36
+ "artifactNamespace": "opendataloader"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "test:runtime": "node --test tests/*.test.mjs"
41
+ }
42
+ }