@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.js
CHANGED
|
@@ -1206,7 +1206,8 @@ var TocValidationError = class extends TocExtractError {
|
|
|
1206
1206
|
// src/extractors/toc-validator.ts
|
|
1207
1207
|
var DEFAULT_OPTIONS = {
|
|
1208
1208
|
totalPages: Infinity,
|
|
1209
|
-
maxTitleLength: 200
|
|
1209
|
+
maxTitleLength: 200,
|
|
1210
|
+
maxFirstEntryPageRatio: 0.3
|
|
1210
1211
|
};
|
|
1211
1212
|
var TocValidator = class {
|
|
1212
1213
|
options;
|
|
@@ -1227,6 +1228,7 @@ var TocValidator = class {
|
|
|
1227
1228
|
validate(entries) {
|
|
1228
1229
|
this.issues = [];
|
|
1229
1230
|
this.validateEntries(entries, "", null, /* @__PURE__ */ new Set());
|
|
1231
|
+
this.validateFirstEntryPagePosition(entries);
|
|
1230
1232
|
const errorCount = this.issues.length;
|
|
1231
1233
|
return {
|
|
1232
1234
|
valid: errorCount === 0,
|
|
@@ -1243,8 +1245,12 @@ var TocValidator = class {
|
|
|
1243
1245
|
validateOrThrow(entries) {
|
|
1244
1246
|
const result = this.validate(entries);
|
|
1245
1247
|
if (!result.valid) {
|
|
1248
|
+
const details = result.issues.map(
|
|
1249
|
+
(issue) => ` [${issue.code}] ${issue.message} (path: ${issue.path}, entry: "${issue.entry.title}" page ${issue.entry.pageNo})`
|
|
1250
|
+
).join("\n");
|
|
1246
1251
|
throw new TocValidationError(
|
|
1247
|
-
`TOC validation failed with ${result.errorCount} error(s)
|
|
1252
|
+
`TOC validation failed with ${result.errorCount} error(s):
|
|
1253
|
+
${details}`,
|
|
1248
1254
|
result
|
|
1249
1255
|
);
|
|
1250
1256
|
}
|
|
@@ -1359,6 +1365,33 @@ var TocValidator = class {
|
|
|
1359
1365
|
});
|
|
1360
1366
|
}
|
|
1361
1367
|
}
|
|
1368
|
+
/**
|
|
1369
|
+
* V007: Validate first entry page position (completeness check)
|
|
1370
|
+
*
|
|
1371
|
+
* If the first level-1 entry starts too late in the document,
|
|
1372
|
+
* earlier entries might be missing from the TOC.
|
|
1373
|
+
*/
|
|
1374
|
+
validateFirstEntryPagePosition(entries) {
|
|
1375
|
+
if (entries.length === 0) {
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (!isFinite(this.options.totalPages)) {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const firstEntry = entries[0];
|
|
1382
|
+
const threshold = Math.max(
|
|
1383
|
+
50,
|
|
1384
|
+
Math.floor(this.options.totalPages * this.options.maxFirstEntryPageRatio)
|
|
1385
|
+
);
|
|
1386
|
+
if (firstEntry.pageNo > threshold) {
|
|
1387
|
+
this.addIssue({
|
|
1388
|
+
code: "V007",
|
|
1389
|
+
message: `TOC may be incomplete - first entry starts at page ${firstEntry.pageNo}, expected within first ${threshold} pages. Earlier entries might be missing.`,
|
|
1390
|
+
path: "[0]",
|
|
1391
|
+
entry: firstEntry
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1362
1395
|
/**
|
|
1363
1396
|
* Add issue to the list
|
|
1364
1397
|
*/
|
|
@@ -1577,22 +1610,42 @@ var TocFinder = class {
|
|
|
1577
1610
|
return numberCount > 0 && numberCount / (num_rows - 1) > 0.5;
|
|
1578
1611
|
}
|
|
1579
1612
|
/**
|
|
1580
|
-
* Expand TOC area to consecutive pages
|
|
1613
|
+
* Expand TOC area to consecutive pages (both backward and forward)
|
|
1581
1614
|
*/
|
|
1582
1615
|
expandToConsecutivePages(initial, doc) {
|
|
1583
1616
|
const itemRefs = [...initial.itemRefs];
|
|
1617
|
+
const seenRefs = new Set(itemRefs);
|
|
1618
|
+
let startPage = initial.startPage;
|
|
1584
1619
|
let endPage = initial.endPage;
|
|
1620
|
+
for (let pageNo = initial.startPage - 1; pageNo >= 1; pageNo--) {
|
|
1621
|
+
const continuationItems = this.findContinuationOnPage(doc, pageNo);
|
|
1622
|
+
if (continuationItems.length === 0) {
|
|
1623
|
+
break;
|
|
1624
|
+
}
|
|
1625
|
+
const newItems = continuationItems.filter((ref) => !seenRefs.has(ref));
|
|
1626
|
+
for (const ref of newItems) {
|
|
1627
|
+
seenRefs.add(ref);
|
|
1628
|
+
}
|
|
1629
|
+
itemRefs.unshift(...newItems);
|
|
1630
|
+
startPage = pageNo;
|
|
1631
|
+
this.logger.info(`[TocFinder] Expanded TOC backward to page ${pageNo}`);
|
|
1632
|
+
}
|
|
1585
1633
|
for (let pageNo = initial.endPage + 1; pageNo <= this.maxSearchPages; pageNo++) {
|
|
1586
1634
|
const continuationItems = this.findContinuationOnPage(doc, pageNo);
|
|
1587
1635
|
if (continuationItems.length === 0) {
|
|
1588
1636
|
break;
|
|
1589
1637
|
}
|
|
1590
|
-
|
|
1638
|
+
const newItems = continuationItems.filter((ref) => !seenRefs.has(ref));
|
|
1639
|
+
for (const ref of newItems) {
|
|
1640
|
+
seenRefs.add(ref);
|
|
1641
|
+
}
|
|
1642
|
+
itemRefs.push(...newItems);
|
|
1591
1643
|
endPage = pageNo;
|
|
1644
|
+
this.logger.info(`[TocFinder] Expanded TOC forward to page ${pageNo}`);
|
|
1592
1645
|
}
|
|
1593
1646
|
return {
|
|
1594
1647
|
itemRefs,
|
|
1595
|
-
startPage
|
|
1648
|
+
startPage,
|
|
1596
1649
|
endPage
|
|
1597
1650
|
};
|
|
1598
1651
|
}
|
|
@@ -1810,12 +1863,22 @@ var TextLLMComponent = class extends BaseLLMComponent {
|
|
|
1810
1863
|
};
|
|
1811
1864
|
|
|
1812
1865
|
// src/extractors/toc-extractor.ts
|
|
1866
|
+
var MAX_VALIDATION_RETRIES = 3;
|
|
1867
|
+
var VALIDATION_CODE_DESCRIPTIONS = {
|
|
1868
|
+
V001: "Page numbers must be in non-decreasing order within the same level. A decrease usually means a hierarchy or page number error.",
|
|
1869
|
+
V002: "Page number is out of valid range (must be >= 1 and <= total pages).",
|
|
1870
|
+
V003: "Title is empty or contains only whitespace.",
|
|
1871
|
+
V004: "Title exceeds the maximum allowed length.",
|
|
1872
|
+
V005: "Child page number is before parent page number. Children must start on or after the parent page.",
|
|
1873
|
+
V006: "Duplicate entry detected (same title and page number).",
|
|
1874
|
+
V007: "First TOC entry starts too late in the document. Earlier entries may be missing."
|
|
1875
|
+
};
|
|
1813
1876
|
var TocEntrySchema = z.lazy(
|
|
1814
1877
|
() => z.object({
|
|
1815
1878
|
title: z.string().describe("Chapter or section title"),
|
|
1816
1879
|
level: z.number().int().min(1).describe("Hierarchy depth (1 = top level)"),
|
|
1817
1880
|
pageNo: z.number().int().min(1).describe("Starting page number"),
|
|
1818
|
-
children: z.array(TocEntrySchema).
|
|
1881
|
+
children: z.array(TocEntrySchema).describe("Child sections (use empty array [] if none)")
|
|
1819
1882
|
})
|
|
1820
1883
|
);
|
|
1821
1884
|
var TocResponseSchema = z.object({
|
|
@@ -1838,12 +1901,15 @@ var TocExtractor = class extends TextLLMComponent {
|
|
|
1838
1901
|
/**
|
|
1839
1902
|
* Extract TOC structure from Markdown
|
|
1840
1903
|
*
|
|
1904
|
+
* When validation fails, retries with correction feedback up to MAX_VALIDATION_RETRIES times.
|
|
1905
|
+
*
|
|
1841
1906
|
* @param markdown - Markdown representation of TOC area
|
|
1842
|
-
* @
|
|
1907
|
+
* @param validationOverrides - Optional overrides for validation options (merged with constructor options)
|
|
1908
|
+
* @returns Object with entries array and token usage array (initial extraction + any corrections)
|
|
1843
1909
|
* @throws {TocParseError} When LLM fails to parse structure
|
|
1844
|
-
* @throws {TocValidationError} When validation fails
|
|
1910
|
+
* @throws {TocValidationError} When validation fails after all retries
|
|
1845
1911
|
*/
|
|
1846
|
-
async extract(markdown) {
|
|
1912
|
+
async extract(markdown, validationOverrides) {
|
|
1847
1913
|
this.log("info", `Starting TOC extraction (${markdown.length} chars)`);
|
|
1848
1914
|
if (!markdown.trim()) {
|
|
1849
1915
|
this.log("error", "Cannot extract TOC from empty markdown content");
|
|
@@ -1858,18 +1924,52 @@ var TocExtractor = class extends TextLLMComponent {
|
|
|
1858
1924
|
this.buildUserPrompt(markdown),
|
|
1859
1925
|
"extraction"
|
|
1860
1926
|
);
|
|
1861
|
-
const
|
|
1927
|
+
const usages = [result.usage];
|
|
1928
|
+
let entries = this.normalizeEntries(result.output.entries);
|
|
1862
1929
|
if (!this.skipValidation) {
|
|
1863
|
-
this.
|
|
1930
|
+
let validationError = this.tryValidateEntries(
|
|
1931
|
+
entries,
|
|
1932
|
+
validationOverrides
|
|
1933
|
+
);
|
|
1934
|
+
for (let attempt = 1; attempt <= MAX_VALIDATION_RETRIES && validationError !== null; attempt++) {
|
|
1935
|
+
this.log(
|
|
1936
|
+
"warn",
|
|
1937
|
+
`Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}), retrying with correction feedback`
|
|
1938
|
+
);
|
|
1939
|
+
const correctionPrompt = this.buildCorrectionPrompt(
|
|
1940
|
+
markdown,
|
|
1941
|
+
entries,
|
|
1942
|
+
validationError.validationResult.issues
|
|
1943
|
+
);
|
|
1944
|
+
const correctionResult = await this.callTextLLM(
|
|
1945
|
+
TocResponseSchema,
|
|
1946
|
+
this.buildSystemPrompt(),
|
|
1947
|
+
correctionPrompt,
|
|
1948
|
+
`correction-${attempt}`
|
|
1949
|
+
);
|
|
1950
|
+
usages.push(correctionResult.usage);
|
|
1951
|
+
entries = this.normalizeEntries(correctionResult.output.entries);
|
|
1952
|
+
validationError = this.tryValidateEntries(
|
|
1953
|
+
entries,
|
|
1954
|
+
validationOverrides
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
if (validationError !== null) {
|
|
1958
|
+
this.log(
|
|
1959
|
+
"error",
|
|
1960
|
+
`Validation failed after ${MAX_VALIDATION_RETRIES} retries:
|
|
1961
|
+
${validationError.getSummary()}`
|
|
1962
|
+
);
|
|
1963
|
+
throw validationError;
|
|
1964
|
+
}
|
|
1864
1965
|
}
|
|
1865
1966
|
this.log(
|
|
1866
1967
|
"info",
|
|
1867
|
-
`Extraction completed: ${entries.length} top-level entries`
|
|
1968
|
+
`Extraction completed: ${entries.length} top-level entries (${usages.length} LLM call(s))`
|
|
1868
1969
|
);
|
|
1869
|
-
return { entries,
|
|
1970
|
+
return { entries, usages };
|
|
1870
1971
|
} catch (error) {
|
|
1871
1972
|
if (error instanceof TocValidationError) {
|
|
1872
|
-
this.log("error", `Validation failed: ${error.message}`);
|
|
1873
1973
|
throw error;
|
|
1874
1974
|
}
|
|
1875
1975
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1880,16 +1980,69 @@ var TocExtractor = class extends TextLLMComponent {
|
|
|
1880
1980
|
}
|
|
1881
1981
|
}
|
|
1882
1982
|
/**
|
|
1883
|
-
* Validate extracted entries
|
|
1983
|
+
* Validate extracted entries and return error or null
|
|
1884
1984
|
*
|
|
1885
|
-
*
|
|
1985
|
+
* Unlike validateOrThrow, this returns the error instead of throwing,
|
|
1986
|
+
* allowing the retry loop to handle it.
|
|
1987
|
+
*
|
|
1988
|
+
* @returns TocValidationError if validation fails, null if valid
|
|
1886
1989
|
*/
|
|
1887
|
-
|
|
1990
|
+
tryValidateEntries(entries, overrides) {
|
|
1888
1991
|
if (entries.length === 0) {
|
|
1889
|
-
return;
|
|
1992
|
+
return null;
|
|
1890
1993
|
}
|
|
1891
|
-
const
|
|
1892
|
-
validator
|
|
1994
|
+
const options = { ...this.validationOptions, ...overrides };
|
|
1995
|
+
const validator = new TocValidator(options);
|
|
1996
|
+
const result = validator.validate(entries);
|
|
1997
|
+
if (!result.valid) {
|
|
1998
|
+
const details = result.issues.map(
|
|
1999
|
+
(issue) => ` [${issue.code}] ${issue.message} (path: ${issue.path}, entry: "${issue.entry.title}" page ${issue.entry.pageNo})`
|
|
2000
|
+
).join("\n");
|
|
2001
|
+
return new TocValidationError(
|
|
2002
|
+
`TOC validation failed with ${result.errorCount} error(s):
|
|
2003
|
+
${details}`,
|
|
2004
|
+
result
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Build correction prompt with validation error feedback
|
|
2011
|
+
*
|
|
2012
|
+
* Includes the original markdown, previous extraction result,
|
|
2013
|
+
* validation errors, and guidance for fixing common mistakes.
|
|
2014
|
+
*/
|
|
2015
|
+
buildCorrectionPrompt(markdown, previousEntries, issues) {
|
|
2016
|
+
const errorLines = issues.map((issue) => {
|
|
2017
|
+
const desc = VALIDATION_CODE_DESCRIPTIONS[issue.code] ?? "Unknown validation error.";
|
|
2018
|
+
return `- [${issue.code}] ${issue.message}
|
|
2019
|
+
Path: ${issue.path}
|
|
2020
|
+
Entry: "${issue.entry.title}" (page ${issue.entry.pageNo})
|
|
2021
|
+
Rule: ${desc}`;
|
|
2022
|
+
});
|
|
2023
|
+
return `Your previous TOC extraction had validation errors. Please fix them and re-extract.
|
|
2024
|
+
|
|
2025
|
+
## Validation Errors
|
|
2026
|
+
|
|
2027
|
+
${errorLines.join("\n\n")}
|
|
2028
|
+
|
|
2029
|
+
## Common Mistakes to Avoid
|
|
2030
|
+
|
|
2031
|
+
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.
|
|
2032
|
+
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.
|
|
2033
|
+
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.
|
|
2034
|
+
|
|
2035
|
+
## Original Markdown
|
|
2036
|
+
|
|
2037
|
+
${markdown}
|
|
2038
|
+
|
|
2039
|
+
## Your Previous Extraction (with errors)
|
|
2040
|
+
|
|
2041
|
+
${JSON.stringify(previousEntries, null, 2)}
|
|
2042
|
+
|
|
2043
|
+
## Instructions
|
|
2044
|
+
|
|
2045
|
+
Re-extract the TOC structure from the original markdown above. Fix all validation errors listed above. Return the corrected entries.`;
|
|
1893
2046
|
}
|
|
1894
2047
|
/**
|
|
1895
2048
|
* Build system prompt for TOC extraction
|
|
@@ -1907,11 +2060,12 @@ var TocExtractor = class extends TextLLMComponent {
|
|
|
1907
2060
|
- Level 3: Subsections (e.g., "1.1.1", "a.", "(1)")
|
|
1908
2061
|
- Use indentation and numbering patterns to infer level
|
|
1909
2062
|
|
|
1910
|
-
3. **Page Number**: Extract the page number from each entry.
|
|
2063
|
+
3. **Page Number**: Extract the page number from each entry. Use only Arabic numerals for page numbers.
|
|
1911
2064
|
|
|
1912
2065
|
4. **Children**: Nest child entries under parent entries based on their hierarchy level.
|
|
1913
2066
|
|
|
1914
|
-
5. **IMPORTANT - Extract Main TOC Only**: Only extract the main document table of contents. EXCLUDE the following
|
|
2067
|
+
5. **IMPORTANT - Extract Main TOC Only**: Only extract the main document table of contents. EXCLUDE the following:
|
|
2068
|
+
- **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.
|
|
1915
2069
|
- Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \uD654\uBCF4 \uBAA9\uCC28, Photo Index, List of Photos, List of Figures)
|
|
1916
2070
|
- Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \uC0BD\uB3C4 \uBAA9\uCC28, Drawing Index, List of Drawings)
|
|
1917
2071
|
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
|
|
@@ -1938,11 +2092,11 @@ Output:
|
|
|
1938
2092
|
"level": 1,
|
|
1939
2093
|
"pageNo": 1,
|
|
1940
2094
|
"children": [
|
|
1941
|
-
{ "title": "1. \uC5F0\uAD6C \uBC30\uACBD", "level": 2, "pageNo": 3 },
|
|
1942
|
-
{ "title": "2. \uC5F0\uAD6C \uBAA9\uC801", "level": 2, "pageNo": 5 }
|
|
2095
|
+
{ "title": "1. \uC5F0\uAD6C \uBC30\uACBD", "level": 2, "pageNo": 3, "children": [] },
|
|
2096
|
+
{ "title": "2. \uC5F0\uAD6C \uBAA9\uC801", "level": 2, "pageNo": 5, "children": [] }
|
|
1943
2097
|
]
|
|
1944
2098
|
},
|
|
1945
|
-
{ "title": "\uC81C2\uC7A5 \uBC29\uBC95\uB860", "level": 1, "pageNo": 10 }
|
|
2099
|
+
{ "title": "\uC81C2\uC7A5 \uBC29\uBC95\uB860", "level": 1, "pageNo": 10, "children": [] }
|
|
1946
2100
|
]
|
|
1947
2101
|
}`;
|
|
1948
2102
|
}
|
|
@@ -2585,7 +2739,7 @@ var PagePattern = /* @__PURE__ */ ((PagePattern2) => {
|
|
|
2585
2739
|
var PageRangeParser = class extends VisionLLMComponent {
|
|
2586
2740
|
// Configuration constants
|
|
2587
2741
|
SAMPLE_SIZE = 3;
|
|
2588
|
-
MAX_PATTERN_RETRIES =
|
|
2742
|
+
MAX_PATTERN_RETRIES = 19;
|
|
2589
2743
|
SIZE_TOLERANCE = 5;
|
|
2590
2744
|
constructor(logger, model, outputPath, maxRetries = 3, fallbackModel, aggregator, abortSignal) {
|
|
2591
2745
|
super(
|
|
@@ -3192,6 +3346,283 @@ Remember: Look for SMALL numbers in MARGINS only. Ignore figure/table/photo numb
|
|
|
3192
3346
|
}
|
|
3193
3347
|
};
|
|
3194
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
|
+
|
|
3195
3626
|
// src/validators/base-validator.ts
|
|
3196
3627
|
var BaseValidator = class extends TextLLMComponent {
|
|
3197
3628
|
/**
|
|
@@ -3251,13 +3682,13 @@ var BaseValidator = class extends TextLLMComponent {
|
|
|
3251
3682
|
};
|
|
3252
3683
|
|
|
3253
3684
|
// src/validators/toc-content-validator.ts
|
|
3254
|
-
import { z as
|
|
3255
|
-
var TocContentValidationSchema =
|
|
3256
|
-
isValid:
|
|
3257
|
-
confidence:
|
|
3258
|
-
contentType:
|
|
3259
|
-
extractedTocMarkdown:
|
|
3260
|
-
reason:
|
|
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")
|
|
3261
3692
|
});
|
|
3262
3693
|
var TocContentValidator = class extends BaseValidator {
|
|
3263
3694
|
confidenceThreshold;
|
|
@@ -3408,14 +3839,14 @@ ${markdown}`;
|
|
|
3408
3839
|
};
|
|
3409
3840
|
|
|
3410
3841
|
// src/validators/caption-validator.ts
|
|
3411
|
-
import { z as
|
|
3412
|
-
var CaptionValidationItemSchema =
|
|
3413
|
-
index:
|
|
3414
|
-
isValid:
|
|
3415
|
-
reason:
|
|
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")
|
|
3416
3847
|
});
|
|
3417
|
-
var CaptionValidationBatchSchema =
|
|
3418
|
-
results:
|
|
3848
|
+
var CaptionValidationBatchSchema = z7.object({
|
|
3849
|
+
results: z7.array(CaptionValidationItemSchema)
|
|
3419
3850
|
});
|
|
3420
3851
|
var CaptionValidator = class extends BaseValidator {
|
|
3421
3852
|
constructor(logger, model, options, fallbackModel, aggregator) {
|
|
@@ -3623,6 +4054,7 @@ var DocumentProcessor = class {
|
|
|
3623
4054
|
validatorModel;
|
|
3624
4055
|
visionTocExtractorModel;
|
|
3625
4056
|
captionParserModel;
|
|
4057
|
+
hanjaQualitySamplerModel;
|
|
3626
4058
|
textCleanerBatchSize;
|
|
3627
4059
|
captionParserBatchSize;
|
|
3628
4060
|
captionValidatorBatchSize;
|
|
@@ -3649,6 +4081,7 @@ var DocumentProcessor = class {
|
|
|
3649
4081
|
this.validatorModel = options.validatorModel ?? options.fallbackModel;
|
|
3650
4082
|
this.visionTocExtractorModel = options.visionTocExtractorModel ?? options.fallbackModel;
|
|
3651
4083
|
this.captionParserModel = options.captionParserModel ?? options.fallbackModel;
|
|
4084
|
+
this.hanjaQualitySamplerModel = options.hanjaQualitySamplerModel ?? options.fallbackModel;
|
|
3652
4085
|
this.textCleanerBatchSize = options.textCleanerBatchSize;
|
|
3653
4086
|
this.captionParserBatchSize = options.captionParserBatchSize;
|
|
3654
4087
|
this.captionValidatorBatchSize = options.captionValidatorBatchSize;
|
|
@@ -3668,6 +4101,36 @@ var DocumentProcessor = class {
|
|
|
3668
4101
|
throw error;
|
|
3669
4102
|
}
|
|
3670
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
|
+
}
|
|
3671
4134
|
/**
|
|
3672
4135
|
* Converts DoclingDocument to ProcessedDocument with token usage tracking.
|
|
3673
4136
|
*
|
|
@@ -3985,8 +4448,8 @@ var DocumentProcessor = class {
|
|
|
3985
4448
|
}
|
|
3986
4449
|
if (!markdown) {
|
|
3987
4450
|
this.logger.info("[DocumentProcessor] Using vision fallback for TOC");
|
|
3988
|
-
const
|
|
3989
|
-
markdown = await this.visionTocExtractor.extract(
|
|
4451
|
+
const totalPages2 = Object.keys(doclingDoc.pages).length;
|
|
4452
|
+
markdown = await this.visionTocExtractor.extract(totalPages2);
|
|
3990
4453
|
if (!markdown) {
|
|
3991
4454
|
const reason = "Both rule-based search and vision fallback failed to locate TOC";
|
|
3992
4455
|
this.logger.error(
|
|
@@ -4000,8 +4463,13 @@ var DocumentProcessor = class {
|
|
|
4000
4463
|
`[DocumentProcessor] Vision extracted TOC markdown (${markdown.length} chars)`
|
|
4001
4464
|
);
|
|
4002
4465
|
}
|
|
4003
|
-
const
|
|
4004
|
-
this.
|
|
4466
|
+
const totalPages = Object.keys(doclingDoc.pages).length;
|
|
4467
|
+
const tocResult = await this.tocExtractor.extract(markdown, {
|
|
4468
|
+
totalPages
|
|
4469
|
+
});
|
|
4470
|
+
for (const usage of tocResult.usages) {
|
|
4471
|
+
this.usageAggregator.track(usage);
|
|
4472
|
+
}
|
|
4005
4473
|
if (tocResult.entries.length === 0) {
|
|
4006
4474
|
const reason = "TOC area was detected but LLM could not extract any structured entries";
|
|
4007
4475
|
this.logger.error(`[DocumentProcessor] TOC extraction failed: ${reason}`);
|
|
@@ -4279,6 +4747,7 @@ export {
|
|
|
4279
4747
|
CaptionValidator,
|
|
4280
4748
|
ChapterConverter,
|
|
4281
4749
|
DocumentProcessor,
|
|
4750
|
+
HanjaQualitySampler,
|
|
4282
4751
|
PAGE_NUMBER_PATTERN,
|
|
4283
4752
|
PagePattern,
|
|
4284
4753
|
PageRangeParseError,
|