@heripo/document-processor 0.1.7 → 0.1.8
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 +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 +5 -5
package/dist/index.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// ../shared/dist/index.mjs
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
NoObjectGeneratedError,
|
|
4
|
+
Output,
|
|
5
|
+
generateText,
|
|
6
|
+
hasToolCall,
|
|
7
|
+
tool
|
|
8
|
+
} from "ai";
|
|
3
9
|
var BatchProcessor = class {
|
|
4
10
|
/**
|
|
5
11
|
* Splits an array into batches of specified size.
|
|
@@ -73,6 +79,15 @@ var BatchProcessor = class {
|
|
|
73
79
|
return results.flat();
|
|
74
80
|
}
|
|
75
81
|
};
|
|
82
|
+
function detectProvider(model) {
|
|
83
|
+
const providerId = model.provider;
|
|
84
|
+
if (!providerId || typeof providerId !== "string") return "unknown";
|
|
85
|
+
if (providerId.includes("openai")) return "openai";
|
|
86
|
+
if (providerId.includes("google")) return "google";
|
|
87
|
+
if (providerId.includes("anthropic")) return "anthropic";
|
|
88
|
+
if (providerId.includes("together")) return "togetherai";
|
|
89
|
+
return "unknown";
|
|
90
|
+
}
|
|
76
91
|
var LLMCaller = class {
|
|
77
92
|
/**
|
|
78
93
|
* Extract model name from LanguageModel object
|
|
@@ -101,10 +116,95 @@ var LLMCaller = class {
|
|
|
101
116
|
totalTokens: response.usage?.totalTokens ?? 0
|
|
102
117
|
};
|
|
103
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Maximum number of retries when structured output generation fails.
|
|
121
|
+
* Total attempts = MAX_STRUCTURED_OUTPUT_RETRIES + 1.
|
|
122
|
+
*
|
|
123
|
+
* Applied to both:
|
|
124
|
+
* - `Output.object()` path: retries on NoObjectGeneratedError (schema mismatch)
|
|
125
|
+
* - Tool call path: retries when model does not produce a tool call
|
|
126
|
+
*/
|
|
127
|
+
static MAX_STRUCTURED_OUTPUT_RETRIES = 10;
|
|
128
|
+
/**
|
|
129
|
+
* Generate structured output via forced tool call.
|
|
130
|
+
*
|
|
131
|
+
* Used for providers (Together AI, unknown) that do not reliably support
|
|
132
|
+
* `Output.object()`. Forces the model to call a tool whose inputSchema
|
|
133
|
+
* is the target Zod schema, then extracts the parsed input.
|
|
134
|
+
*
|
|
135
|
+
* Retries up to MAX_STRUCTURED_OUTPUT_RETRIES times when the model does not
|
|
136
|
+
* produce a tool call, for a total of MAX_STRUCTURED_OUTPUT_RETRIES + 1 attempts.
|
|
137
|
+
*
|
|
138
|
+
* @throws NoObjectGeneratedError when all attempts fail to produce a tool call
|
|
139
|
+
*/
|
|
140
|
+
static async generateViaToolCall(model, schema, promptParams) {
|
|
141
|
+
const submitTool = tool({
|
|
142
|
+
description: "Submit the structured result",
|
|
143
|
+
inputSchema: schema
|
|
144
|
+
});
|
|
145
|
+
let lastResult;
|
|
146
|
+
for (let attempt = 0; attempt <= this.MAX_STRUCTURED_OUTPUT_RETRIES; attempt++) {
|
|
147
|
+
lastResult = await generateText({
|
|
148
|
+
...promptParams,
|
|
149
|
+
model,
|
|
150
|
+
tools: { submitResult: submitTool },
|
|
151
|
+
toolChoice: { type: "tool", toolName: "submitResult" },
|
|
152
|
+
stopWhen: hasToolCall("submitResult")
|
|
153
|
+
});
|
|
154
|
+
const toolCall = lastResult.toolCalls?.[0];
|
|
155
|
+
if (toolCall) {
|
|
156
|
+
return {
|
|
157
|
+
output: toolCall.input,
|
|
158
|
+
usage: lastResult.usage
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw new NoObjectGeneratedError({
|
|
163
|
+
message: "Model did not produce a tool call for structured output",
|
|
164
|
+
text: lastResult.text ?? "",
|
|
165
|
+
response: lastResult.response,
|
|
166
|
+
usage: lastResult.usage,
|
|
167
|
+
finishReason: lastResult.finishReason
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Generate structured output with provider-aware strategy.
|
|
172
|
+
*
|
|
173
|
+
* Strategy per provider:
|
|
174
|
+
* - OpenAI / Anthropic / Google Gemini: `Output.object()` with schema retry
|
|
175
|
+
* - Together AI / unknown: forced tool call pattern
|
|
176
|
+
*
|
|
177
|
+
* Retries up to MAX_STRUCTURED_OUTPUT_RETRIES times on NoObjectGeneratedError
|
|
178
|
+
* (schema mismatch), re-throwing the last error if all attempts fail.
|
|
179
|
+
*/
|
|
180
|
+
static async generateStructuredOutput(model, schema, promptParams) {
|
|
181
|
+
const providerType = detectProvider(model);
|
|
182
|
+
if (providerType === "togetherai" || providerType === "unknown") {
|
|
183
|
+
return this.generateViaToolCall(model, schema, promptParams);
|
|
184
|
+
}
|
|
185
|
+
let lastError;
|
|
186
|
+
for (let attempt = 0; attempt <= this.MAX_STRUCTURED_OUTPUT_RETRIES; attempt++) {
|
|
187
|
+
try {
|
|
188
|
+
return await generateText({
|
|
189
|
+
model,
|
|
190
|
+
output: Output.object({ schema }),
|
|
191
|
+
...promptParams
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (NoObjectGeneratedError.isInstance(error)) {
|
|
195
|
+
lastError = error;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
throw lastError;
|
|
202
|
+
}
|
|
104
203
|
/**
|
|
105
204
|
* Execute LLM call with fallback support
|
|
106
205
|
*
|
|
107
206
|
* Common execution logic for both text and vision calls.
|
|
207
|
+
* Logs additional details when NoObjectGeneratedError occurs.
|
|
108
208
|
*/
|
|
109
209
|
static async executeWithFallback(config, generateFn) {
|
|
110
210
|
const primaryModelName = this.extractModelName(config.primaryModel);
|
|
@@ -139,6 +239,8 @@ var LLMCaller = class {
|
|
|
139
239
|
* 2. If all fail and fallbackModel provided, try fallback up to maxRetries times
|
|
140
240
|
* 3. Throw error if all attempts exhausted
|
|
141
241
|
*
|
|
242
|
+
* Provider-aware strategy is automatically applied based on the model's provider field.
|
|
243
|
+
*
|
|
142
244
|
* @template TOutput - Output type from schema validation
|
|
143
245
|
* @param config - LLM call configuration
|
|
144
246
|
* @returns Result with parsed object and usage information
|
|
@@ -147,11 +249,7 @@ var LLMCaller = class {
|
|
|
147
249
|
static async call(config) {
|
|
148
250
|
return this.executeWithFallback(
|
|
149
251
|
config,
|
|
150
|
-
(model) =>
|
|
151
|
-
model,
|
|
152
|
-
output: Output.object({
|
|
153
|
-
schema: config.schema
|
|
154
|
-
}),
|
|
252
|
+
(model) => this.generateStructuredOutput(model, config.schema, {
|
|
155
253
|
system: config.systemPrompt,
|
|
156
254
|
prompt: config.userPrompt,
|
|
157
255
|
temperature: config.temperature,
|
|
@@ -164,6 +262,7 @@ var LLMCaller = class {
|
|
|
164
262
|
* Call LLM for vision tasks with message format support
|
|
165
263
|
*
|
|
166
264
|
* Same retry and fallback logic as call(), but using message format instead of system/user prompts.
|
|
265
|
+
* Provider-aware strategy is automatically applied based on the model's provider field.
|
|
167
266
|
*
|
|
168
267
|
* @template TOutput - Output type from schema validation
|
|
169
268
|
* @param config - LLM vision call configuration
|
|
@@ -173,11 +272,7 @@ var LLMCaller = class {
|
|
|
173
272
|
static async callVision(config) {
|
|
174
273
|
return this.executeWithFallback(
|
|
175
274
|
config,
|
|
176
|
-
(model) =>
|
|
177
|
-
model,
|
|
178
|
-
output: Output.object({
|
|
179
|
-
schema: config.schema
|
|
180
|
-
}),
|
|
275
|
+
(model) => this.generateStructuredOutput(model, config.schema, {
|
|
181
276
|
messages: config.messages,
|
|
182
277
|
temperature: config.temperature,
|
|
183
278
|
maxRetries: config.maxRetries,
|
|
@@ -1433,6 +1528,20 @@ var CONTINUATION_MARKERS = [
|
|
|
1433
1528
|
"continued"
|
|
1434
1529
|
];
|
|
1435
1530
|
var PAGE_NUMBER_PATTERN = /\.{2,}\s*\d+\s*$|…+\s*\d+\s*$|\s+\d+\s*$/;
|
|
1531
|
+
var RESOURCE_INDEX_PATTERNS = [
|
|
1532
|
+
/^\[도면\s*\d+\]/,
|
|
1533
|
+
/^\[사진\s*\d+\]/,
|
|
1534
|
+
/^\[표\s*\d+\]/,
|
|
1535
|
+
/^\[그림\s*\d+\]/,
|
|
1536
|
+
/^도면\s*\d+[.:)\s]/,
|
|
1537
|
+
/^사진\s*\d+[.:)\s]/,
|
|
1538
|
+
/^표\s*\d+[.:)\s]/,
|
|
1539
|
+
/^Fig\.\s*\d+/i,
|
|
1540
|
+
/^Photo\.?\s*\d+/i,
|
|
1541
|
+
/^Plate\s*\d+/i,
|
|
1542
|
+
/^Map\s*\d+/i,
|
|
1543
|
+
/^圖\s*\d+/
|
|
1544
|
+
];
|
|
1436
1545
|
var TocFinder = class {
|
|
1437
1546
|
constructor(logger, refResolver, options) {
|
|
1438
1547
|
this.logger = logger;
|
|
@@ -1493,6 +1602,10 @@ var TocFinder = class {
|
|
|
1493
1602
|
if (result) {
|
|
1494
1603
|
return this.expandToConsecutivePages(result, doc);
|
|
1495
1604
|
}
|
|
1605
|
+
const siblingResult = this.findSiblingTocItem(doc, text.self_ref, pageNo);
|
|
1606
|
+
if (siblingResult) {
|
|
1607
|
+
return this.expandToConsecutivePages(siblingResult, doc);
|
|
1608
|
+
}
|
|
1496
1609
|
}
|
|
1497
1610
|
return null;
|
|
1498
1611
|
}
|
|
@@ -1524,7 +1637,10 @@ var TocFinder = class {
|
|
|
1524
1637
|
continue;
|
|
1525
1638
|
}
|
|
1526
1639
|
if (this.isTableTocLike(table)) {
|
|
1527
|
-
|
|
1640
|
+
let score = this.calculateTableScore(table, pageNo);
|
|
1641
|
+
if (this.isResourceIndexTable(table)) {
|
|
1642
|
+
score -= 100;
|
|
1643
|
+
}
|
|
1528
1644
|
candidates.push({
|
|
1529
1645
|
result: {
|
|
1530
1646
|
itemRefs: [table.self_ref],
|
|
@@ -1568,6 +1684,37 @@ var TocFinder = class {
|
|
|
1568
1684
|
}
|
|
1569
1685
|
return null;
|
|
1570
1686
|
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Find adjacent TOC item among body siblings following the keyword text.
|
|
1689
|
+
* Used when findTocContainer returns null (e.g., parent is #/body).
|
|
1690
|
+
*/
|
|
1691
|
+
findSiblingTocItem(doc, textRef, pageNo) {
|
|
1692
|
+
const idx = doc.body.children.findIndex((c) => c.$ref === textRef);
|
|
1693
|
+
if (idx < 0) return null;
|
|
1694
|
+
for (let i = idx + 1; i < Math.min(idx + 4, doc.body.children.length); i++) {
|
|
1695
|
+
const ref = doc.body.children[i].$ref;
|
|
1696
|
+
const table = this.refResolver.resolveTable(ref);
|
|
1697
|
+
if (table && table.prov[0]?.page_no === pageNo && this.isTableTocLike(table)) {
|
|
1698
|
+
return {
|
|
1699
|
+
itemRefs: [table.self_ref],
|
|
1700
|
+
startPage: pageNo,
|
|
1701
|
+
endPage: pageNo
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
const group = this.refResolver.resolveGroup(ref);
|
|
1705
|
+
if (group) {
|
|
1706
|
+
const groupPage = this.getGroupFirstPage(group);
|
|
1707
|
+
if (groupPage === pageNo && this.isGroupTocLike(group, doc)) {
|
|
1708
|
+
return {
|
|
1709
|
+
itemRefs: [group.self_ref],
|
|
1710
|
+
startPage: pageNo,
|
|
1711
|
+
endPage: pageNo
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
return null;
|
|
1717
|
+
}
|
|
1571
1718
|
/**
|
|
1572
1719
|
* Check if a group contains TOC-like structure
|
|
1573
1720
|
*/
|
|
@@ -1609,6 +1756,23 @@ var TocFinder = class {
|
|
|
1609
1756
|
}
|
|
1610
1757
|
return numberCount > 0 && numberCount / (num_rows - 1) > 0.5;
|
|
1611
1758
|
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Check if a table is a resource index (drawing/photo/figure list)
|
|
1761
|
+
* rather than a main TOC table
|
|
1762
|
+
*/
|
|
1763
|
+
isResourceIndexTable(table) {
|
|
1764
|
+
const firstColCells = table.data.table_cells.filter(
|
|
1765
|
+
(c) => c.start_col_offset_idx === 0
|
|
1766
|
+
);
|
|
1767
|
+
if (firstColCells.length === 0) return false;
|
|
1768
|
+
let resourceCount = 0;
|
|
1769
|
+
for (const cell of firstColCells) {
|
|
1770
|
+
if (RESOURCE_INDEX_PATTERNS.some((p) => p.test(cell.text.trim()))) {
|
|
1771
|
+
resourceCount++;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return resourceCount / firstColCells.length > 0.5;
|
|
1775
|
+
}
|
|
1612
1776
|
/**
|
|
1613
1777
|
* Expand TOC area to consecutive pages (both backward and forward)
|
|
1614
1778
|
*/
|
|
@@ -1681,7 +1845,7 @@ var TocFinder = class {
|
|
|
1681
1845
|
if (table.prov[0]?.page_no !== pageNo) {
|
|
1682
1846
|
continue;
|
|
1683
1847
|
}
|
|
1684
|
-
if (this.isTableTocLike(table) && !refs.includes(table.self_ref)) {
|
|
1848
|
+
if (this.isTableTocLike(table) && !this.isResourceIndexTable(table) && !refs.includes(table.self_ref)) {
|
|
1685
1849
|
refs.push(table.self_ref);
|
|
1686
1850
|
}
|
|
1687
1851
|
}
|
|
@@ -1873,13 +2037,22 @@ var VALIDATION_CODE_DESCRIPTIONS = {
|
|
|
1873
2037
|
V006: "Duplicate entry detected (same title and page number).",
|
|
1874
2038
|
V007: "First TOC entry starts too late in the document. Earlier entries may be missing."
|
|
1875
2039
|
};
|
|
1876
|
-
|
|
1877
|
-
|
|
2040
|
+
function buildTocEntrySchema(maxDepth) {
|
|
2041
|
+
const base = {
|
|
1878
2042
|
title: z.string().describe("Chapter or section title"),
|
|
1879
2043
|
level: z.number().int().min(1).describe("Hierarchy depth (1 = top level)"),
|
|
1880
|
-
pageNo: z.number().int().min(1).describe("Starting page number")
|
|
1881
|
-
|
|
1882
|
-
|
|
2044
|
+
pageNo: z.number().int().min(1).describe("Starting page number")
|
|
2045
|
+
};
|
|
2046
|
+
if (maxDepth <= 0) {
|
|
2047
|
+
return z.object(base);
|
|
2048
|
+
}
|
|
2049
|
+
return z.object({
|
|
2050
|
+
...base,
|
|
2051
|
+
children: z.array(buildTocEntrySchema(maxDepth - 1)).describe("Child sections (use empty array [] if none)")
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
var TocEntrySchema = buildTocEntrySchema(
|
|
2055
|
+
4
|
|
1883
2056
|
);
|
|
1884
2057
|
var TocResponseSchema = z.object({
|
|
1885
2058
|
entries: z.array(TocEntrySchema).describe("Extracted TOC entries")
|
|
@@ -2066,9 +2239,9 @@ Re-extract the TOC structure from the original markdown above. Fix all validatio
|
|
|
2066
2239
|
|
|
2067
2240
|
5. **IMPORTANT - Extract Main TOC Only**: Only extract the main document table of contents. EXCLUDE the following:
|
|
2068
2241
|
- **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.
|
|
2069
|
-
- Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, \uD654\uBCF4 \uBAA9\uCC28, Photo Index, List of Photos, List of Figures)
|
|
2070
|
-
- Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \uC0BD\uB3C4 \uBAA9\uCC28, Drawing Index, List of Drawings)
|
|
2071
|
-
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
|
|
2242
|
+
- 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)
|
|
2243
|
+
- 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)
|
|
2244
|
+
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, \u8868 \u76EE\u6B21, \u8868\u76EE\u6B21, Table Index, List of Tables)
|
|
2072
2245
|
- Appendix indices (\uBD80\uB85D \uBAA9\uCC28, Appendix Index)
|
|
2073
2246
|
- Any other supplementary material indices
|
|
2074
2247
|
|
|
@@ -2389,19 +2562,34 @@ ${continuation.trim()}`;
|
|
|
2389
2562
|
|
|
2390
2563
|
I am providing ${pageCount} document page images (pages ${startPage}-${endPage}).
|
|
2391
2564
|
|
|
2392
|
-
##
|
|
2565
|
+
## How to Identify TOC Pages:
|
|
2393
2566
|
- TOC typically appears in the first 10-20 pages of a document
|
|
2394
|
-
- Look for pages with
|
|
2395
|
-
-
|
|
2567
|
+
- Look for pages with a heading that indicates "Table of Contents":
|
|
2568
|
+
- Korean: "\uBAA9\uCC28", "\uCC28\uB840", "\uBAA9 \uCC28"
|
|
2569
|
+
- Hanja (Chinese characters used in Korean documents): "\u76EE\u6B21", "\u76EE \u6B21"
|
|
2570
|
+
- English: "Contents", "Table of Contents"
|
|
2571
|
+
- The heading may appear in decorative or stylized fonts on a textured background
|
|
2572
|
+
- Below the heading, look for structured lists with chapter/section titles and page numbers connected by dots or leaders
|
|
2573
|
+
|
|
2574
|
+
## How to Distinguish Main TOC from Supplementary Indices:
|
|
2575
|
+
- **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.
|
|
2576
|
+
- **Supplementary indices** (DO NOT EXTRACT): The heading has a prefix qualifier specifying a resource type:
|
|
2577
|
+
- Photo indices: "\uC0AC\uC9C4 \uBAA9\uCC28", "\uC0AC\uC9C4\uBAA9\uCC28", "\u5BEB\u771E \u76EE\u6B21", "\u5BEB\u771E\u76EE\u6B21"
|
|
2578
|
+
- Drawing/figure indices: "\uB3C4\uBA74 \uBAA9\uCC28", "\uB3C4\uBA74\uBAA9\uCC28", "\u5716\u9762 \u76EE\u6B21", "\u5716\u9762\u76EE\u6B21"
|
|
2579
|
+
- Table indices: "\uD45C \uBAA9\uCC28", "\uD45C\uBAA9\uCC28", "\u8868 \u76EE\u6B21", "\u8868\u76EE\u6B21"
|
|
2580
|
+
- 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".
|
|
2396
2581
|
|
|
2397
2582
|
## What to Extract:
|
|
2398
|
-
Extract the TOC content as markdown format
|
|
2583
|
+
Extract the TOC content as markdown format with this exact structure:
|
|
2399
2584
|
- Use "- " prefix for each list item
|
|
2400
2585
|
- Use 2-space indentation for hierarchy levels
|
|
2401
2586
|
- Include "..... " followed by page number at the end of each entry
|
|
2402
2587
|
- Preserve original chapter/section numbering from the document
|
|
2588
|
+
- Preserve the original language of titles (Korean, Hanja, English, or mixed)
|
|
2403
2589
|
|
|
2404
|
-
## Output Format
|
|
2590
|
+
## Output Format Examples:
|
|
2591
|
+
|
|
2592
|
+
### Example 1: Standard Korean TOC
|
|
2405
2593
|
\`\`\`
|
|
2406
2594
|
- \uC81C1\uC7A5 \uC11C\uB860 ..... 1
|
|
2407
2595
|
- 1. \uC5F0\uAD6C \uBC30\uACBD ..... 3
|
|
@@ -2412,21 +2600,30 @@ Extract the TOC content as markdown format that matches this exact structure:
|
|
|
2412
2600
|
- \uC81C3\uC7A5 \uC5F0\uAD6C \uACB0\uACFC ..... 25
|
|
2413
2601
|
\`\`\`
|
|
2414
2602
|
|
|
2603
|
+
### Example 2: Mixed Hanja-Korean TOC (common in archaeological reports)
|
|
2604
|
+
\`\`\`
|
|
2605
|
+
- \u2160. \u8ABF\u67FB\u6982\u8981 ..... 175
|
|
2606
|
+
- \u2161. \u8ABF\u67FB\u5730\u57DF\uC758 \u74B0\u5883 ..... 177
|
|
2607
|
+
- \u2162. \u8ABF\u67FB\u5185\u5BB9 ..... 199
|
|
2608
|
+
- 1. \uC870\uC0AC\uC9C0\uC5ED \uD1F4\uC801\uC591\uC0C1 ..... 199
|
|
2609
|
+
- 2. \uC720\uC801 \uC870\uC0AC\uB0B4\uC6A9 ..... 200
|
|
2610
|
+
- \u2163. \u8ABF\u67FB\u7D50\u679C ..... 228
|
|
2611
|
+
\`\`\`
|
|
2612
|
+
|
|
2415
2613
|
## Important Rules:
|
|
2416
|
-
1. Extract ONLY the main document TOC
|
|
2417
|
-
2. DO NOT include supplementary indices
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
4. Set continuesOnNextPage to true if the TOC appears to continue beyond the visible pages
|
|
2614
|
+
1. Extract ONLY the main document TOC (the one with unqualified heading "\uBAA9\uCC28"/"\u76EE\u6B21"/"Contents")
|
|
2615
|
+
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.)
|
|
2616
|
+
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.
|
|
2617
|
+
4. Some pages in the batch may be photographs, illustrations, blank pages, or other non-text content with no TOC. Skip these pages.
|
|
2618
|
+
5. If no TOC is found, set hasToc to false and tocMarkdown to null
|
|
2619
|
+
6. Set continuesOnNextPage to true if the TOC appears to continue beyond the visible pages
|
|
2423
2620
|
|
|
2424
2621
|
Please examine these pages and:
|
|
2425
|
-
1. Determine if any page contains a Table of Contents
|
|
2622
|
+
1. Determine if any page contains a main document Table of Contents
|
|
2426
2623
|
2. If found, extract the complete TOC in markdown format
|
|
2427
2624
|
3. Indicate if the TOC continues beyond these pages
|
|
2428
2625
|
|
|
2429
|
-
Remember: Extract the main document TOC only. Ignore photo/table/figure indices.`;
|
|
2626
|
+
Remember: Extract the main document TOC only. Ignore photo/table/figure/drawing indices.`;
|
|
2430
2627
|
}
|
|
2431
2628
|
};
|
|
2432
2629
|
|
|
@@ -2912,7 +3109,10 @@ var PageRangeParser = class extends VisionLLMComponent {
|
|
|
2912
3109
|
const imageContents = [];
|
|
2913
3110
|
for (const pageNo of pageNos) {
|
|
2914
3111
|
const page = pages[pageNo - 1];
|
|
2915
|
-
const imagePath = path3.resolve(
|
|
3112
|
+
const imagePath = path3.resolve(
|
|
3113
|
+
this.outputPath,
|
|
3114
|
+
`pages/page_${pageNo - 1}.png`
|
|
3115
|
+
);
|
|
2916
3116
|
const imageBuffer = fs3.readFileSync(imagePath);
|
|
2917
3117
|
const base64Image = imageBuffer.toString("base64");
|
|
2918
3118
|
const mimeType = page.image.mimetype || "image/png";
|
|
@@ -3346,283 +3546,6 @@ Remember: Look for SMALL numbers in MARGINS only. Ignore figure/table/photo numb
|
|
|
3346
3546
|
}
|
|
3347
3547
|
};
|
|
3348
3548
|
|
|
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
|
-
|
|
3626
3549
|
// src/validators/base-validator.ts
|
|
3627
3550
|
var BaseValidator = class extends TextLLMComponent {
|
|
3628
3551
|
/**
|
|
@@ -3682,13 +3605,13 @@ var BaseValidator = class extends TextLLMComponent {
|
|
|
3682
3605
|
};
|
|
3683
3606
|
|
|
3684
3607
|
// src/validators/toc-content-validator.ts
|
|
3685
|
-
import { z as
|
|
3686
|
-
var TocContentValidationSchema =
|
|
3687
|
-
isValid:
|
|
3688
|
-
confidence:
|
|
3689
|
-
contentType:
|
|
3690
|
-
extractedTocMarkdown:
|
|
3691
|
-
reason:
|
|
3608
|
+
import { z as z5 } from "zod";
|
|
3609
|
+
var TocContentValidationSchema = z5.object({
|
|
3610
|
+
isValid: z5.boolean().describe("Whether valid main document TOC was found"),
|
|
3611
|
+
confidence: z5.number().min(0).max(1).describe("Confidence score between 0 and 1"),
|
|
3612
|
+
contentType: z5.enum(["pure_toc", "mixed", "resource_only", "invalid"]).describe("Type of content detected"),
|
|
3613
|
+
extractedTocMarkdown: z5.string().nullable().describe("Extracted main TOC markdown when mixed; null otherwise"),
|
|
3614
|
+
reason: z5.string().describe("Brief explanation in English")
|
|
3692
3615
|
});
|
|
3693
3616
|
var TocContentValidator = class extends BaseValidator {
|
|
3694
3617
|
confidenceThreshold;
|
|
@@ -3793,9 +3716,9 @@ When classifying as "mixed", you MUST extract ONLY the main TOC portion and retu
|
|
|
3793
3716
|
|
|
3794
3717
|
### 3. resource_only
|
|
3795
3718
|
The content contains ONLY resource indices such as:
|
|
3796
|
-
- Photo/image indices (\uC0AC\uC9C4 \uBAA9\uCC28, \uC0AC\uC9C4\uBAA9\uCC28, Photo Index, List of Figures, List of Photos)
|
|
3797
|
-
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, Table Index, List of Tables)
|
|
3798
|
-
- Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, Drawing Index, List of Drawings)
|
|
3719
|
+
- 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)
|
|
3720
|
+
- Table indices (\uD45C \uBAA9\uCC28, \uD45C\uBAA9\uCC28, \u8868 \u76EE\u6B21, \u8868\u76EE\u6B21, Table Index, List of Tables)
|
|
3721
|
+
- Drawing/diagram indices (\uB3C4\uBA74 \uBAA9\uCC28, \uB3C4\uBA74\uBAA9\uCC28, \u5716\u9762 \u76EE\u6B21, \u5716\u9762\u76EE\u6B21, Drawing Index, List of Drawings)
|
|
3799
3722
|
- Appendix indices (\uBD80\uB85D \uBAA9\uCC28, Appendix Index)
|
|
3800
3723
|
|
|
3801
3724
|
### 4. invalid
|
|
@@ -3839,14 +3762,14 @@ ${markdown}`;
|
|
|
3839
3762
|
};
|
|
3840
3763
|
|
|
3841
3764
|
// src/validators/caption-validator.ts
|
|
3842
|
-
import { z as
|
|
3843
|
-
var CaptionValidationItemSchema =
|
|
3844
|
-
index:
|
|
3845
|
-
isValid:
|
|
3846
|
-
reason:
|
|
3765
|
+
import { z as z6 } from "zod";
|
|
3766
|
+
var CaptionValidationItemSchema = z6.object({
|
|
3767
|
+
index: z6.number().int().describe("Index of the caption in the input array"),
|
|
3768
|
+
isValid: z6.boolean().describe("Whether the parsed caption is correct"),
|
|
3769
|
+
reason: z6.string().nullable().describe("Brief explanation if invalid, null if valid")
|
|
3847
3770
|
});
|
|
3848
|
-
var CaptionValidationBatchSchema =
|
|
3849
|
-
results:
|
|
3771
|
+
var CaptionValidationBatchSchema = z6.object({
|
|
3772
|
+
results: z6.array(CaptionValidationItemSchema)
|
|
3850
3773
|
});
|
|
3851
3774
|
var CaptionValidator = class extends BaseValidator {
|
|
3852
3775
|
constructor(logger, model, options, fallbackModel, aggregator) {
|
|
@@ -4054,13 +3977,13 @@ var DocumentProcessor = class {
|
|
|
4054
3977
|
validatorModel;
|
|
4055
3978
|
visionTocExtractorModel;
|
|
4056
3979
|
captionParserModel;
|
|
4057
|
-
hanjaQualitySamplerModel;
|
|
4058
3980
|
textCleanerBatchSize;
|
|
4059
3981
|
captionParserBatchSize;
|
|
4060
3982
|
captionValidatorBatchSize;
|
|
4061
3983
|
maxRetries;
|
|
4062
3984
|
enableFallbackRetry;
|
|
4063
3985
|
abortSignal;
|
|
3986
|
+
onTokenUsage;
|
|
4064
3987
|
idGenerator = new IdGenerator();
|
|
4065
3988
|
refResolver;
|
|
4066
3989
|
pageRangeParser;
|
|
@@ -4081,13 +4004,22 @@ var DocumentProcessor = class {
|
|
|
4081
4004
|
this.validatorModel = options.validatorModel ?? options.fallbackModel;
|
|
4082
4005
|
this.visionTocExtractorModel = options.visionTocExtractorModel ?? options.fallbackModel;
|
|
4083
4006
|
this.captionParserModel = options.captionParserModel ?? options.fallbackModel;
|
|
4084
|
-
this.hanjaQualitySamplerModel = options.hanjaQualitySamplerModel ?? options.fallbackModel;
|
|
4085
4007
|
this.textCleanerBatchSize = options.textCleanerBatchSize;
|
|
4086
4008
|
this.captionParserBatchSize = options.captionParserBatchSize;
|
|
4087
4009
|
this.captionValidatorBatchSize = options.captionValidatorBatchSize;
|
|
4088
4010
|
this.maxRetries = options.maxRetries ?? 3;
|
|
4089
4011
|
this.enableFallbackRetry = options.enableFallbackRetry ?? false;
|
|
4090
4012
|
this.abortSignal = options.abortSignal;
|
|
4013
|
+
this.onTokenUsage = options.onTokenUsage;
|
|
4014
|
+
}
|
|
4015
|
+
/**
|
|
4016
|
+
* Emit current token usage report via callback
|
|
4017
|
+
*
|
|
4018
|
+
* Calls the onTokenUsage callback with the current cumulative report
|
|
4019
|
+
* from the usage aggregator. Safe to call even if no callback is set.
|
|
4020
|
+
*/
|
|
4021
|
+
emitTokenUsage() {
|
|
4022
|
+
this.onTokenUsage?.(this.usageAggregator.getReport());
|
|
4091
4023
|
}
|
|
4092
4024
|
/**
|
|
4093
4025
|
* Check if abort has been requested and throw error if so
|
|
@@ -4101,36 +4033,6 @@ var DocumentProcessor = class {
|
|
|
4101
4033
|
throw error;
|
|
4102
4034
|
}
|
|
4103
4035
|
}
|
|
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
|
-
}
|
|
4134
4036
|
/**
|
|
4135
4037
|
* Converts DoclingDocument to ProcessedDocument with token usage tracking.
|
|
4136
4038
|
*
|
|
@@ -4172,11 +4074,13 @@ var DocumentProcessor = class {
|
|
|
4172
4074
|
this.logger.info(
|
|
4173
4075
|
`[DocumentProcessor] Page range parsing took ${pageRangeTime}ms`
|
|
4174
4076
|
);
|
|
4077
|
+
this.emitTokenUsage();
|
|
4175
4078
|
this.checkAborted();
|
|
4176
4079
|
const startTimeToc = Date.now();
|
|
4177
4080
|
const tocEntries = await this.extractTableOfContents(doclingDoc, filtered);
|
|
4178
4081
|
const tocTime = Date.now() - startTimeToc;
|
|
4179
4082
|
this.logger.info(`[DocumentProcessor] TOC extraction took ${tocTime}ms`);
|
|
4083
|
+
this.emitTokenUsage();
|
|
4180
4084
|
this.checkAborted();
|
|
4181
4085
|
const startTimeResources = Date.now();
|
|
4182
4086
|
const { images, tables, footnotes } = await this.convertResources(
|
|
@@ -4187,6 +4091,7 @@ var DocumentProcessor = class {
|
|
|
4187
4091
|
this.logger.info(
|
|
4188
4092
|
`[DocumentProcessor] Resource conversion took ${resourcesTime}ms`
|
|
4189
4093
|
);
|
|
4094
|
+
this.emitTokenUsage();
|
|
4190
4095
|
this.checkAborted();
|
|
4191
4096
|
const startTimeChapters = Date.now();
|
|
4192
4097
|
const chapters = await this.convertChapters(
|
|
@@ -4446,7 +4351,9 @@ var DocumentProcessor = class {
|
|
|
4446
4351
|
throw error;
|
|
4447
4352
|
}
|
|
4448
4353
|
}
|
|
4354
|
+
let fromVision = false;
|
|
4449
4355
|
if (!markdown) {
|
|
4356
|
+
fromVision = true;
|
|
4450
4357
|
this.logger.info("[DocumentProcessor] Using vision fallback for TOC");
|
|
4451
4358
|
const totalPages2 = Object.keys(doclingDoc.pages).length;
|
|
4452
4359
|
markdown = await this.visionTocExtractor.extract(totalPages2);
|
|
@@ -4466,12 +4373,52 @@ var DocumentProcessor = class {
|
|
|
4466
4373
|
const totalPages = Object.keys(doclingDoc.pages).length;
|
|
4467
4374
|
const maxTocPageNo = this.extractMaxPageNumber(markdown);
|
|
4468
4375
|
const effectiveTotalPages = maxTocPageNo > totalPages ? void 0 : totalPages;
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4376
|
+
let tocResult;
|
|
4377
|
+
try {
|
|
4378
|
+
tocResult = await this.tocExtractor.extract(markdown, {
|
|
4379
|
+
totalPages: effectiveTotalPages
|
|
4380
|
+
});
|
|
4381
|
+
} catch (error) {
|
|
4382
|
+
if (error instanceof TocValidationError) {
|
|
4383
|
+
this.logger.warn(
|
|
4384
|
+
`[DocumentProcessor] TOC extraction validation failed: ${error.message}`
|
|
4385
|
+
);
|
|
4386
|
+
tocResult = { entries: [], usages: [] };
|
|
4387
|
+
} else {
|
|
4388
|
+
throw error;
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4472
4391
|
for (const usage of tocResult.usages) {
|
|
4473
4392
|
this.usageAggregator.track(usage);
|
|
4474
4393
|
}
|
|
4394
|
+
if (tocResult.entries.length === 0 && !fromVision) {
|
|
4395
|
+
this.logger.warn(
|
|
4396
|
+
"[DocumentProcessor] Text-based TOC extraction yielded 0 entries, retrying with vision"
|
|
4397
|
+
);
|
|
4398
|
+
const visionMarkdown = await this.visionTocExtractor.extract(totalPages);
|
|
4399
|
+
if (visionMarkdown) {
|
|
4400
|
+
this.logger.info(
|
|
4401
|
+
`[DocumentProcessor] Vision extracted TOC markdown (${visionMarkdown.length} chars)`
|
|
4402
|
+
);
|
|
4403
|
+
const visionMaxPageNo = this.extractMaxPageNumber(visionMarkdown);
|
|
4404
|
+
const visionEffectivePages = visionMaxPageNo > totalPages ? void 0 : totalPages;
|
|
4405
|
+
try {
|
|
4406
|
+
const visionResult = await this.tocExtractor.extract(
|
|
4407
|
+
visionMarkdown,
|
|
4408
|
+
{
|
|
4409
|
+
totalPages: visionEffectivePages
|
|
4410
|
+
}
|
|
4411
|
+
);
|
|
4412
|
+
for (const usage of visionResult.usages) {
|
|
4413
|
+
this.usageAggregator.track(usage);
|
|
4414
|
+
}
|
|
4415
|
+
if (visionResult.entries.length > 0) {
|
|
4416
|
+
tocResult = visionResult;
|
|
4417
|
+
}
|
|
4418
|
+
} catch {
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4475
4422
|
if (tocResult.entries.length === 0) {
|
|
4476
4423
|
const reason = "TOC area was detected but LLM could not extract any structured entries";
|
|
4477
4424
|
this.logger.error(`[DocumentProcessor] TOC extraction failed: ${reason}`);
|
|
@@ -4486,15 +4433,20 @@ var DocumentProcessor = class {
|
|
|
4486
4433
|
* Extract the maximum page number from TOC markdown
|
|
4487
4434
|
*
|
|
4488
4435
|
* Parses page numbers from dot-leader patterns (e.g., "..... 175")
|
|
4489
|
-
*
|
|
4490
|
-
* the sub-document's page count.
|
|
4436
|
+
* and table cell patterns (e.g., "| title | 175 |") to detect compiled
|
|
4437
|
+
* volume scenarios where TOC page numbers exceed the sub-document's page count.
|
|
4491
4438
|
*/
|
|
4492
4439
|
extractMaxPageNumber(markdown) {
|
|
4493
|
-
const
|
|
4494
|
-
|
|
4440
|
+
const dotLeaderMatches = [...markdown.matchAll(/\.{2,}\s*(\d+)/g)];
|
|
4441
|
+
const tableCellMatches = [...markdown.matchAll(/\|\s*(\d+)\s*\|\s*$/gm)];
|
|
4442
|
+
const allNumbers = [
|
|
4443
|
+
...dotLeaderMatches.map((m) => parseInt(m[1], 10)),
|
|
4444
|
+
...tableCellMatches.map((m) => parseInt(m[1], 10))
|
|
4445
|
+
];
|
|
4446
|
+
if (allNumbers.length === 0) {
|
|
4495
4447
|
return 0;
|
|
4496
4448
|
}
|
|
4497
|
-
return Math.max(...
|
|
4449
|
+
return Math.max(...allNumbers);
|
|
4498
4450
|
}
|
|
4499
4451
|
/**
|
|
4500
4452
|
* Process resource captions (for images and tables)
|
|
@@ -4763,7 +4715,6 @@ export {
|
|
|
4763
4715
|
CaptionValidator,
|
|
4764
4716
|
ChapterConverter,
|
|
4765
4717
|
DocumentProcessor,
|
|
4766
|
-
HanjaQualitySampler,
|
|
4767
4718
|
PAGE_NUMBER_PATTERN,
|
|
4768
4719
|
PagePattern,
|
|
4769
4720
|
PageRangeParseError,
|