@heripo/document-processor 0.1.7 → 0.1.9
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/index.cjs +315 -371
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -84
- package/dist/index.d.ts +30 -84
- package/dist/index.js +322 -371
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.cjs
CHANGED
|
@@ -39,7 +39,6 @@ __export(index_exports, {
|
|
|
39
39
|
CaptionValidator: () => CaptionValidator,
|
|
40
40
|
ChapterConverter: () => ChapterConverter,
|
|
41
41
|
DocumentProcessor: () => DocumentProcessor,
|
|
42
|
-
HanjaQualitySampler: () => HanjaQualitySampler,
|
|
43
42
|
PAGE_NUMBER_PATTERN: () => PAGE_NUMBER_PATTERN,
|
|
44
43
|
PagePattern: () => PagePattern,
|
|
45
44
|
PageRangeParseError: () => PageRangeParseError,
|
|
@@ -136,6 +135,15 @@ var BatchProcessor = class {
|
|
|
136
135
|
return results.flat();
|
|
137
136
|
}
|
|
138
137
|
};
|
|
138
|
+
function detectProvider(model) {
|
|
139
|
+
const providerId = model.provider;
|
|
140
|
+
if (!providerId || typeof providerId !== "string") return "unknown";
|
|
141
|
+
if (providerId.includes("openai")) return "openai";
|
|
142
|
+
if (providerId.includes("google")) return "google";
|
|
143
|
+
if (providerId.includes("anthropic")) return "anthropic";
|
|
144
|
+
if (providerId.includes("together")) return "togetherai";
|
|
145
|
+
return "unknown";
|
|
146
|
+
}
|
|
139
147
|
var LLMCaller = class {
|
|
140
148
|
/**
|
|
141
149
|
* Extract model name from LanguageModel object
|
|
@@ -164,10 +172,95 @@ var LLMCaller = class {
|
|
|
164
172
|
totalTokens: response.usage?.totalTokens ?? 0
|
|
165
173
|
};
|
|
166
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Maximum number of retries when structured output generation fails.
|
|
177
|
+
* Total attempts = MAX_STRUCTURED_OUTPUT_RETRIES + 1.
|
|
178
|
+
*
|
|
179
|
+
* Applied to both:
|
|
180
|
+
* - `Output.object()` path: retries on NoObjectGeneratedError (schema mismatch)
|
|
181
|
+
* - Tool call path: retries when model does not produce a tool call
|
|
182
|
+
*/
|
|
183
|
+
static MAX_STRUCTURED_OUTPUT_RETRIES = 10;
|
|
184
|
+
/**
|
|
185
|
+
* Generate structured output via forced tool call.
|
|
186
|
+
*
|
|
187
|
+
* Used for providers (Together AI, unknown) that do not reliably support
|
|
188
|
+
* `Output.object()`. Forces the model to call a tool whose inputSchema
|
|
189
|
+
* is the target Zod schema, then extracts the parsed input.
|
|
190
|
+
*
|
|
191
|
+
* Retries up to MAX_STRUCTURED_OUTPUT_RETRIES times when the model does not
|
|
192
|
+
* produce a tool call, for a total of MAX_STRUCTURED_OUTPUT_RETRIES + 1 attempts.
|
|
193
|
+
*
|
|
194
|
+
* @throws NoObjectGeneratedError when all attempts fail to produce a tool call
|
|
195
|
+
*/
|
|
196
|
+
static async generateViaToolCall(model, schema, promptParams) {
|
|
197
|
+
const submitTool = (0, import_ai.tool)({
|
|
198
|
+
description: "Submit the structured result",
|
|
199
|
+
inputSchema: schema
|
|
200
|
+
});
|
|
201
|
+
let lastResult;
|
|
202
|
+
for (let attempt = 0; attempt <= this.MAX_STRUCTURED_OUTPUT_RETRIES; attempt++) {
|
|
203
|
+
lastResult = await (0, import_ai.generateText)({
|
|
204
|
+
...promptParams,
|
|
205
|
+
model,
|
|
206
|
+
tools: { submitResult: submitTool },
|
|
207
|
+
toolChoice: { type: "tool", toolName: "submitResult" },
|
|
208
|
+
stopWhen: (0, import_ai.hasToolCall)("submitResult")
|
|
209
|
+
});
|
|
210
|
+
const toolCall = lastResult.toolCalls?.[0];
|
|
211
|
+
if (toolCall) {
|
|
212
|
+
return {
|
|
213
|
+
output: toolCall.input,
|
|
214
|
+
usage: lastResult.usage
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
throw new import_ai.NoObjectGeneratedError({
|
|
219
|
+
message: "Model did not produce a tool call for structured output",
|
|
220
|
+
text: lastResult.text ?? "",
|
|
221
|
+
response: lastResult.response,
|
|
222
|
+
usage: lastResult.usage,
|
|
223
|
+
finishReason: lastResult.finishReason
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Generate structured output with provider-aware strategy.
|
|
228
|
+
*
|
|
229
|
+
* Strategy per provider:
|
|
230
|
+
* - OpenAI / Anthropic / Google Gemini: `Output.object()` with schema retry
|
|
231
|
+
* - Together AI / unknown: forced tool call pattern
|
|
232
|
+
*
|
|
233
|
+
* Retries up to MAX_STRUCTURED_OUTPUT_RETRIES times on NoObjectGeneratedError
|
|
234
|
+
* (schema mismatch), re-throwing the last error if all attempts fail.
|
|
235
|
+
*/
|
|
236
|
+
static async generateStructuredOutput(model, schema, promptParams) {
|
|
237
|
+
const providerType = detectProvider(model);
|
|
238
|
+
if (providerType === "togetherai" || providerType === "unknown") {
|
|
239
|
+
return this.generateViaToolCall(model, schema, promptParams);
|
|
240
|
+
}
|
|
241
|
+
let lastError;
|
|
242
|
+
for (let attempt = 0; attempt <= this.MAX_STRUCTURED_OUTPUT_RETRIES; attempt++) {
|
|
243
|
+
try {
|
|
244
|
+
return await (0, import_ai.generateText)({
|
|
245
|
+
model,
|
|
246
|
+
output: import_ai.Output.object({ schema }),
|
|
247
|
+
...promptParams
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (import_ai.NoObjectGeneratedError.isInstance(error)) {
|
|
251
|
+
lastError = error;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw lastError;
|
|
258
|
+
}
|
|
167
259
|
/**
|
|
168
260
|
* Execute LLM call with fallback support
|
|
169
261
|
*
|
|
170
262
|
* Common execution logic for both text and vision calls.
|
|
263
|
+
* Logs additional details when NoObjectGeneratedError occurs.
|
|
171
264
|
*/
|
|
172
265
|
static async executeWithFallback(config, generateFn) {
|
|
173
266
|
const primaryModelName = this.extractModelName(config.primaryModel);
|
|
@@ -202,6 +295,8 @@ var LLMCaller = class {
|
|
|
202
295
|
* 2. If all fail and fallbackModel provided, try fallback up to maxRetries times
|
|
203
296
|
* 3. Throw error if all attempts exhausted
|
|
204
297
|
*
|
|
298
|
+
* Provider-aware strategy is automatically applied based on the model's provider field.
|
|
299
|
+
*
|
|
205
300
|
* @template TOutput - Output type from schema validation
|
|
206
301
|
* @param config - LLM call configuration
|
|
207
302
|
* @returns Result with parsed object and usage information
|
|
@@ -210,11 +305,7 @@ var LLMCaller = class {
|
|
|
210
305
|
static async call(config) {
|
|
211
306
|
return this.executeWithFallback(
|
|
212
307
|
config,
|
|
213
|
-
(model) => (
|
|
214
|
-
model,
|
|
215
|
-
output: import_ai.Output.object({
|
|
216
|
-
schema: config.schema
|
|
217
|
-
}),
|
|
308
|
+
(model) => this.generateStructuredOutput(model, config.schema, {
|
|
218
309
|
system: config.systemPrompt,
|
|
219
310
|
prompt: config.userPrompt,
|
|
220
311
|
temperature: config.temperature,
|
|
@@ -227,6 +318,7 @@ var LLMCaller = class {
|
|
|
227
318
|
* Call LLM for vision tasks with message format support
|
|
228
319
|
*
|
|
229
320
|
* Same retry and fallback logic as call(), but using message format instead of system/user prompts.
|
|
321
|
+
* Provider-aware strategy is automatically applied based on the model's provider field.
|
|
230
322
|
*
|
|
231
323
|
* @template TOutput - Output type from schema validation
|
|
232
324
|
* @param config - LLM vision call configuration
|
|
@@ -236,11 +328,7 @@ var LLMCaller = class {
|
|
|
236
328
|
static async callVision(config) {
|
|
237
329
|
return this.executeWithFallback(
|
|
238
330
|
config,
|
|
239
|
-
(model) => (
|
|
240
|
-
model,
|
|
241
|
-
output: import_ai.Output.object({
|
|
242
|
-
schema: config.schema
|
|
243
|
-
}),
|
|
331
|
+
(model) => this.generateStructuredOutput(model, config.schema, {
|
|
244
332
|
messages: config.messages,
|
|
245
333
|
temperature: config.temperature,
|
|
246
334
|
maxRetries: config.maxRetries,
|
|
@@ -1496,6 +1584,20 @@ var CONTINUATION_MARKERS = [
|
|
|
1496
1584
|
"continued"
|
|
1497
1585
|
];
|
|
1498
1586
|
var PAGE_NUMBER_PATTERN = /\.{2,}\s*\d+\s*$|…+\s*\d+\s*$|\s+\d+\s*$/;
|
|
1587
|
+
var RESOURCE_INDEX_PATTERNS = [
|
|
1588
|
+
/^\[도면\s*\d+\]/,
|
|
1589
|
+
/^\[사진\s*\d+\]/,
|
|
1590
|
+
/^\[표\s*\d+\]/,
|
|
1591
|
+
/^\[그림\s*\d+\]/,
|
|
1592
|
+
/^도면\s*\d+[.:)\s]/,
|
|
1593
|
+
/^사진\s*\d+[.:)\s]/,
|
|
1594
|
+
/^표\s*\d+[.:)\s]/,
|
|
1595
|
+
/^Fig\.\s*\d+/i,
|
|
1596
|
+
/^Photo\.?\s*\d+/i,
|
|
1597
|
+
/^Plate\s*\d+/i,
|
|
1598
|
+
/^Map\s*\d+/i,
|
|
1599
|
+
/^圖\s*\d+/
|
|
1600
|
+
];
|
|
1499
1601
|
var TocFinder = class {
|
|
1500
1602
|
constructor(logger, refResolver, options) {
|
|
1501
1603
|
this.logger = logger;
|
|
@@ -1556,6 +1658,10 @@ var TocFinder = class {
|
|
|
1556
1658
|
if (result) {
|
|
1557
1659
|
return this.expandToConsecutivePages(result, doc);
|
|
1558
1660
|
}
|
|
1661
|
+
const siblingResult = this.findSiblingTocItem(doc, text.self_ref, pageNo);
|
|
1662
|
+
if (siblingResult) {
|
|
1663
|
+
return this.expandToConsecutivePages(siblingResult, doc);
|
|
1664
|
+
}
|
|
1559
1665
|
}
|
|
1560
1666
|
return null;
|
|
1561
1667
|
}
|
|
@@ -1587,7 +1693,10 @@ var TocFinder = class {
|
|
|
1587
1693
|
continue;
|
|
1588
1694
|
}
|
|
1589
1695
|
if (this.isTableTocLike(table)) {
|
|
1590
|
-
|
|
1696
|
+
let score = this.calculateTableScore(table, pageNo);
|
|
1697
|
+
if (this.isResourceIndexTable(table)) {
|
|
1698
|
+
score -= 100;
|
|
1699
|
+
}
|
|
1591
1700
|
candidates.push({
|
|
1592
1701
|
result: {
|
|
1593
1702
|
itemRefs: [table.self_ref],
|
|
@@ -1631,6 +1740,37 @@ var TocFinder = class {
|
|
|
1631
1740
|
}
|
|
1632
1741
|
return null;
|
|
1633
1742
|
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Find adjacent TOC item among body siblings following the keyword text.
|
|
1745
|
+
* Used when findTocContainer returns null (e.g., parent is #/body).
|
|
1746
|
+
*/
|
|
1747
|
+
findSiblingTocItem(doc, textRef, pageNo) {
|
|
1748
|
+
const idx = doc.body.children.findIndex((c) => c.$ref === textRef);
|
|
1749
|
+
if (idx < 0) return null;
|
|
1750
|
+
for (let i = idx + 1; i < Math.min(idx + 4, doc.body.children.length); i++) {
|
|
1751
|
+
const ref = doc.body.children[i].$ref;
|
|
1752
|
+
const table = this.refResolver.resolveTable(ref);
|
|
1753
|
+
if (table && table.prov[0]?.page_no === pageNo && this.isTableTocLike(table)) {
|
|
1754
|
+
return {
|
|
1755
|
+
itemRefs: [table.self_ref],
|
|
1756
|
+
startPage: pageNo,
|
|
1757
|
+
endPage: pageNo
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
const group = this.refResolver.resolveGroup(ref);
|
|
1761
|
+
if (group) {
|
|
1762
|
+
const groupPage = this.getGroupFirstPage(group);
|
|
1763
|
+
if (groupPage === pageNo && this.isGroupTocLike(group, doc)) {
|
|
1764
|
+
return {
|
|
1765
|
+
itemRefs: [group.self_ref],
|
|
1766
|
+
startPage: pageNo,
|
|
1767
|
+
endPage: pageNo
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return null;
|
|
1773
|
+
}
|
|
1634
1774
|
/**
|
|
1635
1775
|
* Check if a group contains TOC-like structure
|
|
1636
1776
|
*/
|
|
@@ -1672,6 +1812,23 @@ var TocFinder = class {
|
|
|
1672
1812
|
}
|
|
1673
1813
|
return numberCount > 0 && numberCount / (num_rows - 1) > 0.5;
|
|
1674
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Check if a table is a resource index (drawing/photo/figure list)
|
|
1817
|
+
* rather than a main TOC table
|
|
1818
|
+
*/
|
|
1819
|
+
isResourceIndexTable(table) {
|
|
1820
|
+
const firstColCells = table.data.table_cells.filter(
|
|
1821
|
+
(c) => c.start_col_offset_idx === 0
|
|
1822
|
+
);
|
|
1823
|
+
if (firstColCells.length === 0) return false;
|
|
1824
|
+
let resourceCount = 0;
|
|
1825
|
+
for (const cell of firstColCells) {
|
|
1826
|
+
if (RESOURCE_INDEX_PATTERNS.some((p) => p.test(cell.text.trim()))) {
|
|
1827
|
+
resourceCount++;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
return resourceCount / firstColCells.length > 0.5;
|
|
1831
|
+
}
|
|
1675
1832
|
/**
|
|
1676
1833
|
* Expand TOC area to consecutive pages (both backward and forward)
|
|
1677
1834
|
*/
|
|
@@ -1744,7 +1901,7 @@ var TocFinder = class {
|
|
|
1744
1901
|
if (table.prov[0]?.page_no !== pageNo) {
|
|
1745
1902
|
continue;
|
|
1746
1903
|
}
|
|
1747
|
-
if (this.isTableTocLike(table) && !refs.includes(table.self_ref)) {
|
|
1904
|
+
if (this.isTableTocLike(table) && !this.isResourceIndexTable(table) && !refs.includes(table.self_ref)) {
|
|
1748
1905
|
refs.push(table.self_ref);
|
|
1749
1906
|
}
|
|
1750
1907
|
}
|
|
@@ -1936,13 +2093,22 @@ var VALIDATION_CODE_DESCRIPTIONS = {
|
|
|
1936
2093
|
V006: "Duplicate entry detected (same title and page number).",
|
|
1937
2094
|
V007: "First TOC entry starts too late in the document. Earlier entries may be missing."
|
|
1938
2095
|
};
|
|
1939
|
-
|
|
1940
|
-
|
|
2096
|
+
function buildTocEntrySchema(maxDepth) {
|
|
2097
|
+
const base = {
|
|
1941
2098
|
title: import_zod.z.string().describe("Chapter or section title"),
|
|
1942
2099
|
level: import_zod.z.number().int().min(1).describe("Hierarchy depth (1 = top level)"),
|
|
1943
|
-
pageNo: import_zod.z.number().int().min(1).describe("Starting page number")
|
|
1944
|
-
|
|
1945
|
-
|
|
2100
|
+
pageNo: import_zod.z.number().int().min(1).describe("Starting page number")
|
|
2101
|
+
};
|
|
2102
|
+
if (maxDepth <= 0) {
|
|
2103
|
+
return import_zod.z.object(base);
|
|
2104
|
+
}
|
|
2105
|
+
return import_zod.z.object({
|
|
2106
|
+
...base,
|
|
2107
|
+
children: import_zod.z.array(buildTocEntrySchema(maxDepth - 1)).describe("Child sections (use empty array [] if none)")
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
var TocEntrySchema = buildTocEntrySchema(
|
|
2111
|
+
4
|
|
1946
2112
|
);
|
|
1947
2113
|
var TocResponseSchema = import_zod.z.object({
|
|
1948
2114
|
entries: import_zod.z.array(TocEntrySchema).describe("Extracted TOC entries")
|
|
@@ -2129,9 +2295,9 @@ Re-extract the TOC structure from the original markdown above. Fix all validatio
|
|
|
2129
2295
|
|
|
2130
2296
|
5. **IMPORTANT - Extract Main TOC Only**: Only extract the main document table of contents. EXCLUDE the following:
|
|
2131
2297
|
- **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.
|
|
2132
|
-
- Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \uD654\uBCF4 \uBAA9\uCC28, Photo Index, List of Photos, List of Figures)
|
|
2133
|
-
- Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \uC0BD\uB3C4 \uBAA9\uCC28, Drawing Index, List of Drawings)
|
|
2134
|
-
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
|
|
2298
|
+
- Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \uD654\uBCF4 \uBAA9\uCC28, \u5BEB\u771E \u76EE\u6B21, \u5BEB\u771E\u76EE\u6B21, Photo Index, List of Photos, List of Figures)
|
|
2299
|
+
- Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \uC0BD\uB3C4 \uBAA9\uCC28, \u5716\u9762 \u76EE\u6B21, \u5716\u9762\u76EE\u6B21, Drawing Index, List of Drawings)
|
|
2300
|
+
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, \u8868 \u76EE\u6B21, \u8868\u76EE\u6B21, Table Index, List of Tables)
|
|
2135
2301
|
- Appendix indices (\uBD80\uB85D \uBAA9\uCC28, Appendix Index)
|
|
2136
2302
|
- Any other supplementary material indices
|
|
2137
2303
|
|
|
@@ -2452,19 +2618,34 @@ ${continuation.trim()}`;
|
|
|
2452
2618
|
|
|
2453
2619
|
I am providing ${pageCount} document page images (pages ${startPage}-${endPage}).
|
|
2454
2620
|
|
|
2455
|
-
##
|
|
2621
|
+
## How to Identify TOC Pages:
|
|
2456
2622
|
- TOC typically appears in the first 10-20 pages of a document
|
|
2457
|
-
- Look for pages with
|
|
2458
|
-
-
|
|
2623
|
+
- Look for pages with a heading that indicates "Table of Contents":
|
|
2624
|
+
- Korean: "\uBAA9\uCC28", "\uCC28\uB840", "\uBAA9 \uCC28"
|
|
2625
|
+
- Hanja (Chinese characters used in Korean documents): "\u76EE\u6B21", "\u76EE \u6B21"
|
|
2626
|
+
- English: "Contents", "Table of Contents"
|
|
2627
|
+
- The heading may appear in decorative or stylized fonts on a textured background
|
|
2628
|
+
- Below the heading, look for structured lists with chapter/section titles and page numbers connected by dots or leaders
|
|
2629
|
+
|
|
2630
|
+
## How to Distinguish Main TOC from Supplementary Indices:
|
|
2631
|
+
- **Main TOC** (EXTRACT THIS): The heading is simply "\uBAA9\uCC28", "\u76EE\u6B21", "Contents" with NO prefix qualifier. It lists chapters/sections of the document body.
|
|
2632
|
+
- **Supplementary indices** (DO NOT EXTRACT): The heading has a prefix qualifier specifying a resource type:
|
|
2633
|
+
- Photo indices: "\uC0AC\uC9C4 \uBAA9\uCC28", "\uC0AC\uC9C4\uBAA9\uCC28", "\u5BEB\u771E \u76EE\u6B21", "\u5BEB\u771E\u76EE\u6B21"
|
|
2634
|
+
- Drawing/figure indices: "\uB3C4\uBA74 \uBAA9\uCC28", "\uB3C4\uBA74\uBAA9\uCC28", "\u5716\u9762 \u76EE\u6B21", "\u5716\u9762\u76EE\u6B21"
|
|
2635
|
+
- Table indices: "\uD45C \uBAA9\uCC28", "\uD45C\uBAA9\uCC28", "\u8868 \u76EE\u6B21", "\u8868\u76EE\u6B21"
|
|
2636
|
+
- Key rule: If the heading contains a qualifier word BEFORE "\uBAA9\uCC28"/"\u76EE\u6B21"/"Contents", it is a supplementary index. Only extract content from pages whose heading is the unqualified "\uBAA9\uCC28"/"\u76EE\u6B21"/"Contents".
|
|
2459
2637
|
|
|
2460
2638
|
## What to Extract:
|
|
2461
|
-
Extract the TOC content as markdown format
|
|
2639
|
+
Extract the TOC content as markdown format with this exact structure:
|
|
2462
2640
|
- Use "- " prefix for each list item
|
|
2463
2641
|
- Use 2-space indentation for hierarchy levels
|
|
2464
2642
|
- Include "..... " followed by page number at the end of each entry
|
|
2465
2643
|
- Preserve original chapter/section numbering from the document
|
|
2644
|
+
- Preserve the original language of titles (Korean, Hanja, English, or mixed)
|
|
2466
2645
|
|
|
2467
|
-
## Output Format
|
|
2646
|
+
## Output Format Examples:
|
|
2647
|
+
|
|
2648
|
+
### Example 1: Standard Korean TOC
|
|
2468
2649
|
\`\`\`
|
|
2469
2650
|
- \uC81C1\uC7A5 \uC11C\uB860 ..... 1
|
|
2470
2651
|
- 1. \uC5F0\uAD6C \uBC30\uACBD ..... 3
|
|
@@ -2475,21 +2656,30 @@ Extract the TOC content as markdown format that matches this exact structure:
|
|
|
2475
2656
|
- \uC81C3\uC7A5 \uC5F0\uAD6C \uACB0\uACFC ..... 25
|
|
2476
2657
|
\`\`\`
|
|
2477
2658
|
|
|
2659
|
+
### Example 2: Mixed Hanja-Korean TOC (common in archaeological reports)
|
|
2660
|
+
\`\`\`
|
|
2661
|
+
- \u2160. \u8ABF\u67FB\u6982\u8981 ..... 175
|
|
2662
|
+
- \u2161. \u8ABF\u67FB\u5730\u57DF\uC758 \u74B0\u5883 ..... 177
|
|
2663
|
+
- \u2162. \u8ABF\u67FB\u5185\u5BB9 ..... 199
|
|
2664
|
+
- 1. \uC870\uC0AC\uC9C0\uC5ED \uD1F4\uC801\uC591\uC0C1 ..... 199
|
|
2665
|
+
- 2. \uC720\uC801 \uC870\uC0AC\uB0B4\uC6A9 ..... 200
|
|
2666
|
+
- \u2163. \u8ABF\u67FB\u7D50\u679C ..... 228
|
|
2667
|
+
\`\`\`
|
|
2668
|
+
|
|
2478
2669
|
## Important Rules:
|
|
2479
|
-
1. Extract ONLY the main document TOC
|
|
2480
|
-
2. DO NOT include supplementary indices
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
4. Set continuesOnNextPage to true if the TOC appears to continue beyond the visible pages
|
|
2670
|
+
1. Extract ONLY the main document TOC (the one with unqualified heading "\uBAA9\uCC28"/"\u76EE\u6B21"/"Contents")
|
|
2671
|
+
2. DO NOT include supplementary indices (those with prefixed headings like "\uC0AC\uC9C4 \uBAA9\uCC28", "\u5BEB\u771E \u76EE\u6B21", "\u5716\u9762 \u76EE\u6B21", "\u8868 \u76EE\u6B21", etc.)
|
|
2672
|
+
3. Page numbers can be any valid positive integer - documents in compiled volumes may have high page numbers (e.g., 175, 228, 500+). This is normal and expected.
|
|
2673
|
+
4. Some pages in the batch may be photographs, illustrations, blank pages, or other non-text content with no TOC. Skip these pages.
|
|
2674
|
+
5. If no TOC is found, set hasToc to false and tocMarkdown to null
|
|
2675
|
+
6. Set continuesOnNextPage to true if the TOC appears to continue beyond the visible pages
|
|
2486
2676
|
|
|
2487
2677
|
Please examine these pages and:
|
|
2488
|
-
1. Determine if any page contains a Table of Contents
|
|
2678
|
+
1. Determine if any page contains a main document Table of Contents
|
|
2489
2679
|
2. If found, extract the complete TOC in markdown format
|
|
2490
2680
|
3. Indicate if the TOC continues beyond these pages
|
|
2491
2681
|
|
|
2492
|
-
Remember: Extract the main document TOC only. Ignore photo/table/figure indices.`;
|
|
2682
|
+
Remember: Extract the main document TOC only. Ignore photo/table/figure/drawing indices.`;
|
|
2493
2683
|
}
|
|
2494
2684
|
};
|
|
2495
2685
|
|
|
@@ -2975,7 +3165,10 @@ var PageRangeParser = class extends VisionLLMComponent {
|
|
|
2975
3165
|
const imageContents = [];
|
|
2976
3166
|
for (const pageNo of pageNos) {
|
|
2977
3167
|
const page = pages[pageNo - 1];
|
|
2978
|
-
const imagePath = path3.resolve(
|
|
3168
|
+
const imagePath = path3.resolve(
|
|
3169
|
+
this.outputPath,
|
|
3170
|
+
`pages/page_${pageNo - 1}.png`
|
|
3171
|
+
);
|
|
2979
3172
|
const imageBuffer = fs3.readFileSync(imagePath);
|
|
2980
3173
|
const base64Image = imageBuffer.toString("base64");
|
|
2981
3174
|
const mimeType = page.image.mimetype || "image/png";
|
|
@@ -3409,283 +3602,6 @@ Remember: Look for SMALL numbers in MARGINS only. Ignore figure/table/photo numb
|
|
|
3409
3602
|
}
|
|
3410
3603
|
};
|
|
3411
3604
|
|
|
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
|
-
|
|
3689
3605
|
// src/validators/base-validator.ts
|
|
3690
3606
|
var BaseValidator = class extends TextLLMComponent {
|
|
3691
3607
|
/**
|
|
@@ -3745,13 +3661,13 @@ var BaseValidator = class extends TextLLMComponent {
|
|
|
3745
3661
|
};
|
|
3746
3662
|
|
|
3747
3663
|
// src/validators/toc-content-validator.ts
|
|
3748
|
-
var
|
|
3749
|
-
var TocContentValidationSchema =
|
|
3750
|
-
isValid:
|
|
3751
|
-
confidence:
|
|
3752
|
-
contentType:
|
|
3753
|
-
extractedTocMarkdown:
|
|
3754
|
-
reason:
|
|
3664
|
+
var import_zod5 = require("zod");
|
|
3665
|
+
var TocContentValidationSchema = import_zod5.z.object({
|
|
3666
|
+
isValid: import_zod5.z.boolean().describe("Whether valid main document TOC was found"),
|
|
3667
|
+
confidence: import_zod5.z.number().min(0).max(1).describe("Confidence score between 0 and 1"),
|
|
3668
|
+
contentType: import_zod5.z.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
|
|
3669
|
+
extractedTocMarkdown: import_zod5.z.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
|
|
3670
|
+
reason: import_zod5.z.string().describe("Brief explanation in English")
|
|
3755
3671
|
});
|
|
3756
3672
|
var TocContentValidator = class extends BaseValidator {
|
|
3757
3673
|
confidenceThreshold;
|
|
@@ -3856,9 +3772,9 @@ When classifying as "mixed", you MUST extract ONLY the main TOC portion and retu
|
|
|
3856
3772
|
|
|
3857
3773
|
### 3. resource_only
|
|
3858
3774
|
The content contains ONLY resource indices such as:
|
|
3859
|
-
- Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, Photo Index, List of Figures, List of Photos)
|
|
3860
|
-
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
|
|
3861
|
-
- Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, Drawing Index, List of Drawings)
|
|
3775
|
+
- Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \u5BEB\u771E \u76EE\u6B21, \u5BEB\u771E\u76EE\u6B21, Photo Index, List of Figures, List of Photos)
|
|
3776
|
+
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, \u8868 \u76EE\u6B21, \u8868\u76EE\u6B21, Table Index, List of Tables)
|
|
3777
|
+
- Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \u5716\u9762 \u76EE\u6B21, \u5716\u9762\u76EE\u6B21, Drawing Index, List of Drawings)
|
|
3862
3778
|
- Appendix indices (\uBD80\uB85D \uBAA9\uCC28, Appendix Index)
|
|
3863
3779
|
|
|
3864
3780
|
### 4. invalid
|
|
@@ -3902,14 +3818,14 @@ ${markdown}`;
|
|
|
3902
3818
|
};
|
|
3903
3819
|
|
|
3904
3820
|
// src/validators/caption-validator.ts
|
|
3905
|
-
var
|
|
3906
|
-
var CaptionValidationItemSchema =
|
|
3907
|
-
index:
|
|
3908
|
-
isValid:
|
|
3909
|
-
reason:
|
|
3821
|
+
var import_zod6 = require("zod");
|
|
3822
|
+
var CaptionValidationItemSchema = import_zod6.z.object({
|
|
3823
|
+
index: import_zod6.z.number().int().describe("Index of the caption in the input array"),
|
|
3824
|
+
isValid: import_zod6.z.boolean().describe("Whether the parsed caption is correct"),
|
|
3825
|
+
reason: import_zod6.z.string().nullable().describe("Brief explanation if invalid, null if valid")
|
|
3910
3826
|
});
|
|
3911
|
-
var CaptionValidationBatchSchema =
|
|
3912
|
-
results:
|
|
3827
|
+
var CaptionValidationBatchSchema = import_zod6.z.object({
|
|
3828
|
+
results: import_zod6.z.array(CaptionValidationItemSchema)
|
|
3913
3829
|
});
|
|
3914
3830
|
var CaptionValidator = class extends BaseValidator {
|
|
3915
3831
|
constructor(logger, model, options, fallbackModel, aggregator) {
|
|
@@ -4117,13 +4033,13 @@ var DocumentProcessor = class {
|
|
|
4117
4033
|
validatorModel;
|
|
4118
4034
|
visionTocExtractorModel;
|
|
4119
4035
|
captionParserModel;
|
|
4120
|
-
hanjaQualitySamplerModel;
|
|
4121
4036
|
textCleanerBatchSize;
|
|
4122
4037
|
captionParserBatchSize;
|
|
4123
4038
|
captionValidatorBatchSize;
|
|
4124
4039
|
maxRetries;
|
|
4125
4040
|
enableFallbackRetry;
|
|
4126
4041
|
abortSignal;
|
|
4042
|
+
onTokenUsage;
|
|
4127
4043
|
idGenerator = new IdGenerator();
|
|
4128
4044
|
refResolver;
|
|
4129
4045
|
pageRangeParser;
|
|
@@ -4144,13 +4060,22 @@ var DocumentProcessor = class {
|
|
|
4144
4060
|
this.validatorModel = options.validatorModel ?? options.fallbackModel;
|
|
4145
4061
|
this.visionTocExtractorModel = options.visionTocExtractorModel ?? options.fallbackModel;
|
|
4146
4062
|
this.captionParserModel = options.captionParserModel ?? options.fallbackModel;
|
|
4147
|
-
this.hanjaQualitySamplerModel = options.hanjaQualitySamplerModel ?? options.fallbackModel;
|
|
4148
4063
|
this.textCleanerBatchSize = options.textCleanerBatchSize;
|
|
4149
4064
|
this.captionParserBatchSize = options.captionParserBatchSize;
|
|
4150
4065
|
this.captionValidatorBatchSize = options.captionValidatorBatchSize;
|
|
4151
4066
|
this.maxRetries = options.maxRetries ?? 3;
|
|
4152
4067
|
this.enableFallbackRetry = options.enableFallbackRetry ?? false;
|
|
4153
4068
|
this.abortSignal = options.abortSignal;
|
|
4069
|
+
this.onTokenUsage = options.onTokenUsage;
|
|
4070
|
+
}
|
|
4071
|
+
/**
|
|
4072
|
+
* Emit current token usage report via callback
|
|
4073
|
+
*
|
|
4074
|
+
* Calls the onTokenUsage callback with the current cumulative report
|
|
4075
|
+
* from the usage aggregator. Safe to call even if no callback is set.
|
|
4076
|
+
*/
|
|
4077
|
+
emitTokenUsage() {
|
|
4078
|
+
this.onTokenUsage?.(this.usageAggregator.getReport());
|
|
4154
4079
|
}
|
|
4155
4080
|
/**
|
|
4156
4081
|
* Check if abort has been requested and throw error if so
|
|
@@ -4164,36 +4089,6 @@ var DocumentProcessor = class {
|
|
|
4164
4089
|
throw error;
|
|
4165
4090
|
}
|
|
4166
4091
|
}
|
|
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
|
-
}
|
|
4197
4092
|
/**
|
|
4198
4093
|
* Converts DoclingDocument to ProcessedDocument with token usage tracking.
|
|
4199
4094
|
*
|
|
@@ -4235,11 +4130,13 @@ var DocumentProcessor = class {
|
|
|
4235
4130
|
this.logger.info(
|
|
4236
4131
|
`[DocumentProcessor] Page range parsing took ${pageRangeTime}ms`
|
|
4237
4132
|
);
|
|
4133
|
+
this.emitTokenUsage();
|
|
4238
4134
|
this.checkAborted();
|
|
4239
4135
|
const startTimeToc = Date.now();
|
|
4240
4136
|
const tocEntries = await this.extractTableOfContents(doclingDoc, filtered);
|
|
4241
4137
|
const tocTime = Date.now() - startTimeToc;
|
|
4242
4138
|
this.logger.info(`[DocumentProcessor] TOC extraction took ${tocTime}ms`);
|
|
4139
|
+
this.emitTokenUsage();
|
|
4243
4140
|
this.checkAborted();
|
|
4244
4141
|
const startTimeResources = Date.now();
|
|
4245
4142
|
const { images, tables, footnotes } = await this.convertResources(
|
|
@@ -4250,6 +4147,7 @@ var DocumentProcessor = class {
|
|
|
4250
4147
|
this.logger.info(
|
|
4251
4148
|
`[DocumentProcessor] Resource conversion took ${resourcesTime}ms`
|
|
4252
4149
|
);
|
|
4150
|
+
this.emitTokenUsage();
|
|
4253
4151
|
this.checkAborted();
|
|
4254
4152
|
const startTimeChapters = Date.now();
|
|
4255
4153
|
const chapters = await this.convertChapters(
|
|
@@ -4509,7 +4407,9 @@ var DocumentProcessor = class {
|
|
|
4509
4407
|
throw error;
|
|
4510
4408
|
}
|
|
4511
4409
|
}
|
|
4410
|
+
let fromVision = false;
|
|
4512
4411
|
if (!markdown) {
|
|
4412
|
+
fromVision = true;
|
|
4513
4413
|
this.logger.info("[DocumentProcessor] Using vision fallback for TOC");
|
|
4514
4414
|
const totalPages2 = Object.keys(doclingDoc.pages).length;
|
|
4515
4415
|
markdown = await this.visionTocExtractor.extract(totalPages2);
|
|
@@ -4529,12 +4429,52 @@ var DocumentProcessor = class {
|
|
|
4529
4429
|
const totalPages = Object.keys(doclingDoc.pages).length;
|
|
4530
4430
|
const maxTocPageNo = this.extractMaxPageNumber(markdown);
|
|
4531
4431
|
const effectiveTotalPages = maxTocPageNo > totalPages ? void 0 : totalPages;
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4432
|
+
let tocResult;
|
|
4433
|
+
try {
|
|
4434
|
+
tocResult = await this.tocExtractor.extract(markdown, {
|
|
4435
|
+
totalPages: effectiveTotalPages
|
|
4436
|
+
});
|
|
4437
|
+
} catch (error) {
|
|
4438
|
+
if (error instanceof TocValidationError) {
|
|
4439
|
+
this.logger.warn(
|
|
4440
|
+
`[DocumentProcessor] TOC extraction validation failed: ${error.message}`
|
|
4441
|
+
);
|
|
4442
|
+
tocResult = { entries: [], usages: [] };
|
|
4443
|
+
} else {
|
|
4444
|
+
throw error;
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4535
4447
|
for (const usage of tocResult.usages) {
|
|
4536
4448
|
this.usageAggregator.track(usage);
|
|
4537
4449
|
}
|
|
4450
|
+
if (tocResult.entries.length === 0 && !fromVision) {
|
|
4451
|
+
this.logger.warn(
|
|
4452
|
+
"[DocumentProcessor] Text-based TOC extraction yielded 0 entries, retrying with vision"
|
|
4453
|
+
);
|
|
4454
|
+
const visionMarkdown = await this.visionTocExtractor.extract(totalPages);
|
|
4455
|
+
if (visionMarkdown) {
|
|
4456
|
+
this.logger.info(
|
|
4457
|
+
`[DocumentProcessor] Vision extracted TOC markdown (${visionMarkdown.length} chars)`
|
|
4458
|
+
);
|
|
4459
|
+
const visionMaxPageNo = this.extractMaxPageNumber(visionMarkdown);
|
|
4460
|
+
const visionEffectivePages = visionMaxPageNo > totalPages ? void 0 : totalPages;
|
|
4461
|
+
try {
|
|
4462
|
+
const visionResult = await this.tocExtractor.extract(
|
|
4463
|
+
visionMarkdown,
|
|
4464
|
+
{
|
|
4465
|
+
totalPages: visionEffectivePages
|
|
4466
|
+
}
|
|
4467
|
+
);
|
|
4468
|
+
for (const usage of visionResult.usages) {
|
|
4469
|
+
this.usageAggregator.track(usage);
|
|
4470
|
+
}
|
|
4471
|
+
if (visionResult.entries.length > 0) {
|
|
4472
|
+
tocResult = visionResult;
|
|
4473
|
+
}
|
|
4474
|
+
} catch {
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4538
4478
|
if (tocResult.entries.length === 0) {
|
|
4539
4479
|
const reason = "TOC area was detected but LLM could not extract any structured entries";
|
|
4540
4480
|
this.logger.error(`[DocumentProcessor] TOC extraction failed: ${reason}`);
|
|
@@ -4549,15 +4489,20 @@ var DocumentProcessor = class {
|
|
|
4549
4489
|
* Extract the maximum page number from TOC markdown
|
|
4550
4490
|
*
|
|
4551
4491
|
* Parses page numbers from dot-leader patterns (e.g., "..... 175")
|
|
4552
|
-
*
|
|
4553
|
-
* the sub-document's page count.
|
|
4492
|
+
* and table cell patterns (e.g., "| title | 175 |") to detect compiled
|
|
4493
|
+
* volume scenarios where TOC page numbers exceed the sub-document's page count.
|
|
4554
4494
|
*/
|
|
4555
4495
|
extractMaxPageNumber(markdown) {
|
|
4556
|
-
const
|
|
4557
|
-
|
|
4496
|
+
const dotLeaderMatches = [...markdown.matchAll(/\.{2,}\s*(\d+)/g)];
|
|
4497
|
+
const tableCellMatches = [...markdown.matchAll(/\|\s*(\d+)\s*\|\s*$/gm)];
|
|
4498
|
+
const allNumbers = [
|
|
4499
|
+
...dotLeaderMatches.map((m) => parseInt(m[1], 10)),
|
|
4500
|
+
...tableCellMatches.map((m) => parseInt(m[1], 10))
|
|
4501
|
+
];
|
|
4502
|
+
if (allNumbers.length === 0) {
|
|
4558
4503
|
return 0;
|
|
4559
4504
|
}
|
|
4560
|
-
return Math.max(...
|
|
4505
|
+
return Math.max(...allNumbers);
|
|
4561
4506
|
}
|
|
4562
4507
|
/**
|
|
4563
4508
|
* Process resource captions (for images and tables)
|
|
@@ -4827,7 +4772,6 @@ var DocumentProcessor = class {
|
|
|
4827
4772
|
CaptionValidator,
|
|
4828
4773
|
ChapterConverter,
|
|
4829
4774
|
DocumentProcessor,
|
|
4830
|
-
HanjaQualitySampler,
|
|
4831
4775
|
PAGE_NUMBER_PATTERN,
|
|
4832
4776
|
PagePattern,
|
|
4833
4777
|
PageRangeParseError,
|