@realtimex/folio 0.1.12 → 0.1.13
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/.env.example +1 -0
- package/api/src/services/IngestionService.ts +412 -169
- package/api/src/services/ModelCapabilityService.ts +20 -3
- package/dist/api/src/services/IngestionService.js +372 -161
- package/dist/api/src/services/ModelCapabilityService.js +18 -3
- package/dist/assets/{index-tVGLBfz6.js → index-CLpalZvv.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
2
5
|
import { PDFParse } from "pdf-parse";
|
|
6
|
+
import { promisify } from "util";
|
|
3
7
|
import { createLogger } from "../utils/logger.js";
|
|
4
8
|
import { PolicyLoader } from "./PolicyLoader.js";
|
|
5
9
|
import { PolicyEngine } from "./PolicyEngine.js";
|
|
@@ -11,6 +15,7 @@ import { RAGService } from "./RAGService.js";
|
|
|
11
15
|
import { SDKService } from "./SDKService.js";
|
|
12
16
|
import { ModelCapabilityService } from "./ModelCapabilityService.js";
|
|
13
17
|
const logger = createLogger("IngestionService");
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
14
19
|
/**
|
|
15
20
|
* Multi-signal classifier that decides whether pdf-parse extracted enough
|
|
16
21
|
* real text to skip GPU OCR and go straight to the local LLM (Fast Path).
|
|
@@ -53,6 +58,15 @@ function isPdfTextExtractable(pdfData) {
|
|
|
53
58
|
export class IngestionService {
|
|
54
59
|
static FAST_EXTS = ["txt", "md", "csv", "json"];
|
|
55
60
|
static IMAGE_EXTS = ["png", "jpg", "jpeg", "webp"];
|
|
61
|
+
static IMAGE_REENCODE_TIMEOUT_MS = 15000;
|
|
62
|
+
static IMAGE_REENCODE_RETRY_ENABLED = (process.env.FOLIO_VLM_IMAGE_REENCODE_RETRY_ENABLED ?? "true").toLowerCase() !== "false";
|
|
63
|
+
static IMAGE_REENCODE_RETRY_METRICS = {
|
|
64
|
+
attempted: 0,
|
|
65
|
+
succeeded: 0,
|
|
66
|
+
failed: 0,
|
|
67
|
+
skipped_disabled: 0,
|
|
68
|
+
skipped_unavailable: 0,
|
|
69
|
+
};
|
|
56
70
|
static NON_IDEMPOTENT_ACTION_TYPES = new Set([
|
|
57
71
|
"append_to_google_sheet",
|
|
58
72
|
"webhook",
|
|
@@ -172,6 +186,67 @@ export class IngestionService {
|
|
|
172
186
|
const base64 = buffer.toString("base64");
|
|
173
187
|
return `data:${mimeType};base64,${base64}`;
|
|
174
188
|
}
|
|
189
|
+
static errorToMessage(error) {
|
|
190
|
+
if (error instanceof Error)
|
|
191
|
+
return error.message;
|
|
192
|
+
if (typeof error === "string")
|
|
193
|
+
return error;
|
|
194
|
+
if (error && typeof error === "object") {
|
|
195
|
+
const candidate = error;
|
|
196
|
+
if (typeof candidate.message === "string")
|
|
197
|
+
return candidate.message;
|
|
198
|
+
}
|
|
199
|
+
return String(error ?? "");
|
|
200
|
+
}
|
|
201
|
+
static isInvalidModelError(error) {
|
|
202
|
+
const message = this.errorToMessage(error).toLowerCase();
|
|
203
|
+
return message.includes("invalid model");
|
|
204
|
+
}
|
|
205
|
+
static async reencodeImageToPngDataUrl(filePath) {
|
|
206
|
+
const tempOutputPath = path.join(os.tmpdir(), `folio-vlm-reencode-${Date.now()}-${Math.random().toString(16).slice(2)}.png`);
|
|
207
|
+
try {
|
|
208
|
+
await execFileAsync("sips", ["-s", "format", "png", filePath, "--out", tempOutputPath], {
|
|
209
|
+
timeout: this.IMAGE_REENCODE_TIMEOUT_MS,
|
|
210
|
+
maxBuffer: 1024 * 1024,
|
|
211
|
+
});
|
|
212
|
+
const pngBuffer = await fs.readFile(tempOutputPath);
|
|
213
|
+
return `data:image/png;base64,${pngBuffer.toString("base64")}`;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
await fs.unlink(tempOutputPath).catch(() => undefined);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
static async maybeBuildImageRetryMarker(opts) {
|
|
223
|
+
if (!this.isInvalidModelError(opts.error))
|
|
224
|
+
return null;
|
|
225
|
+
if (!this.IMAGE_REENCODE_RETRY_ENABLED) {
|
|
226
|
+
this.bumpImageReencodeRetryMetric("skipped_disabled", opts);
|
|
227
|
+
logger.info(`VLM ${opts.phase} retry skipped for ${opts.filename}: re-encode retry disabled (${opts.provider}/${opts.model}).`);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
const retryDataUrl = await this.reencodeImageToPngDataUrl(opts.filePath);
|
|
231
|
+
if (!retryDataUrl) {
|
|
232
|
+
this.bumpImageReencodeRetryMetric("skipped_unavailable", opts);
|
|
233
|
+
logger.warn(`VLM ${opts.phase} retry skipped for ${opts.filename}: image re-encode unavailable (${opts.provider}/${opts.model}).`);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
logger.warn(`VLM ${opts.phase} failed for ${opts.filename} with invalid model. Retrying once with re-encoded image payload (${opts.provider}/${opts.model}).`);
|
|
237
|
+
return this.buildVlmPayloadMarker("image", retryDataUrl);
|
|
238
|
+
}
|
|
239
|
+
static bumpImageReencodeRetryMetric(outcome, meta) {
|
|
240
|
+
this.IMAGE_REENCODE_RETRY_METRICS[outcome] += 1;
|
|
241
|
+
logger.info("VLM image re-encode retry metric", {
|
|
242
|
+
outcome,
|
|
243
|
+
phase: meta.phase,
|
|
244
|
+
provider: meta.provider,
|
|
245
|
+
model: meta.model,
|
|
246
|
+
filename: meta.filename,
|
|
247
|
+
counters: { ...this.IMAGE_REENCODE_RETRY_METRICS },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
175
250
|
/**
|
|
176
251
|
* Ingest a document using Hybrid Routing Architecture.
|
|
177
252
|
*/
|
|
@@ -328,119 +403,183 @@ export class IngestionService {
|
|
|
328
403
|
embedding_provider: processingSettingsRow.data?.embedding_provider ?? undefined,
|
|
329
404
|
embedding_model: processingSettingsRow.data?.embedding_model ?? undefined,
|
|
330
405
|
};
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
406
|
+
const resolvedProvider = llmSettings.llm_provider ?? llmProvider;
|
|
407
|
+
const resolvedModel = llmSettings.llm_model ?? llmModel;
|
|
408
|
+
const runFastPathAttempt = async (attemptContent, attemptType) => {
|
|
409
|
+
const doc = { filePath: filePath, text: attemptContent, ingestionId: ingestion.id, userId, supabase };
|
|
410
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
411
|
+
const baselineTrace = [];
|
|
412
|
+
// Fire and forget Semantic Embedding Storage
|
|
413
|
+
RAGService.chunkAndEmbed(ingestion.id, userId, doc.text, supabase, embedSettings).catch(err => {
|
|
414
|
+
logger.error(`RAG embedding failed for ${ingestion.id}`, err);
|
|
415
|
+
});
|
|
416
|
+
// 4. Stage 1: Baseline extraction (always runs, LLM call 1 of max 2)
|
|
417
|
+
baselineTrace.push({
|
|
418
|
+
timestamp: new Date().toISOString(),
|
|
419
|
+
step: "LLM request (baseline extraction)",
|
|
420
|
+
details: {
|
|
421
|
+
provider: resolvedProvider,
|
|
422
|
+
model: resolvedModel,
|
|
423
|
+
mode: isMultimodalFastPath
|
|
424
|
+
? `vision:${multimodalModality ?? "image"}${attemptType === "reencoded_image_retry" ? ":reencoded" : ""}`
|
|
425
|
+
: "text",
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
const baselineResult = await PolicyEngine.extractBaseline(doc, { context: baselineConfig?.context, fields: baselineConfig?.fields }, llmSettings);
|
|
429
|
+
const baselineEntities = baselineResult.entities;
|
|
430
|
+
const autoTags = baselineResult.tags;
|
|
431
|
+
baselineTrace.push({
|
|
432
|
+
timestamp: new Date().toISOString(),
|
|
433
|
+
step: "LLM response (baseline extraction)",
|
|
434
|
+
details: {
|
|
435
|
+
entities_count: Object.keys(baselineEntities).length,
|
|
436
|
+
uncertain_count: baselineResult.uncertain_fields.length,
|
|
437
|
+
tags_count: autoTags.length,
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
// Enrich the document with extracted entities so policy keyword/semantic
|
|
441
|
+
// conditions can match against semantic field values (e.g. document_type:
|
|
442
|
+
// "invoice") even when those exact words don't appear in the raw text.
|
|
443
|
+
const entityLines = Object.entries(baselineEntities)
|
|
444
|
+
.filter(([, v]) => v != null)
|
|
445
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}`);
|
|
446
|
+
const enrichedDoc = entityLines.length > 0
|
|
447
|
+
? { ...doc, text: doc.text + "\n\n[Extracted fields]\n" + entityLines.join("\n") }
|
|
448
|
+
: doc;
|
|
449
|
+
// 5. Stage 2: Policy matching + policy-specific field extraction
|
|
450
|
+
let result;
|
|
451
|
+
if (userPolicies.length > 0) {
|
|
452
|
+
result = await PolicyEngine.processWithPolicies(enrichedDoc, userPolicies, llmSettings, baselineEntities);
|
|
346
453
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const baselineEntities = baselineResult.entities;
|
|
350
|
-
const autoTags = baselineResult.tags;
|
|
351
|
-
baselineTrace.push({
|
|
352
|
-
timestamp: new Date().toISOString(),
|
|
353
|
-
step: "LLM response (baseline extraction)",
|
|
354
|
-
details: {
|
|
355
|
-
entities_count: Object.keys(baselineEntities).length,
|
|
356
|
-
uncertain_count: baselineResult.uncertain_fields.length,
|
|
357
|
-
tags_count: autoTags.length,
|
|
454
|
+
else {
|
|
455
|
+
result = await PolicyEngine.process(enrichedDoc, llmSettings, baselineEntities);
|
|
358
456
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
457
|
+
const policyName = userPolicies.find((p) => p.metadata.id === result.matchedPolicy)?.metadata.name;
|
|
458
|
+
const finalStatus = result.status === "fallback" ? "no_match" : result.status;
|
|
459
|
+
// Merge: baseline entities are the foundation; policy-specific fields
|
|
460
|
+
// are overlaid on top so more precise extractions take precedence.
|
|
461
|
+
const mergedExtracted = { ...baselineEntities, ...result.extractedData };
|
|
462
|
+
let finalTrace = [...baselineTrace, ...(result.trace || [])];
|
|
463
|
+
const { data: updatedIngestion } = await supabase
|
|
464
|
+
.from("ingestions")
|
|
465
|
+
.update({
|
|
466
|
+
status: finalStatus,
|
|
467
|
+
policy_id: result.matchedPolicy,
|
|
468
|
+
policy_name: policyName,
|
|
469
|
+
extracted: mergedExtracted,
|
|
470
|
+
actions_taken: result.actionsExecuted,
|
|
471
|
+
trace: finalTrace,
|
|
472
|
+
tags: autoTags,
|
|
473
|
+
baseline_config_id: baselineConfig?.id ?? null,
|
|
474
|
+
})
|
|
475
|
+
.eq("id", ingestion.id)
|
|
476
|
+
.select()
|
|
477
|
+
.single();
|
|
478
|
+
if (isMultimodalFastPath && multimodalModality) {
|
|
479
|
+
const embeddingMeta = this.queueVlmSemanticEmbedding({
|
|
480
|
+
ingestionId: ingestion.id,
|
|
481
|
+
userId,
|
|
482
|
+
filename,
|
|
483
|
+
finalStatus,
|
|
484
|
+
policyName,
|
|
485
|
+
extracted: mergedExtracted,
|
|
486
|
+
tags: autoTags,
|
|
487
|
+
modality: multimodalModality,
|
|
488
|
+
supabase,
|
|
489
|
+
embedSettings,
|
|
490
|
+
});
|
|
491
|
+
finalTrace = [
|
|
492
|
+
...finalTrace,
|
|
493
|
+
{
|
|
494
|
+
timestamp: new Date().toISOString(),
|
|
495
|
+
step: "Queued synthetic VLM embedding",
|
|
496
|
+
details: embeddingMeta,
|
|
497
|
+
}
|
|
498
|
+
];
|
|
499
|
+
await supabase
|
|
500
|
+
.from("ingestions")
|
|
501
|
+
.update({ trace: finalTrace })
|
|
502
|
+
.eq("id", ingestion.id);
|
|
503
|
+
}
|
|
504
|
+
if (isMultimodalFastPath && multimodalModality) {
|
|
505
|
+
await ModelCapabilityService.learnVisionSuccess({
|
|
506
|
+
supabase,
|
|
507
|
+
userId,
|
|
508
|
+
provider: resolvedProvider,
|
|
509
|
+
model: resolvedModel,
|
|
510
|
+
modality: multimodalModality,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return updatedIngestion;
|
|
514
|
+
};
|
|
515
|
+
let terminalError = null;
|
|
516
|
+
try {
|
|
517
|
+
return await runFastPathAttempt(extractionContent, "primary");
|
|
373
518
|
}
|
|
374
|
-
|
|
375
|
-
|
|
519
|
+
catch (primaryErr) {
|
|
520
|
+
terminalError = primaryErr;
|
|
376
521
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const mergedExtracted = { ...baselineEntities, ...result.extractedData };
|
|
382
|
-
let finalTrace = [...baselineTrace, ...(result.trace || [])];
|
|
383
|
-
const { data: updatedIngestion } = await supabase
|
|
384
|
-
.from("ingestions")
|
|
385
|
-
.update({
|
|
386
|
-
status: finalStatus,
|
|
387
|
-
policy_id: result.matchedPolicy,
|
|
388
|
-
policy_name: policyName,
|
|
389
|
-
extracted: mergedExtracted,
|
|
390
|
-
actions_taken: result.actionsExecuted,
|
|
391
|
-
trace: finalTrace,
|
|
392
|
-
tags: autoTags,
|
|
393
|
-
baseline_config_id: baselineConfig?.id ?? null,
|
|
394
|
-
})
|
|
395
|
-
.eq("id", ingestion.id)
|
|
396
|
-
.select()
|
|
397
|
-
.single();
|
|
398
|
-
if (isMultimodalFastPath && multimodalModality) {
|
|
399
|
-
const embeddingMeta = this.queueVlmSemanticEmbedding({
|
|
400
|
-
ingestionId: ingestion.id,
|
|
401
|
-
userId,
|
|
522
|
+
if (isMultimodalFastPath && multimodalModality === "image") {
|
|
523
|
+
const retryMarker = await this.maybeBuildImageRetryMarker({
|
|
524
|
+
error: terminalError,
|
|
525
|
+
filePath,
|
|
402
526
|
filename,
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
tags: autoTags,
|
|
407
|
-
modality: multimodalModality,
|
|
408
|
-
supabase,
|
|
409
|
-
embedSettings,
|
|
527
|
+
provider: resolvedProvider,
|
|
528
|
+
model: resolvedModel,
|
|
529
|
+
phase: "ingest",
|
|
410
530
|
});
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
531
|
+
if (retryMarker) {
|
|
532
|
+
this.bumpImageReencodeRetryMetric("attempted", {
|
|
533
|
+
phase: "ingest",
|
|
534
|
+
provider: resolvedProvider,
|
|
535
|
+
model: resolvedModel,
|
|
536
|
+
filename,
|
|
537
|
+
});
|
|
538
|
+
Actuator.logEvent(ingestion.id, userId, "info", "Processing", {
|
|
539
|
+
action: "Retrying VLM with re-encoded image payload",
|
|
540
|
+
provider: resolvedProvider,
|
|
541
|
+
model: resolvedModel,
|
|
542
|
+
}, supabase);
|
|
543
|
+
try {
|
|
544
|
+
const retryResult = await runFastPathAttempt(retryMarker, "reencoded_image_retry");
|
|
545
|
+
this.bumpImageReencodeRetryMetric("succeeded", {
|
|
546
|
+
phase: "ingest",
|
|
547
|
+
provider: resolvedProvider,
|
|
548
|
+
model: resolvedModel,
|
|
549
|
+
filename,
|
|
550
|
+
});
|
|
551
|
+
Actuator.logEvent(ingestion.id, userId, "analysis", "Processing", {
|
|
552
|
+
action: "VLM re-encoded image retry succeeded",
|
|
553
|
+
provider: resolvedProvider,
|
|
554
|
+
model: resolvedModel,
|
|
555
|
+
}, supabase);
|
|
556
|
+
return retryResult;
|
|
417
557
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
558
|
+
catch (retryErr) {
|
|
559
|
+
this.bumpImageReencodeRetryMetric("failed", {
|
|
560
|
+
phase: "ingest",
|
|
561
|
+
provider: resolvedProvider,
|
|
562
|
+
model: resolvedModel,
|
|
563
|
+
filename,
|
|
564
|
+
});
|
|
565
|
+
Actuator.logEvent(ingestion.id, userId, "error", "Processing", {
|
|
566
|
+
action: "VLM re-encoded image retry failed",
|
|
567
|
+
provider: resolvedProvider,
|
|
568
|
+
model: resolvedModel,
|
|
569
|
+
error: this.errorToMessage(retryErr),
|
|
570
|
+
}, supabase);
|
|
571
|
+
terminalError = retryErr;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
432
574
|
}
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
catch (err) {
|
|
436
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
575
|
+
const msg = this.errorToMessage(terminalError);
|
|
437
576
|
if (isMultimodalFastPath && multimodalModality) {
|
|
438
577
|
const learnedState = await ModelCapabilityService.learnVisionFailure({
|
|
439
578
|
supabase,
|
|
440
579
|
userId,
|
|
441
|
-
provider:
|
|
442
|
-
model:
|
|
443
|
-
error:
|
|
580
|
+
provider: resolvedProvider,
|
|
581
|
+
model: resolvedModel,
|
|
582
|
+
error: terminalError,
|
|
444
583
|
modality: multimodalModality,
|
|
445
584
|
});
|
|
446
585
|
logger.warn(`VLM extraction failed for ${filename}. Falling back to Heavy Path. Error: ${msg}`);
|
|
@@ -463,6 +602,17 @@ export class IngestionService {
|
|
|
463
602
|
return updatedIngestion;
|
|
464
603
|
}
|
|
465
604
|
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
const msg = this.errorToMessage(err);
|
|
607
|
+
Actuator.logEvent(ingestion.id, userId, "error", "Processing", { error: msg }, supabase);
|
|
608
|
+
const { data: updatedIngestion } = await supabase
|
|
609
|
+
.from("ingestions")
|
|
610
|
+
.update({ status: "error", error_message: msg })
|
|
611
|
+
.eq("id", ingestion.id)
|
|
612
|
+
.select()
|
|
613
|
+
.single();
|
|
614
|
+
return updatedIngestion;
|
|
615
|
+
}
|
|
466
616
|
}
|
|
467
617
|
// 4. Heavy Path (Delegate to RealTimeX)
|
|
468
618
|
const { error: rtxErr } = await supabase
|
|
@@ -606,44 +756,47 @@ export class IngestionService {
|
|
|
606
756
|
embedding_provider: processingSettingsRow.data?.embedding_provider ?? undefined,
|
|
607
757
|
embedding_model: processingSettingsRow.data?.embedding_model ?? undefined,
|
|
608
758
|
};
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
759
|
+
const resolvedProvider = llmSettings.llm_provider ?? llmProvider;
|
|
760
|
+
const resolvedModel = llmSettings.llm_model ?? llmModel;
|
|
761
|
+
const runFastPathAttempt = async (attemptContent, attemptType) => {
|
|
762
|
+
const doc = { filePath, text: attemptContent, ingestionId, userId, supabase };
|
|
763
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
764
|
+
const baselineTrace = [];
|
|
765
|
+
// Fire and forget Semantic Embedding Storage for re-runs
|
|
766
|
+
RAGService.chunkAndEmbed(ingestionId, userId, doc.text, supabase, embedSettings).catch(err => {
|
|
767
|
+
logger.error(`RAG embedding failed during rerun for ${ingestionId}`, err);
|
|
768
|
+
});
|
|
769
|
+
baselineTrace.push({
|
|
770
|
+
timestamp: new Date().toISOString(),
|
|
771
|
+
step: "LLM request (baseline extraction)",
|
|
772
|
+
details: {
|
|
773
|
+
provider: resolvedProvider,
|
|
774
|
+
model: resolvedModel,
|
|
775
|
+
mode: isMultimodalFastPath
|
|
776
|
+
? `vision:${multimodalModality ?? "image"}${attemptType === "reencoded_image_retry" ? ":reencoded" : ""}`
|
|
777
|
+
: "text",
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
const baselineResult = await PolicyEngine.extractBaseline(doc, { context: baselineConfig?.context, fields: baselineConfig?.fields }, llmSettings);
|
|
781
|
+
const baselineEntities = baselineResult.entities;
|
|
782
|
+
const autoTags = baselineResult.tags;
|
|
783
|
+
baselineTrace.push({
|
|
784
|
+
timestamp: new Date().toISOString(),
|
|
785
|
+
step: "LLM response (baseline extraction)",
|
|
786
|
+
details: {
|
|
787
|
+
entities_count: Object.keys(baselineEntities).length,
|
|
788
|
+
uncertain_count: baselineResult.uncertain_fields.length,
|
|
789
|
+
tags_count: autoTags.length,
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
const entityLines = Object.entries(baselineEntities)
|
|
793
|
+
.filter(([, v]) => v != null)
|
|
794
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}`);
|
|
795
|
+
const enrichedDoc = entityLines.length > 0
|
|
796
|
+
? { ...doc, text: doc.text + "\n\n[Extracted fields]\n" + entityLines.join("\n") }
|
|
797
|
+
: doc;
|
|
798
|
+
let finalStatus = "no_match";
|
|
799
|
+
let result;
|
|
647
800
|
const forcedPolicyId = opts.forcedPolicyId?.trim();
|
|
648
801
|
const activePolicies = forcedPolicyId
|
|
649
802
|
? userPolicies.filter((policy) => policy.metadata.id === forcedPolicyId)
|
|
@@ -660,7 +813,7 @@ export class IngestionService {
|
|
|
660
813
|
else {
|
|
661
814
|
result = await PolicyEngine.process(enrichedDoc, llmSettings, baselineEntities);
|
|
662
815
|
}
|
|
663
|
-
policyName = result.matchedPolicy ? activePolicies.find((p) => p.metadata.id === result.matchedPolicy)?.metadata.name : undefined;
|
|
816
|
+
const policyName = result.matchedPolicy ? activePolicies.find((p) => p.metadata.id === result.matchedPolicy)?.metadata.name : undefined;
|
|
664
817
|
finalStatus = result.status === "fallback" ? "no_match" : result.status;
|
|
665
818
|
const mergedExtracted = { ...baselineEntities, ...result.extractedData };
|
|
666
819
|
// Preserve any human-added tags; merge with freshly generated auto-tags.
|
|
@@ -715,36 +868,94 @@ export class IngestionService {
|
|
|
715
868
|
await ModelCapabilityService.learnVisionSuccess({
|
|
716
869
|
supabase,
|
|
717
870
|
userId,
|
|
718
|
-
provider:
|
|
719
|
-
model:
|
|
871
|
+
provider: resolvedProvider,
|
|
872
|
+
model: resolvedModel,
|
|
720
873
|
modality: multimodalModality,
|
|
721
874
|
});
|
|
722
875
|
}
|
|
723
876
|
return finalStatus === "matched";
|
|
877
|
+
};
|
|
878
|
+
let terminalError = null;
|
|
879
|
+
try {
|
|
880
|
+
return await runFastPathAttempt(extractionContent, "primary");
|
|
724
881
|
}
|
|
725
|
-
catch (
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
882
|
+
catch (primaryErr) {
|
|
883
|
+
terminalError = primaryErr;
|
|
884
|
+
}
|
|
885
|
+
if (isMultimodalFastPath && multimodalModality === "image") {
|
|
886
|
+
const retryMarker = await this.maybeBuildImageRetryMarker({
|
|
887
|
+
error: terminalError,
|
|
888
|
+
filePath,
|
|
889
|
+
filename,
|
|
890
|
+
provider: resolvedProvider,
|
|
891
|
+
model: resolvedModel,
|
|
892
|
+
phase: "rerun",
|
|
893
|
+
});
|
|
894
|
+
if (retryMarker) {
|
|
895
|
+
this.bumpImageReencodeRetryMetric("attempted", {
|
|
896
|
+
phase: "rerun",
|
|
897
|
+
provider: resolvedProvider,
|
|
898
|
+
model: resolvedModel,
|
|
899
|
+
filename,
|
|
735
900
|
});
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
learned_state: learnedState,
|
|
901
|
+
Actuator.logEvent(ingestionId, userId, "info", "Processing", {
|
|
902
|
+
action: "Retrying VLM with re-encoded image payload",
|
|
903
|
+
provider: resolvedProvider,
|
|
904
|
+
model: resolvedModel,
|
|
741
905
|
}, supabase);
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
906
|
+
try {
|
|
907
|
+
const retryResult = await runFastPathAttempt(retryMarker, "reencoded_image_retry");
|
|
908
|
+
this.bumpImageReencodeRetryMetric("succeeded", {
|
|
909
|
+
phase: "rerun",
|
|
910
|
+
provider: resolvedProvider,
|
|
911
|
+
model: resolvedModel,
|
|
912
|
+
filename,
|
|
913
|
+
});
|
|
914
|
+
Actuator.logEvent(ingestionId, userId, "analysis", "Processing", {
|
|
915
|
+
action: "VLM re-encoded image retry succeeded",
|
|
916
|
+
provider: resolvedProvider,
|
|
917
|
+
model: resolvedModel,
|
|
918
|
+
}, supabase);
|
|
919
|
+
return retryResult;
|
|
920
|
+
}
|
|
921
|
+
catch (retryErr) {
|
|
922
|
+
this.bumpImageReencodeRetryMetric("failed", {
|
|
923
|
+
phase: "rerun",
|
|
924
|
+
provider: resolvedProvider,
|
|
925
|
+
model: resolvedModel,
|
|
926
|
+
filename,
|
|
927
|
+
});
|
|
928
|
+
Actuator.logEvent(ingestionId, userId, "error", "Processing", {
|
|
929
|
+
action: "VLM re-encoded image retry failed",
|
|
930
|
+
provider: resolvedProvider,
|
|
931
|
+
model: resolvedModel,
|
|
932
|
+
error: this.errorToMessage(retryErr),
|
|
933
|
+
}, supabase);
|
|
934
|
+
terminalError = retryErr;
|
|
935
|
+
}
|
|
746
936
|
}
|
|
747
937
|
}
|
|
938
|
+
const msg = this.errorToMessage(terminalError);
|
|
939
|
+
if (isMultimodalFastPath && multimodalModality) {
|
|
940
|
+
const learnedState = await ModelCapabilityService.learnVisionFailure({
|
|
941
|
+
supabase,
|
|
942
|
+
userId,
|
|
943
|
+
provider: resolvedProvider,
|
|
944
|
+
model: resolvedModel,
|
|
945
|
+
error: terminalError,
|
|
946
|
+
modality: multimodalModality,
|
|
947
|
+
});
|
|
948
|
+
logger.warn(`VLM extraction failed during rerun for ${filename}. Falling back to Heavy Path. Error: ${msg}`);
|
|
949
|
+
Actuator.logEvent(ingestionId, userId, "error", "Processing", {
|
|
950
|
+
action: "VLM Failed, Fallback to Heavy",
|
|
951
|
+
error: msg,
|
|
952
|
+
learned_state: learnedState,
|
|
953
|
+
}, supabase);
|
|
954
|
+
isFastPath = false; // Trigger heavy path fallthrough
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
throw terminalError instanceof Error ? terminalError : new Error(msg); // Re-throw to caller
|
|
958
|
+
}
|
|
748
959
|
}
|
|
749
960
|
// Re-delegate to rtx_activities
|
|
750
961
|
await supabase
|