@sylphx/image-reader-mcp 0.1.2 → 0.1.3
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.md +2 -0
- package/dist/doctor-cli.js +3 -4
- package/package.json +1 -1
- package/src/handlers/readImage.ts +1 -0
- package/src/schemas/readImage.ts +13 -1
- package/src/utils/agentMap.ts +1 -0
- package/src/utils/applyImageIntelligence.ts +21 -5
- package/src/utils/layout.ts +50 -3
- package/src/utils/ocr.ts +114 -34
- package/src/utils/optionalLlm.ts +185 -24
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ Iris is **local-first**: geometry + OCR + **layout blocks** + **agent_map** so a
|
|
|
43
43
|
|
|
44
44
|
Spec: [docs/specs/agent-image-read-contract.md](docs/specs/agent-image-read-contract.md)
|
|
45
45
|
|
|
46
|
+
**Local-first frontier:** Rust decode, Tesseract native layout (no npm ML), optional Ollama VLM; cloud URL optional. Zero API key.
|
|
47
|
+
|
|
46
48
|
## Product docs
|
|
47
49
|
|
|
48
50
|
| Doc | Purpose |
|
package/dist/doctor-cli.js
CHANGED
|
@@ -6501,9 +6501,11 @@ var listTesseractLanguages = () => {
|
|
|
6501
6501
|
}
|
|
6502
6502
|
const result = spawnSync("tesseract", ["--list-langs"], {
|
|
6503
6503
|
encoding: "utf8",
|
|
6504
|
-
timeout:
|
|
6504
|
+
timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
|
|
6505
6505
|
windowsHide: true
|
|
6506
6506
|
});
|
|
6507
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
6508
|
+
const languages = stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available"));
|
|
6507
6509
|
if (result.status !== 0) {
|
|
6508
6510
|
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
6509
6511
|
return {
|
|
@@ -6512,9 +6514,6 @@ var listTesseractLanguages = () => {
|
|
|
6512
6514
|
warning: stderr || "tesseract --list-langs failed"
|
|
6513
6515
|
};
|
|
6514
6516
|
}
|
|
6515
|
-
const out = `${result.stdout ?? ""}
|
|
6516
|
-
${result.stderr ?? ""}`;
|
|
6517
|
-
const languages = out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available languages") && !l.startsWith("Error"));
|
|
6518
6517
|
return { available: true, languages };
|
|
6519
6518
|
};
|
|
6520
6519
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/image-reader-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"mcpName": "io.github.SylphxAI/image-reader-mcp",
|
|
5
5
|
"description": "Iris \u2014 Evidence-first image reading for AI agents \u2014 metadata, OCR text, regions, and citeable evidence without generative LLM.",
|
|
6
6
|
"type": "module",
|
|
@@ -202,6 +202,7 @@ export const readImage = tool()
|
|
|
202
202
|
? { languages_warning: ocr.languages_warning }
|
|
203
203
|
: {}),
|
|
204
204
|
...(ocr.words !== undefined ? { words: ocr.words } : {}),
|
|
205
|
+
...(ocr.native_blocks !== undefined ? { native_blocks: ocr.native_blocks } : {}),
|
|
205
206
|
};
|
|
206
207
|
}
|
|
207
208
|
|
package/src/schemas/readImage.ts
CHANGED
|
@@ -79,7 +79,7 @@ export const readImageArgsSchema = z.object({
|
|
|
79
79
|
.boolean()
|
|
80
80
|
.optional()
|
|
81
81
|
.describe(
|
|
82
|
-
'Optional caption via IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
|
|
82
|
+
'Optional local frontier caption via Ollama vision models or IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
|
|
83
83
|
),
|
|
84
84
|
});
|
|
85
85
|
|
|
@@ -110,6 +110,17 @@ export const agentMediaTwinSchema = z.object({
|
|
|
110
110
|
})
|
|
111
111
|
)
|
|
112
112
|
.optional(),
|
|
113
|
+
native_blocks: z
|
|
114
|
+
.array(
|
|
115
|
+
z.object({
|
|
116
|
+
id: z.string(),
|
|
117
|
+
kind: z.enum(['block', 'paragraph']),
|
|
118
|
+
text: z.string(),
|
|
119
|
+
bbox: boundingBoxSchema,
|
|
120
|
+
confidence: z.number().optional(),
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
.optional(),
|
|
113
124
|
})
|
|
114
125
|
.optional(),
|
|
115
126
|
region_evidence: z
|
|
@@ -157,6 +168,7 @@ export const agentMediaTwinSchema = z.object({
|
|
|
157
168
|
skipped_reason: z.string().optional(),
|
|
158
169
|
route: z.string().optional(),
|
|
159
170
|
caption: z.string().optional(),
|
|
171
|
+
model: z.string().optional(),
|
|
160
172
|
})
|
|
161
173
|
.optional(),
|
|
162
174
|
})
|
package/src/utils/agentMap.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
|
|
2
2
|
import { buildAgentImageMap } from './agentMap.js';
|
|
3
|
-
import {
|
|
3
|
+
import { buildBestEffortLayout } from './layout.js';
|
|
4
4
|
import { maybeOptionalImageCaption } from './optionalLlm.js';
|
|
5
5
|
import { samplePalette } from './palette.js';
|
|
6
6
|
|
|
@@ -18,14 +18,30 @@ export async function applyImageIntelligence(
|
|
|
18
18
|
|
|
19
19
|
const includeLayout = input.include_layout ?? includeOcr;
|
|
20
20
|
if (includeLayout && next.ocr?.lines && next.ocr.lines.length > 0) {
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
const ocrAny = next.ocr as {
|
|
22
|
+
lines: Array<{
|
|
23
|
+
text: string;
|
|
24
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
25
|
+
confidence?: number;
|
|
26
|
+
}>;
|
|
27
|
+
native_blocks?: Array<{
|
|
28
|
+
id: string;
|
|
29
|
+
kind: 'block' | 'paragraph';
|
|
30
|
+
text: string;
|
|
31
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
32
|
+
confidence?: number;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
const layout = buildBestEffortLayout({
|
|
36
|
+
lines: ocrAny.lines.map((line) => ({
|
|
23
37
|
text: line.text,
|
|
24
38
|
bbox: line.bbox,
|
|
25
39
|
...(line.confidence !== undefined ? { confidence: line.confidence } : {}),
|
|
26
|
-
}))
|
|
27
|
-
|
|
40
|
+
})),
|
|
41
|
+
...(ocrAny.native_blocks ? { nativeBlocks: ocrAny.native_blocks } : {}),
|
|
42
|
+
});
|
|
28
43
|
next.layout = layout;
|
|
44
|
+
next.trust_warnings.push(`layout_policy: ${layout.policy}`);
|
|
29
45
|
for (const warning of layout.warnings) {
|
|
30
46
|
next.trust_warnings.push(`layout: ${warning}`);
|
|
31
47
|
}
|
package/src/utils/layout.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Local-first layout: prefer Tesseract native block/paragraph units; fall back to line clustering.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export type BBox = { x: number; y: number; width: number; height: number };
|
|
@@ -17,6 +17,7 @@ export type LayoutBlock = {
|
|
|
17
17
|
bbox: BBox;
|
|
18
18
|
line_count: number;
|
|
19
19
|
reading_order: number;
|
|
20
|
+
source?: 'tesseract_native' | 'line_cluster';
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export type ImageLayout = {
|
|
@@ -27,6 +28,14 @@ export type ImageLayout = {
|
|
|
27
28
|
warnings: string[];
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
export type NativeBlockInput = {
|
|
32
|
+
id: string;
|
|
33
|
+
kind: 'block' | 'paragraph';
|
|
34
|
+
text: string;
|
|
35
|
+
bbox: BBox;
|
|
36
|
+
confidence?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
30
39
|
const unionBBox = (boxes: BBox[]): BBox => {
|
|
31
40
|
const minX = Math.min(...boxes.map((b) => b.x));
|
|
32
41
|
const minY = Math.min(...boxes.map((b) => b.y));
|
|
@@ -50,7 +59,7 @@ const shouldMerge = (prev: LayoutLine, next: LayoutLine, yGap: number, xSlop: nu
|
|
|
50
59
|
return verticalClose && horizontalOverlap;
|
|
51
60
|
};
|
|
52
61
|
|
|
53
|
-
const
|
|
62
|
+
const toClusterBlock = (lines: LayoutLine[], index: number): LayoutBlock => {
|
|
54
63
|
const ordered = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
55
64
|
const text = ordered
|
|
56
65
|
.map((l) => l.text.trim())
|
|
@@ -63,9 +72,35 @@ const toBlock = (lines: LayoutLine[], index: number): LayoutBlock => {
|
|
|
63
72
|
bbox: unionBBox(ordered.map((l) => l.bbox)),
|
|
64
73
|
line_count: ordered.length,
|
|
65
74
|
reading_order: index + 1,
|
|
75
|
+
source: 'line_cluster',
|
|
66
76
|
};
|
|
67
77
|
};
|
|
68
78
|
|
|
79
|
+
export function buildLayoutFromNativeBlocks(native: NativeBlockInput[]): ImageLayout {
|
|
80
|
+
const usable = native
|
|
81
|
+
.filter((b) => b.text.trim().length > 0 && b.bbox.width > 0 && b.bbox.height > 0)
|
|
82
|
+
.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
83
|
+
const blocks: LayoutBlock[] = usable.map((b, index) => ({
|
|
84
|
+
id: b.id || `native-${index + 1}`,
|
|
85
|
+
kind: 'text_block',
|
|
86
|
+
text: b.text.trim(),
|
|
87
|
+
bbox: b.bbox,
|
|
88
|
+
line_count: Math.max(1, b.text.split(/\n/).length),
|
|
89
|
+
reading_order: index + 1,
|
|
90
|
+
source: 'tesseract_native',
|
|
91
|
+
}));
|
|
92
|
+
return {
|
|
93
|
+
policy: 'tesseract_native_layout_v1',
|
|
94
|
+
block_count: blocks.length,
|
|
95
|
+
blocks,
|
|
96
|
+
full_text: blocks.map((b) => b.text).join('\n\n'),
|
|
97
|
+
warnings:
|
|
98
|
+
blocks.length === 0
|
|
99
|
+
? ['Native Tesseract blocks present but empty text; falling back recommended.']
|
|
100
|
+
: [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
export function buildLayoutFromOcrLines(lines: LayoutLine[]): ImageLayout {
|
|
70
105
|
if (lines.length === 0) {
|
|
71
106
|
return {
|
|
@@ -96,7 +131,7 @@ export function buildLayoutFromOcrLines(lines: LayoutLine[]): ImageLayout {
|
|
|
96
131
|
}
|
|
97
132
|
}
|
|
98
133
|
|
|
99
|
-
const blocks = clusters.map((cluster, index) =>
|
|
134
|
+
const blocks = clusters.map((cluster, index) => toClusterBlock(cluster, index));
|
|
100
135
|
const warnings: string[] = [];
|
|
101
136
|
if (blocks.length === 1 && lines.length > 8) {
|
|
102
137
|
warnings.push(
|
|
@@ -112,3 +147,15 @@ export function buildLayoutFromOcrLines(lines: LayoutLine[]): ImageLayout {
|
|
|
112
147
|
warnings,
|
|
113
148
|
};
|
|
114
149
|
}
|
|
150
|
+
|
|
151
|
+
/** Local-first frontier layout: native Tesseract structure first, heuristic second. */
|
|
152
|
+
export function buildBestEffortLayout(input: {
|
|
153
|
+
lines: LayoutLine[];
|
|
154
|
+
nativeBlocks?: NativeBlockInput[];
|
|
155
|
+
}): ImageLayout {
|
|
156
|
+
if (input.nativeBlocks?.some((b) => b.text.trim().length > 0)) {
|
|
157
|
+
const native = buildLayoutFromNativeBlocks(input.nativeBlocks);
|
|
158
|
+
if (native.block_count > 0) return native;
|
|
159
|
+
}
|
|
160
|
+
return buildLayoutFromOcrLines(input.lines);
|
|
161
|
+
}
|
package/src/utils/ocr.ts
CHANGED
|
@@ -10,6 +10,17 @@ export interface OcrWord {
|
|
|
10
10
|
confidence?: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Native Tesseract layout unit (block or paragraph) — frontier-local structure free with classic OCR. */
|
|
14
|
+
export interface OcrNativeBlock {
|
|
15
|
+
id: string;
|
|
16
|
+
kind: 'block' | 'paragraph';
|
|
17
|
+
text: string;
|
|
18
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
19
|
+
block_num?: number;
|
|
20
|
+
par_num?: number;
|
|
21
|
+
confidence?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export interface OcrResult {
|
|
14
25
|
available: boolean;
|
|
15
26
|
skipped_reason?: string;
|
|
@@ -21,6 +32,8 @@ export interface OcrResult {
|
|
|
21
32
|
dropped_low_confidence?: number;
|
|
22
33
|
lines: OcrLine[];
|
|
23
34
|
words?: OcrWord[];
|
|
35
|
+
/** Prefer for layout when present (Tesseract levels 2/3). */
|
|
36
|
+
native_blocks?: OcrNativeBlock[];
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
export interface ParseTesseractOptions {
|
|
@@ -28,21 +41,31 @@ export interface ParseTesseractOptions {
|
|
|
28
41
|
includeWords?: boolean;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
44
|
+
const num = (v: string | undefined): number => Number.parseFloat(v ?? '');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pure TSV parser — lines (level 4 aggregated from words), words (level 5),
|
|
48
|
+
* and native blocks/paragraphs (levels 2/3) for local-first layout frontier.
|
|
49
|
+
*/
|
|
32
50
|
export const parseTesseractTsv = (
|
|
33
51
|
raw: string,
|
|
34
52
|
options: ParseTesseractOptions = {}
|
|
35
|
-
): {
|
|
53
|
+
): {
|
|
54
|
+
lines: OcrLine[];
|
|
55
|
+
words: OcrWord[];
|
|
56
|
+
native_blocks: OcrNativeBlock[];
|
|
57
|
+
dropped_low_confidence: number;
|
|
58
|
+
} => {
|
|
36
59
|
const minConfidence = options.minConfidence ?? 0;
|
|
37
60
|
const includeWords = options.includeWords ?? false;
|
|
38
61
|
const linesRaw = raw.split(/\r?\n/).filter((line) => line.length > 0);
|
|
39
62
|
if (linesRaw.length <= 1) {
|
|
40
|
-
return { lines: [], words: [], dropped_low_confidence: 0 };
|
|
63
|
+
return { lines: [], words: [], native_blocks: [], dropped_low_confidence: 0 };
|
|
41
64
|
}
|
|
42
65
|
|
|
43
66
|
const rows = linesRaw.slice(1).map((line) => line.split('\t'));
|
|
44
67
|
const lineMap = new Map<
|
|
45
|
-
|
|
68
|
+
string,
|
|
46
69
|
{
|
|
47
70
|
words: Array<{
|
|
48
71
|
text: string;
|
|
@@ -57,22 +80,47 @@ export const parseTesseractTsv = (
|
|
|
57
80
|
|
|
58
81
|
let dropped = 0;
|
|
59
82
|
const flatWords: OcrWord[] = [];
|
|
83
|
+
const nativeBlocks: OcrNativeBlock[] = [];
|
|
60
84
|
|
|
61
85
|
for (const columns of rows) {
|
|
62
86
|
if (columns.length < 12) continue;
|
|
63
87
|
const level = Number.parseInt(columns[0] ?? '', 10);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const text = columns[11]?.trim() ?? '';
|
|
67
|
-
if (text.length === 0) continue;
|
|
68
|
-
|
|
88
|
+
const blockNum = Number.parseInt(columns[2] ?? '', 10);
|
|
89
|
+
const parNum = Number.parseInt(columns[3] ?? '', 10);
|
|
69
90
|
const lineNum = Number.parseInt(columns[4] ?? '', 10);
|
|
70
91
|
const left = Number.parseInt(columns[6] ?? '', 10);
|
|
71
92
|
const top = Number.parseInt(columns[7] ?? '', 10);
|
|
72
93
|
const width = Number.parseInt(columns[8] ?? '', 10);
|
|
73
94
|
const height = Number.parseInt(columns[9] ?? '', 10);
|
|
74
|
-
const conf =
|
|
95
|
+
const conf = num(columns[10]);
|
|
96
|
+
const text = columns[11]?.trim() ?? '';
|
|
97
|
+
|
|
98
|
+
// Native layout units from Tesseract itself (better than pure heuristic when present)
|
|
99
|
+
if ((level === 2 || level === 3) && Number.isFinite(left) && Number.isFinite(top)) {
|
|
100
|
+
if (text.length > 0 || level === 2) {
|
|
101
|
+
// level 2 blocks often have empty text; still keep geometry if non-zero
|
|
102
|
+
if (width > 0 && height > 0) {
|
|
103
|
+
const nb: OcrNativeBlock = {
|
|
104
|
+
id: level === 2 ? `tess-block-${blockNum}` : `tess-par-${blockNum}-${parNum}`,
|
|
105
|
+
kind: level === 2 ? 'block' : 'paragraph',
|
|
106
|
+
text,
|
|
107
|
+
bbox: {
|
|
108
|
+
x: left,
|
|
109
|
+
y: top,
|
|
110
|
+
width: Number.isFinite(width) ? width : 0,
|
|
111
|
+
height: Number.isFinite(height) ? height : 0,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
if (Number.isFinite(blockNum)) nb.block_num = blockNum;
|
|
115
|
+
if (Number.isFinite(parNum)) nb.par_num = parNum;
|
|
116
|
+
if (Number.isFinite(conf) && conf >= 0) nb.confidence = conf;
|
|
117
|
+
nativeBlocks.push(nb);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
75
121
|
|
|
122
|
+
if (level !== 5) continue;
|
|
123
|
+
if (text.length === 0) continue;
|
|
76
124
|
if (!Number.isFinite(lineNum) || !Number.isFinite(left) || !Number.isFinite(top)) continue;
|
|
77
125
|
|
|
78
126
|
const confVal = Number.isFinite(conf) ? conf : 0;
|
|
@@ -81,7 +129,8 @@ export const parseTesseractTsv = (
|
|
|
81
129
|
continue;
|
|
82
130
|
}
|
|
83
131
|
|
|
84
|
-
const
|
|
132
|
+
const lineKey = `${blockNum}:${parNum}:${lineNum}`;
|
|
133
|
+
const bucket = lineMap.get(lineKey) ?? { words: [] };
|
|
85
134
|
bucket.words.push({
|
|
86
135
|
text,
|
|
87
136
|
left,
|
|
@@ -90,7 +139,7 @@ export const parseTesseractTsv = (
|
|
|
90
139
|
height: Number.isFinite(height) ? height : 0,
|
|
91
140
|
conf: confVal,
|
|
92
141
|
});
|
|
93
|
-
lineMap.set(
|
|
142
|
+
lineMap.set(lineKey, bucket);
|
|
94
143
|
|
|
95
144
|
if (includeWords) {
|
|
96
145
|
flatWords.push({
|
|
@@ -106,18 +155,34 @@ export const parseTesseractTsv = (
|
|
|
106
155
|
}
|
|
107
156
|
}
|
|
108
157
|
|
|
109
|
-
|
|
158
|
+
// Fill empty native block text from aggregated words in that block
|
|
159
|
+
const wordsByBlock = new Map<number, string[]>();
|
|
160
|
+
for (const [key, bucket] of lineMap) {
|
|
161
|
+
const blockNum = Number.parseInt(key.split(':')[0] ?? '', 10);
|
|
162
|
+
if (!Number.isFinite(blockNum)) continue;
|
|
163
|
+
const list = wordsByBlock.get(blockNum) ?? [];
|
|
164
|
+
const sorted = [...bucket.words].sort((a, b) => a.top - b.top || a.left - b.left);
|
|
165
|
+
list.push(sorted.map((w) => w.text).join(' '));
|
|
166
|
+
wordsByBlock.set(blockNum, list);
|
|
167
|
+
}
|
|
168
|
+
for (const block of nativeBlocks) {
|
|
169
|
+
if (block.text.length > 0) continue;
|
|
170
|
+
if (block.block_num === undefined) continue;
|
|
171
|
+
const parts = wordsByBlock.get(block.block_num);
|
|
172
|
+
if (parts && parts.length > 0) {
|
|
173
|
+
block.text = parts.join('\n').trim();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
110
176
|
|
|
177
|
+
const ocrLines: OcrLine[] = [];
|
|
111
178
|
for (const bucket of lineMap.values()) {
|
|
112
179
|
if (bucket.words.length === 0) continue;
|
|
113
|
-
|
|
114
180
|
const sorted = [...bucket.words].sort((a, b) => a.left - b.left);
|
|
115
181
|
const text = sorted
|
|
116
182
|
.map((word) => word.text)
|
|
117
183
|
.join(' ')
|
|
118
184
|
.trim();
|
|
119
185
|
if (text.length === 0) continue;
|
|
120
|
-
|
|
121
186
|
const left = Math.min(...sorted.map((word) => word.left));
|
|
122
187
|
const top = Math.min(...sorted.map((word) => word.top));
|
|
123
188
|
const right = Math.max(...sorted.map((word) => word.left + word.width));
|
|
@@ -127,7 +192,6 @@ export const parseTesseractTsv = (
|
|
|
127
192
|
confidenceValues.length > 0
|
|
128
193
|
? confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length
|
|
129
194
|
: undefined;
|
|
130
|
-
|
|
131
195
|
ocrLines.push({
|
|
132
196
|
text,
|
|
133
197
|
bbox: {
|
|
@@ -140,9 +204,20 @@ export const parseTesseractTsv = (
|
|
|
140
204
|
});
|
|
141
205
|
}
|
|
142
206
|
|
|
207
|
+
// Prefer paragraph native blocks with text; else blocks
|
|
208
|
+
const paragraphs = nativeBlocks.filter((b) => b.kind === 'paragraph' && b.text.length > 0);
|
|
209
|
+
const blocksOnly = nativeBlocks.filter((b) => b.kind === 'block');
|
|
210
|
+
const preferredNative =
|
|
211
|
+
paragraphs.length > 0
|
|
212
|
+
? paragraphs
|
|
213
|
+
: blocksOnly.filter((b) => b.text.length > 0).length > 0
|
|
214
|
+
? blocksOnly.filter((b) => b.text.length > 0)
|
|
215
|
+
: blocksOnly;
|
|
216
|
+
|
|
143
217
|
return {
|
|
144
218
|
lines: ocrLines.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x),
|
|
145
219
|
words: includeWords ? flatWords.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x) : [],
|
|
220
|
+
native_blocks: preferredNative.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x),
|
|
146
221
|
dropped_low_confidence: dropped,
|
|
147
222
|
};
|
|
148
223
|
};
|
|
@@ -156,7 +231,6 @@ export const isTesseractAvailable = (): boolean => {
|
|
|
156
231
|
return result.status === 0;
|
|
157
232
|
};
|
|
158
233
|
|
|
159
|
-
/** List installed Tesseract traineddata languages (honest empty when unavailable). */
|
|
160
234
|
export const listTesseractLanguages = (): {
|
|
161
235
|
available: boolean;
|
|
162
236
|
languages: string[];
|
|
@@ -171,9 +245,14 @@ export const listTesseractLanguages = (): {
|
|
|
171
245
|
}
|
|
172
246
|
const result = spawnSync('tesseract', ['--list-langs'], {
|
|
173
247
|
encoding: 'utf8',
|
|
174
|
-
timeout:
|
|
248
|
+
timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
|
|
175
249
|
windowsHide: true,
|
|
176
250
|
});
|
|
251
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
252
|
+
const languages = stdout
|
|
253
|
+
.split(/\r?\n/)
|
|
254
|
+
.map((l) => l.trim())
|
|
255
|
+
.filter((l) => l && !l.toLowerCase().includes('list of available'));
|
|
177
256
|
if (result.status !== 0) {
|
|
178
257
|
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
179
258
|
return {
|
|
@@ -182,13 +261,6 @@ export const listTesseractLanguages = (): {
|
|
|
182
261
|
warning: stderr || 'tesseract --list-langs failed',
|
|
183
262
|
};
|
|
184
263
|
}
|
|
185
|
-
const out = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
186
|
-
const languages = out
|
|
187
|
-
.split(/\r?\n/)
|
|
188
|
-
.map((l) => l.trim())
|
|
189
|
-
.filter(
|
|
190
|
-
(l) => l && !l.toLowerCase().includes('list of available languages') && !l.startsWith('Error')
|
|
191
|
-
);
|
|
192
264
|
return { available: true, languages };
|
|
193
265
|
};
|
|
194
266
|
|
|
@@ -205,7 +277,7 @@ export const runTesseractOcr = (
|
|
|
205
277
|
const options: RunOcrOptions = Array.isArray(languagesOrOptions)
|
|
206
278
|
? { languages: languagesOrOptions }
|
|
207
279
|
: languagesOrOptions;
|
|
208
|
-
const languages = options.languages
|
|
280
|
+
const languages = options.languages ?? ['eng'];
|
|
209
281
|
const minConfidence = options.minConfidence ?? 0;
|
|
210
282
|
const includeWords = options.includeWords ?? false;
|
|
211
283
|
|
|
@@ -218,6 +290,7 @@ export const runTesseractOcr = (
|
|
|
218
290
|
lines: [],
|
|
219
291
|
line_count: 0,
|
|
220
292
|
dropped_low_confidence: 0,
|
|
293
|
+
native_blocks: [],
|
|
221
294
|
};
|
|
222
295
|
}
|
|
223
296
|
|
|
@@ -227,22 +300,27 @@ export const runTesseractOcr = (
|
|
|
227
300
|
);
|
|
228
301
|
|
|
229
302
|
const languageArg = languages.join('+');
|
|
230
|
-
const result = spawnSync(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
303
|
+
const result = spawnSync(
|
|
304
|
+
'tesseract',
|
|
305
|
+
[imagePath, 'stdout', '-l', languageArg, 'tsv', '--psm', '3'],
|
|
306
|
+
{
|
|
307
|
+
encoding: 'utf8',
|
|
308
|
+
timeout: OCR_TIMEOUT_MS,
|
|
309
|
+
windowsHide: true,
|
|
310
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
311
|
+
}
|
|
312
|
+
);
|
|
236
313
|
|
|
237
314
|
if (result.error) {
|
|
238
315
|
return {
|
|
239
316
|
available: false,
|
|
240
|
-
skipped_reason:
|
|
317
|
+
skipped_reason: result.error.message,
|
|
241
318
|
route: 'tesseract_tsv',
|
|
242
319
|
languages,
|
|
243
320
|
lines: [],
|
|
244
321
|
line_count: 0,
|
|
245
322
|
dropped_low_confidence: 0,
|
|
323
|
+
native_blocks: [],
|
|
246
324
|
};
|
|
247
325
|
}
|
|
248
326
|
|
|
@@ -257,6 +335,7 @@ export const runTesseractOcr = (
|
|
|
257
335
|
lines: [],
|
|
258
336
|
line_count: 0,
|
|
259
337
|
dropped_low_confidence: 0,
|
|
338
|
+
native_blocks: [],
|
|
260
339
|
};
|
|
261
340
|
}
|
|
262
341
|
|
|
@@ -264,11 +343,12 @@ export const runTesseractOcr = (
|
|
|
264
343
|
const parsed = parseTesseractTsv(stdout, { minConfidence, includeWords });
|
|
265
344
|
return {
|
|
266
345
|
available: true,
|
|
267
|
-
route: '
|
|
346
|
+
route: 'tesseract_tsv_psm3_native_layout',
|
|
268
347
|
languages,
|
|
269
348
|
lines: parsed.lines,
|
|
270
349
|
line_count: parsed.lines.length,
|
|
271
350
|
dropped_low_confidence: parsed.dropped_low_confidence,
|
|
351
|
+
native_blocks: parsed.native_blocks,
|
|
272
352
|
...(missingLangs.length
|
|
273
353
|
? {
|
|
274
354
|
languages_warning: `Requested OCR language(s) not listed by tesseract --list-langs: ${missingLangs.join(', ')}. Installed: ${installed.languages.join(', ') || '(none)'}.`,
|
package/src/utils/optionalLlm.ts
CHANGED
|
@@ -1,36 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Local-first frontier optional vision caption.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1) IRIS_OPTIONAL_LLM_URL — custom POST { path, mime, purpose } → { caption }
|
|
6
|
+
* 2) Local Ollama at IRIS_OLLAMA_URL (default http://127.0.0.1:11434) if a vision model is installed
|
|
7
|
+
*
|
|
8
|
+
* Never authority over OCR/layout locators. OFF unless include_optional_llm=true.
|
|
6
9
|
*/
|
|
7
10
|
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
|
|
8
13
|
export type OptionalLlmResult = {
|
|
9
14
|
available: boolean;
|
|
10
15
|
skipped_reason?: string;
|
|
11
16
|
route?: string;
|
|
12
17
|
caption?: string;
|
|
18
|
+
model?: string;
|
|
13
19
|
};
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
const DEFAULT_OLLAMA = 'http://127.0.0.1:11434';
|
|
22
|
+
|
|
23
|
+
const VISION_MODEL_HINTS = [
|
|
24
|
+
'llava',
|
|
25
|
+
'minicpm-v',
|
|
26
|
+
'minicpm_v',
|
|
27
|
+
'qwen2-vl',
|
|
28
|
+
'qwen2.5-vl',
|
|
29
|
+
'qwen3-vl',
|
|
30
|
+
'bakllava',
|
|
31
|
+
'moondream',
|
|
32
|
+
'gemma3',
|
|
33
|
+
'llama3.2-vision',
|
|
34
|
+
'pixtral',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function isVisionModel(name: string): boolean {
|
|
38
|
+
const n = name.toLowerCase();
|
|
39
|
+
return VISION_MODEL_HINTS.some((h) => n.includes(h));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function tryCustomUrl(input: {
|
|
16
43
|
path: string;
|
|
17
44
|
mime: string;
|
|
18
|
-
|
|
45
|
+
url: string;
|
|
19
46
|
}): Promise<OptionalLlmResult> {
|
|
20
|
-
if (!input.enabled) {
|
|
21
|
-
return { available: false, skipped_reason: 'include_optional_llm is false (default).' };
|
|
22
|
-
}
|
|
23
|
-
const url = process.env['IRIS_OPTIONAL_LLM_URL'];
|
|
24
|
-
if (!url) {
|
|
25
|
-
return {
|
|
26
|
-
available: false,
|
|
27
|
-
skipped_reason:
|
|
28
|
-
'IRIS_OPTIONAL_LLM_URL is not set. Local OCR/layout remain the evidence authority.',
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
47
|
try {
|
|
33
|
-
const res = await fetch(url, {
|
|
48
|
+
const res = await fetch(input.url, {
|
|
34
49
|
method: 'POST',
|
|
35
50
|
headers: { 'content-type': 'application/json' },
|
|
36
51
|
body: JSON.stringify({
|
|
@@ -38,13 +53,13 @@ export async function maybeOptionalImageCaption(input: {
|
|
|
38
53
|
mime: input.mime,
|
|
39
54
|
purpose: 'image_caption',
|
|
40
55
|
}),
|
|
41
|
-
signal: AbortSignal.timeout(
|
|
56
|
+
signal: AbortSignal.timeout(20_000),
|
|
42
57
|
});
|
|
43
58
|
if (!res.ok) {
|
|
44
59
|
return {
|
|
45
60
|
available: false,
|
|
46
61
|
skipped_reason: `optional LLM HTTP ${res.status}`,
|
|
47
|
-
route: url,
|
|
62
|
+
route: input.url,
|
|
48
63
|
};
|
|
49
64
|
}
|
|
50
65
|
const body = (await res.json()) as { caption?: string };
|
|
@@ -52,16 +67,162 @@ export async function maybeOptionalImageCaption(input: {
|
|
|
52
67
|
return {
|
|
53
68
|
available: false,
|
|
54
69
|
skipped_reason: 'optional LLM response missing caption string',
|
|
55
|
-
route: url,
|
|
70
|
+
route: input.url,
|
|
56
71
|
};
|
|
57
72
|
}
|
|
58
73
|
return {
|
|
59
74
|
available: true,
|
|
60
|
-
route: `optional-llm:${url}`,
|
|
75
|
+
route: `optional-llm:${input.url}`,
|
|
61
76
|
caption: body.caption.slice(0, 4000),
|
|
62
77
|
};
|
|
63
78
|
} catch (error: unknown) {
|
|
64
79
|
const message = error instanceof Error ? error.message : 'optional LLM failed';
|
|
65
|
-
return { available: false, skipped_reason: message, route: url };
|
|
80
|
+
return { available: false, skipped_reason: message, route: input.url };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function listOllamaModels(base: string): Promise<string[]> {
|
|
85
|
+
const res = await fetch(`${base.replace(/\/$/, '')}/api/tags`, {
|
|
86
|
+
signal: AbortSignal.timeout(3_000),
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) return [];
|
|
89
|
+
const body = (await res.json()) as { models?: Array<{ name?: string }> };
|
|
90
|
+
return (body.models ?? [])
|
|
91
|
+
.map((m) => m.name)
|
|
92
|
+
.filter((n): n is string => typeof n === 'string' && n.length > 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function tryOllamaVision(input: {
|
|
96
|
+
path: string;
|
|
97
|
+
baseUrl: string;
|
|
98
|
+
modelHint?: string;
|
|
99
|
+
}): Promise<OptionalLlmResult> {
|
|
100
|
+
const base = input.baseUrl.replace(/\/$/, '');
|
|
101
|
+
let models: string[] = [];
|
|
102
|
+
try {
|
|
103
|
+
models = await listOllamaModels(base);
|
|
104
|
+
} catch {
|
|
105
|
+
return {
|
|
106
|
+
available: false,
|
|
107
|
+
skipped_reason: `Ollama not reachable at ${base}`,
|
|
108
|
+
route: base,
|
|
109
|
+
};
|
|
66
110
|
}
|
|
111
|
+
if (models.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
available: false,
|
|
114
|
+
skipped_reason: 'Ollama has no models installed',
|
|
115
|
+
route: base,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const preferred =
|
|
120
|
+
(input.modelHint &&
|
|
121
|
+
models.find((m) => m === input.modelHint || m.startsWith(`${input.modelHint}:`))) ||
|
|
122
|
+
models.find((m) => isVisionModel(m));
|
|
123
|
+
if (!preferred) {
|
|
124
|
+
return {
|
|
125
|
+
available: false,
|
|
126
|
+
skipped_reason: `No local vision model found in Ollama (have: ${models.slice(0, 6).join(', ')}). Install e.g. llava or minicpm-v.`,
|
|
127
|
+
route: base,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let b64: string;
|
|
132
|
+
try {
|
|
133
|
+
b64 = readFileSync(input.path).toString('base64');
|
|
134
|
+
} catch (error: unknown) {
|
|
135
|
+
const message = error instanceof Error ? error.message : 'read failed';
|
|
136
|
+
return { available: false, skipped_reason: message, route: base };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Cap image payload ~4MB base64
|
|
140
|
+
if (b64.length > 5_500_000) {
|
|
141
|
+
return {
|
|
142
|
+
available: false,
|
|
143
|
+
skipped_reason: 'image too large for default Ollama caption path; crop_region first',
|
|
144
|
+
route: `ollama:${preferred}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'content-type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
model: preferred,
|
|
154
|
+
stream: false,
|
|
155
|
+
messages: [
|
|
156
|
+
{
|
|
157
|
+
role: 'user',
|
|
158
|
+
content:
|
|
159
|
+
'Describe this image for a software agent. Focus on structure, UI/text regions, objects, and layout. Be concise and factual. Do not invent unreadable text.',
|
|
160
|
+
images: [b64],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
164
|
+
signal: AbortSignal.timeout(60_000),
|
|
165
|
+
});
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
return {
|
|
168
|
+
available: false,
|
|
169
|
+
skipped_reason: `Ollama chat HTTP ${res.status}`,
|
|
170
|
+
route: `ollama:${preferred}`,
|
|
171
|
+
model: preferred,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const body = (await res.json()) as {
|
|
175
|
+
message?: { content?: string };
|
|
176
|
+
response?: string;
|
|
177
|
+
};
|
|
178
|
+
const caption = body.message?.content ?? body.response;
|
|
179
|
+
if (!caption || typeof caption !== 'string') {
|
|
180
|
+
return {
|
|
181
|
+
available: false,
|
|
182
|
+
skipped_reason: 'Ollama response missing message content',
|
|
183
|
+
route: `ollama:${preferred}`,
|
|
184
|
+
model: preferred,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
available: true,
|
|
189
|
+
route: `ollama-local-vlm:${preferred}`,
|
|
190
|
+
model: preferred,
|
|
191
|
+
caption: caption.slice(0, 4000),
|
|
192
|
+
};
|
|
193
|
+
} catch (error: unknown) {
|
|
194
|
+
const message = error instanceof Error ? error.message : 'Ollama caption failed';
|
|
195
|
+
return {
|
|
196
|
+
available: false,
|
|
197
|
+
skipped_reason: message,
|
|
198
|
+
route: `ollama:${preferred}`,
|
|
199
|
+
model: preferred,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function maybeOptionalImageCaption(input: {
|
|
205
|
+
path: string;
|
|
206
|
+
mime: string;
|
|
207
|
+
enabled: boolean;
|
|
208
|
+
}): Promise<OptionalLlmResult> {
|
|
209
|
+
if (!input.enabled) {
|
|
210
|
+
return { available: false, skipped_reason: 'include_optional_llm is false (default).' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const custom = process.env['IRIS_OPTIONAL_LLM_URL'];
|
|
214
|
+
if (custom) {
|
|
215
|
+
return tryCustomUrl({ path: input.path, mime: input.mime, url: custom });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const ollamaBase = process.env['IRIS_OLLAMA_URL'] ?? process.env['OLLAMA_HOST'] ?? DEFAULT_OLLAMA;
|
|
219
|
+
// OLLAMA_HOST may be host:port without scheme
|
|
220
|
+
const baseUrl = ollamaBase.startsWith('http') ? ollamaBase : `http://${ollamaBase}`;
|
|
221
|
+
const modelHint = process.env['IRIS_OLLAMA_VISION_MODEL'] ?? process.env['OLLAMA_VISION_MODEL'];
|
|
222
|
+
|
|
223
|
+
return tryOllamaVision({
|
|
224
|
+
path: input.path,
|
|
225
|
+
baseUrl,
|
|
226
|
+
...(modelHint ? { modelHint } : {}),
|
|
227
|
+
});
|
|
67
228
|
}
|