@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.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { LoggerMethods } from '@heripo/logger';
2
- import { DoclingDocument, DocumentProcessResult, Caption, PageRange, DoclingTextItem, DoclingPictureItem, DoclingTableItem, DoclingGroupItem, ProcessedImage, ProcessedTable, ProcessedFootnote, Chapter } from '@heripo/model';
2
+ import { DoclingDocument, HanjaAssessment, DocumentProcessResult, Caption, PageRange, DoclingTextItem, DoclingPictureItem, DoclingTableItem, DoclingGroupItem, ProcessedImage, ProcessedTable, ProcessedFootnote, Chapter } from '@heripo/model';
3
3
  import { LanguageModel } from 'ai';
4
4
  import { LLMTokenUsageAggregator, ExtendedTokenUsage } from '@heripo/shared';
5
5
  import { z } from 'zod';
@@ -43,6 +43,11 @@ interface DocumentProcessorOptions {
43
43
  * Falls back to 'fallbackModel' if not provided.
44
44
  */
45
45
  captionParserModel?: LanguageModel;
46
+ /**
47
+ * Model for HanjaQualitySampler - evaluates KCJ character quality in OCR output.
48
+ * Requires vision capabilities. Falls back to 'fallbackModel' if not provided.
49
+ */
50
+ hanjaQualitySamplerModel?: LanguageModel;
46
51
  /**
47
52
  * Batch size for TextCleaner text normalization (synchronous processing)
48
53
  */
@@ -132,6 +137,7 @@ declare class DocumentProcessor {
132
137
  private readonly validatorModel;
133
138
  private readonly visionTocExtractorModel;
134
139
  private readonly captionParserModel;
140
+ private readonly hanjaQualitySamplerModel;
135
141
  private readonly textCleanerBatchSize;
136
142
  private readonly captionParserBatchSize;
137
143
  private readonly captionValidatorBatchSize;
@@ -157,6 +163,18 @@ declare class DocumentProcessor {
157
163
  * @throws {Error} with name 'AbortError' if aborted
158
164
  */
159
165
  private checkAborted;
166
+ /**
167
+ * Assess the quality of KCJ (Hanja) character recognition in the OCR output.
168
+ *
169
+ * Samples pages containing KCJ characters and uses Vision LLM to compare
170
+ * the OCR text against the original page images. Returns an assessment
171
+ * indicating whether the document should be re-parsed with VLM pipeline.
172
+ *
173
+ * @param doclingDoc - Original document extracted from Docling SDK
174
+ * @param outputPath - Path containing pages subdirectory (pages/page_0.png, etc.)
175
+ * @returns Assessment result with severity and recommendation
176
+ */
177
+ assessHanjaQuality(doclingDoc: DoclingDocument, outputPath: string): Promise<HanjaAssessment>;
160
178
  /**
161
179
  * Converts DoclingDocument to ProcessedDocument with token usage tracking.
162
180
  *
@@ -232,6 +250,14 @@ declare class DocumentProcessor {
232
250
  * 5. TocExtractor - LLM-based structured extraction
233
251
  */
234
252
  private extractTableOfContents;
253
+ /**
254
+ * Extract the maximum page number from TOC markdown
255
+ *
256
+ * Parses page numbers from dot-leader patterns (e.g., "..... 175")
257
+ * to detect compiled volume scenarios where TOC page numbers exceed
258
+ * the sub-document's page count.
259
+ */
260
+ private extractMaxPageNumber;
235
261
  /**
236
262
  * Process resource captions (for images and tables)
237
263
  *
@@ -1482,4 +1508,64 @@ declare class ChapterConverter {
1482
1508
  private buildChapterMap;
1483
1509
  }
1484
1510
 
1485
- export { BaseLLMComponent, type BaseLLMComponentOptions, BaseValidator, type BaseValidatorOptions, CONTINUATION_MARKERS, CaptionParseError, CaptionParser, type CaptionParserOptions, CaptionValidationError, CaptionValidator, type CaptionValidatorOptions, ChapterConverter, DocumentProcessor, type DocumentProcessorOptions, type ImageContent, PAGE_NUMBER_PATTERN, PagePattern, PageRangeParseError, PageRangeParser, type PageSizeGroup, TOC_KEYWORDS, TextLLMComponent, type TocAreaResult, type TocContentValidationResult, TocContentValidationSchema, TocContentValidator, type TocContentValidatorOptions, type TocEntry, TocEntrySchema, TocExtractError, TocExtractor, type TocExtractorOptions, TocFinder, type TocFinderOptions, TocNotFoundError, TocParseError, type TocResponse, TocResponseSchema, VisionLLMComponent, type VisionLLMComponentOptions, type VisionTocExtractionResult, VisionTocExtractionSchema, VisionTocExtractor, type VisionTocExtractorOptions };
1511
+ /**
1512
+ * HanjaQualitySampler
1513
+ *
1514
+ * Evaluates the quality of KCJ (Chinese/Japanese/Korean) character recognition
1515
+ * in OCR-processed documents. Samples a subset of pages containing KCJ text and
1516
+ * uses Vision LLM to compare the OCR output against the original page images.
1517
+ *
1518
+ * This is used to determine whether a document should be re-parsed using the
1519
+ * VLM pipeline for better KCJ character accuracy.
1520
+ */
1521
+ declare class HanjaQualitySampler extends VisionLLMComponent {
1522
+ constructor(logger: LoggerMethods, model: LanguageModel, outputPath: string, maxRetries?: number, fallbackModel?: LanguageModel, aggregator?: LLMTokenUsageAggregator, abortSignal?: AbortSignal);
1523
+ /**
1524
+ * Assess the quality of KCJ character recognition in the document
1525
+ *
1526
+ * @param doclingDoc - DoclingDocument to assess
1527
+ * @returns Assessment result indicating whether VLM re-parse is needed
1528
+ */
1529
+ assess(doclingDoc: DoclingDocument): Promise<HanjaAssessment>;
1530
+ /**
1531
+ * Get the eligible page range after trimming front/back edges.
1532
+ * Returns cutoff values where eligible pages satisfy: pageNo > frontCutoff && pageNo <= backCutoff
1533
+ */
1534
+ private getEligiblePageRange;
1535
+ /**
1536
+ * Get page numbers that are "image-only" (contain pictures but minimal text).
1537
+ * These pages (e.g., photo plates/도판) are unsuitable for KCJ quality assessment.
1538
+ */
1539
+ private getImageOnlyPages;
1540
+ /**
1541
+ * Get all pages with substantial text content.
1542
+ * Pages with fewer characters than MIN_TEXT_LENGTH (e.g., page numbers, headers only) are excluded.
1543
+ */
1544
+ private getTextPages;
1545
+ /**
1546
+ * Select sample pages with highest text density.
1547
+ * Prefers pages in the eligible range (not edge-trimmed) and not image-only.
1548
+ * Falls back to all text pages if filtering leaves too few candidates.
1549
+ */
1550
+ private selectSamplePages;
1551
+ /**
1552
+ * Evaluate sampled pages using Vision LLM
1553
+ */
1554
+ private evaluatePages;
1555
+ /**
1556
+ * Evaluate a single page by comparing OCR text against the page image
1557
+ */
1558
+ private evaluateSinglePage;
1559
+ /**
1560
+ * Aggregate individual page results into final assessment
1561
+ */
1562
+ private aggregateResults;
1563
+ /**
1564
+ * Get page number from a text item
1565
+ */
1566
+ private getPageNo;
1567
+ protected buildSystemPrompt(): string;
1568
+ protected buildUserPrompt(ocrText: string): string;
1569
+ }
1570
+
1571
+ export { BaseLLMComponent, type BaseLLMComponentOptions, BaseValidator, type BaseValidatorOptions, CONTINUATION_MARKERS, CaptionParseError, CaptionParser, type CaptionParserOptions, CaptionValidationError, CaptionValidator, type CaptionValidatorOptions, ChapterConverter, DocumentProcessor, type DocumentProcessorOptions, HanjaQualitySampler, type ImageContent, PAGE_NUMBER_PATTERN, PagePattern, PageRangeParseError, PageRangeParser, type PageSizeGroup, TOC_KEYWORDS, TextLLMComponent, type TocAreaResult, type TocContentValidationResult, TocContentValidationSchema, TocContentValidator, type TocContentValidatorOptions, type TocEntry, TocEntrySchema, TocExtractError, TocExtractor, type TocExtractorOptions, TocFinder, type TocFinderOptions, TocNotFoundError, TocParseError, type TocResponse, TocResponseSchema, VisionLLMComponent, type VisionLLMComponentOptions, type VisionTocExtractionResult, VisionTocExtractionSchema, VisionTocExtractor, type VisionTocExtractorOptions };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { LoggerMethods } from '@heripo/logger';
2
- import { DoclingDocument, DocumentProcessResult, Caption, PageRange, DoclingTextItem, DoclingPictureItem, DoclingTableItem, DoclingGroupItem, ProcessedImage, ProcessedTable, ProcessedFootnote, Chapter } from '@heripo/model';
2
+ import { DoclingDocument, HanjaAssessment, DocumentProcessResult, Caption, PageRange, DoclingTextItem, DoclingPictureItem, DoclingTableItem, DoclingGroupItem, ProcessedImage, ProcessedTable, ProcessedFootnote, Chapter } from '@heripo/model';
3
3
  import { LanguageModel } from 'ai';
4
4
  import { LLMTokenUsageAggregator, ExtendedTokenUsage } from '@heripo/shared';
5
5
  import { z } from 'zod';
@@ -43,6 +43,11 @@ interface DocumentProcessorOptions {
43
43
  * Falls back to 'fallbackModel' if not provided.
44
44
  */
45
45
  captionParserModel?: LanguageModel;
46
+ /**
47
+ * Model for HanjaQualitySampler - evaluates KCJ character quality in OCR output.
48
+ * Requires vision capabilities. Falls back to 'fallbackModel' if not provided.
49
+ */
50
+ hanjaQualitySamplerModel?: LanguageModel;
46
51
  /**
47
52
  * Batch size for TextCleaner text normalization (synchronous processing)
48
53
  */
@@ -132,6 +137,7 @@ declare class DocumentProcessor {
132
137
  private readonly validatorModel;
133
138
  private readonly visionTocExtractorModel;
134
139
  private readonly captionParserModel;
140
+ private readonly hanjaQualitySamplerModel;
135
141
  private readonly textCleanerBatchSize;
136
142
  private readonly captionParserBatchSize;
137
143
  private readonly captionValidatorBatchSize;
@@ -157,6 +163,18 @@ declare class DocumentProcessor {
157
163
  * @throws {Error} with name 'AbortError' if aborted
158
164
  */
159
165
  private checkAborted;
166
+ /**
167
+ * Assess the quality of KCJ (Hanja) character recognition in the OCR output.
168
+ *
169
+ * Samples pages containing KCJ characters and uses Vision LLM to compare
170
+ * the OCR text against the original page images. Returns an assessment
171
+ * indicating whether the document should be re-parsed with VLM pipeline.
172
+ *
173
+ * @param doclingDoc - Original document extracted from Docling SDK
174
+ * @param outputPath - Path containing pages subdirectory (pages/page_0.png, etc.)
175
+ * @returns Assessment result with severity and recommendation
176
+ */
177
+ assessHanjaQuality(doclingDoc: DoclingDocument, outputPath: string): Promise<HanjaAssessment>;
160
178
  /**
161
179
  * Converts DoclingDocument to ProcessedDocument with token usage tracking.
162
180
  *
@@ -232,6 +250,14 @@ declare class DocumentProcessor {
232
250
  * 5. TocExtractor - LLM-based structured extraction
233
251
  */
234
252
  private extractTableOfContents;
253
+ /**
254
+ * Extract the maximum page number from TOC markdown
255
+ *
256
+ * Parses page numbers from dot-leader patterns (e.g., "..... 175")
257
+ * to detect compiled volume scenarios where TOC page numbers exceed
258
+ * the sub-document's page count.
259
+ */
260
+ private extractMaxPageNumber;
235
261
  /**
236
262
  * Process resource captions (for images and tables)
237
263
  *
@@ -1482,4 +1508,64 @@ declare class ChapterConverter {
1482
1508
  private buildChapterMap;
1483
1509
  }
1484
1510
 
1485
- export { BaseLLMComponent, type BaseLLMComponentOptions, BaseValidator, type BaseValidatorOptions, CONTINUATION_MARKERS, CaptionParseError, CaptionParser, type CaptionParserOptions, CaptionValidationError, CaptionValidator, type CaptionValidatorOptions, ChapterConverter, DocumentProcessor, type DocumentProcessorOptions, type ImageContent, PAGE_NUMBER_PATTERN, PagePattern, PageRangeParseError, PageRangeParser, type PageSizeGroup, TOC_KEYWORDS, TextLLMComponent, type TocAreaResult, type TocContentValidationResult, TocContentValidationSchema, TocContentValidator, type TocContentValidatorOptions, type TocEntry, TocEntrySchema, TocExtractError, TocExtractor, type TocExtractorOptions, TocFinder, type TocFinderOptions, TocNotFoundError, TocParseError, type TocResponse, TocResponseSchema, VisionLLMComponent, type VisionLLMComponentOptions, type VisionTocExtractionResult, VisionTocExtractionSchema, VisionTocExtractor, type VisionTocExtractorOptions };
1511
+ /**
1512
+ * HanjaQualitySampler
1513
+ *
1514
+ * Evaluates the quality of KCJ (Chinese/Japanese/Korean) character recognition
1515
+ * in OCR-processed documents. Samples a subset of pages containing KCJ text and
1516
+ * uses Vision LLM to compare the OCR output against the original page images.
1517
+ *
1518
+ * This is used to determine whether a document should be re-parsed using the
1519
+ * VLM pipeline for better KCJ character accuracy.
1520
+ */
1521
+ declare class HanjaQualitySampler extends VisionLLMComponent {
1522
+ constructor(logger: LoggerMethods, model: LanguageModel, outputPath: string, maxRetries?: number, fallbackModel?: LanguageModel, aggregator?: LLMTokenUsageAggregator, abortSignal?: AbortSignal);
1523
+ /**
1524
+ * Assess the quality of KCJ character recognition in the document
1525
+ *
1526
+ * @param doclingDoc - DoclingDocument to assess
1527
+ * @returns Assessment result indicating whether VLM re-parse is needed
1528
+ */
1529
+ assess(doclingDoc: DoclingDocument): Promise<HanjaAssessment>;
1530
+ /**
1531
+ * Get the eligible page range after trimming front/back edges.
1532
+ * Returns cutoff values where eligible pages satisfy: pageNo > frontCutoff && pageNo <= backCutoff
1533
+ */
1534
+ private getEligiblePageRange;
1535
+ /**
1536
+ * Get page numbers that are "image-only" (contain pictures but minimal text).
1537
+ * These pages (e.g., photo plates/도판) are unsuitable for KCJ quality assessment.
1538
+ */
1539
+ private getImageOnlyPages;
1540
+ /**
1541
+ * Get all pages with substantial text content.
1542
+ * Pages with fewer characters than MIN_TEXT_LENGTH (e.g., page numbers, headers only) are excluded.
1543
+ */
1544
+ private getTextPages;
1545
+ /**
1546
+ * Select sample pages with highest text density.
1547
+ * Prefers pages in the eligible range (not edge-trimmed) and not image-only.
1548
+ * Falls back to all text pages if filtering leaves too few candidates.
1549
+ */
1550
+ private selectSamplePages;
1551
+ /**
1552
+ * Evaluate sampled pages using Vision LLM
1553
+ */
1554
+ private evaluatePages;
1555
+ /**
1556
+ * Evaluate a single page by comparing OCR text against the page image
1557
+ */
1558
+ private evaluateSinglePage;
1559
+ /**
1560
+ * Aggregate individual page results into final assessment
1561
+ */
1562
+ private aggregateResults;
1563
+ /**
1564
+ * Get page number from a text item
1565
+ */
1566
+ private getPageNo;
1567
+ protected buildSystemPrompt(): string;
1568
+ protected buildUserPrompt(ocrText: string): string;
1569
+ }
1570
+
1571
+ export { BaseLLMComponent, type BaseLLMComponentOptions, BaseValidator, type BaseValidatorOptions, CONTINUATION_MARKERS, CaptionParseError, CaptionParser, type CaptionParserOptions, CaptionValidationError, CaptionValidator, type CaptionValidatorOptions, ChapterConverter, DocumentProcessor, type DocumentProcessorOptions, HanjaQualitySampler, type ImageContent, PAGE_NUMBER_PATTERN, PagePattern, PageRangeParseError, PageRangeParser, type PageSizeGroup, TOC_KEYWORDS, TextLLMComponent, type TocAreaResult, type TocContentValidationResult, TocContentValidationSchema, TocContentValidator, type TocContentValidatorOptions, type TocEntry, TocEntrySchema, TocExtractError, TocExtractor, type TocExtractorOptions, TocFinder, type TocFinderOptions, TocNotFoundError, TocParseError, type TocResponse, TocResponseSchema, VisionLLMComponent, type VisionLLMComponentOptions, type VisionTocExtractionResult, VisionTocExtractionSchema, VisionTocExtractor, type VisionTocExtractorOptions };
package/dist/index.js CHANGED
@@ -3346,6 +3346,283 @@ Remember: Look for SMALL numbers in MARGINS only. Ignore figure/table/photo numb
3346
3346
  }
3347
3347
  };
3348
3348
 
3349
+ // src/samplers/hanja-quality-sampler.ts
3350
+ import { z as z5 } from "zod";
3351
+ var MIN_TEXT_LENGTH = 100;
3352
+ var MAX_SAMPLE_PAGES = 10;
3353
+ var EDGE_TRIM_RATIO = 0.1;
3354
+ var IMAGE_PAGE_TEXT_THRESHOLD = 50;
3355
+ var CORRUPTION_THRESHOLD = 0.5;
3356
+ var MIN_SEVERE_CORRUPTED_COUNT = 3;
3357
+ var HanjaQualityResponseSchema = z5.object({
3358
+ isCorrupted: z5.boolean().describe(
3359
+ "Whether the KCJ (Hanja) characters in the OCR text are significantly corrupted compared to the page image"
3360
+ ),
3361
+ corruptedCharCount: z5.number().int().nonnegative().describe(
3362
+ "Approximate number of corrupted or incorrectly recognized KCJ characters"
3363
+ ),
3364
+ totalKcjCharCount: z5.number().int().nonnegative().describe("Total number of KCJ characters found in the OCR text"),
3365
+ explanation: z5.string().describe("Brief explanation of the assessment result")
3366
+ });
3367
+ var HanjaQualitySampler = class extends VisionLLMComponent {
3368
+ constructor(logger, model, outputPath, maxRetries, fallbackModel, aggregator, abortSignal) {
3369
+ super(
3370
+ logger,
3371
+ model,
3372
+ "HanjaQualitySampler",
3373
+ outputPath,
3374
+ { maxRetries, abortSignal },
3375
+ fallbackModel,
3376
+ aggregator
3377
+ );
3378
+ }
3379
+ /**
3380
+ * Assess the quality of KCJ character recognition in the document
3381
+ *
3382
+ * @param doclingDoc - DoclingDocument to assess
3383
+ * @returns Assessment result indicating whether VLM re-parse is needed
3384
+ */
3385
+ async assess(doclingDoc) {
3386
+ const totalPages = Object.keys(doclingDoc.pages).length;
3387
+ this.log("info", "Starting KCJ quality assessment...");
3388
+ const { frontCutoff, backCutoff } = this.getEligiblePageRange(totalPages);
3389
+ const imageOnlyPages = this.getImageOnlyPages(doclingDoc);
3390
+ this.log(
3391
+ "info",
3392
+ `Total pages: ${totalPages}, eligible range: (${frontCutoff}, ${backCutoff}], image-only pages excluded: ${imageOnlyPages.size}`
3393
+ );
3394
+ const textPages = this.getTextPages(doclingDoc);
3395
+ if (textPages.length === 0) {
3396
+ this.log("info", "No text pages found for assessment");
3397
+ return {
3398
+ needsVlmReparse: false,
3399
+ severity: "none",
3400
+ kcjPageCount: 0,
3401
+ sampledPageCount: 0,
3402
+ corruptedRatio: 0,
3403
+ reason: "No text pages found for assessment"
3404
+ };
3405
+ }
3406
+ this.log(
3407
+ "info",
3408
+ `Found ${textPages.length} text pages (min length: ${MIN_TEXT_LENGTH})`
3409
+ );
3410
+ const sampled = this.selectSamplePages(
3411
+ textPages,
3412
+ totalPages,
3413
+ imageOnlyPages
3414
+ );
3415
+ this.log(
3416
+ "info",
3417
+ `Sampling ${sampled.length} pages: [${sampled.map((p) => p.pageNo).join(", ")}]`
3418
+ );
3419
+ const results = await this.evaluatePages(sampled);
3420
+ const assessment = this.aggregateResults(
3421
+ textPages.length,
3422
+ sampled.length,
3423
+ results
3424
+ );
3425
+ this.log(
3426
+ "info",
3427
+ `Assessment complete: severity=${assessment.severity}, corrupted=${assessment.corruptedRatio.toFixed(2)}, needsVlmReparse=${assessment.needsVlmReparse}`
3428
+ );
3429
+ return assessment;
3430
+ }
3431
+ /**
3432
+ * Get the eligible page range after trimming front/back edges.
3433
+ * Returns cutoff values where eligible pages satisfy: pageNo > frontCutoff && pageNo <= backCutoff
3434
+ */
3435
+ getEligiblePageRange(totalPages) {
3436
+ const trimCount = Math.ceil(totalPages * EDGE_TRIM_RATIO);
3437
+ return {
3438
+ frontCutoff: trimCount,
3439
+ backCutoff: totalPages - trimCount
3440
+ };
3441
+ }
3442
+ /**
3443
+ * Get page numbers that are "image-only" (contain pictures but minimal text).
3444
+ * These pages (e.g., photo plates/도판) are unsuitable for KCJ quality assessment.
3445
+ */
3446
+ getImageOnlyPages(doclingDoc) {
3447
+ const picturePages = /* @__PURE__ */ new Set();
3448
+ for (const picture of doclingDoc.pictures) {
3449
+ const pageNo = picture.prov?.[0]?.page_no;
3450
+ if (pageNo != null) {
3451
+ picturePages.add(pageNo);
3452
+ }
3453
+ }
3454
+ if (picturePages.size === 0) return /* @__PURE__ */ new Set();
3455
+ const pageTextLength = /* @__PURE__ */ new Map();
3456
+ for (const text of doclingDoc.texts) {
3457
+ const pageNo = this.getPageNo(text);
3458
+ if (picturePages.has(pageNo)) {
3459
+ pageTextLength.set(
3460
+ pageNo,
3461
+ (pageTextLength.get(pageNo) ?? 0) + text.text.length
3462
+ );
3463
+ }
3464
+ }
3465
+ const imageOnlyPages = /* @__PURE__ */ new Set();
3466
+ for (const pageNo of picturePages) {
3467
+ const textLength = pageTextLength.get(pageNo) ?? 0;
3468
+ if (textLength < IMAGE_PAGE_TEXT_THRESHOLD) {
3469
+ imageOnlyPages.add(pageNo);
3470
+ }
3471
+ }
3472
+ return imageOnlyPages;
3473
+ }
3474
+ /**
3475
+ * Get all pages with substantial text content.
3476
+ * Pages with fewer characters than MIN_TEXT_LENGTH (e.g., page numbers, headers only) are excluded.
3477
+ */
3478
+ getTextPages(doclingDoc) {
3479
+ const pageMap = /* @__PURE__ */ new Map();
3480
+ for (const text of doclingDoc.texts) {
3481
+ const pageNo = this.getPageNo(text);
3482
+ const entry = pageMap.get(pageNo) ?? { textLength: 0, texts: [] };
3483
+ entry.textLength += text.text.length;
3484
+ entry.texts.push(text.text);
3485
+ pageMap.set(pageNo, entry);
3486
+ }
3487
+ return Array.from(pageMap.entries()).filter(([, data]) => data.textLength >= MIN_TEXT_LENGTH).map(([pageNo, data]) => ({
3488
+ pageNo,
3489
+ textLength: data.textLength,
3490
+ texts: data.texts
3491
+ }));
3492
+ }
3493
+ /**
3494
+ * Select sample pages with highest text density.
3495
+ * Prefers pages in the eligible range (not edge-trimmed) and not image-only.
3496
+ * Falls back to all text pages if filtering leaves too few candidates.
3497
+ */
3498
+ selectSamplePages(textPages, totalPages, imageOnlyPages) {
3499
+ const { frontCutoff, backCutoff } = this.getEligiblePageRange(totalPages);
3500
+ const filtered = totalPages > 0 ? textPages.filter(
3501
+ (p) => p.pageNo > frontCutoff && p.pageNo <= backCutoff && !imageOnlyPages.has(p.pageNo)
3502
+ ) : textPages;
3503
+ const candidates = filtered.length > 0 ? filtered : textPages;
3504
+ if (candidates !== filtered) {
3505
+ this.log(
3506
+ "warn",
3507
+ `All text pages were filtered out by edge/image exclusion. Falling back to all ${textPages.length} text pages.`
3508
+ );
3509
+ }
3510
+ const sorted = [...candidates].sort((a, b) => b.textLength - a.textLength);
3511
+ const count = Math.min(MAX_SAMPLE_PAGES, sorted.length);
3512
+ return sorted.slice(0, count);
3513
+ }
3514
+ /**
3515
+ * Evaluate sampled pages using Vision LLM
3516
+ */
3517
+ async evaluatePages(pages) {
3518
+ const results = [];
3519
+ for (const page of pages) {
3520
+ const result = await this.evaluateSinglePage(page);
3521
+ results.push(result);
3522
+ }
3523
+ return results;
3524
+ }
3525
+ /**
3526
+ * Evaluate a single page by comparing OCR text against the page image
3527
+ */
3528
+ async evaluateSinglePage(page) {
3529
+ const imagePath = `pages/page_${page.pageNo - 1}.png`;
3530
+ let imageContent;
3531
+ try {
3532
+ imageContent = this.buildImageContent(imagePath);
3533
+ } catch {
3534
+ this.log(
3535
+ "warn",
3536
+ `Failed to load page image for page ${page.pageNo}, marking as not corrupted`
3537
+ );
3538
+ return {
3539
+ isCorrupted: false,
3540
+ corruptedCharCount: 0,
3541
+ totalKcjCharCount: 0,
3542
+ explanation: `Page image not available for page ${page.pageNo}`
3543
+ };
3544
+ }
3545
+ const ocrText = page.texts.join("\n");
3546
+ const messages = [
3547
+ {
3548
+ role: "user",
3549
+ content: [
3550
+ imageContent,
3551
+ {
3552
+ type: "text",
3553
+ text: this.buildUserPrompt(ocrText)
3554
+ }
3555
+ ]
3556
+ }
3557
+ ];
3558
+ const { output } = await this.callVisionLLM(
3559
+ HanjaQualityResponseSchema,
3560
+ messages,
3561
+ `page-${page.pageNo}`
3562
+ );
3563
+ this.log(
3564
+ "info",
3565
+ `Page ${page.pageNo}: corrupted=${output.isCorrupted}, ${output.corruptedCharCount}/${output.totalKcjCharCount} KCJ chars corrupted`
3566
+ );
3567
+ return output;
3568
+ }
3569
+ /**
3570
+ * Aggregate individual page results into final assessment
3571
+ */
3572
+ aggregateResults(totalTextPages, sampledCount, results) {
3573
+ const corruptedCount = results.filter((r) => r.isCorrupted).length;
3574
+ const corruptedRatio = sampledCount > 0 ? corruptedCount / sampledCount : 0;
3575
+ let severity;
3576
+ let needsVlmReparse;
3577
+ if (corruptedRatio >= CORRUPTION_THRESHOLD || corruptedCount >= MIN_SEVERE_CORRUPTED_COUNT) {
3578
+ severity = "severe";
3579
+ needsVlmReparse = true;
3580
+ } else if (corruptedCount > 0) {
3581
+ severity = "minor";
3582
+ needsVlmReparse = false;
3583
+ } else {
3584
+ severity = "none";
3585
+ needsVlmReparse = false;
3586
+ }
3587
+ const reason = corruptedCount === 0 ? "No Hanja character corruption detected" : `${corruptedCount}/${sampledCount} sampled pages have corrupted Hanja characters (ratio: ${corruptedRatio.toFixed(2)})`;
3588
+ return {
3589
+ needsVlmReparse,
3590
+ severity,
3591
+ kcjPageCount: totalTextPages,
3592
+ sampledPageCount: sampledCount,
3593
+ corruptedRatio,
3594
+ reason
3595
+ };
3596
+ }
3597
+ /**
3598
+ * Get page number from a text item
3599
+ */
3600
+ getPageNo(text) {
3601
+ return text.prov?.[0]?.page_no ?? 1;
3602
+ }
3603
+ buildSystemPrompt() {
3604
+ return "";
3605
+ }
3606
+ buildUserPrompt(ocrText) {
3607
+ return `You are evaluating the quality of OCR text extraction for Hanja (\u6F22\u5B57) characters used in Korean archaeological reports.
3608
+
3609
+ 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.
3610
+
3611
+ ## Evaluation Criteria
3612
+ - Find Hanja (\u6F22\u5B57) characters in the page image first
3613
+ - Check if each Hanja character is correctly recognized in the OCR text
3614
+ - 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
3615
+ - 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
3616
+ - Minor font rendering differences are NOT corruption
3617
+ - If no Hanja characters are visible in the page image, report as not corrupted with 0 total characters
3618
+
3619
+ ## OCR Text to Evaluate
3620
+ ${ocrText}
3621
+
3622
+ Compare the Hanja characters visible in the page image against the OCR text above and assess whether they were correctly recognized.`;
3623
+ }
3624
+ };
3625
+
3349
3626
  // src/validators/base-validator.ts
3350
3627
  var BaseValidator = class extends TextLLMComponent {
3351
3628
  /**
@@ -3405,13 +3682,13 @@ var BaseValidator = class extends TextLLMComponent {
3405
3682
  };
3406
3683
 
3407
3684
  // src/validators/toc-content-validator.ts
3408
- import { z as z5 } from "zod";
3409
- var TocContentValidationSchema = z5.object({
3410
- isValid: z5.boolean().describe("Whether valid main document TOC was found"),
3411
- confidence: z5.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3412
- contentType: z5.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3413
- extractedTocMarkdown: z5.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3414
- reason: z5.string().describe("Brief explanation in English")
3685
+ import { z as z6 } from "zod";
3686
+ var TocContentValidationSchema = z6.object({
3687
+ isValid: z6.boolean().describe("Whether valid main document TOC was found"),
3688
+ confidence: z6.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3689
+ contentType: z6.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3690
+ extractedTocMarkdown: z6.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3691
+ reason: z6.string().describe("Brief explanation in English")
3415
3692
  });
3416
3693
  var TocContentValidator = class extends BaseValidator {
3417
3694
  confidenceThreshold;
@@ -3562,14 +3839,14 @@ ${markdown}`;
3562
3839
  };
3563
3840
 
3564
3841
  // src/validators/caption-validator.ts
3565
- import { z as z6 } from "zod";
3566
- var CaptionValidationItemSchema = z6.object({
3567
- index: z6.number().int().describe("Index of the caption in the input array"),
3568
- isValid: z6.boolean().describe("Whether the parsed caption is correct"),
3569
- reason: z6.string().nullable().describe("Brief explanation if invalid, null if valid")
3842
+ import { z as z7 } from "zod";
3843
+ var CaptionValidationItemSchema = z7.object({
3844
+ index: z7.number().int().describe("Index of the caption in the input array"),
3845
+ isValid: z7.boolean().describe("Whether the parsed caption is correct"),
3846
+ reason: z7.string().nullable().describe("Brief explanation if invalid, null if valid")
3570
3847
  });
3571
- var CaptionValidationBatchSchema = z6.object({
3572
- results: z6.array(CaptionValidationItemSchema)
3848
+ var CaptionValidationBatchSchema = z7.object({
3849
+ results: z7.array(CaptionValidationItemSchema)
3573
3850
  });
3574
3851
  var CaptionValidator = class extends BaseValidator {
3575
3852
  constructor(logger, model, options, fallbackModel, aggregator) {
@@ -3777,6 +4054,7 @@ var DocumentProcessor = class {
3777
4054
  validatorModel;
3778
4055
  visionTocExtractorModel;
3779
4056
  captionParserModel;
4057
+ hanjaQualitySamplerModel;
3780
4058
  textCleanerBatchSize;
3781
4059
  captionParserBatchSize;
3782
4060
  captionValidatorBatchSize;
@@ -3803,6 +4081,7 @@ var DocumentProcessor = class {
3803
4081
  this.validatorModel = options.validatorModel ?? options.fallbackModel;
3804
4082
  this.visionTocExtractorModel = options.visionTocExtractorModel ?? options.fallbackModel;
3805
4083
  this.captionParserModel = options.captionParserModel ?? options.fallbackModel;
4084
+ this.hanjaQualitySamplerModel = options.hanjaQualitySamplerModel ?? options.fallbackModel;
3806
4085
  this.textCleanerBatchSize = options.textCleanerBatchSize;
3807
4086
  this.captionParserBatchSize = options.captionParserBatchSize;
3808
4087
  this.captionValidatorBatchSize = options.captionValidatorBatchSize;
@@ -3822,6 +4101,36 @@ var DocumentProcessor = class {
3822
4101
  throw error;
3823
4102
  }
3824
4103
  }
4104
+ /**
4105
+ * Assess the quality of KCJ (Hanja) character recognition in the OCR output.
4106
+ *
4107
+ * Samples pages containing KCJ characters and uses Vision LLM to compare
4108
+ * the OCR text against the original page images. Returns an assessment
4109
+ * indicating whether the document should be re-parsed with VLM pipeline.
4110
+ *
4111
+ * @param doclingDoc - Original document extracted from Docling SDK
4112
+ * @param outputPath - Path containing pages subdirectory (pages/page_0.png, etc.)
4113
+ * @returns Assessment result with severity and recommendation
4114
+ */
4115
+ async assessHanjaQuality(doclingDoc, outputPath) {
4116
+ this.logger.info(
4117
+ "[DocumentProcessor] Starting Hanja quality assessment..."
4118
+ );
4119
+ const sampler = new HanjaQualitySampler(
4120
+ this.logger,
4121
+ this.hanjaQualitySamplerModel,
4122
+ outputPath,
4123
+ this.maxRetries,
4124
+ this.enableFallbackRetry ? this.fallbackModel : void 0,
4125
+ this.usageAggregator,
4126
+ this.abortSignal
4127
+ );
4128
+ const assessment = await sampler.assess(doclingDoc);
4129
+ this.logger.info(
4130
+ `[DocumentProcessor] Hanja assessment: severity=${assessment.severity}, needsVlmReparse=${assessment.needsVlmReparse}`
4131
+ );
4132
+ return assessment;
4133
+ }
3825
4134
  /**
3826
4135
  * Converts DoclingDocument to ProcessedDocument with token usage tracking.
3827
4136
  *
@@ -4155,8 +4464,10 @@ var DocumentProcessor = class {
4155
4464
  );
4156
4465
  }
4157
4466
  const totalPages = Object.keys(doclingDoc.pages).length;
4467
+ const maxTocPageNo = this.extractMaxPageNumber(markdown);
4468
+ const effectiveTotalPages = maxTocPageNo > totalPages ? void 0 : totalPages;
4158
4469
  const tocResult = await this.tocExtractor.extract(markdown, {
4159
- totalPages
4470
+ totalPages: effectiveTotalPages
4160
4471
  });
4161
4472
  for (const usage of tocResult.usages) {
4162
4473
  this.usageAggregator.track(usage);
@@ -4171,6 +4482,20 @@ var DocumentProcessor = class {
4171
4482
  );
4172
4483
  return tocResult.entries;
4173
4484
  }
4485
+ /**
4486
+ * Extract the maximum page number from TOC markdown
4487
+ *
4488
+ * Parses page numbers from dot-leader patterns (e.g., "..... 175")
4489
+ * to detect compiled volume scenarios where TOC page numbers exceed
4490
+ * the sub-document's page count.
4491
+ */
4492
+ extractMaxPageNumber(markdown) {
4493
+ const matches = [...markdown.matchAll(/\.{2,}\s*(\d+)/g)];
4494
+ if (matches.length === 0) {
4495
+ return 0;
4496
+ }
4497
+ return Math.max(...matches.map((m) => parseInt(m[1], 10)));
4498
+ }
4174
4499
  /**
4175
4500
  * Process resource captions (for images and tables)
4176
4501
  *
@@ -4438,6 +4763,7 @@ export {
4438
4763
  CaptionValidator,
4439
4764
  ChapterConverter,
4440
4765
  DocumentProcessor,
4766
+ HanjaQualitySampler,
4441
4767
  PAGE_NUMBER_PATTERN,
4442
4768
  PagePattern,
4443
4769
  PageRangeParseError,