@heripo/document-processor 0.1.5 → 0.1.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/dist/index.cjs CHANGED
@@ -39,6 +39,7 @@ __export(index_exports, {
39
39
  CaptionValidator: () => CaptionValidator,
40
40
  ChapterConverter: () => ChapterConverter,
41
41
  DocumentProcessor: () => DocumentProcessor,
42
+ HanjaQualitySampler: () => HanjaQualitySampler,
42
43
  PAGE_NUMBER_PATTERN: () => PAGE_NUMBER_PATTERN,
43
44
  PagePattern: () => PagePattern,
44
45
  PageRangeParseError: () => PageRangeParseError,
@@ -3408,6 +3409,283 @@ Remember: Look for SMALL numbers in MARGINS only. Ignore figure/table/photo numb
3408
3409
  }
3409
3410
  };
3410
3411
 
3412
+ // src/samplers/hanja-quality-sampler.ts
3413
+ var import_zod5 = require("zod");
3414
+ var MIN_TEXT_LENGTH = 100;
3415
+ var MAX_SAMPLE_PAGES = 10;
3416
+ var EDGE_TRIM_RATIO = 0.1;
3417
+ var IMAGE_PAGE_TEXT_THRESHOLD = 50;
3418
+ var CORRUPTION_THRESHOLD = 0.5;
3419
+ var MIN_SEVERE_CORRUPTED_COUNT = 3;
3420
+ var HanjaQualityResponseSchema = import_zod5.z.object({
3421
+ isCorrupted: import_zod5.z.boolean().describe(
3422
+ "Whether the KCJ (Hanja) characters in the OCR text are significantly corrupted compared to the page image"
3423
+ ),
3424
+ corruptedCharCount: import_zod5.z.number().int().nonnegative().describe(
3425
+ "Approximate number of corrupted or incorrectly recognized KCJ characters"
3426
+ ),
3427
+ totalKcjCharCount: import_zod5.z.number().int().nonnegative().describe("Total number of KCJ characters found in the OCR text"),
3428
+ explanation: import_zod5.z.string().describe("Brief explanation of the assessment result")
3429
+ });
3430
+ var HanjaQualitySampler = class extends VisionLLMComponent {
3431
+ constructor(logger, model, outputPath, maxRetries, fallbackModel, aggregator, abortSignal) {
3432
+ super(
3433
+ logger,
3434
+ model,
3435
+ "HanjaQualitySampler",
3436
+ outputPath,
3437
+ { maxRetries, abortSignal },
3438
+ fallbackModel,
3439
+ aggregator
3440
+ );
3441
+ }
3442
+ /**
3443
+ * Assess the quality of KCJ character recognition in the document
3444
+ *
3445
+ * @param doclingDoc - DoclingDocument to assess
3446
+ * @returns Assessment result indicating whether VLM re-parse is needed
3447
+ */
3448
+ async assess(doclingDoc) {
3449
+ const totalPages = Object.keys(doclingDoc.pages).length;
3450
+ this.log("info", "Starting KCJ quality assessment...");
3451
+ const { frontCutoff, backCutoff } = this.getEligiblePageRange(totalPages);
3452
+ const imageOnlyPages = this.getImageOnlyPages(doclingDoc);
3453
+ this.log(
3454
+ "info",
3455
+ `Total pages: ${totalPages}, eligible range: (${frontCutoff}, ${backCutoff}], image-only pages excluded: ${imageOnlyPages.size}`
3456
+ );
3457
+ const textPages = this.getTextPages(doclingDoc);
3458
+ if (textPages.length === 0) {
3459
+ this.log("info", "No text pages found for assessment");
3460
+ return {
3461
+ needsVlmReparse: false,
3462
+ severity: "none",
3463
+ kcjPageCount: 0,
3464
+ sampledPageCount: 0,
3465
+ corruptedRatio: 0,
3466
+ reason: "No text pages found for assessment"
3467
+ };
3468
+ }
3469
+ this.log(
3470
+ "info",
3471
+ `Found ${textPages.length} text pages (min length: ${MIN_TEXT_LENGTH})`
3472
+ );
3473
+ const sampled = this.selectSamplePages(
3474
+ textPages,
3475
+ totalPages,
3476
+ imageOnlyPages
3477
+ );
3478
+ this.log(
3479
+ "info",
3480
+ `Sampling ${sampled.length} pages: [${sampled.map((p) => p.pageNo).join(", ")}]`
3481
+ );
3482
+ const results = await this.evaluatePages(sampled);
3483
+ const assessment = this.aggregateResults(
3484
+ textPages.length,
3485
+ sampled.length,
3486
+ results
3487
+ );
3488
+ this.log(
3489
+ "info",
3490
+ `Assessment complete: severity=${assessment.severity}, corrupted=${assessment.corruptedRatio.toFixed(2)}, needsVlmReparse=${assessment.needsVlmReparse}`
3491
+ );
3492
+ return assessment;
3493
+ }
3494
+ /**
3495
+ * Get the eligible page range after trimming front/back edges.
3496
+ * Returns cutoff values where eligible pages satisfy: pageNo > frontCutoff && pageNo <= backCutoff
3497
+ */
3498
+ getEligiblePageRange(totalPages) {
3499
+ const trimCount = Math.ceil(totalPages * EDGE_TRIM_RATIO);
3500
+ return {
3501
+ frontCutoff: trimCount,
3502
+ backCutoff: totalPages - trimCount
3503
+ };
3504
+ }
3505
+ /**
3506
+ * Get page numbers that are "image-only" (contain pictures but minimal text).
3507
+ * These pages (e.g., photo plates/도판) are unsuitable for KCJ quality assessment.
3508
+ */
3509
+ getImageOnlyPages(doclingDoc) {
3510
+ const picturePages = /* @__PURE__ */ new Set();
3511
+ for (const picture of doclingDoc.pictures) {
3512
+ const pageNo = picture.prov?.[0]?.page_no;
3513
+ if (pageNo != null) {
3514
+ picturePages.add(pageNo);
3515
+ }
3516
+ }
3517
+ if (picturePages.size === 0) return /* @__PURE__ */ new Set();
3518
+ const pageTextLength = /* @__PURE__ */ new Map();
3519
+ for (const text of doclingDoc.texts) {
3520
+ const pageNo = this.getPageNo(text);
3521
+ if (picturePages.has(pageNo)) {
3522
+ pageTextLength.set(
3523
+ pageNo,
3524
+ (pageTextLength.get(pageNo) ?? 0) + text.text.length
3525
+ );
3526
+ }
3527
+ }
3528
+ const imageOnlyPages = /* @__PURE__ */ new Set();
3529
+ for (const pageNo of picturePages) {
3530
+ const textLength = pageTextLength.get(pageNo) ?? 0;
3531
+ if (textLength < IMAGE_PAGE_TEXT_THRESHOLD) {
3532
+ imageOnlyPages.add(pageNo);
3533
+ }
3534
+ }
3535
+ return imageOnlyPages;
3536
+ }
3537
+ /**
3538
+ * Get all pages with substantial text content.
3539
+ * Pages with fewer characters than MIN_TEXT_LENGTH (e.g., page numbers, headers only) are excluded.
3540
+ */
3541
+ getTextPages(doclingDoc) {
3542
+ const pageMap = /* @__PURE__ */ new Map();
3543
+ for (const text of doclingDoc.texts) {
3544
+ const pageNo = this.getPageNo(text);
3545
+ const entry = pageMap.get(pageNo) ?? { textLength: 0, texts: [] };
3546
+ entry.textLength += text.text.length;
3547
+ entry.texts.push(text.text);
3548
+ pageMap.set(pageNo, entry);
3549
+ }
3550
+ return Array.from(pageMap.entries()).filter(([, data]) => data.textLength >= MIN_TEXT_LENGTH).map(([pageNo, data]) => ({
3551
+ pageNo,
3552
+ textLength: data.textLength,
3553
+ texts: data.texts
3554
+ }));
3555
+ }
3556
+ /**
3557
+ * Select sample pages with highest text density.
3558
+ * Prefers pages in the eligible range (not edge-trimmed) and not image-only.
3559
+ * Falls back to all text pages if filtering leaves too few candidates.
3560
+ */
3561
+ selectSamplePages(textPages, totalPages, imageOnlyPages) {
3562
+ const { frontCutoff, backCutoff } = this.getEligiblePageRange(totalPages);
3563
+ const filtered = totalPages > 0 ? textPages.filter(
3564
+ (p) => p.pageNo > frontCutoff && p.pageNo <= backCutoff && !imageOnlyPages.has(p.pageNo)
3565
+ ) : textPages;
3566
+ const candidates = filtered.length > 0 ? filtered : textPages;
3567
+ if (candidates !== filtered) {
3568
+ this.log(
3569
+ "warn",
3570
+ `All text pages were filtered out by edge/image exclusion. Falling back to all ${textPages.length} text pages.`
3571
+ );
3572
+ }
3573
+ const sorted = [...candidates].sort((a, b) => b.textLength - a.textLength);
3574
+ const count = Math.min(MAX_SAMPLE_PAGES, sorted.length);
3575
+ return sorted.slice(0, count);
3576
+ }
3577
+ /**
3578
+ * Evaluate sampled pages using Vision LLM
3579
+ */
3580
+ async evaluatePages(pages) {
3581
+ const results = [];
3582
+ for (const page of pages) {
3583
+ const result = await this.evaluateSinglePage(page);
3584
+ results.push(result);
3585
+ }
3586
+ return results;
3587
+ }
3588
+ /**
3589
+ * Evaluate a single page by comparing OCR text against the page image
3590
+ */
3591
+ async evaluateSinglePage(page) {
3592
+ const imagePath = `pages/page_${page.pageNo - 1}.png`;
3593
+ let imageContent;
3594
+ try {
3595
+ imageContent = this.buildImageContent(imagePath);
3596
+ } catch {
3597
+ this.log(
3598
+ "warn",
3599
+ `Failed to load page image for page ${page.pageNo}, marking as not corrupted`
3600
+ );
3601
+ return {
3602
+ isCorrupted: false,
3603
+ corruptedCharCount: 0,
3604
+ totalKcjCharCount: 0,
3605
+ explanation: `Page image not available for page ${page.pageNo}`
3606
+ };
3607
+ }
3608
+ const ocrText = page.texts.join("\n");
3609
+ const messages = [
3610
+ {
3611
+ role: "user",
3612
+ content: [
3613
+ imageContent,
3614
+ {
3615
+ type: "text",
3616
+ text: this.buildUserPrompt(ocrText)
3617
+ }
3618
+ ]
3619
+ }
3620
+ ];
3621
+ const { output } = await this.callVisionLLM(
3622
+ HanjaQualityResponseSchema,
3623
+ messages,
3624
+ `page-${page.pageNo}`
3625
+ );
3626
+ this.log(
3627
+ "info",
3628
+ `Page ${page.pageNo}: corrupted=${output.isCorrupted}, ${output.corruptedCharCount}/${output.totalKcjCharCount} KCJ chars corrupted`
3629
+ );
3630
+ return output;
3631
+ }
3632
+ /**
3633
+ * Aggregate individual page results into final assessment
3634
+ */
3635
+ aggregateResults(totalTextPages, sampledCount, results) {
3636
+ const corruptedCount = results.filter((r) => r.isCorrupted).length;
3637
+ const corruptedRatio = sampledCount > 0 ? corruptedCount / sampledCount : 0;
3638
+ let severity;
3639
+ let needsVlmReparse;
3640
+ if (corruptedRatio >= CORRUPTION_THRESHOLD || corruptedCount >= MIN_SEVERE_CORRUPTED_COUNT) {
3641
+ severity = "severe";
3642
+ needsVlmReparse = true;
3643
+ } else if (corruptedCount > 0) {
3644
+ severity = "minor";
3645
+ needsVlmReparse = false;
3646
+ } else {
3647
+ severity = "none";
3648
+ needsVlmReparse = false;
3649
+ }
3650
+ const reason = corruptedCount === 0 ? "No Hanja character corruption detected" : `${corruptedCount}/${sampledCount} sampled pages have corrupted Hanja characters (ratio: ${corruptedRatio.toFixed(2)})`;
3651
+ return {
3652
+ needsVlmReparse,
3653
+ severity,
3654
+ kcjPageCount: totalTextPages,
3655
+ sampledPageCount: sampledCount,
3656
+ corruptedRatio,
3657
+ reason
3658
+ };
3659
+ }
3660
+ /**
3661
+ * Get page number from a text item
3662
+ */
3663
+ getPageNo(text) {
3664
+ return text.prov?.[0]?.page_no ?? 1;
3665
+ }
3666
+ buildSystemPrompt() {
3667
+ return "";
3668
+ }
3669
+ buildUserPrompt(ocrText) {
3670
+ return `You are evaluating the quality of OCR text extraction for Hanja (\u6F22\u5B57) characters used in Korean archaeological reports.
3671
+
3672
+ Look at the page image and find any Hanja (\u6F22\u5B57) characters visible in the original document. Then compare them against the OCR text below.
3673
+
3674
+ ## Evaluation Criteria
3675
+ - Find Hanja (\u6F22\u5B57) characters in the page image first
3676
+ - Check if each Hanja character is correctly recognized in the OCR text
3677
+ - A character is "corrupted" if a Hanja character in the image was replaced by Korean hangul, garbled text, symbols, or a completely different character in the OCR text
3678
+ - Common corruption patterns: Hanja replaced by similar-looking hangul (e.g., \u7C98\u571F \u2192 \uADF8\uC73C), wrong character substitution, partial recognition, question marks or boxes
3679
+ - Minor font rendering differences are NOT corruption
3680
+ - If no Hanja characters are visible in the page image, report as not corrupted with 0 total characters
3681
+
3682
+ ## OCR Text to Evaluate
3683
+ ${ocrText}
3684
+
3685
+ Compare the Hanja characters visible in the page image against the OCR text above and assess whether they were correctly recognized.`;
3686
+ }
3687
+ };
3688
+
3411
3689
  // src/validators/base-validator.ts
