@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 +514 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -10
- package/dist/index.d.ts +131 -10
- package/dist/index.js +513 -44
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
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
|
-
|
|
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
|
|
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).
|
|
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
|
-
* @
|
|
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
|
|
1990
|
+
const usages = [result.usage];
|
|
1991
|
+
let entries = this.normalizeEntries(result.output.entries);
|
|
1924
1992
|
if (!this.skipValidation) {
|
|
1925
|
-
this.
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
|
|
2053
|
+
tryValidateEntries(entries, overrides) {
|
|
1950
2054
|
if (entries.length === 0) {
|
|
1951
|
-
return;
|
|
2055
|
+
return null;
|
|
1952
2056
|
}
|
|
1953
|
-
const
|
|
1954
|
-
validator
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
3317
|
-
var TocContentValidationSchema =
|
|
3318
|
-
isValid:
|
|
3319
|
-
confidence:
|
|
3320
|
-
contentType:
|
|
3321
|
-
extractedTocMarkdown:
|
|
3322
|
-
reason:
|
|
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
|
|
3474
|
-
var CaptionValidationItemSchema =
|
|
3475
|
-
index:
|
|
3476
|
-
isValid:
|
|
3477
|
-
reason:
|
|
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 =
|
|
3480
|
-
results:
|
|
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
|
|
4051
|
-
markdown = await this.visionTocExtractor.extract(
|
|
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
|
|
4066
|
-
this.
|
|
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,
|