@heripo/document-processor 0.1.4 → 0.1.6

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,
@@ -1268,7 +1269,8 @@ var TocValidationError = class extends TocExtractError {
1268
1269
  // src/extractors/toc-validator.ts
1269
1270
  var DEFAULT_OPTIONS = {
1270
1271
  totalPages: Infinity,
1271
- maxTitleLength: 200
1272
+ maxTitleLength: 200,
1273
+ maxFirstEntryPageRatio: 0.3
1272
1274
  };
1273
1275
  var TocValidator = class {
1274
1276
  options;
@@ -1289,6 +1291,7 @@ var TocValidator = class {
1289
1291
  validate(entries) {
1290
1292
  this.issues = [];
1291
1293
  this.validateEntries(entries, "", null, /* @__PURE__ */ new Set());
1294
+ this.validateFirstEntryPagePosition(entries);
1292
1295
  const errorCount = this.issues.length;
1293
1296
  return {
1294
1297
  valid: errorCount === 0,
@@ -1305,8 +1308,12 @@ var TocValidator = class {
1305
1308
  validateOrThrow(entries) {
1306
1309
  const result = this.validate(entries);
1307
1310
  if (!result.valid) {
1311
+ const details = result.issues.map(
1312
+ (issue) => ` [${issue.code}] ${issue.message} (path: ${issue.path}, entry: "${issue.entry.title}" page ${issue.entry.pageNo})`
1313
+ ).join("\n");
1308
1314
  throw new TocValidationError(
1309
- `TOC validation failed with ${result.errorCount} error(s)`,
1315
+ `TOC validation failed with ${result.errorCount} error(s):
1316
+ ${details}`,
1310
1317
  result
1311
1318
  );
1312
1319
  }
@@ -1421,6 +1428,33 @@ var TocValidator = class {
1421
1428
  });
1422
1429
  }
1423
1430
  }
1431
+ /**
1432
+ * V007: Validate first entry page position (completeness check)
1433
+ *
1434
+ * If the first level-1 entry starts too late in the document,
1435
+ * earlier entries might be missing from the TOC.
1436
+ */
1437
+ validateFirstEntryPagePosition(entries) {
1438
+ if (entries.length === 0) {
1439
+ return;
1440
+ }
1441
+ if (!isFinite(this.options.totalPages)) {
1442
+ return;
1443
+ }
1444
+ const firstEntry = entries[0];
1445
+ const threshold = Math.max(
1446
+ 50,
1447
+ Math.floor(this.options.totalPages * this.options.maxFirstEntryPageRatio)
1448
+ );
1449
+ if (firstEntry.pageNo > threshold) {
1450
+ this.addIssue({
1451
+ code: "V007",
1452
+ message: `TOC may be incomplete - first entry starts at page ${firstEntry.pageNo}, expected within first ${threshold} pages. Earlier entries might be missing.`,
1453
+ path: "[0]",
1454
+ entry: firstEntry
1455
+ });
1456
+ }
1457
+ }
1424
1458
  /**
1425
1459
  * Add issue to the list
1426
1460
  */
@@ -1639,22 +1673,42 @@ var TocFinder = class {
1639
1673
  return numberCount > 0 && numberCount / (num_rows - 1) > 0.5;
1640
1674
  }
1641
1675
  /**
1642
- * Expand TOC area to consecutive pages
1676
+ * Expand TOC area to consecutive pages (both backward and forward)
1643
1677
  */
1644
1678
  expandToConsecutivePages(initial, doc) {
1645
1679
  const itemRefs = [...initial.itemRefs];
1680
+ const seenRefs = new Set(itemRefs);
1681
+ let startPage = initial.startPage;
1646
1682
  let endPage = initial.endPage;
1683
+ for (let pageNo = initial.startPage - 1; pageNo >= 1; pageNo--) {
1684
+ const continuationItems = this.findContinuationOnPage(doc, pageNo);
1685
+ if (continuationItems.length === 0) {
1686
+ break;
1687
+ }
1688
+ const newItems = continuationItems.filter((ref) => !seenRefs.has(ref));
1689
+ for (const ref of newItems) {
1690
+ seenRefs.add(ref);
1691
+ }
1692
+ itemRefs.unshift(...newItems);
1693
+ startPage = pageNo;
1694
+ this.logger.info(`[TocFinder] Expanded TOC backward to page ${pageNo}`);
1695
+ }
1647
1696
  for (let pageNo = initial.endPage + 1; pageNo <= this.maxSearchPages; pageNo++) {
1648
1697
  const continuationItems = this.findContinuationOnPage(doc, pageNo);
1649
1698
  if (continuationItems.length === 0) {
1650
1699
  break;
1651
1700
  }
1652
- itemRefs.push(...continuationItems);
1701
+ const newItems = continuationItems.filter((ref) => !seenRefs.has(ref));
1702
+ for (const ref of newItems) {
1703
+ seenRefs.add(ref);
1704
+ }
1705
+ itemRefs.push(...newItems);
1653
1706
  endPage = pageNo;
1707
+ this.logger.info(`[TocFinder] Expanded TOC forward to page ${pageNo}`);
1654
1708
  }
1655
1709
  return {
1656
1710
  itemRefs,
1657
- startPage: initial.startPage,
1711
+ startPage,
1658
1712
  endPage
1659
1713
  };
1660
1714
  }
@@ -1872,12 +1926,22 @@ var TextLLMComponent = class extends BaseLLMComponent {
1872
1926
  };
1873
1927
 
1874
1928
  // src/extractors/toc-extractor.ts
1929
+ var MAX_VALIDATION_RETRIES = 3;
1930
+ var VALIDATION_CODE_DESCRIPTIONS = {
1931
+ V001: "Page numbers must be in non-decreasing order within the same level. A decrease usually means a hierarchy or page number error.",
1932
+ V002: "Page number is out of valid range (must be >= 1 and <= total pages).",
1933
+ V003: "Title is empty or contains only whitespace.",
1934
+ V004: "Title exceeds the maximum allowed length.",
1935
+ V005: "Child page number is before parent page number. Children must start on or after the parent page.",
1936
+ V006: "Duplicate entry detected (same title and page number).",
1937
+ V007: "First TOC entry starts too late in the document. Earlier entries may be missing."
1938
+ };
1875
1939
  var TocEntrySchema = import_zod.z.lazy(
1876
1940
  () => import_zod.z.object({
1877
1941
  title: import_zod.z.string().describe("Chapter or section title"),
1878
1942
  level: import_zod.z.number().int().min(1).describe("Hierarchy depth (1 = top level)"),
1879
1943
  pageNo: import_zod.z.number().int().min(1).describe("Starting page number"),
1880
- children: import_zod.z.array(TocEntrySchema).optional().describe("Child sections")
1944
+ children: import_zod.z.array(TocEntrySchema).describe("Child sections (use empty array [] if none)")
1881
1945
  })
1882
1946
  );
1883
1947
  var TocResponseSchema = import_zod.z.object({
@@ -1900,12 +1964,15 @@ var TocExtractor = class extends TextLLMComponent {
1900
1964
  /**
1901
1965
  * Extract TOC structure from Markdown
1902
1966
  *
1967
+ * When validation fails, retries with correction feedback up to MAX_VALIDATION_RETRIES times.
1968
+ *
1903
1969
  * @param markdown - Markdown representation of TOC area
1904
- * @returns Object with entries array and token usage information
1970
+ * @param validationOverrides - Optional overrides for validation options (merged with constructor options)
1971
+ * @returns Object with entries array and token usage array (initial extraction + any corrections)
1905
1972
  * @throws {TocParseError} When LLM fails to parse structure
1906
- * @throws {TocValidationError} When validation fails
1973
+ * @throws {TocValidationError} When validation fails after all retries
1907
1974
  */
1908
- async extract(markdown) {
1975
+ async extract(markdown, validationOverrides) {
1909
1976
  this.log("info", `Starting TOC extraction (${markdown.length} chars)`);
1910
1977
  if (!markdown.trim()) {
1911
1978
  this.log("error", "Cannot extract TOC from empty markdown content");
@@ -1920,18 +1987,52 @@ var TocExtractor = class extends TextLLMComponent {
1920
1987
  this.buildUserPrompt(markdown),
1921
1988
  "extraction"
1922
1989
  );
1923
- const entries = this.normalizeEntries(result.output.entries);
1990
+ const usages = [result.usage];
1991
+ let entries = this.normalizeEntries(result.output.entries);
1924
1992
  if (!this.skipValidation) {
1925
- this.validateEntries(entries);
1993
+ let validationError = this.tryValidateEntries(
1994
+ entries,
1995
+ validationOverrides
1996
+ );
1997
+ for (let attempt = 1; attempt <= MAX_VALIDATION_RETRIES && validationError !== null; attempt++) {
1998
+ this.log(
1999
+ "warn",
2000
+ `Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}), retrying with correction feedback`
2001
+ );
2002
+ const correctionPrompt = this.buildCorrectionPrompt(
2003
+ markdown,
2004
+ entries,
2005
+ validationError.validationResult.issues
2006
+ );
2007
+ const correctionResult = await this.callTextLLM(
2008
+ TocResponseSchema,
2009
+ this.buildSystemPrompt(),
2010
+ correctionPrompt,
2011
+ `correction-${attempt}`
2012
+ );
2013
+ usages.push(correctionResult.usage);
2014
+ entries = this.normalizeEntries(correctionResult.output.entries);
2015
+ validationError = this.tryValidateEntries(
2016
+ entries,
2017
+ validationOverrides
2018
+ );
2019
+ }
2020
+ if (validationError !== null) {
2021
+ this.log(
2022
+ "error",
2023
+ `Validation failed after ${MAX_VALIDATION_RETRIES} retries:
2024
+ ${validationError.getSummary()}`
2025
+ );
2026
+ throw validationError;
2027
+ }
1926
2028
  }
1927
2029
  this.log(
1928
2030
  "info",
1929
- `Extraction completed: ${entries.length} top-level entries`
2031
+ `Extraction completed: ${entries.length} top-level entries (${usages.length} LLM call(s))`
1930
2032
  );
1931
- return { entries, usage: result.usage };
2033
+ return { entries, usages };
1932
2034
  } catch (error) {
1933
2035
  if (error instanceof TocValidationError) {
1934
- this.log("error", `Validation failed: ${error.message}`);
1935
2036
  throw error;
1936
2037
  }
1937
2038
  const message = error instanceof Error ? error.message : String(error);
@@ -1942,16 +2043,69 @@ var TocExtractor = class extends TextLLMComponent {
1942
2043
  }
1943
2044
  }
1944
2045
  /**
1945
- * Validate extracted entries
2046
+ * Validate extracted entries and return error or null
1946
2047
  *
1947
- * @throws {TocValidationError} When validation fails
2048
+ * Unlike validateOrThrow, this returns the error instead of throwing,
2049
+ * allowing the retry loop to handle it.
2050
+ *
2051
+ * @returns TocValidationError if validation fails, null if valid
1948
2052
  */
1949
- validateEntries(entries) {
2053
+ tryValidateEntries(entries, overrides) {
1950
2054
  if (entries.length === 0) {
1951
- return;
2055
+ return null;
1952
2056
  }
1953
- const validator = new TocValidator(this.validationOptions);
1954
- validator.validateOrThrow(entries);
2057
+ const options = { ...this.validationOptions, ...overrides };
2058
+ const validator = new TocValidator(options);
2059
+ const result = validator.validate(entries);
2060
+ if (!result.valid) {
2061
+ const details = result.issues.map(
2062
+ (issue) => ` [${issue.code}] ${issue.message} (path: ${issue.path}, entry: "${issue.entry.title}" page ${issue.entry.pageNo})`
2063
+ ).join("\n");
2064
+ return new TocValidationError(
2065
+ `TOC validation failed with ${result.errorCount} error(s):
2066
+ ${details}`,
2067
+ result
2068
+ );
2069
+ }
2070
+ return null;
2071
+ }
2072
+ /**
2073
+ * Build correction prompt with validation error feedback
2074
+ *
2075
+ * Includes the original markdown, previous extraction result,
2076
+ * validation errors, and guidance for fixing common mistakes.
2077
+ */
2078
+ buildCorrectionPrompt(markdown, previousEntries, issues) {
2079
+ const errorLines = issues.map((issue) => {
2080
+ const desc = VALIDATION_CODE_DESCRIPTIONS[issue.code] ?? "Unknown validation error.";
2081
+ return `- [${issue.code}] ${issue.message}
2082
+ Path: ${issue.path}
2083
+ Entry: "${issue.entry.title}" (page ${issue.entry.pageNo})
2084
+ Rule: ${desc}`;
2085
+ });
2086
+ return `Your previous TOC extraction had validation errors. Please fix them and re-extract.
2087
+
2088
+ ## Validation Errors
2089
+
2090
+ ${errorLines.join("\n\n")}
2091
+
2092
+ ## Common Mistakes to Avoid
2093
+
2094
+ 1. **Hierarchy confusion**: Entries with the same numbering prefix (e.g., "4)") can belong to different hierarchy levels depending on context. Use indentation and surrounding entries to determine the correct parent-child relationship.
2095
+ 2. **Page number misread**: Carefully distinguish Roman numerals (VI=6) from Arabic numerals. "VI. \uACE0\uCC30" at page 277 is NOT "V. \uACE0\uCC30" at page 27.
2096
+ 3. **Page order**: Within the same parent, sibling entries must have non-decreasing page numbers. If a page number decreases, the entry likely belongs to a different hierarchy level.
2097
+
2098
+ ## Original Markdown
2099
+
2100
+ ${markdown}
2101
+
2102
+ ## Your Previous Extraction (with errors)
2103
+
2104
+ ${JSON.stringify(previousEntries, null, 2)}
2105
+
2106
+ ## Instructions
2107
+
2108
+ Re-extract the TOC structure from the original markdown above. Fix all validation errors listed above. Return the corrected entries.`;
1955
2109
  }
1956
2110
  /**
1957
2111
  * Build system prompt for TOC extraction
@@ -1969,11 +2123,12 @@ var TocExtractor = class extends TextLLMComponent {
1969
2123
  - Level 3: Subsections (e.g., "1.1.1", "a.", "(1)")
1970
2124
  - Use indentation and numbering patterns to infer level
1971
2125
 
1972
- 3. **Page Number**: Extract the page number from each entry. Convert Roman numerals to Arabic numerals if present (e.g., "iv" \u2192 4).
2126
+ 3. **Page Number**: Extract the page number from each entry. Use only Arabic numerals for page numbers.
1973
2127
 
1974
2128
  4. **Children**: Nest child entries under parent entries based on their hierarchy level.
1975
2129
 
1976
- 5. **IMPORTANT - Extract Main TOC Only**: Only extract the main document table of contents. EXCLUDE the following supplementary indices:
2130
+ 5. **IMPORTANT - Extract Main TOC Only**: Only extract the main document table of contents. EXCLUDE the following:
2131
+ - **Front matter with Roman numeral pages**: Entries whose page numbers are Roman numerals (i, ii, xxi, etc.) such as \uC77C\uB7EC\uB450\uAE30, \uBC1C\uAC04\uC0AC, \uC11C\uBB38, \uBC94\uB840, Preface, Foreword, Editorial Notes. These use a separate page numbering system and are not part of the main content.
1977
2132
  - Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \uD654\uBCF4 \uBAA9\uCC28, Photo Index, List of Photos, List of Figures)
1978
2133
  - Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \uC0BD\uB3C4 \uBAA9\uCC28, Drawing Index, List of Drawings)
1979
2134
  - Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
@@ -2000,11 +2155,11 @@ Output:
2000
2155
  "level": 1,
2001
2156
  "pageNo": 1,
2002
2157
  "children": [
2003
- { "title": "1. \uC5F0\uAD6C \uBC30\uACBD", "level": 2, "pageNo": 3 },
2004
- { "title": "2. \uC5F0\uAD6C \uBAA9\uC801", "level": 2, "pageNo": 5 }
2158
+ { "title": "1. \uC5F0\uAD6C \uBC30\uACBD", "level": 2, "pageNo": 3, "children": [] },
2159
+ { "title": "2. \uC5F0\uAD6C \uBAA9\uC801", "level": 2, "pageNo": 5, "children": [] }
2005
2160
  ]
2006
2161
  },
2007
- { "title": "\uC81C2\uC7A5 \uBC29\uBC95\uB860", "level": 1, "pageNo": 10 }
2162
+ { "title": "\uC81C2\uC7A5 \uBC29\uBC95\uB860", "level": 1, "pageNo": 10, "children": [] }
2008
2163
  ]
2009
2164
  }`;
2010
2165
  }
@@ -2647,7 +2802,7 @@ var PagePattern = /* @__PURE__ */ ((PagePattern2) => {
2647
2802
  var PageRangeParser = class extends VisionLLMComponent {
2648
2803
  // Configuration constants
2649
2804
  SAMPLE_SIZE = 3;
2650
- MAX_PATTERN_RETRIES = 6;
2805
+ MAX_PATTERN_RETRIES = 19;
2651
2806
  SIZE_TOLERANCE = 5;
2652
2807
  constructor(logger, model, outputPath, maxRetries = 3, fallbackModel, aggregator, abortSignal) {
2653
2808
  super(
@@ -3254,6 +3409,283 @@ Remember: Look for SMALL numbers in MARGINS only. Ignore figure/table/photo numb
3254
3409
  }
3255
3410
  };
3256
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
+
3257
3689
  // src/validators/base-validator.ts
3258
3690
  var BaseValidator = class extends TextLLMComponent {
3259
3691
  /**
@@ -3313,13 +3745,13 @@ var BaseValidator = class extends TextLLMComponent {
3313
3745
  };
3314
3746
 
3315
3747
  // src/validators/toc-content-validator.ts
3316
- var import_zod5 = require("zod");
3317
- var TocContentValidationSchema = import_zod5.z.object({
3318
- isValid: import_zod5.z.boolean().describe("Whether valid main document TOC was found"),
3319
- confidence: import_zod5.z.number().min(0).max(1).describe("Confidence score between 0 and 1"),
3320
- contentType: import_zod5.z.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
3321
- extractedTocMarkdown: import_zod5.z.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
3322
- 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")
3323
3755
  });
3324
3756
  var TocContentValidator = class extends BaseValidator {
3325
3757
  confidenceThreshold;
@@ -3470,14 +3902,14 @@ ${markdown}`;
3470
3902
  };
3471
3903
 
3472
3904
  // src/validators/caption-validator.ts
3473
- var import_zod6 = require("zod");
3474
- var CaptionValidationItemSchema = import_zod6.z.object({
3475
- index: import_zod6.z.number().int().describe("Index of the caption in the input array"),
3476
- isValid: import_zod6.z.boolean().describe("Whether the parsed caption is correct"),
3477
- 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")
3478
3910
  });
3479
- var CaptionValidationBatchSchema = import_zod6.z.object({
3480
- results: import_zod6.z.array(CaptionValidationItemSchema)
3911
+ var CaptionValidationBatchSchema = import_zod7.z.object({
3912
+ results: import_zod7.z.array(CaptionValidationItemSchema)
3481
3913
  });
3482
3914
  var CaptionValidator = class extends BaseValidator {
3483
3915
  constructor(logger, model, options, fallbackModel, aggregator) {
@@ -3685,6 +4117,7 @@ var DocumentProcessor = class {
3685
4117
  validatorModel;
3686
4118
  visionTocExtractorModel;
3687
4119
  captionParserModel;
4120
+ hanjaQualitySamplerModel;
3688
4121
  textCleanerBatchSize;
3689
4122
  captionParserBatchSize;
3690
4123
  captionValidatorBatchSize;
@@ -3711,6 +4144,7 @@ var DocumentProcessor = class {
3711
4144
  this.validatorModel = options.validatorModel ?? options.fallbackModel;
3712
4145
  this.visionTocExtractorModel = options.visionTocExtractorModel ?? options.fallbackModel;
3713
4146
  this.captionParserModel = options.captionParserModel ?? options.fallbackModel;
4147
+ this.hanjaQualitySamplerModel = options.hanjaQualitySamplerModel ?? options.fallbackModel;
3714
4148
  this.textCleanerBatchSize = options.textCleanerBatchSize;
3715
4149
  this.captionParserBatchSize = options.captionParserBatchSize;
3716
4150
  this.captionValidatorBatchSize = options.captionValidatorBatchSize;
@@ -3730,6 +4164,36 @@ var DocumentProcessor = class {
3730
4164
  throw error;
3731
4165
  }
3732
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
+ }
3733
4197
  /**
3734
4198
  * Converts DoclingDocument to ProcessedDocument with token usage tracking.
3735
4199
  *
@@ -4047,8 +4511,8 @@ var DocumentProcessor = class {
4047
4511
  }
4048
4512
  if (!markdown) {
4049
4513
  this.logger.info("[DocumentProcessor] Using vision fallback for TOC");
4050
- const totalPages = Object.keys(doclingDoc.pages).length;
4051
- markdown = await this.visionTocExtractor.extract(totalPages);
4514
+ const totalPages2 = Object.keys(doclingDoc.pages).length;
4515
+ markdown = await this.visionTocExtractor.extract(totalPages2);
4052
4516
  if (!markdown) {
4053
4517
  const reason = "Both rule-based search and vision fallback failed to locate TOC";
4054
4518
  this.logger.error(
@@ -4062,8 +4526,13 @@ var DocumentProcessor = class {
4062
4526
  `[DocumentProcessor] Vision extracted TOC markdown (${markdown.length} chars)`
4063
4527
  );
4064
4528
  }
4065
- const tocResult = await this.tocExtractor.extract(markdown);
4066
- this.usageAggregator.track(tocResult.usage);
4529
+ const totalPages = Object.keys(doclingDoc.pages).length;
4530
+ const tocResult = await this.tocExtractor.extract(markdown, {
4531
+ totalPages
4532
+ });
4533
+ for (const usage of tocResult.usages) {
4534
+ this.usageAggregator.track(usage);
4535
+ }
4067
4536
  if (tocResult.entries.length === 0) {
4068
4537
  const reason = "TOC area was detected but LLM could not extract any structured entries";
4069
4538
  this.logger.error(`[DocumentProcessor] TOC extraction failed: ${reason}`);
@@ -4342,6 +4811,7 @@ var DocumentProcessor = class {
4342
4811
  CaptionValidator,
4343
4812
  ChapterConverter,
4344
4813
  DocumentProcessor,
4814
+ HanjaQualitySampler,
4345
4815
  PAGE_NUMBER_PATTERN,
4346
4816
  PagePattern,
4347
4817
  PageRangeParseError,