3412
3690
  var BaseValidator = class extends TextLLMComponent {
3413
3691
  /**
@@ -3467,13 +3745,13 @@ var BaseValidator = class extends TextLLMComponent {
3467
3745
  };
3468
3746
 
3469
3747
  // src/validators/toc-content-validator.ts
3470
- var import_zod5 = require("zod");
3471
- var TocContentValidationSchema = import_zod5.z.object({
3472
- isValid: import_zod5.z.boolean().describe("Whether valid main document TOC was found"),
3473
- confidence: import_zod5.z.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3474
- contentType: import_zod5.z.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3475
- extractedTocMarkdown: import_zod5.z.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3476
- reason: import_zod5.z.string().describe("Brief explanation in English")
3748
+ var import_zod6 = require("zod");
3749
+ var TocContentValidationSchema = import_zod6.z.object({
3750
+ isValid: import_zod6.z.boolean().describe("Whether valid main document TOC was found"),
3751
+ confidence: import_zod6.z.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3752
+ contentType: import_zod6.z.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3753
+ extractedTocMarkdown: import_zod6.z.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3754
+ reason: import_zod6.z.string().describe("Brief explanation in English")
3477
3755
  });
3478
3756
  var TocContentValidator = class extends BaseValidator {
3479
3757
  confidenceThreshold;
@@ -3624,14 +3902,14 @@ ${markdown}`;
3624
3902
  };
3625
3903
 
3626
3904
  // src/validators/caption-validator.ts
3627
- var import_zod6 = require("zod");
3628
- var CaptionValidationItemSchema = import_zod6.z.object({
3629
- index: import_zod6.z.number().int().describe("Index of the caption in the input array"),
3630
- isValid: import_zod6.z.boolean().describe("Whether the parsed caption is correct"),
3631
- reason: import_zod6.z.string().nullable().describe("Brief explanation if invalid, null if valid")
3905
+ var import_zod7 = require("zod");
3906
+ var CaptionValidationItemSchema = import_zod7.z.object({
3907
+ index: import_zod7.z.number().int().describe("Index of the caption in the input array"),
3908
+ isValid: import_zod7.z.boolean().describe("Whether the parsed caption is correct"),
3909
+ reason: import_zod7.z.string().nullable().describe("Brief explanation if invalid, null if valid")
3632
3910
  });
3633
- var CaptionValidationBatchSchema = import_zod6.z.object({
3634
- results: import_zod6.z.array(CaptionValidationItemSchema)
3911
+ var CaptionValidationBatchSchema = import_zod7.z.object({
3912
+ results: import_zod7.z.array(CaptionValidationItemSchema)
3635
3913
  });
3636
3914
  var CaptionValidator = class extends BaseValidator {
3637
3915
  constructor(logger, model, options, fallbackModel, aggregator) {
@@ -3839,6 +4117,7 @@ var DocumentProcessor = class {
3839
4117
  validatorModel;
3840
4118
  visionTocExtractorModel;
3841
4119
  captionParserModel;
4120
+ hanjaQualitySamplerModel;
3842
4121
  textCleanerBatchSize;
3843
4122
  captionParserBatchSize;
3844
4123
  captionValidatorBatchSize;
@@ -3865,6 +4144,7 @@ var DocumentProcessor = class {
3865
4144
  this.validatorModel = options.validatorModel ?? options.fallbackModel;
3866
4145
  this.visionTocExtractorModel = options.visionTocExtractorModel ?? options.fallbackModel;
3867
4146
  this.captionParserModel = options.captionParserModel ?? options.fallbackModel;
4147
+ this.hanjaQualitySamplerModel = options.hanjaQualitySamplerModel ?? options.fallbackModel;
3868
4148
  this.textCleanerBatchSize = options.textCleanerBatchSize;
3869
4149
  this.captionParserBatchSize = options.captionParserBatchSize;
3870
4150
  this.captionValidatorBatchSize = options.captionValidatorBatchSize;
@@ -3884,6 +4164,36 @@ var DocumentProcessor = class {
3884
4164
  throw error;
3885
4165
  }
3886
4166
  }
4167
+ /**
4168
+ * Assess the quality of KCJ (Hanja) character recognition in the OCR output.
4169
+ *
4170
+ * Samples pages containing KCJ characters and uses Vision LLM to compare
4171
+ * the OCR text against the original page images. Returns an assessment
4172
+ * indicating whether the document should be re-parsed with VLM pipeline.
4173
+ *
4174
+ * @param doclingDoc - Original document extracted from Docling SDK
4175
+ * @param outputPath - Path containing pages subdirectory (pages/page_0.png, etc.)
4176
+ * @returns Assessment result with severity and recommendation
4177
+ */
4178
+ async assessHanjaQuality(doclingDoc, outputPath) {
4179
+ this.logger.info(
4180
+ "[DocumentProcessor] Starting Hanja quality assessment..."
4181
+ );
4182
+ const sampler = new HanjaQualitySampler(
4183
+ this.logger,
4184
+ this.hanjaQualitySamplerModel,
4185
+ outputPath,
4186
+ this.maxRetries,
4187
+ this.enableFallbackRetry ? this.fallbackModel : void 0,
4188
+ this.usageAggregator,
4189
+ this.abortSignal
4190
+ );
4191
+ const assessment = await sampler.assess(doclingDoc);
4192
+ this.logger.info(
4193
+ `[DocumentProcessor] Hanja assessment: severity=${assessment.severity}, needsVlmReparse=${assessment.needsVlmReparse}`
4194
+ );
4195
+ return assessment;
4196
+ }
3887
4197
  /**
3888
4198
  * Converts DoclingDocument to ProcessedDocument with token usage tracking.
3889
4199
  *
@@ -4217,8 +4527,10 @@ var DocumentProcessor = class {
4217
4527
  );
4218
4528
  }
4219
4529
  const totalPages = Object.keys(doclingDoc.pages).length;
4530
+ const maxTocPageNo = this.extractMaxPageNumber(markdown);
4531
+ const effectiveTotalPages = maxTocPageNo > totalPages ? void 0 : totalPages;
4220
4532
  const tocResult = await this.tocExtractor.extract(markdown, {
4221
- totalPages
4533
+ totalPages: effectiveTotalPages
4222
4534
  });
4223
4535
  for (const usage of tocResult.usages) {
4224
4536
  this.usageAggregator.track(usage);
@@ -4233,6 +4545,20 @@ var DocumentProcessor = class {
4233
4545
  );
4234
4546
  return tocResult.entries;
4235
4547
  }
4548
+ /**
4549
+ * Extract the maximum page number from TOC markdown
4550
+ *
4551
+ * Parses page numbers from dot-leader patterns (e.g., "..... 175")
4552
+ * to detect compiled volume scenarios where TOC page numbers exceed
4553
+ * the sub-document's page count.
4554
+ */
4555
+ extractMaxPageNumber(markdown) {
4556
+ const matches = [...markdown.matchAll(/\.{2,}\s*(\d+)/g)];
4557
+ if (matches.length === 0) {
4558
+ return 0;
4559
+ }
4560
+ return Math.max(...matches.map((m) => parseInt(m[1], 10)));
4561
+ }
4236
4562
  /**
4237
4563
  * Process resource captions (for images and tables)
4238
4564
  *
@@ -4501,6 +4827,7 @@ var DocumentProcessor = class {
4501
4827
  CaptionValidator,
4502
4828
  ChapterConverter,
4503
4829
  DocumentProcessor,
4830
+ HanjaQualitySampler,
4504
4831
  PAGE_NUMBER_PATTERN,
4505
4832
  PagePattern,
4506
4833
  PageRangeParseError,