@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 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 |
@@ -6501,9 +6501,11 @@ var listTesseractLanguages = () => {
6501
6501
  }
6502
6502
  const result = spawnSync("tesseract", ["--list-langs"], {
6503
6503
  encoding: "utf8",
6504
- timeout: 5000,
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.2",
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
 
@@ -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
  })
@@ -20,6 +20,7 @@ export type AgentImageMap = {
20
20
  skipped_reason?: string;
21
21
  route?: string;
22
22
  caption?: string;
23
+ model?: string;
23
24
  };
24
25
  };
25
26
 
@@ -1,6 +1,6 @@
1
1
  import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
2
2
  import { buildAgentImageMap } from './agentMap.js';
3
- import { buildLayoutFromOcrLines } from './layout.js';
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 layout = buildLayoutFromOcrLines(
22
- next.ocr.lines.map((line) => ({
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
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Deterministic layout structure from OCR line boxes (no generative model).
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 toBlock = (lines: LayoutLine[], index: number): LayoutBlock => {
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) => toBlock(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
- /** Pure TSV parser offline-testable evidence path for OCR bbox lines. */
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
- ): { lines: OcrLine[]; words: OcrWord[]; dropped_low_confidence: number } => {
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
- number,
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
- if (level !== 5) continue;
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 = Number.parseFloat(columns[10] ?? '');
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 bucket = lineMap.get(lineNum) ?? { words: [] };
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(lineNum, bucket);
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
- const ocrLines: OcrLine[] = [];
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: 5_000,
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?.length ? options.languages : ['eng'];
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('tesseract', [imagePath, 'stdout', '-l', languageArg, 'tsv'], {
231
- encoding: 'utf8',
232
- timeout: OCR_TIMEOUT_MS,
233
- windowsHide: true,
234
- maxBuffer: 10 * 1024 * 1024,
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: `Tesseract failed to start: ${result.error.message}`,
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: 'tesseract_tsv',
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)'}.`,
@@ -1,36 +1,51 @@
1
1
  /**
2
- * Optional local/remote vision caption — OFF by default (local-first).
3
- * Set IRIS_OPTIONAL_LLM_URL to a POST endpoint that accepts JSON
4
- * { path, mime, purpose: "image_caption" } and returns { caption: string }.
5
- * Never treated as authority over OCR/layout evidence.
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
- export async function maybeOptionalImageCaption(input: {
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
- enabled: boolean;
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(15_000),
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
  }