@tiangong-ai/cli 0.0.6 → 0.0.8
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/AGENTS.md +2 -2
- package/README.md +35 -23
- package/dist/cli.js +455 -189
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { createReadStream, openAsBlob, readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { basename, dirname, extname, join, posix as pathPosix, relative, resolve, sep, } from "node:path";
|
|
6
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
@@ -9,19 +9,18 @@ import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
|
9
9
|
import sharp from "sharp";
|
|
10
10
|
export const DEFAULT_API_BASE_URL = "https://thuenv.tiangong.world:7300";
|
|
11
11
|
export const DEFAULT_API_PATH_PREFIX = "/api/v1/kb";
|
|
12
|
-
const DEFAULT_CONCURRENCY = 3;
|
|
13
12
|
const DEFAULT_RETRIES = 3;
|
|
14
|
-
const DEFAULT_MANIFEST = ".tiangong-kb-ingest-manifest.jsonl";
|
|
15
13
|
const DEFAULT_BULK_WINDOW_SIZE = 100;
|
|
16
14
|
const DEFAULT_BULK_TOP_UP_MAX = 50;
|
|
17
15
|
const DEFAULT_BULK_UPLOAD_CONCURRENCY = 4;
|
|
18
16
|
const DEFAULT_BULK_POLL_INTERVAL_SECONDS = 30;
|
|
17
|
+
const DEFAULT_BULK_MAX_POLLS = 120;
|
|
19
18
|
const DEFAULT_BULK_MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
|
|
20
19
|
const DEFAULT_BULK_DERIVED_DIR = ".tiangong-kb-ingest-derived";
|
|
21
20
|
const DOCX_TARGET_IMAGE_DPI = 300;
|
|
21
|
+
const DOCX_NORMALIZE_MIN_BYTES = 10 * 1024 * 1024;
|
|
22
22
|
const EMUS_PER_INCH = 914400;
|
|
23
23
|
const RETRYABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
24
|
-
const TERMINAL_STATUSES = new Set(["completed", "failed", "deleted"]);
|
|
25
24
|
const BULK_FAILED_STATUSES = new Set(["failed", "deleted", "dead", "timeout"]);
|
|
26
25
|
const BULK_IN_FLIGHT_STATUSES = new Set(["uploaded", "waiting_for_index_flags", "uploading"]);
|
|
27
26
|
const BULK_SUPPORTED_EXTENSIONS = new Set([
|
|
@@ -40,8 +39,6 @@ const BULK_SUPPORTED_EXTENSIONS = new Set([
|
|
|
40
39
|
".xls",
|
|
41
40
|
".xlsx",
|
|
42
41
|
]);
|
|
43
|
-
const DEFAULT_POLL_INTERVAL_SECONDS = 2;
|
|
44
|
-
const DEFAULT_WAIT_TIMEOUT_SECONDS = 300;
|
|
45
42
|
class CliError extends Error {
|
|
46
43
|
exitCode;
|
|
47
44
|
constructor(message, exitCode = 1) {
|
|
@@ -249,6 +246,7 @@ async function kbIngestStatus(argv, io) {
|
|
|
249
246
|
`status=${job.status}`,
|
|
250
247
|
`state=${job.statePath}`,
|
|
251
248
|
`root=${job.rootPath}`,
|
|
249
|
+
`pressure=${job.pipelineHealth?.pressure ?? "unknown"} action=${job.pipelineHealth?.recommendedAction ?? "continue"}`,
|
|
252
250
|
`files=${summary.total} pending=${summary.pending} inflight=${summary.inflight} completed=${summary.completed} failed=${summary.failed}`,
|
|
253
251
|
"",
|
|
254
252
|
].join("\n"));
|
|
@@ -258,64 +256,10 @@ async function kbIngestStatus(argv, io) {
|
|
|
258
256
|
}
|
|
259
257
|
async function kbIngest(argv, io) {
|
|
260
258
|
const args = parseArgs(argv);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (!targetPath)
|
|
264
|
-
throw new CliError("Usage: tiangong-ai kb ingest <file-or-folder>");
|
|
265
|
-
const selector = resolveCollectionSelector(args, io.env);
|
|
266
|
-
const selectorFields = await resolveSelectorFields(config, selector);
|
|
267
|
-
const recursive = getBoolean(args, "recursive");
|
|
268
|
-
const force = getBoolean(args, "force");
|
|
269
|
-
const waitForTerminal = getBoolean(args, "wait");
|
|
270
|
-
const concurrency = positiveIntegerValue(getString(args, "concurrency") ?? firstEnv(io.env, "TIANGONG_KB_UPLOAD_CONCURRENCY"), DEFAULT_CONCURRENCY, "--concurrency");
|
|
271
|
-
const retries = nonNegativeIntegerValue(getString(args, "retries") ?? firstEnv(io.env, "TIANGONG_KB_UPLOAD_RETRIES"), DEFAULT_RETRIES, "--retries");
|
|
272
|
-
const manifestPath = resolve(getString(args, "manifest") ??
|
|
273
|
-
firstEnv(io.env, "TIANGONG_KB_MANIFEST_PATH") ??
|
|
274
|
-
DEFAULT_MANIFEST);
|
|
275
|
-
const metadata = await loadMetadata(args);
|
|
276
|
-
const files = await collectFiles(resolve(targetPath), recursive);
|
|
277
|
-
const manifest = await loadManifest(manifestPath);
|
|
278
|
-
const plans = await Promise.all(files.map((file) => fingerprintFile(file)));
|
|
279
|
-
const uploadPlans = plans.filter((plan) => force || manifest.get(plan.manifestKey)?.status !== "succeeded");
|
|
280
|
-
await mkdir(dirname(manifestPath), { recursive: true });
|
|
281
|
-
if (uploadPlans.length === 0) {
|
|
282
|
-
const summary = { total: plans.length, uploaded: 0, skipped: plans.length, failed: 0 };
|
|
283
|
-
writeJsonOrText(io.stdout, args, summary, () => `No files to upload; ${plans.length} already succeeded.\n`);
|
|
284
|
-
return 0;
|
|
285
|
-
}
|
|
286
|
-
const results = await runPool(uploadPlans, concurrency, async (plan) => {
|
|
287
|
-
const result = await uploadWithRetries({
|
|
288
|
-
args,
|
|
289
|
-
config,
|
|
290
|
-
selectorFields,
|
|
291
|
-
plan,
|
|
292
|
-
metadata,
|
|
293
|
-
retries,
|
|
294
|
-
waitForTerminal,
|
|
295
|
-
env: io.env,
|
|
296
|
-
});
|
|
297
|
-
await appendManifest(manifestPath, result);
|
|
298
|
-
if (!getBoolean(args, "json")) {
|
|
299
|
-
write(io.stdout, formatUploadLine(result));
|
|
300
|
-
}
|
|
301
|
-
return result;
|
|
302
|
-
});
|
|
303
|
-
const failed = results.filter((item) => item.status === "failed").length;
|
|
304
|
-
const summary = {
|
|
305
|
-
total: plans.length,
|
|
306
|
-
uploaded: results.filter((item) => item.status === "succeeded").length,
|
|
307
|
-
skipped: plans.length - uploadPlans.length,
|
|
308
|
-
failed,
|
|
309
|
-
manifest: manifestPath,
|
|
310
|
-
results,
|
|
311
|
-
};
|
|
312
|
-
if (getBoolean(args, "json")) {
|
|
313
|
-
write(io.stdout, `${JSON.stringify(summary, null, 2)}\n`);
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
write(io.stdout, `Summary: uploaded=${summary.uploaded} skipped=${summary.skipped} failed=${failed}\n`);
|
|
259
|
+
if (args.flags.has("manifest")) {
|
|
260
|
+
throw new CliError("The --manifest option was removed. Use kb ingest bulk --state for checkpoints.");
|
|
317
261
|
}
|
|
318
|
-
return
|
|
262
|
+
return await kbIngestBulkRun(argv, io);
|
|
319
263
|
}
|
|
320
264
|
async function kbIngestBulk(argv, io) {
|
|
321
265
|
const [verb, ...rest] = argv;
|
|
@@ -390,7 +334,7 @@ async function kbIngestBulkRun(argv, io) {
|
|
|
390
334
|
const args = parseArgs(argv);
|
|
391
335
|
const rootPath = args.positionals[0];
|
|
392
336
|
if (!rootPath)
|
|
393
|
-
throw new CliError("Usage: tiangong-ai kb ingest bulk <folder>");
|
|
337
|
+
throw new CliError("Usage: tiangong-ai kb ingest bulk <file-or-folder>");
|
|
394
338
|
const config = resolveConfig(args, io.env);
|
|
395
339
|
const root = resolve(rootPath);
|
|
396
340
|
const selector = resolveCollectionSelector(args, io.env);
|
|
@@ -400,7 +344,8 @@ async function kbIngestBulkRun(argv, io) {
|
|
|
400
344
|
const metadataMap = await loadMetadataMap(args);
|
|
401
345
|
const files = await collectBulkFilePlans(root, true, args);
|
|
402
346
|
const schemaSnapshot = await loadSchemaSnapshot(args, io.env, config, selector);
|
|
403
|
-
const
|
|
347
|
+
const preflightOptions = preflightOptionsFromArgs(args, schemaSnapshot, root, false);
|
|
348
|
+
const preflightPlans = await prepareBulkPreflightPlans(files, preflightOptions);
|
|
404
349
|
const dryRunSummary = metadataDryRun(preflightPlans.allPlans, metadataMap, schemaSnapshot);
|
|
405
350
|
dryRunSummary.preflight = buildPreflightSummary(preflightPlans.allPlans, preflightPlans.maxUploadBytes);
|
|
406
351
|
await initializeBulkJob({
|
|
@@ -421,6 +366,7 @@ async function kbIngestBulkRun(argv, io) {
|
|
|
421
366
|
metadataMap,
|
|
422
367
|
env: io.env,
|
|
423
368
|
stdout: io.stdout,
|
|
369
|
+
preflightOptions,
|
|
424
370
|
});
|
|
425
371
|
writeJsonOrText(io.stdout, args, result, () => formatBulkRunSummary(result));
|
|
426
372
|
return result.failed > 0 || result.blocked > 0 ? 1 : 0;
|
|
@@ -449,7 +395,7 @@ async function kbIngestJobs(argv, io) {
|
|
|
449
395
|
}
|
|
450
396
|
}))).filter((job) => Boolean(job));
|
|
451
397
|
writeJsonOrText(io.stdout, args, { jobs }, () => jobs
|
|
452
|
-
.map((job) => `${job.jobId}\t${job.status}\t${job.summary.completed}/${job.summary.total}\t${job.statePath}`)
|
|
398
|
+
.map((job) => `${job.jobId}\t${job.status}\t${job.summary.completed}/${job.summary.total}\tpressure=${job.pipelineHealth?.pressure ?? "unknown"}\taction=${job.pipelineHealth?.recommendedAction ?? "continue"}\t${job.statePath}`)
|
|
453
399
|
.join("\n")
|
|
454
400
|
.concat(jobs.length ? "\n" : ""));
|
|
455
401
|
return 0;
|
|
@@ -465,6 +411,7 @@ async function kbIngestResume(argv, io) {
|
|
|
465
411
|
const job = await readBulkJob(statePath);
|
|
466
412
|
const config = resolveConfig(args, io.env);
|
|
467
413
|
const selectorFields = await resolveSelectorFields(config, job.collectionSelector);
|
|
414
|
+
const preflightOptions = preflightOptionsFromArgs(args, job.schemaSnapshot, job.rootPath, false);
|
|
468
415
|
const result = await runBulkLoop({
|
|
469
416
|
args,
|
|
470
417
|
config,
|
|
@@ -473,6 +420,7 @@ async function kbIngestResume(argv, io) {
|
|
|
473
420
|
metadataMap: job.metadataMap,
|
|
474
421
|
env: io.env,
|
|
475
422
|
stdout: io.stdout,
|
|
423
|
+
preflightOptions,
|
|
476
424
|
});
|
|
477
425
|
writeJsonOrText(io.stdout, args, result, () => formatBulkRunSummary(result));
|
|
478
426
|
return result.failed > 0 || result.blocked > 0 ? 1 : 0;
|
|
@@ -487,6 +435,7 @@ async function kbIngestExport(argv, io) {
|
|
|
487
435
|
throw new CliError(`Bulk job not found: ${jobId}`);
|
|
488
436
|
const format = getString(args, "format") ?? "jsonl";
|
|
489
437
|
const rows = await readBulkFiles(statePath);
|
|
438
|
+
const job = await readBulkJob(statePath);
|
|
490
439
|
if (format === "csv") {
|
|
491
440
|
write(io.stdout, [
|
|
492
441
|
csvLine([
|
|
@@ -534,7 +483,7 @@ async function kbIngestExport(argv, io) {
|
|
|
534
483
|
throw new CliError("--format must be jsonl, json, or csv.");
|
|
535
484
|
}
|
|
536
485
|
if (format === "json") {
|
|
537
|
-
write(io.stdout, `${JSON.stringify({ files: rows }, null, 2)}\n`);
|
|
486
|
+
write(io.stdout, `${JSON.stringify({ job, files: rows }, null, 2)}\n`);
|
|
538
487
|
}
|
|
539
488
|
else {
|
|
540
489
|
write(io.stdout, rows
|
|
@@ -547,37 +496,51 @@ async function kbIngestExport(argv, io) {
|
|
|
547
496
|
async function runBulkLoop(input) {
|
|
548
497
|
const windowSize = getPositiveInteger(input.args, "window-size", DEFAULT_BULK_WINDOW_SIZE);
|
|
549
498
|
const topUpMax = getPositiveInteger(input.args, "top-up-max", DEFAULT_BULK_TOP_UP_MAX);
|
|
550
|
-
const uploadConcurrency =
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
499
|
+
const uploadConcurrency = positiveIntegerValue(getString(input.args, "upload-concurrency") ??
|
|
500
|
+
getString(input.args, "concurrency") ??
|
|
501
|
+
firstEnv(input.env, "TIANGONG_KB_UPLOAD_CONCURRENCY"), DEFAULT_BULK_UPLOAD_CONCURRENCY, "--upload-concurrency");
|
|
502
|
+
const pollInterval = positiveNumberValue(getString(input.args, "poll-interval") ?? firstEnv(input.env, "TIANGONG_KB_BULK_POLL_INTERVAL"), DEFAULT_BULK_POLL_INTERVAL_SECONDS, "--poll-interval");
|
|
503
|
+
const retries = nonNegativeIntegerValue(getString(input.args, "retries") ?? firstEnv(input.env, "TIANGONG_KB_UPLOAD_RETRIES"), DEFAULT_RETRIES, "--retries");
|
|
504
|
+
const maxPolls = getNonNegativeInteger(input.args, "max-polls", nonNegativeIntegerValue(firstEnv(input.env, "TIANGONG_KB_BULK_MAX_POLLS"), DEFAULT_BULK_MAX_POLLS, "TIANGONG_KB_BULK_MAX_POLLS"));
|
|
554
505
|
let polls = 0;
|
|
555
506
|
await resetInterruptedBulkUploads(input.statePath);
|
|
556
507
|
await updateJobStatus(input.statePath, "running");
|
|
508
|
+
let lastPipelineHealth;
|
|
557
509
|
while (true) {
|
|
558
510
|
polls += 1;
|
|
559
511
|
await pollBulkStatuses(input.statePath, input.config);
|
|
560
512
|
const summary = await bulkJobSummary(input.statePath);
|
|
561
513
|
const capacity = Math.max(0, windowSize - summary.inflight);
|
|
562
|
-
|
|
514
|
+
lastPipelineHealth = await readBulkPipelineHealth(input.config, pollInterval);
|
|
515
|
+
await saveBulkPipelineHealth(input.statePath, lastPipelineHealth);
|
|
516
|
+
const uploadLimit = bulkUploadLimitForHealth(Math.min(capacity, topUpMax), lastPipelineHealth);
|
|
517
|
+
const effectiveUploadConcurrency = lastPipelineHealth.recommendedAction === "slow_down" ? 1 : uploadConcurrency;
|
|
563
518
|
if (uploadLimit > 0) {
|
|
564
519
|
const pending = await claimPendingBulkFiles(input.statePath, uploadLimit);
|
|
565
|
-
await runPool(pending,
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
520
|
+
await runPool(pending, effectiveUploadConcurrency, async (row) => {
|
|
521
|
+
const materialized = await materializeBulkFileForUpload({
|
|
522
|
+
statePath: input.statePath,
|
|
523
|
+
metadataMap: input.metadataMap,
|
|
524
|
+
preflightOptions: input.preflightOptions,
|
|
570
525
|
row,
|
|
571
|
-
retries,
|
|
572
|
-
env: input.env,
|
|
573
526
|
});
|
|
574
|
-
await
|
|
575
|
-
|
|
527
|
+
return await runPool(materialized.uploadRows, 1, async (uploadRow) => {
|
|
528
|
+
const result = await uploadBulkFile({
|
|
529
|
+
args: input.args,
|
|
530
|
+
config: input.config,
|
|
531
|
+
selectorFields: input.selectorFields,
|
|
532
|
+
row: uploadRow,
|
|
533
|
+
retries,
|
|
534
|
+
env: input.env,
|
|
535
|
+
});
|
|
536
|
+
await saveBulkUploadResult(input.statePath, uploadRow.relativePath, result);
|
|
537
|
+
return result;
|
|
538
|
+
});
|
|
576
539
|
});
|
|
577
540
|
}
|
|
578
541
|
const nextSummary = await bulkJobSummary(input.statePath);
|
|
579
542
|
if (getBoolean(input.args, "verbose")) {
|
|
580
|
-
write(input.stdout, `poll=${polls} pending=${nextSummary.pending} inflight=${nextSummary.inflight} completed=${nextSummary.completed} failed=${nextSummary.failed} waiting_for_index_flags=${nextSummary.waitingForIndexFlags}\n`);
|
|
543
|
+
write(input.stdout, `poll=${polls} pending=${nextSummary.pending} inflight=${nextSummary.inflight} completed=${nextSummary.completed} failed=${nextSummary.failed} waiting_for_index_flags=${nextSummary.waitingForIndexFlags} pressure=${lastPipelineHealth.pressure} action=${lastPipelineHealth.recommendedAction}\n`);
|
|
581
544
|
}
|
|
582
545
|
if (nextSummary.pending === 0 && nextSummary.inflight === 0) {
|
|
583
546
|
await updateJobStatus(input.statePath, nextSummary.failed > 0 || nextSummary.blocked > 0 ? "failed" : "completed");
|
|
@@ -586,6 +549,7 @@ async function runBulkLoop(input) {
|
|
|
586
549
|
jobId: jobIdFromStatePath(input.statePath),
|
|
587
550
|
statePath: input.statePath,
|
|
588
551
|
polls,
|
|
552
|
+
pipelineHealth: lastPipelineHealth,
|
|
589
553
|
};
|
|
590
554
|
}
|
|
591
555
|
if (maxPolls > 0 && polls >= maxPolls) {
|
|
@@ -595,11 +559,28 @@ async function runBulkLoop(input) {
|
|
|
595
559
|
jobId: jobIdFromStatePath(input.statePath),
|
|
596
560
|
statePath: input.statePath,
|
|
597
561
|
polls,
|
|
562
|
+
pipelineHealth: lastPipelineHealth,
|
|
598
563
|
};
|
|
599
564
|
}
|
|
600
|
-
await sleep(pollInterval * 1000);
|
|
565
|
+
await sleep(bulkPollIntervalForHealth(pollInterval, lastPipelineHealth) * 1000);
|
|
601
566
|
}
|
|
602
567
|
}
|
|
568
|
+
function bulkUploadLimitForHealth(limit, health) {
|
|
569
|
+
if (limit <= 0)
|
|
570
|
+
return 0;
|
|
571
|
+
if (health.recommendedAction === "pause_top_up")
|
|
572
|
+
return 0;
|
|
573
|
+
if (health.recommendedAction === "slow_down")
|
|
574
|
+
return Math.max(1, Math.ceil(limit / 2));
|
|
575
|
+
return limit;
|
|
576
|
+
}
|
|
577
|
+
function bulkPollIntervalForHealth(baseSeconds, health) {
|
|
578
|
+
if (!health)
|
|
579
|
+
return baseSeconds;
|
|
580
|
+
if (health.recommendedAction === "continue")
|
|
581
|
+
return baseSeconds;
|
|
582
|
+
return Math.max(baseSeconds, health.recommendedPollAfterSeconds);
|
|
583
|
+
}
|
|
603
584
|
async function uploadBulkFile(input) {
|
|
604
585
|
const plan = {
|
|
605
586
|
path: input.row.path,
|
|
@@ -607,7 +588,7 @@ async function uploadBulkFile(input) {
|
|
|
607
588
|
size: input.row.size,
|
|
608
589
|
mtimeMs: input.row.mtimeMs,
|
|
609
590
|
sha256: input.row.sha256,
|
|
610
|
-
|
|
591
|
+
uploadKey: `${input.row.sha256}:${input.row.size}`,
|
|
611
592
|
};
|
|
612
593
|
return uploadWithRetries({
|
|
613
594
|
args: input.args,
|
|
@@ -616,10 +597,146 @@ async function uploadBulkFile(input) {
|
|
|
616
597
|
plan,
|
|
617
598
|
metadata: input.row.metadataJson,
|
|
618
599
|
retries: input.retries,
|
|
619
|
-
waitForTerminal: false,
|
|
620
600
|
env: input.env,
|
|
621
601
|
});
|
|
622
602
|
}
|
|
603
|
+
async function materializeBulkFileForUpload(input) {
|
|
604
|
+
if (input.row.ingestVariant === "direct_upload") {
|
|
605
|
+
return { uploadRows: [input.row] };
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
const preflightOptions = materializeOptionsForRow(input.preflightOptions, input.row);
|
|
609
|
+
if (input.row.ingestVariant === "compressed_docx") {
|
|
610
|
+
const docxRow = await materializeDocxBulkRow(input.statePath, input.metadataMap, preflightOptions, input.row);
|
|
611
|
+
return { uploadRows: docxRow ? [docxRow] : [] };
|
|
612
|
+
}
|
|
613
|
+
if (input.row.ingestVariant === "page_split_pdf") {
|
|
614
|
+
const pdfRows = await materializePdfBulkRow(input.statePath, input.metadataMap, preflightOptions, input.row);
|
|
615
|
+
return { uploadRows: pdfRows };
|
|
616
|
+
}
|
|
617
|
+
await markBulkFileFailed(input.statePath, input.row.relativePath, "UNSUPPORTED_INGEST_VARIANT");
|
|
618
|
+
return { uploadRows: [] };
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
await markBulkFileFailed(input.statePath, input.row.relativePath, error instanceof Error ? error.message : String(error));
|
|
622
|
+
return { uploadRows: [] };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function materializeOptionsForRow(options, row) {
|
|
626
|
+
const rowMaxUploadBytes = Number(row.preflight.maxUploadBytes);
|
|
627
|
+
return {
|
|
628
|
+
...options,
|
|
629
|
+
maxUploadBytes: Number.isFinite(rowMaxUploadBytes) && rowMaxUploadBytes > 0
|
|
630
|
+
? rowMaxUploadBytes
|
|
631
|
+
: options.maxUploadBytes,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
async function materializeDocxBulkRow(statePath, metadataMap, options, row) {
|
|
635
|
+
if (row.derivedPath && row.derivedSize !== undefined && row.derivedSha256) {
|
|
636
|
+
const existing = await stat(row.derivedPath).catch(() => undefined);
|
|
637
|
+
if (existing?.isFile() && existing.size === row.derivedSize)
|
|
638
|
+
return row;
|
|
639
|
+
}
|
|
640
|
+
const source = bulkRecordToOriginalPlan(row);
|
|
641
|
+
const docx = await analyzeDocx(source.originalPath);
|
|
642
|
+
if (isEmptyDocxAnalysis(docx)) {
|
|
643
|
+
await markBulkFileSkipped(statePath, row.relativePath, "empty_docx");
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
const materialized = await createDocxIngestCopy(source, options, docx, row.classification);
|
|
647
|
+
return await updateMaterializedBulkRow(statePath, metadataMap, row.relativePath, materialized);
|
|
648
|
+
}
|
|
649
|
+
async function materializePdfBulkRow(statePath, metadataMap, options, row) {
|
|
650
|
+
if (row.partIndex !== undefined && row.derivedPath) {
|
|
651
|
+
const existing = await stat(row.derivedPath).catch(() => undefined);
|
|
652
|
+
if (existing?.isFile() && existing.size === row.derivedSize)
|
|
653
|
+
return [row];
|
|
654
|
+
}
|
|
655
|
+
const source = bulkRecordToOriginalPlan(row);
|
|
656
|
+
const pdf = (await analyzePdf(source.originalPath).catch((error) => ({
|
|
657
|
+
error: error instanceof Error ? error.message : String(error),
|
|
658
|
+
pageCount: 0,
|
|
659
|
+
imageCount: 0,
|
|
660
|
+
imageHeavy: false,
|
|
661
|
+
})));
|
|
662
|
+
const partPlans = await createPdfPartPlans(source, options, pdf, row.classification);
|
|
663
|
+
if (row.partIndex !== undefined) {
|
|
664
|
+
const currentPart = partPlans.find((part) => part.relativePath === row.relativePath);
|
|
665
|
+
if (!currentPart) {
|
|
666
|
+
await markBulkFileFailed(statePath, row.relativePath, "PDF_SPLIT_PART_REGENERATION_MISMATCH");
|
|
667
|
+
return [];
|
|
668
|
+
}
|
|
669
|
+
return [await updateMaterializedBulkRow(statePath, metadataMap, row.relativePath, currentPart)];
|
|
670
|
+
}
|
|
671
|
+
const now = new Date().toISOString();
|
|
672
|
+
const db = await openSqlite(statePath);
|
|
673
|
+
try {
|
|
674
|
+
createBulkSchema(db);
|
|
675
|
+
db.exec("BEGIN");
|
|
676
|
+
try {
|
|
677
|
+
for (const part of partPlans) {
|
|
678
|
+
insertBulkFilePlan(db, metadataMap, part, bulkInitialStatus(part), now);
|
|
679
|
+
}
|
|
680
|
+
db.prepare("DELETE FROM files WHERE relative_path = ?").run(row.relativePath);
|
|
681
|
+
touchJob(db, now);
|
|
682
|
+
db.exec("COMMIT");
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
db.exec("ROLLBACK");
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
db.close();
|
|
691
|
+
}
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
async function updateMaterializedBulkRow(statePath, metadataMap, relativePath, file) {
|
|
695
|
+
const evaluated = evaluateMetadata(metadataMap, file);
|
|
696
|
+
const now = new Date().toISOString();
|
|
697
|
+
const db = await openSqlite(statePath);
|
|
698
|
+
try {
|
|
699
|
+
createBulkSchema(db);
|
|
700
|
+
db.prepare(`UPDATE files
|
|
701
|
+
SET path = ?, size = ?, mtime_ms = ?, sha256 = ?, ext = ?, path_segments_json = ?, path_depth = ?,
|
|
702
|
+
metadata_json = ?, matched_rules_json = ?, classification = ?, ingest_variant = ?, source_document_key = ?,
|
|
703
|
+
derived_path = ?, derived_size = ?, derived_sha256 = ?, normalize_strategy = ?, generated_metadata_json = ?,
|
|
704
|
+
preflight_json = ?, updated_at = ?
|
|
705
|
+
WHERE relative_path = ?`).run(file.path, file.size, file.mtimeMs, file.sha256, file.ext, JSON.stringify(file.pathSegments), file.pathDepth, JSON.stringify(evaluated.metadata), JSON.stringify(evaluated.matchedRules), file.classification, file.ingestVariant, file.sourceDocumentKey, file.derivedPath ?? null, file.derivedSize ?? null, file.derivedSha256 ?? null, file.normalizeStrategy ?? null, JSON.stringify(file.generatedMetadata), JSON.stringify(file.preflight), now, relativePath);
|
|
706
|
+
touchJob(db, now);
|
|
707
|
+
const updated = db.prepare("SELECT * FROM files WHERE relative_path = ?").get(relativePath);
|
|
708
|
+
if (!updated)
|
|
709
|
+
throw new CliError(`Bulk row disappeared during materialization: ${relativePath}`);
|
|
710
|
+
return rowToBulkFile(updated);
|
|
711
|
+
}
|
|
712
|
+
finally {
|
|
713
|
+
db.close();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async function markBulkFileFailed(statePath, relativePath, error) {
|
|
717
|
+
const db = await openSqlite(statePath);
|
|
718
|
+
const now = new Date().toISOString();
|
|
719
|
+
try {
|
|
720
|
+
createBulkSchema(db);
|
|
721
|
+
db.prepare("UPDATE files SET status = 'failed', last_error = ?, updated_at = ? WHERE relative_path = ?").run(error, now, relativePath);
|
|
722
|
+
touchJob(db, now);
|
|
723
|
+
}
|
|
724
|
+
finally {
|
|
725
|
+
db.close();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
async function markBulkFileSkipped(statePath, relativePath, reason) {
|
|
729
|
+
const db = await openSqlite(statePath);
|
|
730
|
+
const now = new Date().toISOString();
|
|
731
|
+
try {
|
|
732
|
+
createBulkSchema(db);
|
|
733
|
+
db.prepare("UPDATE files SET status = 'skipped', last_error = ?, updated_at = ? WHERE relative_path = ?").run(reason, now, relativePath);
|
|
734
|
+
touchJob(db, now);
|
|
735
|
+
}
|
|
736
|
+
finally {
|
|
737
|
+
db.close();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
623
740
|
async function pollBulkStatuses(statePath, config) {
|
|
624
741
|
const inflight = await readInflightBulkFiles(statePath);
|
|
625
742
|
const documentIds = inflight.map((row) => row.documentId).filter(Boolean);
|
|
@@ -645,6 +762,12 @@ async function pollBulkStatuses(statePath, config) {
|
|
|
645
762
|
}
|
|
646
763
|
}
|
|
647
764
|
function judgeBulkStatus(item) {
|
|
765
|
+
if (item.itemError) {
|
|
766
|
+
const message = `${item.itemError.code}: ${item.itemError.message}`;
|
|
767
|
+
return item.itemError.retryable
|
|
768
|
+
? { status: "uploaded", lastError: message }
|
|
769
|
+
: { status: "failed", lastError: message };
|
|
770
|
+
}
|
|
648
771
|
const status = item.status ?? "";
|
|
649
772
|
if (BULK_FAILED_STATUSES.has(status)) {
|
|
650
773
|
return {
|
|
@@ -698,9 +821,7 @@ function batchStatusItems(payload) {
|
|
|
698
821
|
: Array.isArray(data)
|
|
699
822
|
? data
|
|
700
823
|
: [];
|
|
701
|
-
return items
|
|
702
|
-
.filter(isObject)
|
|
703
|
-
.map((item) => statusItemFromPayload(item, stringField(item, "documentId") ?? stringField(item, "document_id") ?? ""));
|
|
824
|
+
return items.filter(isObject).map((item) => batchStatusItemFromPayload(item));
|
|
704
825
|
}
|
|
705
826
|
function statusItemFromPayload(payload, fallbackDocumentId = "") {
|
|
706
827
|
const data = responseData(payload);
|
|
@@ -717,6 +838,90 @@ function statusItemFromPayload(payload, fallbackDocumentId = "") {
|
|
|
717
838
|
raw: payload,
|
|
718
839
|
};
|
|
719
840
|
}
|
|
841
|
+
async function readBulkPipelineHealth(config, fallbackPollAfterSeconds) {
|
|
842
|
+
try {
|
|
843
|
+
const payload = await jsonRequest(config, "pipeline/health");
|
|
844
|
+
return pipelineHealthFromPayload(payload, fallbackPollAfterSeconds);
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
if (error instanceof HttpError && error.status && [404, 405, 501].includes(error.status)) {
|
|
848
|
+
return {
|
|
849
|
+
healthy: true,
|
|
850
|
+
pressure: "unknown",
|
|
851
|
+
recommendedAction: "continue",
|
|
852
|
+
recommendedPollAfterSeconds: fallbackPollAfterSeconds,
|
|
853
|
+
message: "Pipeline health endpoint is unavailable; continuing without backpressure.",
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
if (error instanceof HttpError) {
|
|
857
|
+
return {
|
|
858
|
+
healthy: false,
|
|
859
|
+
pressure: "paused",
|
|
860
|
+
recommendedAction: "pause_top_up",
|
|
861
|
+
recommendedPollAfterSeconds: error.retryAfterSeconds ?? Math.max(fallbackPollAfterSeconds, 30),
|
|
862
|
+
message: error.message,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
healthy: false,
|
|
867
|
+
pressure: "paused",
|
|
868
|
+
recommendedAction: "pause_top_up",
|
|
869
|
+
recommendedPollAfterSeconds: Math.max(fallbackPollAfterSeconds, 30),
|
|
870
|
+
message: error instanceof Error ? error.message : String(error),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function pipelineHealthFromPayload(payload, fallbackPollAfterSeconds) {
|
|
875
|
+
const data = responseData(payload);
|
|
876
|
+
if (!isObject(data)) {
|
|
877
|
+
throw new CliError("Pipeline health response did not contain an object payload.");
|
|
878
|
+
}
|
|
879
|
+
const action = stringField(data, "recommendedAction");
|
|
880
|
+
if (action !== "continue" && action !== "slow_down" && action !== "pause_top_up") {
|
|
881
|
+
throw new CliError("Pipeline health response did not contain a valid recommendedAction.");
|
|
882
|
+
}
|
|
883
|
+
const pressure = stringField(data, "pressure");
|
|
884
|
+
const pollAfter = Number(data.recommendedPollAfterSeconds);
|
|
885
|
+
return {
|
|
886
|
+
healthy: typeof data.healthy === "boolean" ? data.healthy : action === "continue",
|
|
887
|
+
pressure: pressure === "ok" || pressure === "degraded" || pressure === "paused" ? pressure : "unknown",
|
|
888
|
+
recommendedAction: action,
|
|
889
|
+
recommendedPollAfterSeconds: Number.isFinite(pollAfter) && pollAfter > 0 ? pollAfter : fallbackPollAfterSeconds,
|
|
890
|
+
checkedAt: stringField(data, "checkedAt"),
|
|
891
|
+
message: stringField(data, "message") ??
|
|
892
|
+
(isObject(data.indexPreflight) ? stringField(data.indexPreflight, "message") : undefined),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
async function saveBulkPipelineHealth(statePath, health) {
|
|
896
|
+
const db = await openSqlite(statePath);
|
|
897
|
+
const now = new Date().toISOString();
|
|
898
|
+
try {
|
|
899
|
+
createBulkSchema(db);
|
|
900
|
+
db.prepare("UPDATE jobs SET pipeline_health_json = ?, updated_at = ?").run(JSON.stringify(health), now);
|
|
901
|
+
}
|
|
902
|
+
finally {
|
|
903
|
+
db.close();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function batchStatusItemFromPayload(item) {
|
|
907
|
+
const documentId = stringField(item, "documentId") ?? stringField(item, "document_id") ?? "";
|
|
908
|
+
if (item.ok === true && isObject(item.status)) {
|
|
909
|
+
return statusItemFromPayload(item.status, documentId);
|
|
910
|
+
}
|
|
911
|
+
if (item.ok === false && isObject(item.error)) {
|
|
912
|
+
const error = item.error;
|
|
913
|
+
return {
|
|
914
|
+
documentId,
|
|
915
|
+
itemError: {
|
|
916
|
+
code: stringField(error, "code") ?? "STATUS_ITEM_ERROR",
|
|
917
|
+
message: stringField(error, "message") ?? "Document status lookup failed.",
|
|
918
|
+
retryable: typeof error.retryable === "boolean" ? error.retryable : false,
|
|
919
|
+
},
|
|
920
|
+
raw: item,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
return statusItemFromPayload(item, documentId);
|
|
924
|
+
}
|
|
720
925
|
async function initializeBulkJob(input) {
|
|
721
926
|
await mkdir(dirname(input.statePath), { recursive: true });
|
|
722
927
|
const db = await openSqlite(input.statePath);
|
|
@@ -728,15 +933,9 @@ async function initializeBulkJob(input) {
|
|
|
728
933
|
db.prepare(`INSERT INTO jobs (job_id, root_path, collection_selector_json, schema_snapshot_json, metadata_map_json, dry_run_summary_json, status, created_at, updated_at)
|
|
729
934
|
VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?)`).run(input.jobId, input.rootPath, JSON.stringify(input.selector), JSON.stringify(input.schemaSnapshot ?? null), JSON.stringify(input.metadataMap), JSON.stringify(input.dryRunSummary), now, now);
|
|
730
935
|
}
|
|
731
|
-
const insert = db.prepare(`INSERT OR IGNORE INTO files
|
|
732
|
-
(path, relative_path, size, mtime_ms, sha256, ext, path_segments_json, path_depth, metadata_json, matched_rules_json, status, attempts, created_at, updated_at,
|
|
733
|
-
original_path, original_relative_path, original_size, original_mtime_ms, original_sha256, original_ext, classification, ingest_variant, source_document_key,
|
|
734
|
-
derived_path, derived_size, derived_sha256, normalize_strategy, part_index, part_count, page_start, page_end, generated_metadata_json, preflight_json)
|
|
735
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
736
936
|
for (const file of input.files) {
|
|
737
|
-
const evaluated = evaluateMetadata(input.metadataMap, file);
|
|
738
937
|
const status = bulkInitialStatus(file);
|
|
739
|
-
|
|
938
|
+
insertBulkFilePlan(db, input.metadataMap, file, status, now);
|
|
740
939
|
}
|
|
741
940
|
touchJob(db, now);
|
|
742
941
|
}
|
|
@@ -751,6 +950,14 @@ async function openSqlite(path) {
|
|
|
751
950
|
db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
752
951
|
return db;
|
|
753
952
|
}
|
|
953
|
+
function insertBulkFilePlan(db, metadataMap, file, status, now) {
|
|
954
|
+
const evaluated = evaluateMetadata(metadataMap, file);
|
|
955
|
+
db.prepare(`INSERT OR IGNORE INTO files
|
|
956
|
+
(path, relative_path, size, mtime_ms, sha256, ext, path_segments_json, path_depth, metadata_json, matched_rules_json, status, attempts, created_at, updated_at,
|
|
957
|
+
original_path, original_relative_path, original_size, original_mtime_ms, original_sha256, original_ext, classification, ingest_variant, source_document_key,
|
|
958
|
+
derived_path, derived_size, derived_sha256, normalize_strategy, part_index, part_count, page_start, page_end, generated_metadata_json, preflight_json)
|
|
959
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(file.path, file.relativePath, file.size, file.mtimeMs, file.sha256, file.ext, JSON.stringify(file.pathSegments), file.pathDepth, JSON.stringify(evaluated.metadata), JSON.stringify(evaluated.matchedRules), status, now, now, file.originalPath, file.originalRelativePath, file.originalSize, file.originalMtimeMs, file.originalSha256, file.originalExt, file.classification, file.ingestVariant, file.sourceDocumentKey, file.derivedPath ?? null, file.derivedSize ?? null, file.derivedSha256 ?? null, file.normalizeStrategy ?? null, file.partIndex ?? null, file.partCount ?? null, file.pageStart ?? null, file.pageEnd ?? null, JSON.stringify(file.generatedMetadata), JSON.stringify(file.preflight));
|
|
960
|
+
}
|
|
754
961
|
function createBulkSchema(db) {
|
|
755
962
|
db.exec(`
|
|
756
963
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
@@ -760,6 +967,7 @@ function createBulkSchema(db) {
|
|
|
760
967
|
schema_snapshot_json TEXT,
|
|
761
968
|
metadata_map_json TEXT NOT NULL,
|
|
762
969
|
dry_run_summary_json TEXT,
|
|
970
|
+
pipeline_health_json TEXT,
|
|
763
971
|
status TEXT NOT NULL,
|
|
764
972
|
created_at TEXT NOT NULL,
|
|
765
973
|
updated_at TEXT NOT NULL
|
|
@@ -804,8 +1012,16 @@ function createBulkSchema(db) {
|
|
|
804
1012
|
CREATE INDEX IF NOT EXISTS idx_files_status ON files(status);
|
|
805
1013
|
CREATE INDEX IF NOT EXISTS idx_files_document_id ON files(document_id);
|
|
806
1014
|
`);
|
|
1015
|
+
ensureBulkJobColumns(db);
|
|
807
1016
|
ensureBulkFileColumns(db);
|
|
808
1017
|
}
|
|
1018
|
+
function ensureBulkJobColumns(db) {
|
|
1019
|
+
const rows = db.prepare("PRAGMA table_info(jobs)").all();
|
|
1020
|
+
const columns = new Set(rows.map((row) => String(row.name)));
|
|
1021
|
+
if (!columns.has("pipeline_health_json")) {
|
|
1022
|
+
db.exec("ALTER TABLE jobs ADD COLUMN pipeline_health_json TEXT;");
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
809
1025
|
function ensureBulkFileColumns(db) {
|
|
810
1026
|
const rows = db.prepare("PRAGMA table_info(files)").all();
|
|
811
1027
|
const columns = new Set(rows.map((row) => String(row.name)));
|
|
@@ -855,6 +1071,9 @@ async function readBulkJob(statePath) {
|
|
|
855
1071
|
dryRunSummary: row.dry_run_summary_json
|
|
856
1072
|
? JSON.parse(String(row.dry_run_summary_json))
|
|
857
1073
|
: undefined,
|
|
1074
|
+
pipelineHealth: row.pipeline_health_json
|
|
1075
|
+
? JSON.parse(String(row.pipeline_health_json))
|
|
1076
|
+
: undefined,
|
|
858
1077
|
createdAt: String(row.created_at),
|
|
859
1078
|
updatedAt: String(row.updated_at),
|
|
860
1079
|
};
|
|
@@ -944,7 +1163,8 @@ async function resetInterruptedBulkUploads(statePath) {
|
|
|
944
1163
|
const db = await openSqlite(statePath);
|
|
945
1164
|
const now = new Date().toISOString();
|
|
946
1165
|
try {
|
|
947
|
-
db
|
|
1166
|
+
createBulkSchema(db);
|
|
1167
|
+
db.prepare("UPDATE files SET status = 'pending', last_error = ?, updated_at = ? WHERE status IN ('uploading', 'planned') AND document_id IS NULL").run("Upload was interrupted before a document id was recorded.", now);
|
|
948
1168
|
touchJob(db, now);
|
|
949
1169
|
}
|
|
950
1170
|
finally {
|
|
@@ -1014,6 +1234,32 @@ function rowToBulkFile(row) {
|
|
|
1014
1234
|
: {},
|
|
1015
1235
|
};
|
|
1016
1236
|
}
|
|
1237
|
+
function bulkRecordToOriginalPlan(row) {
|
|
1238
|
+
const pathSegments = row.originalRelativePath.split("/").filter(Boolean);
|
|
1239
|
+
return {
|
|
1240
|
+
path: row.originalPath,
|
|
1241
|
+
filename: basename(row.originalPath),
|
|
1242
|
+
size: row.originalSize,
|
|
1243
|
+
mtimeMs: row.originalMtimeMs,
|
|
1244
|
+
sha256: row.originalSha256,
|
|
1245
|
+
uploadKey: row.originalRelativePath,
|
|
1246
|
+
relativePath: row.originalRelativePath,
|
|
1247
|
+
ext: row.originalExt,
|
|
1248
|
+
pathSegments,
|
|
1249
|
+
pathDepth: pathSegments.length,
|
|
1250
|
+
originalPath: row.originalPath,
|
|
1251
|
+
originalRelativePath: row.originalRelativePath,
|
|
1252
|
+
originalSize: row.originalSize,
|
|
1253
|
+
originalMtimeMs: row.originalMtimeMs,
|
|
1254
|
+
originalSha256: row.originalSha256,
|
|
1255
|
+
originalExt: row.originalExt,
|
|
1256
|
+
classification: row.classification,
|
|
1257
|
+
ingestVariant: row.ingestVariant,
|
|
1258
|
+
sourceDocumentKey: row.sourceDocumentKey,
|
|
1259
|
+
generatedMetadata: row.generatedMetadata,
|
|
1260
|
+
preflight: row.preflight,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1017
1263
|
async function resolveBulkStatePath(args, jobIdInput) {
|
|
1018
1264
|
const explicit = getString(args, "state");
|
|
1019
1265
|
if (explicit)
|
|
@@ -1048,10 +1294,16 @@ function jobIdFromStatePath(statePath) {
|
|
|
1048
1294
|
return basename(statePath).replace(/\.sqlite$/i, "");
|
|
1049
1295
|
}
|
|
1050
1296
|
async function collectBulkFilePlans(root, recursive, args) {
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1297
|
+
const item = await stat(root).catch(() => undefined);
|
|
1298
|
+
if (!item)
|
|
1299
|
+
throw new CliError(`Path not found: ${root}`);
|
|
1300
|
+
const relativeRoot = item.isFile() ? dirname(root) : root;
|
|
1301
|
+
const files = item.isFile()
|
|
1302
|
+
? [root]
|
|
1303
|
+
: await collectFiles(root, recursive, {
|
|
1304
|
+
excludeDirectories: bulkScanExcludeDirs(root, args),
|
|
1305
|
+
});
|
|
1306
|
+
return Promise.all(files.map((file) => fingerprintBulkFile(relativeRoot, file)));
|
|
1055
1307
|
}
|
|
1056
1308
|
function bulkScanExcludeDirs(root, args) {
|
|
1057
1309
|
const excludeDirectories = new Set([resolve(root, DEFAULT_BULK_DERIVED_DIR)]);
|
|
@@ -1121,12 +1373,31 @@ async function preflightOneBulkFile(file, options) {
|
|
|
1121
1373
|
const docx = await analyzeDocx(file.path).catch((error) => ({
|
|
1122
1374
|
error: error instanceof Error ? error.message : String(error),
|
|
1123
1375
|
}));
|
|
1376
|
+
if (isEmptyDocxAnalysis(docx)) {
|
|
1377
|
+
return [
|
|
1378
|
+
decorateBulkPlan(file, "empty", "skipped", {
|
|
1379
|
+
...docx,
|
|
1380
|
+
reason: "empty_docx",
|
|
1381
|
+
maxUploadBytes: options.maxUploadBytes,
|
|
1382
|
+
}),
|
|
1383
|
+
];
|
|
1384
|
+
}
|
|
1385
|
+
if (file.size <= DOCX_NORMALIZE_MIN_BYTES) {
|
|
1386
|
+
return [
|
|
1387
|
+
decorateBulkPlan(file, "direct_upload", "direct_upload", {
|
|
1388
|
+
...docx,
|
|
1389
|
+
maxUploadBytes: options.maxUploadBytes,
|
|
1390
|
+
normalizeThresholdBytes: DOCX_NORMALIZE_MIN_BYTES,
|
|
1391
|
+
}),
|
|
1392
|
+
];
|
|
1393
|
+
}
|
|
1124
1394
|
const classification = file.size > options.maxUploadBytes ? "oversize_docx_image_heavy" : "direct_upload";
|
|
1125
1395
|
if (!options.generateDerived) {
|
|
1126
1396
|
return [
|
|
1127
1397
|
decorateBulkPlan(file, classification, "compressed_docx", {
|
|
1128
1398
|
...docx,
|
|
1129
1399
|
maxUploadBytes: options.maxUploadBytes,
|
|
1400
|
+
normalizeThresholdBytes: DOCX_NORMALIZE_MIN_BYTES,
|
|
1130
1401
|
normalizeStrategy: "docx_image_300dpi_normalize",
|
|
1131
1402
|
}),
|
|
1132
1403
|
];
|
|
@@ -1254,15 +1525,16 @@ function bulkInitialStatus(file) {
|
|
|
1254
1525
|
return "skipped";
|
|
1255
1526
|
if (file.ingestVariant === "skipped")
|
|
1256
1527
|
return "blocked";
|
|
1257
|
-
if (
|
|
1258
|
-
file.derivedSize === undefined) {
|
|
1259
|
-
return "planned";
|
|
1260
|
-
}
|
|
1261
|
-
if (file.ingestVariant === "compressed_docx")
|
|
1528
|
+
if (file.ingestVariant === "compressed_docx" && file.derivedSize === undefined)
|
|
1262
1529
|
return "pending";
|
|
1263
|
-
if (file.
|
|
1530
|
+
if (file.ingestVariant === "page_split_pdf" && file.derivedSize === undefined)
|
|
1264
1531
|
return "pending";
|
|
1265
|
-
if (file.
|
|
1532
|
+
if (file.ingestVariant === "compressed_docx") {
|
|
1533
|
+
if (file.derivedSize !== undefined && file.derivedSize <= Number(file.preflight.maxUploadBytes))
|
|
1534
|
+
return "pending";
|
|
1535
|
+
return "blocked";
|
|
1536
|
+
}
|
|
1537
|
+
if (file.size > 0 && file.size <= Number(file.preflight.maxUploadBytes ?? Infinity))
|
|
1266
1538
|
return "pending";
|
|
1267
1539
|
if (file.derivedSize !== undefined && file.derivedSize <= Number(file.preflight.maxUploadBytes))
|
|
1268
1540
|
return "pending";
|
|
@@ -1378,13 +1650,61 @@ async function analyzeDocx(path) {
|
|
|
1378
1650
|
const entries = readZipEntries(await readFile(path));
|
|
1379
1651
|
const media = entries.filter((entry) => entry.name.startsWith("word/media/") && !entry.name.endsWith("/"));
|
|
1380
1652
|
const mediaSizes = media.map((entry) => entry.uncompressedSize);
|
|
1653
|
+
const documentXml = zipEntryText(entries, "word/document.xml") ?? "";
|
|
1654
|
+
const bodyText = extractDocxBodyText(documentXml);
|
|
1655
|
+
const appXml = zipEntryText(entries, "docProps/app.xml") ?? "";
|
|
1381
1656
|
return {
|
|
1382
1657
|
mediaCount: media.length,
|
|
1383
1658
|
mediaTotalBytes: mediaSizes.reduce((sum, size) => sum + size, 0),
|
|
1384
1659
|
mediaMaxBytes: mediaSizes.reduce((max, size) => Math.max(max, size), 0),
|
|
1660
|
+
textCharacterCount: bodyText.length,
|
|
1661
|
+
paragraphCount: countRegex(documentXml, /<w:p(?:\s|>)/g),
|
|
1662
|
+
drawingCount: countRegex(documentXml, /<w:drawing(?:\s|>)/g),
|
|
1663
|
+
pictCount: countRegex(documentXml, /<w:pict(?:\s|>)/g),
|
|
1664
|
+
appWords: xmlElementNumber(appXml, "Words"),
|
|
1665
|
+
appCharacters: xmlElementNumber(appXml, "Characters"),
|
|
1666
|
+
appParagraphs: xmlElementNumber(appXml, "Paragraphs"),
|
|
1385
1667
|
zipEntryCount: entries.length,
|
|
1386
1668
|
};
|
|
1387
1669
|
}
|
|
1670
|
+
function isEmptyDocxAnalysis(docx) {
|
|
1671
|
+
if (docx.error)
|
|
1672
|
+
return false;
|
|
1673
|
+
const mediaCount = Number(docx.mediaCount ?? 0);
|
|
1674
|
+
const textCharacterCount = Number(docx.textCharacterCount ?? 0);
|
|
1675
|
+
const appWords = Number(docx.appWords ?? 0);
|
|
1676
|
+
return mediaCount === 0 && textCharacterCount === 0 && appWords === 0;
|
|
1677
|
+
}
|
|
1678
|
+
function zipEntryText(entries, name) {
|
|
1679
|
+
const entry = entries.find((candidate) => candidate.name === name);
|
|
1680
|
+
return entry?.data.toString("utf8");
|
|
1681
|
+
}
|
|
1682
|
+
function extractDocxBodyText(xml) {
|
|
1683
|
+
const text = [...xml.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)]
|
|
1684
|
+
.map((match) => decodeXmlText(match[1] ?? ""))
|
|
1685
|
+
.join("");
|
|
1686
|
+
return text.trim();
|
|
1687
|
+
}
|
|
1688
|
+
function countRegex(input, regex) {
|
|
1689
|
+
return (input.match(regex) ?? []).length;
|
|
1690
|
+
}
|
|
1691
|
+
function xmlElementNumber(xml, name) {
|
|
1692
|
+
const match = new RegExp(`<${name}>(\\d+)</${name}>`).exec(xml);
|
|
1693
|
+
if (!match)
|
|
1694
|
+
return undefined;
|
|
1695
|
+
const value = Number(match[1]);
|
|
1696
|
+
return Number.isFinite(value) ? value : undefined;
|
|
1697
|
+
}
|
|
1698
|
+
function decodeXmlText(input) {
|
|
1699
|
+
return input
|
|
1700
|
+
.replace(/</g, "<")
|
|
1701
|
+
.replace(/>/g, ">")
|
|
1702
|
+
.replace(/"/g, '"')
|
|
1703
|
+
.replace(/'/g, "'")
|
|
1704
|
+
.replace(/&/g, "&")
|
|
1705
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
1706
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
|
|
1707
|
+
}
|
|
1388
1708
|
async function createDocxIngestCopy(file, options, docx, classification) {
|
|
1389
1709
|
const derivedPath = join(options.workDir, "docx", `${safeDerivedName(file.originalRelativePath)}-${file.sha256.slice(0, 12)}.docx`);
|
|
1390
1710
|
await mkdir(dirname(derivedPath), { recursive: true });
|
|
@@ -1408,7 +1728,7 @@ async function createDocxIngestCopy(file, options, docx, classification) {
|
|
|
1408
1728
|
size: derivedPlan.size,
|
|
1409
1729
|
mtimeMs: derivedPlan.mtimeMs,
|
|
1410
1730
|
sha256: derivedPlan.sha256,
|
|
1411
|
-
|
|
1731
|
+
uploadKey: derivedPlan.uploadKey,
|
|
1412
1732
|
relativePath: file.originalRelativePath,
|
|
1413
1733
|
pathSegments: file.pathSegments,
|
|
1414
1734
|
pathDepth: file.pathDepth,
|
|
@@ -1486,7 +1806,7 @@ async function createPdfPartPlans(file, options, pdf, classification) {
|
|
|
1486
1806
|
size: derivedPlan.size,
|
|
1487
1807
|
mtimeMs: derivedPlan.mtimeMs,
|
|
1488
1808
|
sha256: derivedPlan.sha256,
|
|
1489
|
-
|
|
1809
|
+
uploadKey: derivedPlan.uploadKey,
|
|
1490
1810
|
relativePath: logicalRelativePath,
|
|
1491
1811
|
pathSegments: logicalPathSegments,
|
|
1492
1812
|
pathDepth: logicalPathSegments.length,
|
|
@@ -2249,7 +2569,9 @@ function csvLine(values) {
|
|
|
2249
2569
|
return values.map((value) => `"${value.replace(/"/g, '""')}"`).join(",");
|
|
2250
2570
|
}
|
|
2251
2571
|
function formatBulkRunSummary(summary) {
|
|
2252
|
-
|
|
2572
|
+
const pressure = summary.pipelineHealth?.pressure ?? "unknown";
|
|
2573
|
+
const action = summary.pipelineHealth?.recommendedAction ?? "continue";
|
|
2574
|
+
return `Bulk job ${summary.jobId}: completed=${summary.completed} failed=${summary.failed} skipped=${summary.skipped} blocked=${summary.blocked} pending=${summary.pending} inflight=${summary.inflight} waiting_for_index_flags=${summary.waitingForIndexFlags} pressure=${pressure} action=${action} state=${summary.statePath}\n`;
|
|
2253
2575
|
}
|
|
2254
2576
|
async function uploadWithRetries(input) {
|
|
2255
2577
|
let lastError;
|
|
@@ -2264,17 +2586,13 @@ async function uploadWithRetries(input) {
|
|
|
2264
2586
|
});
|
|
2265
2587
|
const documentId = documentIdFromUpload(response);
|
|
2266
2588
|
const existingDocumentId = existingDocumentIdFromUpload(response);
|
|
2267
|
-
let finalResponse = response;
|
|
2268
|
-
if (input.waitForTerminal && documentId) {
|
|
2269
|
-
finalResponse = await waitForStatus(input.config, documentId, input.args, input.env);
|
|
2270
|
-
}
|
|
2271
2589
|
const result = {
|
|
2272
|
-
key: input.plan.
|
|
2590
|
+
key: input.plan.uploadKey,
|
|
2273
2591
|
path: input.plan.path,
|
|
2274
2592
|
sha256: input.plan.sha256,
|
|
2275
2593
|
status: "succeeded",
|
|
2276
2594
|
attempts: attempt,
|
|
2277
|
-
response
|
|
2595
|
+
response,
|
|
2278
2596
|
requestId,
|
|
2279
2597
|
idempotencyKey,
|
|
2280
2598
|
updatedAt: new Date().toISOString(),
|
|
@@ -2297,7 +2615,7 @@ async function uploadWithRetries(input) {
|
|
|
2297
2615
|
}
|
|
2298
2616
|
}
|
|
2299
2617
|
return {
|
|
2300
|
-
key: input.plan.
|
|
2618
|
+
key: input.plan.uploadKey,
|
|
2301
2619
|
path: input.plan.path,
|
|
2302
2620
|
sha256: input.plan.sha256,
|
|
2303
2621
|
status: "failed",
|
|
@@ -2373,19 +2691,6 @@ async function listCollections(config, capability, limit) {
|
|
|
2373
2691
|
offset += limit;
|
|
2374
2692
|
}
|
|
2375
2693
|
}
|
|
2376
|
-
async function waitForStatus(config, documentId, args, env) {
|
|
2377
|
-
const timeoutSeconds = positiveNumberValue(getString(args, "wait-timeout") ?? firstEnv(env, "TIANGONG_KB_WAIT_TIMEOUT"), DEFAULT_WAIT_TIMEOUT_SECONDS, "--wait-timeout");
|
|
2378
|
-
const intervalSeconds = positiveNumberValue(getString(args, "poll-interval") ?? firstEnv(env, "TIANGONG_KB_POLL_INTERVAL"), DEFAULT_POLL_INTERVAL_SECONDS, "--poll-interval");
|
|
2379
|
-
const startedAt = Date.now();
|
|
2380
|
-
while (Date.now() - startedAt < timeoutSeconds * 1000) {
|
|
2381
|
-
const payload = await getDocumentStatus(config, documentId);
|
|
2382
|
-
const status = statusFromPayload(payload);
|
|
2383
|
-
if (status && TERMINAL_STATUSES.has(status))
|
|
2384
|
-
return payload;
|
|
2385
|
-
await sleep(intervalSeconds * 1000);
|
|
2386
|
-
}
|
|
2387
|
-
throw new CliError(`Timed out waiting for document ${documentId}.`);
|
|
2388
|
-
}
|
|
2389
2694
|
async function jsonRequest(config, path, init = {}) {
|
|
2390
2695
|
const url = `${config.apiBaseUrl}${config.apiPathPrefix}/${path.replace(/^\/+/, "")}`;
|
|
2391
2696
|
const headers = new Headers(init.headers);
|
|
@@ -2444,7 +2749,7 @@ async function collectFiles(path, recursive, options = {}) {
|
|
|
2444
2749
|
if (!item.isDirectory())
|
|
2445
2750
|
throw new CliError(`Path is not a file or directory: ${path}`);
|
|
2446
2751
|
if (!recursive)
|
|
2447
|
-
throw new CliError(`Path is a directory
|
|
2752
|
+
throw new CliError(`Path is a directory and recursive traversal is disabled: ${path}`);
|
|
2448
2753
|
const { readdir } = await import("node:fs/promises");
|
|
2449
2754
|
const files = [];
|
|
2450
2755
|
async function walk(dir) {
|
|
@@ -2481,41 +2786,9 @@ async function fingerprintFile(path) {
|
|
|
2481
2786
|
size: info.size,
|
|
2482
2787
|
mtimeMs: info.mtimeMs,
|
|
2483
2788
|
sha256,
|
|
2484
|
-
|
|
2789
|
+
uploadKey: `${sha256}:${info.size}`,
|
|
2485
2790
|
};
|
|
2486
2791
|
}
|
|
2487
|
-
async function loadManifest(path) {
|
|
2488
|
-
const records = new Map();
|
|
2489
|
-
const content = await readFile(path, "utf8").catch((error) => {
|
|
2490
|
-
if (error.code === "ENOENT")
|
|
2491
|
-
return "";
|
|
2492
|
-
throw error;
|
|
2493
|
-
});
|
|
2494
|
-
for (const line of content.split(/\r?\n/)) {
|
|
2495
|
-
if (!line.trim())
|
|
2496
|
-
continue;
|
|
2497
|
-
const record = JSON.parse(line);
|
|
2498
|
-
records.set(record.key, record);
|
|
2499
|
-
}
|
|
2500
|
-
return records;
|
|
2501
|
-
}
|
|
2502
|
-
async function appendManifest(path, record) {
|
|
2503
|
-
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
2504
|
-
}
|
|
2505
|
-
async function loadMetadata(args) {
|
|
2506
|
-
const metadataFile = getString(args, "metadata-file");
|
|
2507
|
-
const metadataJson = getString(args, "metadata-json");
|
|
2508
|
-
if (metadataFile && metadataJson)
|
|
2509
|
-
throw new CliError("Use only one of --metadata-file or --metadata-json.");
|
|
2510
|
-
if (!metadataFile && !metadataJson)
|
|
2511
|
-
return {};
|
|
2512
|
-
const raw = metadataFile ? await readFile(resolve(metadataFile), "utf8") : metadataJson;
|
|
2513
|
-
const parsed = JSON.parse(raw ?? "{}");
|
|
2514
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2515
|
-
throw new CliError("Metadata must be a JSON object.");
|
|
2516
|
-
}
|
|
2517
|
-
return parsed;
|
|
2518
|
-
}
|
|
2519
2792
|
async function runPool(items, concurrency, worker) {
|
|
2520
2793
|
const results = [];
|
|
2521
2794
|
let index = 0;
|
|
@@ -2580,12 +2853,6 @@ function duplicateFromUpload(payload) {
|
|
|
2580
2853
|
const value = data.duplicate ?? data.isDuplicate;
|
|
2581
2854
|
return typeof value === "boolean" ? value : undefined;
|
|
2582
2855
|
}
|
|
2583
|
-
function statusFromPayload(payload) {
|
|
2584
|
-
const data = responseData(payload);
|
|
2585
|
-
if (!isObject(data))
|
|
2586
|
-
return undefined;
|
|
2587
|
-
return stringField(data, "status");
|
|
2588
|
-
}
|
|
2589
2856
|
function writeJsonOrText(stdout, args, payload, text) {
|
|
2590
2857
|
write(stdout, getBoolean(args, "json") ? `${JSON.stringify(payload, null, 2)}\n` : text());
|
|
2591
2858
|
}
|
|
@@ -2733,8 +3000,7 @@ function topHelp() {
|
|
|
2733
3000
|
|
|
2734
3001
|
Usage:
|
|
2735
3002
|
tiangong-ai doctor [--json]
|
|
2736
|
-
tiangong-ai kb ingest
|
|
2737
|
-
tiangong-ai kb ingest bulk <folder> [--window-size 100] [--top-up-max 50]
|
|
3003
|
+
tiangong-ai kb ingest bulk <file-or-folder> [--window-size 100] [--top-up-max 50]
|
|
2738
3004
|
tiangong-ai kb ingest jobs
|
|
2739
3005
|
tiangong-ai kb ingest status <document-id>
|
|
2740
3006
|
tiangong-ai kb collections list [--capability upload]
|
|
@@ -2746,13 +3012,12 @@ function kbHelp() {
|
|
|
2746
3012
|
return `Tiangong KB commands
|
|
2747
3013
|
|
|
2748
3014
|
Usage:
|
|
2749
|
-
tiangong-ai kb ingest
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
tiangong-ai kb ingest metadata dry-run <folder> --metadata-map <path> [--json]
|
|
3015
|
+
tiangong-ai kb ingest bulk <file-or-folder> [options]
|
|
3016
|
+
tiangong-ai kb ingest bulk scan <folder> [--json]
|
|
3017
|
+
tiangong-ai kb ingest bulk preflight <folder> [--json]
|
|
3018
|
+
tiangong-ai kb ingest bulk dry-run <folder> --metadata-map <path> [--json]
|
|
3019
|
+
tiangong-ai kb ingest normalize dry-run <folder> [--json]
|
|
3020
|
+
tiangong-ai kb ingest metadata dry-run <folder> --metadata-map <path> [--json]
|
|
2756
3021
|
tiangong-ai kb ingest jobs [--json]
|
|
2757
3022
|
tiangong-ai kb ingest status <job-id-or-document-id> [--json]
|
|
2758
3023
|
tiangong-ai kb ingest resume <job-id> [options]
|
|
@@ -2771,22 +3036,23 @@ Common options:
|
|
|
2771
3036
|
--json
|
|
2772
3037
|
|
|
2773
3038
|
Ingest options:
|
|
2774
|
-
--
|
|
2775
|
-
--
|
|
2776
|
-
--
|
|
3039
|
+
--state <path>
|
|
3040
|
+
--metadata-map <path>
|
|
3041
|
+
--schema-file <path>
|
|
3042
|
+
--window-size <n>
|
|
3043
|
+
--top-up-max <n>
|
|
3044
|
+
--upload-concurrency <n>
|
|
2777
3045
|
--retries <n>
|
|
2778
|
-
--
|
|
2779
|
-
--
|
|
2780
|
-
--metadata-json <json>
|
|
2781
|
-
--metadata-file <path>
|
|
3046
|
+
--poll-interval <seconds>
|
|
3047
|
+
--max-polls <n> (default 120, use 0 for no limit)
|
|
2782
3048
|
`;
|
|
2783
3049
|
}
|
|
2784
3050
|
function bulkHelp() {
|
|
2785
3051
|
return `Tiangong KB bulk ingest
|
|
2786
3052
|
|
|
2787
3053
|
Usage:
|
|
2788
|
-
tiangong-ai kb ingest bulk <folder> --collection-path <path> [options]
|
|
2789
|
-
tiangong-ai kb ingest bulk run <folder> --collection-path <path> [options]
|
|
3054
|
+
tiangong-ai kb ingest bulk <file-or-folder> --collection-path <path> [options]
|
|
3055
|
+
tiangong-ai kb ingest bulk run <file-or-folder> --collection-path <path> [options]
|
|
2790
3056
|
tiangong-ai kb ingest bulk scan <folder> --json
|
|
2791
3057
|
tiangong-ai kb ingest bulk preflight <folder> --json
|
|
2792
3058
|
tiangong-ai kb ingest bulk dry-run <folder> --metadata-map metadata-map.yaml --json
|
|
@@ -2801,7 +3067,7 @@ Bulk options:
|
|
|
2801
3067
|
--top-up-max <n>
|
|
2802
3068
|
--upload-concurrency <n>
|
|
2803
3069
|
--poll-interval <seconds>
|
|
2804
|
-
--max-polls <n>
|
|
3070
|
+
--max-polls <n> (default 120, use 0 for no limit)
|
|
2805
3071
|
--json
|
|
2806
3072
|
`;
|
|
2807
3073
|
}
|