@tiangong-ai/cli 0.0.4 → 0.0.7
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 +9 -8
- package/dist/cli.js +412 -37
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ const DEFAULT_BULK_POLL_INTERVAL_SECONDS = 30;
|
|
|
19
19
|
const DEFAULT_BULK_MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
|
|
20
20
|
const DEFAULT_BULK_DERIVED_DIR = ".tiangong-kb-ingest-derived";
|
|
21
21
|
const DOCX_TARGET_IMAGE_DPI = 300;
|
|
22
|
+
const DOCX_NORMALIZE_MIN_BYTES = 10 * 1024 * 1024;
|
|
22
23
|
const EMUS_PER_INCH = 914400;
|
|
23
24
|
const RETRYABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
24
25
|
const TERMINAL_STATUSES = new Set(["completed", "failed", "deleted"]);
|
|
@@ -249,6 +250,7 @@ async function kbIngestStatus(argv, io) {
|
|
|
249
250
|
`status=${job.status}`,
|
|
250
251
|
`state=${job.statePath}`,
|
|
251
252
|
`root=${job.rootPath}`,
|
|
253
|
+
`pressure=${job.pipelineHealth?.pressure ?? "unknown"} action=${job.pipelineHealth?.recommendedAction ?? "continue"}`,
|
|
252
254
|
`files=${summary.total} pending=${summary.pending} inflight=${summary.inflight} completed=${summary.completed} failed=${summary.failed}`,
|
|
253
255
|
"",
|
|
254
256
|
].join("\n"));
|
|
@@ -400,7 +402,8 @@ async function kbIngestBulkRun(argv, io) {
|
|
|
400
402
|
const metadataMap = await loadMetadataMap(args);
|
|
401
403
|
const files = await collectBulkFilePlans(root, true, args);
|
|
402
404
|
const schemaSnapshot = await loadSchemaSnapshot(args, io.env, config, selector);
|
|
403
|
-
const
|
|
405
|
+
const preflightOptions = preflightOptionsFromArgs(args, schemaSnapshot, root, false);
|
|
406
|
+
const preflightPlans = await prepareBulkPreflightPlans(files, preflightOptions);
|
|
404
407
|
const dryRunSummary = metadataDryRun(preflightPlans.allPlans, metadataMap, schemaSnapshot);
|
|
405
408
|
dryRunSummary.preflight = buildPreflightSummary(preflightPlans.allPlans, preflightPlans.maxUploadBytes);
|
|
406
409
|
await initializeBulkJob({
|
|
@@ -421,6 +424,7 @@ async function kbIngestBulkRun(argv, io) {
|
|
|
421
424
|
metadataMap,
|
|
422
425
|
env: io.env,
|
|
423
426
|
stdout: io.stdout,
|
|
427
|
+
preflightOptions,
|
|
424
428
|
});
|
|
425
429
|
writeJsonOrText(io.stdout, args, result, () => formatBulkRunSummary(result));
|
|
426
430
|
return result.failed > 0 || result.blocked > 0 ? 1 : 0;
|
|
@@ -449,7 +453,7 @@ async function kbIngestJobs(argv, io) {
|
|
|
449
453
|
}
|
|
450
454
|
}))).filter((job) => Boolean(job));
|
|
451
455
|
writeJsonOrText(io.stdout, args, { jobs }, () => jobs
|
|
452
|
-
.map((job) => `${job.jobId}\t${job.status}\t${job.summary.completed}/${job.summary.total}\t${job.statePath}`)
|
|
456
|
+
.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
457
|
.join("\n")
|
|
454
458
|
.concat(jobs.length ? "\n" : ""));
|
|
455
459
|
return 0;
|
|
@@ -465,6 +469,7 @@ async function kbIngestResume(argv, io) {
|
|
|
465
469
|
const job = await readBulkJob(statePath);
|
|
466
470
|
const config = resolveConfig(args, io.env);
|
|
467
471
|
const selectorFields = await resolveSelectorFields(config, job.collectionSelector);
|
|
472
|
+
const preflightOptions = preflightOptionsFromArgs(args, job.schemaSnapshot, job.rootPath, false);
|
|
468
473
|
const result = await runBulkLoop({
|
|
469
474
|
args,
|
|
470
475
|
config,
|
|
@@ -473,6 +478,7 @@ async function kbIngestResume(argv, io) {
|
|
|
473
478
|
metadataMap: job.metadataMap,
|
|
474
479
|
env: io.env,
|
|
475
480
|
stdout: io.stdout,
|
|
481
|
+
preflightOptions,
|
|
476
482
|
});
|
|
477
483
|
writeJsonOrText(io.stdout, args, result, () => formatBulkRunSummary(result));
|
|
478
484
|
return result.failed > 0 || result.blocked > 0 ? 1 : 0;
|
|
@@ -487,6 +493,7 @@ async function kbIngestExport(argv, io) {
|
|
|
487
493
|
throw new CliError(`Bulk job not found: ${jobId}`);
|
|
488
494
|
const format = getString(args, "format") ?? "jsonl";
|
|
489
495
|
const rows = await readBulkFiles(statePath);
|
|
496
|
+
const job = await readBulkJob(statePath);
|
|
490
497
|
if (format === "csv") {
|
|
491
498
|
write(io.stdout, [
|
|
492
499
|
csvLine([
|
|
@@ -534,7 +541,7 @@ async function kbIngestExport(argv, io) {
|
|
|
534
541
|
throw new CliError("--format must be jsonl, json, or csv.");
|
|
535
542
|
}
|
|
536
543
|
if (format === "json") {
|
|
537
|
-
write(io.stdout, `${JSON.stringify({ files: rows }, null, 2)}\n`);
|
|
544
|
+
write(io.stdout, `${JSON.stringify({ job, files: rows }, null, 2)}\n`);
|
|
538
545
|
}
|
|
539
546
|
else {
|
|
540
547
|
write(io.stdout, rows
|
|
@@ -554,30 +561,42 @@ async function runBulkLoop(input) {
|
|
|
554
561
|
let polls = 0;
|
|
555
562
|
await resetInterruptedBulkUploads(input.statePath);
|
|
556
563
|
await updateJobStatus(input.statePath, "running");
|
|
564
|
+
let lastPipelineHealth;
|
|
557
565
|
while (true) {
|
|
558
566
|
polls += 1;
|
|
559
567
|
await pollBulkStatuses(input.statePath, input.config);
|
|
560
568
|
const summary = await bulkJobSummary(input.statePath);
|
|
561
569
|
const capacity = Math.max(0, windowSize - summary.inflight);
|
|
562
|
-
|
|
570
|
+
lastPipelineHealth = await readBulkPipelineHealth(input.config, pollInterval);
|
|
571
|
+
await saveBulkPipelineHealth(input.statePath, lastPipelineHealth);
|
|
572
|
+
const uploadLimit = bulkUploadLimitForHealth(Math.min(capacity, topUpMax), lastPipelineHealth);
|
|
573
|
+
const effectiveUploadConcurrency = lastPipelineHealth.recommendedAction === "slow_down" ? 1 : uploadConcurrency;
|
|
563
574
|
if (uploadLimit > 0) {
|
|
564
575
|
const pending = await claimPendingBulkFiles(input.statePath, uploadLimit);
|
|
565
|
-
await runPool(pending,
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
576
|
+
await runPool(pending, effectiveUploadConcurrency, async (row) => {
|
|
577
|
+
const materialized = await materializeBulkFileForUpload({
|
|
578
|
+
statePath: input.statePath,
|
|
579
|
+
metadataMap: input.metadataMap,
|
|
580
|
+
preflightOptions: input.preflightOptions,
|
|
570
581
|
row,
|
|
571
|
-
retries,
|
|
572
|
-
env: input.env,
|
|
573
582
|
});
|
|
574
|
-
await
|
|
575
|
-
|
|
583
|
+
return await runPool(materialized.uploadRows, 1, async (uploadRow) => {
|
|
584
|
+
const result = await uploadBulkFile({
|
|
585
|
+
args: input.args,
|
|
586
|
+
config: input.config,
|
|
587
|
+
selectorFields: input.selectorFields,
|
|
588
|
+
row: uploadRow,
|
|
589
|
+
retries,
|
|
590
|
+
env: input.env,
|
|
591
|
+
});
|
|
592
|
+
await saveBulkUploadResult(input.statePath, uploadRow.relativePath, result);
|
|
593
|
+
return result;
|
|
594
|
+
});
|
|
576
595
|
});
|
|
577
596
|
}
|
|
578
597
|
const nextSummary = await bulkJobSummary(input.statePath);
|
|
579
598
|
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`);
|
|
599
|
+
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
600
|
}
|
|
582
601
|
if (nextSummary.pending === 0 && nextSummary.inflight === 0) {
|
|
583
602
|
await updateJobStatus(input.statePath, nextSummary.failed > 0 || nextSummary.blocked > 0 ? "failed" : "completed");
|
|
@@ -586,6 +605,7 @@ async function runBulkLoop(input) {
|
|
|
586
605
|
jobId: jobIdFromStatePath(input.statePath),
|
|
587
606
|
statePath: input.statePath,
|
|
588
607
|
polls,
|
|
608
|
+
pipelineHealth: lastPipelineHealth,
|
|
589
609
|
};
|
|
590
610
|
}
|
|
591
611
|
if (maxPolls > 0 && polls >= maxPolls) {
|
|
@@ -595,11 +615,28 @@ async function runBulkLoop(input) {
|
|
|
595
615
|
jobId: jobIdFromStatePath(input.statePath),
|
|
596
616
|
statePath: input.statePath,
|
|
597
617
|
polls,
|
|
618
|
+
pipelineHealth: lastPipelineHealth,
|
|
598
619
|
};
|
|
599
620
|
}
|
|
600
|
-
await sleep(pollInterval * 1000);
|
|
621
|
+
await sleep(bulkPollIntervalForHealth(pollInterval, lastPipelineHealth) * 1000);
|
|
601
622
|
}
|
|
602
623
|
}
|
|
624
|
+
function bulkUploadLimitForHealth(limit, health) {
|
|
625
|
+
if (limit <= 0)
|
|
626
|
+
return 0;
|
|
627
|
+
if (health.recommendedAction === "pause_top_up")
|
|
628
|
+
return 0;
|
|
629
|
+
if (health.recommendedAction === "slow_down")
|
|
630
|
+
return Math.max(1, Math.ceil(limit / 2));
|
|
631
|
+
return limit;
|
|
632
|
+
}
|
|
633
|
+
function bulkPollIntervalForHealth(baseSeconds, health) {
|
|
634
|
+
if (!health)
|
|
635
|
+
return baseSeconds;
|
|
636
|
+
if (health.recommendedAction === "continue")
|
|
637
|
+
return baseSeconds;
|
|
638
|
+
return Math.max(baseSeconds, health.recommendedPollAfterSeconds);
|
|
639
|
+
}
|
|
603
640
|
async function uploadBulkFile(input) {
|
|
604
641
|
const plan = {
|
|
605
642
|
path: input.row.path,
|
|
@@ -620,6 +657,143 @@ async function uploadBulkFile(input) {
|
|
|
620
657
|
env: input.env,
|
|
621
658
|
});
|
|
622
659
|
}
|
|
660
|
+
async function materializeBulkFileForUpload(input) {
|
|
661
|
+
if (input.row.ingestVariant === "direct_upload") {
|
|
662
|
+
return { uploadRows: [input.row] };
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
const preflightOptions = materializeOptionsForRow(input.preflightOptions, input.row);
|
|
666
|
+
if (input.row.ingestVariant === "compressed_docx") {
|
|
667
|
+
const docxRow = await materializeDocxBulkRow(input.statePath, input.metadataMap, preflightOptions, input.row);
|
|
668
|
+
return { uploadRows: docxRow ? [docxRow] : [] };
|
|
669
|
+
}
|
|
670
|
+
if (input.row.ingestVariant === "page_split_pdf") {
|
|
671
|
+
const pdfRows = await materializePdfBulkRow(input.statePath, input.metadataMap, preflightOptions, input.row);
|
|
672
|
+
return { uploadRows: pdfRows };
|
|
673
|
+
}
|
|
674
|
+
await markBulkFileFailed(input.statePath, input.row.relativePath, "UNSUPPORTED_INGEST_VARIANT");
|
|
675
|
+
return { uploadRows: [] };
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
await markBulkFileFailed(input.statePath, input.row.relativePath, error instanceof Error ? error.message : String(error));
|
|
679
|
+
return { uploadRows: [] };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function materializeOptionsForRow(options, row) {
|
|
683
|
+
const rowMaxUploadBytes = Number(row.preflight.maxUploadBytes);
|
|
684
|
+
return {
|
|
685
|
+
...options,
|
|
686
|
+
maxUploadBytes: Number.isFinite(rowMaxUploadBytes) && rowMaxUploadBytes > 0
|
|
687
|
+
? rowMaxUploadBytes
|
|
688
|
+
: options.maxUploadBytes,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
async function materializeDocxBulkRow(statePath, metadataMap, options, row) {
|
|
692
|
+
if (row.derivedPath && row.derivedSize !== undefined && row.derivedSha256) {
|
|
693
|
+
const existing = await stat(row.derivedPath).catch(() => undefined);
|
|
694
|
+
if (existing?.isFile() && existing.size === row.derivedSize)
|
|
695
|
+
return row;
|
|
696
|
+
}
|
|
697
|
+
const source = bulkRecordToOriginalPlan(row);
|
|
698
|
+
const docx = await analyzeDocx(source.originalPath);
|
|
699
|
+
if (isEmptyDocxAnalysis(docx)) {
|
|
700
|
+
await markBulkFileSkipped(statePath, row.relativePath, "empty_docx");
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
703
|
+
const materialized = await createDocxIngestCopy(source, options, docx, row.classification);
|
|
704
|
+
return await updateMaterializedBulkRow(statePath, metadataMap, row.relativePath, materialized);
|
|
705
|
+
}
|
|
706
|
+
async function materializePdfBulkRow(statePath, metadataMap, options, row) {
|
|
707
|
+
if (row.partIndex !== undefined && row.derivedPath) {
|
|
708
|
+
const existing = await stat(row.derivedPath).catch(() => undefined);
|
|
709
|
+
if (existing?.isFile() && existing.size === row.derivedSize)
|
|
710
|
+
return [row];
|
|
711
|
+
}
|
|
712
|
+
const source = bulkRecordToOriginalPlan(row);
|
|
713
|
+
const pdf = (await analyzePdf(source.originalPath).catch((error) => ({
|
|
714
|
+
error: error instanceof Error ? error.message : String(error),
|
|
715
|
+
pageCount: 0,
|
|
716
|
+
imageCount: 0,
|
|
717
|
+
imageHeavy: false,
|
|
718
|
+
})));
|
|
719
|
+
const partPlans = await createPdfPartPlans(source, options, pdf, row.classification);
|
|
720
|
+
if (row.partIndex !== undefined) {
|
|
721
|
+
const currentPart = partPlans.find((part) => part.relativePath === row.relativePath);
|
|
722
|
+
if (!currentPart) {
|
|
723
|
+
await markBulkFileFailed(statePath, row.relativePath, "PDF_SPLIT_PART_REGENERATION_MISMATCH");
|
|
724
|
+
return [];
|
|
725
|
+
}
|
|
726
|
+
return [await updateMaterializedBulkRow(statePath, metadataMap, row.relativePath, currentPart)];
|
|
727
|
+
}
|
|
728
|
+
const now = new Date().toISOString();
|
|
729
|
+
const db = await openSqlite(statePath);
|
|
730
|
+
try {
|
|
731
|
+
createBulkSchema(db);
|
|
732
|
+
db.exec("BEGIN");
|
|
733
|
+
try {
|
|
734
|
+
for (const part of partPlans) {
|
|
735
|
+
insertBulkFilePlan(db, metadataMap, part, bulkInitialStatus(part), now);
|
|
736
|
+
}
|
|
737
|
+
db.prepare("DELETE FROM files WHERE relative_path = ?").run(row.relativePath);
|
|
738
|
+
touchJob(db, now);
|
|
739
|
+
db.exec("COMMIT");
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
db.exec("ROLLBACK");
|
|
743
|
+
throw error;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
finally {
|
|
747
|
+
db.close();
|
|
748
|
+
}
|
|
749
|
+
return [];
|
|
750
|
+
}
|
|
751
|
+
async function updateMaterializedBulkRow(statePath, metadataMap, relativePath, file) {
|
|
752
|
+
const evaluated = evaluateMetadata(metadataMap, file);
|
|
753
|
+
const now = new Date().toISOString();
|
|
754
|
+
const db = await openSqlite(statePath);
|
|
755
|
+
try {
|
|
756
|
+
createBulkSchema(db);
|
|
757
|
+
db.prepare(`UPDATE files
|
|
758
|
+
SET path = ?, size = ?, mtime_ms = ?, sha256 = ?, ext = ?, path_segments_json = ?, path_depth = ?,
|
|
759
|
+
metadata_json = ?, matched_rules_json = ?, classification = ?, ingest_variant = ?, source_document_key = ?,
|
|
760
|
+
derived_path = ?, derived_size = ?, derived_sha256 = ?, normalize_strategy = ?, generated_metadata_json = ?,
|
|
761
|
+
preflight_json = ?, updated_at = ?
|
|
762
|
+
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);
|
|
763
|
+
touchJob(db, now);
|
|
764
|
+
const updated = db.prepare("SELECT * FROM files WHERE relative_path = ?").get(relativePath);
|
|
765
|
+
if (!updated)
|
|
766
|
+
throw new CliError(`Bulk row disappeared during materialization: ${relativePath}`);
|
|
767
|
+
return rowToBulkFile(updated);
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
db.close();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async function markBulkFileFailed(statePath, relativePath, error) {
|
|
774
|
+
const db = await openSqlite(statePath);
|
|
775
|
+
const now = new Date().toISOString();
|
|
776
|
+
try {
|
|
777
|
+
createBulkSchema(db);
|
|
778
|
+
db.prepare("UPDATE files SET status = 'failed', last_error = ?, updated_at = ? WHERE relative_path = ?").run(error, now, relativePath);
|
|
779
|
+
touchJob(db, now);
|
|
780
|
+
}
|
|
781
|
+
finally {
|
|
782
|
+
db.close();
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
async function markBulkFileSkipped(statePath, relativePath, reason) {
|
|
786
|
+
const db = await openSqlite(statePath);
|
|
787
|
+
const now = new Date().toISOString();
|
|
788
|
+
try {
|
|
789
|
+
createBulkSchema(db);
|
|
790
|
+
db.prepare("UPDATE files SET status = 'skipped', last_error = ?, updated_at = ? WHERE relative_path = ?").run(reason, now, relativePath);
|
|
791
|
+
touchJob(db, now);
|
|
792
|
+
}
|
|
793
|
+
finally {
|
|
794
|
+
db.close();
|
|
795
|
+
}
|
|
796
|
+
}
|
|
623
797
|
async function pollBulkStatuses(statePath, config) {
|
|
624
798
|
const inflight = await readInflightBulkFiles(statePath);
|
|
625
799
|
const documentIds = inflight.map((row) => row.documentId).filter(Boolean);
|
|
@@ -645,6 +819,12 @@ async function pollBulkStatuses(statePath, config) {
|
|
|
645
819
|
}
|
|
646
820
|
}
|
|
647
821
|
function judgeBulkStatus(item) {
|
|
822
|
+
if (item.itemError) {
|
|
823
|
+
const message = `${item.itemError.code}: ${item.itemError.message}`;
|
|
824
|
+
return item.itemError.retryable
|
|
825
|
+
? { status: "uploaded", lastError: message }
|
|
826
|
+
: { status: "failed", lastError: message };
|
|
827
|
+
}
|
|
648
828
|
const status = item.status ?? "";
|
|
649
829
|
if (BULK_FAILED_STATUSES.has(status)) {
|
|
650
830
|
return {
|
|
@@ -693,12 +873,12 @@ function batchStatusItems(payload) {
|
|
|
693
873
|
const data = responseData(payload);
|
|
694
874
|
const items = isObject(data) && Array.isArray(data.documents)
|
|
695
875
|
? data.documents
|
|
696
|
-
: Array.isArray(data)
|
|
697
|
-
? data
|
|
698
|
-
:
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
876
|
+
: isObject(data) && Array.isArray(data.results)
|
|
877
|
+
? data.results
|
|
878
|
+
: Array.isArray(data)
|
|
879
|
+
? data
|
|
880
|
+
: [];
|
|
881
|
+
return items.filter(isObject).map((item) => batchStatusItemFromPayload(item));
|
|
702
882
|
}
|
|
703
883
|
function statusItemFromPayload(payload, fallbackDocumentId = "") {
|
|
704
884
|
const data = responseData(payload);
|
|
@@ -715,6 +895,90 @@ function statusItemFromPayload(payload, fallbackDocumentId = "") {
|
|
|
715
895
|
raw: payload,
|
|
716
896
|
};
|
|
717
897
|
}
|
|
898
|
+
async function readBulkPipelineHealth(config, fallbackPollAfterSeconds) {
|
|
899
|
+
try {
|
|
900
|
+
const payload = await jsonRequest(config, "pipeline/health");
|
|
901
|
+
return pipelineHealthFromPayload(payload, fallbackPollAfterSeconds);
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
if (error instanceof HttpError && error.status && [404, 405, 501].includes(error.status)) {
|
|
905
|
+
return {
|
|
906
|
+
healthy: true,
|
|
907
|
+
pressure: "unknown",
|
|
908
|
+
recommendedAction: "continue",
|
|
909
|
+
recommendedPollAfterSeconds: fallbackPollAfterSeconds,
|
|
910
|
+
message: "Pipeline health endpoint is unavailable; continuing without backpressure.",
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
if (error instanceof HttpError) {
|
|
914
|
+
return {
|
|
915
|
+
healthy: false,
|
|
916
|
+
pressure: "paused",
|
|
917
|
+
recommendedAction: "pause_top_up",
|
|
918
|
+
recommendedPollAfterSeconds: error.retryAfterSeconds ?? Math.max(fallbackPollAfterSeconds, 30),
|
|
919
|
+
message: error.message,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
healthy: false,
|
|
924
|
+
pressure: "paused",
|
|
925
|
+
recommendedAction: "pause_top_up",
|
|
926
|
+
recommendedPollAfterSeconds: Math.max(fallbackPollAfterSeconds, 30),
|
|
927
|
+
message: error instanceof Error ? error.message : String(error),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function pipelineHealthFromPayload(payload, fallbackPollAfterSeconds) {
|
|
932
|
+
const data = responseData(payload);
|
|
933
|
+
if (!isObject(data)) {
|
|
934
|
+
throw new CliError("Pipeline health response did not contain an object payload.");
|
|
935
|
+
}
|
|
936
|
+
const action = stringField(data, "recommendedAction");
|
|
937
|
+
if (action !== "continue" && action !== "slow_down" && action !== "pause_top_up") {
|
|
938
|
+
throw new CliError("Pipeline health response did not contain a valid recommendedAction.");
|
|
939
|
+
}
|
|
940
|
+
const pressure = stringField(data, "pressure");
|
|
941
|
+
const pollAfter = Number(data.recommendedPollAfterSeconds);
|
|
942
|
+
return {
|
|
943
|
+
healthy: typeof data.healthy === "boolean" ? data.healthy : action === "continue",
|
|
944
|
+
pressure: pressure === "ok" || pressure === "degraded" || pressure === "paused" ? pressure : "unknown",
|
|
945
|
+
recommendedAction: action,
|
|
946
|
+
recommendedPollAfterSeconds: Number.isFinite(pollAfter) && pollAfter > 0 ? pollAfter : fallbackPollAfterSeconds,
|
|
947
|
+
checkedAt: stringField(data, "checkedAt"),
|
|
948
|
+
message: stringField(data, "message") ??
|
|
949
|
+
(isObject(data.indexPreflight) ? stringField(data.indexPreflight, "message") : undefined),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
async function saveBulkPipelineHealth(statePath, health) {
|
|
953
|
+
const db = await openSqlite(statePath);
|
|
954
|
+
const now = new Date().toISOString();
|
|
955
|
+
try {
|
|
956
|
+
createBulkSchema(db);
|
|
957
|
+
db.prepare("UPDATE jobs SET pipeline_health_json = ?, updated_at = ?").run(JSON.stringify(health), now);
|
|
958
|
+
}
|
|
959
|
+
finally {
|
|
960
|
+
db.close();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function batchStatusItemFromPayload(item) {
|
|
964
|
+
const documentId = stringField(item, "documentId") ?? stringField(item, "document_id") ?? "";
|
|
965
|
+
if (item.ok === true && isObject(item.status)) {
|
|
966
|
+
return statusItemFromPayload(item.status, documentId);
|
|
967
|
+
}
|
|
968
|
+
if (item.ok === false && isObject(item.error)) {
|
|
969
|
+
const error = item.error;
|
|
970
|
+
return {
|
|
971
|
+
documentId,
|
|
972
|
+
itemError: {
|
|
973
|
+
code: stringField(error, "code") ?? "STATUS_ITEM_ERROR",
|
|
974
|
+
message: stringField(error, "message") ?? "Document status lookup failed.",
|
|
975
|
+
retryable: typeof error.retryable === "boolean" ? error.retryable : false,
|
|
976
|
+
},
|
|
977
|
+
raw: item,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
return statusItemFromPayload(item, documentId);
|
|
981
|
+
}
|
|
718
982
|
async function initializeBulkJob(input) {
|
|
719
983
|
await mkdir(dirname(input.statePath), { recursive: true });
|
|
720
984
|
const db = await openSqlite(input.statePath);
|
|
@@ -726,15 +990,9 @@ async function initializeBulkJob(input) {
|
|
|
726
990
|
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)
|
|
727
991
|
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);
|
|
728
992
|
}
|
|
729
|
-
const insert = db.prepare(`INSERT OR IGNORE INTO files
|
|
730
|
-
(path, relative_path, size, mtime_ms, sha256, ext, path_segments_json, path_depth, metadata_json, matched_rules_json, status, attempts, created_at, updated_at,
|
|
731
|
-
original_path, original_relative_path, original_size, original_mtime_ms, original_sha256, original_ext, classification, ingest_variant, source_document_key,
|
|
732
|
-
derived_path, derived_size, derived_sha256, normalize_strategy, part_index, part_count, page_start, page_end, generated_metadata_json, preflight_json)
|
|
733
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
734
993
|
for (const file of input.files) {
|
|
735
|
-
const evaluated = evaluateMetadata(input.metadataMap, file);
|
|
736
994
|
const status = bulkInitialStatus(file);
|
|
737
|
-
|
|
995
|
+
insertBulkFilePlan(db, input.metadataMap, file, status, now);
|
|
738
996
|
}
|
|
739
997
|
touchJob(db, now);
|
|
740
998
|
}
|
|
@@ -749,6 +1007,14 @@ async function openSqlite(path) {
|
|
|
749
1007
|
db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
750
1008
|
return db;
|
|
751
1009
|
}
|
|
1010
|
+
function insertBulkFilePlan(db, metadataMap, file, status, now) {
|
|
1011
|
+
const evaluated = evaluateMetadata(metadataMap, file);
|
|
1012
|
+
db.prepare(`INSERT OR IGNORE INTO files
|
|
1013
|
+
(path, relative_path, size, mtime_ms, sha256, ext, path_segments_json, path_depth, metadata_json, matched_rules_json, status, attempts, created_at, updated_at,
|
|
1014
|
+
original_path, original_relative_path, original_size, original_mtime_ms, original_sha256, original_ext, classification, ingest_variant, source_document_key,
|
|
1015
|
+
derived_path, derived_size, derived_sha256, normalize_strategy, part_index, part_count, page_start, page_end, generated_metadata_json, preflight_json)
|
|
1016
|
+
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));
|
|
1017
|
+
}
|
|
752
1018
|
function createBulkSchema(db) {
|
|
753
1019
|
db.exec(`
|
|
754
1020
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
@@ -758,6 +1024,7 @@ function createBulkSchema(db) {
|
|
|
758
1024
|
schema_snapshot_json TEXT,
|
|
759
1025
|
metadata_map_json TEXT NOT NULL,
|
|
760
1026
|
dry_run_summary_json TEXT,
|
|
1027
|
+
pipeline_health_json TEXT,
|
|
761
1028
|
status TEXT NOT NULL,
|
|
762
1029
|
created_at TEXT NOT NULL,
|
|
763
1030
|
updated_at TEXT NOT NULL
|
|
@@ -802,8 +1069,16 @@ function createBulkSchema(db) {
|
|
|
802
1069
|
CREATE INDEX IF NOT EXISTS idx_files_status ON files(status);
|
|
803
1070
|
CREATE INDEX IF NOT EXISTS idx_files_document_id ON files(document_id);
|
|
804
1071
|
`);
|
|
1072
|
+
ensureBulkJobColumns(db);
|
|
805
1073
|
ensureBulkFileColumns(db);
|
|
806
1074
|
}
|
|
1075
|
+
function ensureBulkJobColumns(db) {
|
|
1076
|
+
const rows = db.prepare("PRAGMA table_info(jobs)").all();
|
|
1077
|
+
const columns = new Set(rows.map((row) => String(row.name)));
|
|
1078
|
+
if (!columns.has("pipeline_health_json")) {
|
|
1079
|
+
db.exec("ALTER TABLE jobs ADD COLUMN pipeline_health_json TEXT;");
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
807
1082
|
function ensureBulkFileColumns(db) {
|
|
808
1083
|
const rows = db.prepare("PRAGMA table_info(files)").all();
|
|
809
1084
|
const columns = new Set(rows.map((row) => String(row.name)));
|
|
@@ -853,6 +1128,9 @@ async function readBulkJob(statePath) {
|
|
|
853
1128
|
dryRunSummary: row.dry_run_summary_json
|
|
854
1129
|
? JSON.parse(String(row.dry_run_summary_json))
|
|
855
1130
|
: undefined,
|
|
1131
|
+
pipelineHealth: row.pipeline_health_json
|
|
1132
|
+
? JSON.parse(String(row.pipeline_health_json))
|
|
1133
|
+
: undefined,
|
|
856
1134
|
createdAt: String(row.created_at),
|
|
857
1135
|
updatedAt: String(row.updated_at),
|
|
858
1136
|
};
|
|
@@ -942,7 +1220,8 @@ async function resetInterruptedBulkUploads(statePath) {
|
|
|
942
1220
|
const db = await openSqlite(statePath);
|
|
943
1221
|
const now = new Date().toISOString();
|
|
944
1222
|
try {
|
|
945
|
-
db
|
|
1223
|
+
createBulkSchema(db);
|
|
1224
|
+
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);
|
|
946
1225
|
touchJob(db, now);
|
|
947
1226
|
}
|
|
948
1227
|
finally {
|
|
@@ -1012,6 +1291,32 @@ function rowToBulkFile(row) {
|
|
|
1012
1291
|
: {},
|
|
1013
1292
|
};
|
|
1014
1293
|
}
|
|
1294
|
+
function bulkRecordToOriginalPlan(row) {
|
|
1295
|
+
const pathSegments = row.originalRelativePath.split("/").filter(Boolean);
|
|
1296
|
+
return {
|
|
1297
|
+
path: row.originalPath,
|
|
1298
|
+
filename: basename(row.originalPath),
|
|
1299
|
+
size: row.originalSize,
|
|
1300
|
+
mtimeMs: row.originalMtimeMs,
|
|
1301
|
+
sha256: row.originalSha256,
|
|
1302
|
+
manifestKey: row.originalRelativePath,
|
|
1303
|
+
relativePath: row.originalRelativePath,
|
|
1304
|
+
ext: row.originalExt,
|
|
1305
|
+
pathSegments,
|
|
1306
|
+
pathDepth: pathSegments.length,
|
|
1307
|
+
originalPath: row.originalPath,
|
|
1308
|
+
originalRelativePath: row.originalRelativePath,
|
|
1309
|
+
originalSize: row.originalSize,
|
|
1310
|
+
originalMtimeMs: row.originalMtimeMs,
|
|
1311
|
+
originalSha256: row.originalSha256,
|
|
1312
|
+
originalExt: row.originalExt,
|
|
1313
|
+
classification: row.classification,
|
|
1314
|
+
ingestVariant: row.ingestVariant,
|
|
1315
|
+
sourceDocumentKey: row.sourceDocumentKey,
|
|
1316
|
+
generatedMetadata: row.generatedMetadata,
|
|
1317
|
+
preflight: row.preflight,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1015
1320
|
async function resolveBulkStatePath(args, jobIdInput) {
|
|
1016
1321
|
const explicit = getString(args, "state");
|
|
1017
1322
|
if (explicit)
|
|
@@ -1119,12 +1424,31 @@ async function preflightOneBulkFile(file, options) {
|
|
|
1119
1424
|
const docx = await analyzeDocx(file.path).catch((error) => ({
|
|
1120
1425
|
error: error instanceof Error ? error.message : String(error),
|
|
1121
1426
|
}));
|
|
1427
|
+
if (isEmptyDocxAnalysis(docx)) {
|
|
1428
|
+
return [
|
|
1429
|
+
decorateBulkPlan(file, "empty", "skipped", {
|
|
1430
|
+
...docx,
|
|
1431
|
+
reason: "empty_docx",
|
|
1432
|
+
maxUploadBytes: options.maxUploadBytes,
|
|
1433
|
+
}),
|
|
1434
|
+
];
|
|
1435
|
+
}
|
|
1436
|
+
if (file.size <= DOCX_NORMALIZE_MIN_BYTES) {
|
|
1437
|
+
return [
|
|
1438
|
+
decorateBulkPlan(file, "direct_upload", "direct_upload", {
|
|
1439
|
+
...docx,
|
|
1440
|
+
maxUploadBytes: options.maxUploadBytes,
|
|
1441
|
+
normalizeThresholdBytes: DOCX_NORMALIZE_MIN_BYTES,
|
|
1442
|
+
}),
|
|
1443
|
+
];
|
|
1444
|
+
}
|
|
1122
1445
|
const classification = file.size > options.maxUploadBytes ? "oversize_docx_image_heavy" : "direct_upload";
|
|
1123
1446
|
if (!options.generateDerived) {
|
|
1124
1447
|
return [
|
|
1125
1448
|
decorateBulkPlan(file, classification, "compressed_docx", {
|
|
1126
1449
|
...docx,
|
|
1127
1450
|
maxUploadBytes: options.maxUploadBytes,
|
|
1451
|
+
normalizeThresholdBytes: DOCX_NORMALIZE_MIN_BYTES,
|
|
1128
1452
|
normalizeStrategy: "docx_image_300dpi_normalize",
|
|
1129
1453
|
}),
|
|
1130
1454
|
];
|
|
@@ -1252,15 +1576,16 @@ function bulkInitialStatus(file) {
|
|
|
1252
1576
|
return "skipped";
|
|
1253
1577
|
if (file.ingestVariant === "skipped")
|
|
1254
1578
|
return "blocked";
|
|
1255
|
-
if (
|
|
1256
|
-
file.derivedSize === undefined) {
|
|
1257
|
-
return "planned";
|
|
1258
|
-
}
|
|
1259
|
-
if (file.ingestVariant === "compressed_docx")
|
|
1579
|
+
if (file.ingestVariant === "compressed_docx" && file.derivedSize === undefined)
|
|
1260
1580
|
return "pending";
|
|
1261
|
-
if (file.
|
|
1581
|
+
if (file.ingestVariant === "page_split_pdf" && file.derivedSize === undefined)
|
|
1262
1582
|
return "pending";
|
|
1263
|
-
if (file.
|
|
1583
|
+
if (file.ingestVariant === "compressed_docx") {
|
|
1584
|
+
if (file.derivedSize !== undefined && file.derivedSize <= Number(file.preflight.maxUploadBytes))
|
|
1585
|
+
return "pending";
|
|
1586
|
+
return "blocked";
|
|
1587
|
+
}
|
|
1588
|
+
if (file.size > 0 && file.size <= Number(file.preflight.maxUploadBytes ?? Infinity))
|
|
1264
1589
|
return "pending";
|
|
1265
1590
|
if (file.derivedSize !== undefined && file.derivedSize <= Number(file.preflight.maxUploadBytes))
|
|
1266
1591
|
return "pending";
|
|
@@ -1376,13 +1701,61 @@ async function analyzeDocx(path) {
|
|
|
1376
1701
|
const entries = readZipEntries(await readFile(path));
|
|
1377
1702
|
const media = entries.filter((entry) => entry.name.startsWith("word/media/") && !entry.name.endsWith("/"));
|
|
1378
1703
|
const mediaSizes = media.map((entry) => entry.uncompressedSize);
|
|
1704
|
+
const documentXml = zipEntryText(entries, "word/document.xml") ?? "";
|
|
1705
|
+
const bodyText = extractDocxBodyText(documentXml);
|
|
1706
|
+
const appXml = zipEntryText(entries, "docProps/app.xml") ?? "";
|
|
1379
1707
|
return {
|
|
1380
1708
|
mediaCount: media.length,
|
|
1381
1709
|
mediaTotalBytes: mediaSizes.reduce((sum, size) => sum + size, 0),
|
|
1382
1710
|
mediaMaxBytes: mediaSizes.reduce((max, size) => Math.max(max, size), 0),
|
|
1711
|
+
textCharacterCount: bodyText.length,
|
|
1712
|
+
paragraphCount: countRegex(documentXml, /<w:p(?:\s|>)/g),
|
|
1713
|
+
drawingCount: countRegex(documentXml, /<w:drawing(?:\s|>)/g),
|
|
1714
|
+
pictCount: countRegex(documentXml, /<w:pict(?:\s|>)/g),
|
|
1715
|
+
appWords: xmlElementNumber(appXml, "Words"),
|
|
1716
|
+
appCharacters: xmlElementNumber(appXml, "Characters"),
|
|
1717
|
+
appParagraphs: xmlElementNumber(appXml, "Paragraphs"),
|
|
1383
1718
|
zipEntryCount: entries.length,
|
|
1384
1719
|
};
|
|
1385
1720
|
}
|
|
1721
|
+
function isEmptyDocxAnalysis(docx) {
|
|
1722
|
+
if (docx.error)
|
|
1723
|
+
return false;
|
|
1724
|
+
const mediaCount = Number(docx.mediaCount ?? 0);
|
|
1725
|
+
const textCharacterCount = Number(docx.textCharacterCount ?? 0);
|
|
1726
|
+
const appWords = Number(docx.appWords ?? 0);
|
|
1727
|
+
return mediaCount === 0 && textCharacterCount === 0 && appWords === 0;
|
|
1728
|
+
}
|
|
1729
|
+
function zipEntryText(entries, name) {
|
|
1730
|
+
const entry = entries.find((candidate) => candidate.name === name);
|
|
1731
|
+
return entry?.data.toString("utf8");
|
|
1732
|
+
}
|
|
1733
|
+
function extractDocxBodyText(xml) {
|
|
1734
|
+
const text = [...xml.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)]
|
|
1735
|
+
.map((match) => decodeXmlText(match[1] ?? ""))
|
|
1736
|
+
.join("");
|
|
1737
|
+
return text.trim();
|
|
1738
|
+
}
|
|
1739
|
+
function countRegex(input, regex) {
|
|
1740
|
+
return (input.match(regex) ?? []).length;
|
|
1741
|
+
}
|
|
1742
|
+
function xmlElementNumber(xml, name) {
|
|
1743
|
+
const match = new RegExp(`<${name}>(\\d+)</${name}>`).exec(xml);
|
|
1744
|
+
if (!match)
|
|
1745
|
+
return undefined;
|
|
1746
|
+
const value = Number(match[1]);
|
|
1747
|
+
return Number.isFinite(value) ? value : undefined;
|
|
1748
|
+
}
|
|
1749
|
+
function decodeXmlText(input) {
|
|
1750
|
+
return input
|
|
1751
|
+
.replace(/</g, "<")
|
|
1752
|
+
.replace(/>/g, ">")
|
|
1753
|
+
.replace(/"/g, '"')
|
|
1754
|
+
.replace(/'/g, "'")
|
|
1755
|
+
.replace(/&/g, "&")
|
|
1756
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
|
1757
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
|
|
1758
|
+
}
|
|
1386
1759
|
async function createDocxIngestCopy(file, options, docx, classification) {
|
|
1387
1760
|
const derivedPath = join(options.workDir, "docx", `${safeDerivedName(file.originalRelativePath)}-${file.sha256.slice(0, 12)}.docx`);
|
|
1388
1761
|
await mkdir(dirname(derivedPath), { recursive: true });
|
|
@@ -2247,7 +2620,9 @@ function csvLine(values) {
|
|
|
2247
2620
|
return values.map((value) => `"${value.replace(/"/g, '""')}"`).join(",");
|
|
2248
2621
|
}
|
|
2249
2622
|
function formatBulkRunSummary(summary) {
|
|
2250
|
-
|
|
2623
|
+
const pressure = summary.pipelineHealth?.pressure ?? "unknown";
|
|
2624
|
+
const action = summary.pipelineHealth?.recommendedAction ?? "continue";
|
|
2625
|
+
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`;
|
|
2251
2626
|
}
|
|
2252
2627
|
async function uploadWithRetries(input) {
|
|
2253
2628
|
let lastError;
|