2026-03-25 13:52:45 +01:00
// src/lib/services/ai.service.ts
import { GoogleGenerativeAI } from "@google/generative-ai" ;
import { prisma } from "@/lib/db/prisma" ;
2026-03-28 23:46:45 +01:00
import {
AnalyzeOptions ,
ContractPrecheckResult ,
NormalizedAnalysis ,
} from "@/lib/services/ai/analysis.types" ;
2026-04-12 19:24:24 +01:00
import type { Prisma } from "@prisma/client" ;
2026-03-28 23:46:45 +01:00
import {
buildAnalysisPrompt ,
buildPrevalidationPrompt ,
} from "@/lib/services/ai/analysis.prompt" ;
import { parseJsonResponse as parseAiJsonResponse } from "@/lib/services/ai/analysis.parser" ;
import { normalizeAnalysis as normalizeAiAnalysis } from "@/lib/services/ai/analysis.normalizer" ;
2026-04-12 19:24:24 +01:00
import { RAGService } from "@/lib/services/rag.service" ;
2026-03-25 13:52:45 +01:00
2026-04-12 19:24:24 +01:00
import { keyManager } from "@/lib/services/ai/key-manager" ;
2026-03-25 13:52:45 +01:00
2026-03-28 23:46:45 +01:00
const PRIMARY_ANALYSIS_MODEL =
2026-04-12 19:24:24 +01:00
process . env . AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview" ;
2026-04-19 01:42:00 +01:00
const GEMINI_SECONDARY_ANALYSIS_MODEL =
2026-05-03 13:26:31 +01:00
process . env . AI_MODEL_SECONDARY_GEMINI || process . env . AI_MODEL_SECONDARY || "" ;
2026-03-28 23:46:45 +01:00
const FALLBACK_ANALYSIS_MODEL =
2026-05-03 13:26:31 +01:00
process . env . AI_MODEL_FALLBACK || "mistral-large-latest" ;
2026-04-19 01:42:00 +01:00
const FALLBACK_REPAIR_MODEL =
2026-05-03 13:26:31 +01:00
process . env . AI_MODEL_FALLBACK_REPAIR || "mistral-large-latest" ;
const MISTRAL_API_KEY = process . env . MISTRAL_API_KEY ? . trim ( ) || "" ;
const MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions" ;
const MISTRAL_OCR_API_URL = "https://api.mistral.ai/v1/ocr" ;
const MISTRAL_VISION_MODEL =
process . env . AI_MODEL_MISTRAL_VISION || "pixtral-large-latest" ;
const MISTRAL_OCR_MODEL =
process . env . AI_MODEL_MISTRAL_OCR || "mistral-ocr-latest" ;
2026-04-19 01:42:00 +01:00
const GEMINI_ANALYSIS_MODELS = Array . from (
new Set (
[ PRIMARY_ANALYSIS_MODEL , GEMINI_SECONDARY_ANALYSIS_MODEL ] . filter ( Boolean ) ,
) ,
) ;
2026-03-28 23:46:45 +01:00
const ANALYSIS_MODELS = Array . from (
2026-05-03 13:26:31 +01:00
new Set ( [ . . . GEMINI_ANALYSIS_MODELS , ` mistral: ${ FALLBACK_ANALYSIS_MODEL } ` ] ) ,
2026-03-28 23:46:45 +01:00
) ;
2026-03-25 13:52:45 +01:00
2026-04-19 01:42:00 +01:00
const FORCE_FALLBACK_TEST =
process . env . AI_FORCE_FALLBACK_TEST === "1" ||
String ( process . env . AI_FORCE_FALLBACK_TEST ) . toLowerCase ( ) === "true" ;
2026-04-12 19:24:24 +01:00
type ValidationEnvelope = {
contractValidation ? : {
isValidContract? : boolean ;
confidence? : number ;
reason? : string | null ;
} ;
} ;
type PrevalidationResponse = {
isValidContract? : boolean ;
confidence? : number ;
reason? : string | null ;
} ;
type AdaptiveExplainability = {
field? : string ;
sourceHints ? : {
confidence? : number ;
} ;
} ;
type AdaptiveAiMeta = {
language? : string | null ;
keyPeople? : Array < { role? : string | null } > ;
} ;
type AdaptiveKeyPoints = {
explainability? : AdaptiveExplainability [ ] ;
aiMeta? : AdaptiveAiMeta ;
} ;
type AdaptiveContractExample = {
type ? : string | null ;
provider? : string | null ;
policyNumber? : string | null ;
summary? : string | null ;
keyPoints? : Prisma.JsonValue | null ;
} ;
const isAdaptiveKeyPoints = (
value : Prisma.JsonValue | null | undefined ,
) : value is AdaptiveKeyPoints = > {
return typeof value === "object" && value !== null && ! Array . isArray ( value ) ;
} ;
2026-03-25 13:52:45 +01:00
export class AIService {
2026-05-03 13:26:31 +01:00
private static isTransientAIError ( message : string ) : boolean {
2026-04-19 01:42:00 +01:00
const normalized = message . toLowerCase ( ) ;
return (
normalized . includes ( "503" ) ||
normalized . includes ( "service unavailable" ) ||
normalized . includes ( "high demand" ) ||
normalized . includes ( "temporarily unavailable" ) ||
normalized . includes ( "backend error" ) ||
normalized . includes ( "internal server error" ) ||
normalized . includes ( "bad gateway" ) ||
normalized . includes ( "gateway timeout" ) ||
normalized . includes ( "deadline exceeded" )
) ;
}
2026-03-25 13:52:45 +01:00
/ * *
* Domain - specific guidance for contract Q & A .
* This keeps responses focused on what matters most for each contract family .
* /
private static getContractTypeGuidance ( type ? : string | null ) : string {
switch ( type ) {
case "INSURANCE_AUTO" :
return "Focus on coverage scope, exclusions, deductible/franchise impact, claims workflow, and driver/vehicle obligations." ;
case "INSURANCE_HOME" :
return "Focus on covered perils, property limits, occupancy obligations, exclusions, and claims evidence requirements." ;
case "INSURANCE_HEALTH" :
return "Focus on reimbursement rules, waiting periods, provider network constraints, exclusions, and pre-authorization requirements." ;
case "INSURANCE_LIFE" :
return "Focus on beneficiary clauses, premium continuity, surrender/termination conditions, exclusions, and payout trigger conditions." ;
case "LOAN" :
return "Focus on repayment schedule, interest mechanics, default triggers, penalties, early repayment clauses, and covenant obligations." ;
case "CREDIT_CARD" :
return "Focus on APR/fees, billing cycle deadlines, late-payment penalties, credit limit terms, and dispute/chargeback conditions." ;
case "INVESTMENT" :
return "Focus on risk profile, fee structure, lock-in/liquidity constraints, reporting duties, and suitability/compliance implications." ;
default :
return "Focus on obligations, financial exposure, compliance risks, termination conditions, and operational next steps." ;
}
}
/ * *
* Analyze contract with Gemini 2.5 Flash .
*
* Pipeline overview :
* 1 ) Download uploaded file
* 2 ) Resolve MIME type safely
* 3 ) Build adaptive prompt context from previous completed analyses
* 4 ) Ask Gemini for strict JSON output
* 5 ) Parse + normalize output
* 6 ) Validate contract legitimacy and required fields
* 7 ) Retry with correction hints if output is invalid
* 8 ) Return canonical analysis object
*
* Supports both PDF and image files
* /
static async analyzeContract ( fileUrl : string , options? : AnalyzeOptions ) {
2026-04-12 19:24:24 +01:00
keyManager . resetKeys ( ) ;
2026-03-25 13:52:45 +01:00
try {
const maxRetries = Math . min ( 3 , Math . max ( 1 , options ? . maxRetries ? ? 2 ) ) ;
2026-04-19 01:42:00 +01:00
const forceFallbackModelTest =
options ? . forceFallbackModelTest ? ? FORCE_FALLBACK_TEST ;
2026-03-25 13:52:45 +01:00
// Step 1: Download raw file bytes from storage URL.
const response = await fetch ( fileUrl ) ;
if ( ! response . ok ) {
throw new Error ( ` Failed to download file: ${ response . statusText } ` ) ;
}
const buffer = await response . arrayBuffer ( ) ;
const base64 = Buffer . from ( buffer ) . toString ( "base64" ) ;
// Step 2: Resolve MIME type from response header and URL fallback.
const mimeType = this . resolveMimeType (
fileUrl ,
response . headers . get ( "content-type" ) ,
) ;
// Quick pre-validation to short-circuit obvious non-contract files.
const precheck = await this . preValidateContract ( {
base64 ,
mimeType ,
fileName : options?.fileName ,
} ) ;
if ( ! precheck . isValidContract || precheck . confidence < 45 ) {
throw new Error (
` INVALID_CONTRACT: ${ precheck . reason || "Uploaded file is not recognized as a valid contract." } ` ,
) ;
}
// Step 4: Build adaptive extraction context from previously analyzed contracts.
const adaptiveContext = await this . buildAdaptiveContext ( options ? . userId ) ;
2026-03-28 23:46:45 +01:00
const basePrompt = buildAnalysisPrompt ( {
2026-03-25 13:52:45 +01:00
adaptiveContext ,
fileName : options?.fileName ,
} ) ;
let previousRawResponse = "" ;
let lastValidationError = "" ;
for ( let attempt = 1 ; attempt <= maxRetries ; attempt ++ ) {
const startTime = Date . now ( ) ;
const correctionHint =
attempt === 1
? ""
: ` \ n \ nCORRECTION MODE: \ nYour previous response was invalid. \ nReason: ${ lastValidationError || "Invalid structure" } . \ nReturn JSON only and keep every required field. \ nPrevious invalid response: \ n ${ previousRawResponse . slice ( 0 , 2000 ) } ` ;
// Step 5: Ask model to extract strict JSON from the uploaded file.
2026-03-28 23:46:45 +01:00
const text = await this . generateAnalysisWithFallback ( {
prompt : ` ${ basePrompt } ${ correctionHint } ` ,
base64 ,
mimeType ,
2026-04-19 01:42:00 +01:00
forceFallbackModelTest ,
2026-03-28 23:46:45 +01:00
} ) ;
2026-03-25 13:52:45 +01:00
if ( ! text ) {
lastValidationError = "No content in AI response" ;
continue ;
}
previousRawResponse = text ;
try {
// Step 6: Parse and normalize output into canonical structure.
2026-03-28 23:46:45 +01:00
let parsed : unknown ;
try {
parsed = this . parseJsonResponse ( text ) ;
} catch ( parseError ) {
console . warn (
"Initial JSON parse failed. Attempting repair with fallback model..." ,
) ;
const repaired = await this . repairMalformedJson (
text ,
parseError instanceof Error
? parseError . message
: "Invalid JSON response" ,
) ;
if ( ! repaired ) {
// Emergency fallback: try to extract key fields from raw text
console . warn (
"Repair model failed. Attempting emergency field extraction..." ,
) ;
const emergency = this . emergencyExtractFields ( text ) ;
if ( emergency ) {
console . log ( "✅ Emergency extraction succeeded" ) ;
parsed = this . parseJsonResponse ( emergency ) ;
} else {
throw parseError ;
}
} else {
parsed = this . parseJsonResponse ( repaired ) ;
}
}
2026-03-25 13:52:45 +01:00
const normalized = this . normalizeAnalysis ( parsed ) ;
// Step 7: Reject non-contract uploads with explicit error.
this . assertValidContract ( parsed , normalized ) ;
console . log (
"📄 Extracted text length:" ,
normalized . extractedText . length ,
"chars" ,
) ;
console . log (
"✅ Analysis completed in" ,
( ( Date . now ( ) - startTime ) / 1000 ) . toFixed ( 2 ) ,
"seconds" ,
) ;
return normalized ;
2026-04-12 19:24:24 +01:00
} catch ( validationError : unknown ) {
2026-03-25 13:52:45 +01:00
// If validation fails, keep reason and retry with correction guidance.
lastValidationError =
2026-04-12 19:24:24 +01:00
validationError instanceof Error
? validationError . message
: "Failed to parse model output" ;
2026-03-25 13:52:45 +01:00
if ( attempt === maxRetries ) {
throw new Error ( lastValidationError ) ;
}
}
}
throw new Error ( "AI analysis failed after retries." ) ;
2026-04-12 19:24:24 +01:00
} catch ( error : unknown ) {
const errorMessage =
error instanceof Error ? error.message : String ( error ) ;
2026-03-25 13:52:45 +01:00
// Better error messages
2026-04-12 19:24:24 +01:00
if ( errorMessage . includes ( "API key" ) ) {
2026-03-25 13:52:45 +01:00
throw new Error (
2026-05-03 13:26:31 +01:00
"Invalid or missing AI API key. Check AI_API_KEY1/2/3 for Gemini and MISTRAL_API_KEY for Mistral fallback." ,
2026-03-25 13:52:45 +01:00
) ;
2026-04-12 19:24:24 +01:00
} else if ( errorMessage . includes ( "INVALID_CONTRACT:" ) ) {
const reason = String ( errorMessage )
2026-03-25 13:52:45 +01:00
. replace ( "INVALID_CONTRACT:" , "" )
. trim ( ) ;
throw new Error (
reason || "Uploaded file is not recognized as a valid contract." ,
) ;
2026-05-03 13:26:31 +01:00
} else if ( this . isTransientAIError ( errorMessage ) ) {
2026-04-19 01:42:00 +01:00
throw new Error (
2026-05-03 13:26:31 +01:00
` The AI providers (Gemini/Mistral) are temporarily overloaded for the configured analysis models ( ${ ANALYSIS_MODELS . join ( ", " ) } ). The app retried automatically, but both providers are still busy. Please try again in a few minutes. ` ,
2026-04-19 01:42:00 +01:00
) ;
2026-03-25 13:52:45 +01:00
} else if (
2026-04-12 19:24:24 +01:00
errorMessage . includes ( "not found" ) ||
errorMessage . includes ( "404" )
2026-03-25 13:52:45 +01:00
) {
throw new Error (
2026-03-28 23:46:45 +01:00
` Invalid Gemini model configuration. Current models: ${ ANALYSIS_MODELS . join ( ", " ) } . Check model availability in your Gemini account. ` ,
2026-03-25 13:52:45 +01:00
) ;
} else if (
2026-04-12 19:24:24 +01:00
errorMessage . includes ( "fetch" ) &&
! errorMessage . includes ( "generativelanguage" )
2026-03-25 13:52:45 +01:00
) {
throw new Error (
"Download failed. Check if the file URL is correct and accessible." ,
) ;
2026-03-28 23:46:45 +01:00
} else if (
2026-04-12 19:24:24 +01:00
errorMessage . includes ( "JSON" ) ||
errorMessage . includes ( "No complete JSON object" ) ||
errorMessage . includes ( "parse failed" )
2026-03-28 23:46:45 +01:00
) {
2026-03-25 13:52:45 +01:00
console . error ( "❌ Raw response that failed to parse:" , error ) ;
2026-04-12 19:24:24 +01:00
console . error ( "Full error message:" , errorMessage ) ;
2026-03-25 13:52:45 +01:00
// Help user understand what went wrong
2026-04-12 19:24:24 +01:00
if ( errorMessage . includes ( "escaped quotes" ) ) {
2026-03-25 13:52:45 +01:00
throw new Error (
"The contract contains special characters that corrupted the analysis. Try uploading a cleaner version." ,
) ;
2026-04-12 19:24:24 +01:00
} else if ( errorMessage . includes ( "incomplete" ) ) {
2026-03-25 13:52:45 +01:00
throw new Error (
"AI analysis failed to complete properly. This might be a large or complex contract. Try a smaller contract first." ,
) ;
2026-04-12 19:24:24 +01:00
} else if ( errorMessage . includes ( "missing expected" ) ) {
2026-03-25 13:52:45 +01:00
throw new Error (
"This doesn't appear to be a valid financial/insurance contract. Please upload a legitimate contract document." ,
) ;
} else {
throw new Error (
2026-03-28 23:46:45 +01:00
"AI returned a malformed response format. Please retry analysis; if it fails again, the file may require OCR cleanup." ,
2026-03-25 13:52:45 +01:00
) ;
}
2026-04-12 19:24:24 +01:00
} else if ( errorMessage . includes ( "quota" ) ) {
2026-03-25 13:52:45 +01:00
throw new Error (
2026-05-03 13:26:31 +01:00
"Limit exceeded. Gemini or Mistral quota may be exhausted. Check your provider dashboards for usage and limits." ,
2026-03-25 13:52:45 +01:00
) ;
} else {
2026-04-12 19:24:24 +01:00
throw new Error ( ` Error analyzing contract: ${ errorMessage } ` ) ;
2026-03-25 13:52:45 +01:00
}
}
}
/ * *
2026-03-28 23:46:45 +01:00
* Prompt generation has been moved to lib / services / ai / analysis . prompt . ts .
2026-03-25 13:52:45 +01:00
* /
private static buildPrompt ( input ? : {
adaptiveContext? : string ;
fileName? : string ;
} ) : string {
2026-03-28 23:46:45 +01:00
return buildAnalysisPrompt ( input ) ;
2026-03-25 13:52:45 +01:00
}
/ * *
* Resolve MIME type from HTTP headers first , then URL extension fallback .
* /
private static resolveMimeType (
fileUrl : string ,
headerContentType : string | null ,
) : string {
const normalizedHeader = headerContentType ? . toLowerCase ( ) || "" ;
if ( normalizedHeader . startsWith ( "application/pdf" ) ) {
return "application/pdf" ;
}
if ( normalizedHeader . startsWith ( "image/png" ) ) {
return "image/png" ;
}
if ( normalizedHeader . startsWith ( "image/jpeg" ) ) {
return "image/jpeg" ;
}
if ( normalizedHeader . startsWith ( "image/webp" ) ) {
return "image/webp" ;
}
const lowerUrl = fileUrl . toLowerCase ( ) ;
if ( lowerUrl . includes ( ".pdf" ) ) return "application/pdf" ;
if ( lowerUrl . includes ( ".png" ) ) return "image/png" ;
if ( lowerUrl . includes ( ".jpg" ) || lowerUrl . includes ( ".jpeg" ) )
return "image/jpeg" ;
if ( lowerUrl . includes ( ".webp" ) ) return "image/webp" ;
return "application/pdf" ; // Default
}
private static parseJsonResponse ( text : string ) : unknown {
2026-03-28 23:46:45 +01:00
return parseAiJsonResponse ( text ) ;
}
2026-03-25 13:52:45 +01:00
2026-05-03 13:26:31 +01:00
private static isMistralConfigured ( ) : boolean {
return MISTRAL_API_KEY . length > 0 ;
2026-04-19 01:42:00 +01:00
}
2026-05-03 13:26:31 +01:00
private static async generateWithMistral ( input : {
2026-04-19 01:42:00 +01:00
model? : string ;
prompt : string ;
systemPrompt? : string ;
responseAsJson : boolean ;
maxOutputTokens : number ;
temperature? : number ;
topP? : number ;
} ) : Promise < string > {
2026-05-03 13:26:31 +01:00
if ( ! this . isMistralConfigured ( ) ) {
2026-04-19 01:42:00 +01:00
throw new Error (
2026-05-03 13:26:31 +01:00
"Mistral fallback is not configured. Set MISTRAL_API_KEY." ,
2026-04-19 01:42:00 +01:00
) ;
}
const modelName = input . model || FALLBACK_ANALYSIS_MODEL ;
// Build messages with system/user role separation for better instruction adherence
const messages : Array < { role : string ; content : string } > = [ ] ;
if ( input . systemPrompt ) {
messages . push ( { role : "system" , content : input.systemPrompt } ) ;
}
messages . push ( { role : "user" , content : input.prompt } ) ;
// Use json_object mode (compatible with all models)
2026-05-03 13:26:31 +01:00
const responseFormat : Record < string , unknown > | undefined =
input . responseAsJson ? { type : "json_object" as const } : undefined ;
const temperature = input . temperature ? ? 0 ;
const top_p = temperature === 0 ? 1 : ( input . topP ? ? 0.95 ) ;
2026-04-19 01:42:00 +01:00
const body : Record < string , unknown > = {
model : modelName ,
2026-05-03 13:26:31 +01:00
temperature ,
top_p ,
2026-04-19 01:42:00 +01:00
max_tokens : input.maxOutputTokens ,
response_format : responseFormat ,
messages ,
} ;
2026-05-03 13:26:31 +01:00
const response = await fetch ( MISTRAL_API_URL , {
method : "POST" ,
headers : {
Authorization : ` Bearer ${ MISTRAL_API_KEY } ` ,
"Content-Type" : "application/json" ,
} ,
body : JSON.stringify ( body ) ,
} ) ;
if ( ! response . ok ) {
const details = await response . text ( ) ;
throw new Error (
` Mistral API error ${ response . status } : ${ details . slice ( 0 , 300 ) } ` ,
) ;
}
const json = ( await response . json ( ) ) as {
choices? : Array < { message ? : { content? : string | null } } > ;
} ;
const text = json . choices ? . [ 0 ] ? . message ? . content ? . trim ( ) || "" ;
if ( ! text ) {
throw new Error ( "Empty response from Mistral fallback model." ) ;
}
return text ;
}
/ * *
* Multimodal analysis using Mistral Pixtral vision model .
* Sends base64 - encoded images directly to Pixtral for analysis ,
* eliminating the need for a separate OCR bridge when Gemini is down .
* /
private static async generateWithMistralVision ( input : {
prompt : string ;
base64 : string ;
mimeType : string ;
systemPrompt? : string ;
responseAsJson? : boolean ;
maxOutputTokens? : number ;
} ) : Promise < string > {
if ( ! this . isMistralConfigured ( ) ) {
throw new Error (
"Mistral fallback is not configured. Set MISTRAL_API_KEY." ,
) ;
}
const messages : Array < { role : string ; content : unknown } > = [ ] ;
if ( input . systemPrompt ) {
messages . push ( { role : "system" , content : input.systemPrompt } ) ;
}
// OpenAI-compatible multimodal content format for Pixtral vision
messages . push ( {
role : "user" ,
content : [
{ type : "text" , text : input.prompt } ,
{
type : "image_url" ,
image_url : {
url : ` data: ${ input . mimeType } ;base64, ${ input . base64 } ` ,
} ,
} ,
] ,
} ) ;
const responseFormat : Record < string , unknown > | undefined =
input . responseAsJson ? { type : "json_object" as const } : undefined ;
const body : Record < string , unknown > = {
model : MISTRAL_VISION_MODEL ,
temperature : 0 ,
top_p : 1 ,
max_tokens : input.maxOutputTokens ? ? 16384 ,
response_format : responseFormat ,
messages ,
} ;
const response = await fetch ( MISTRAL_API_URL , {
2026-04-19 01:42:00 +01:00
method : "POST" ,
headers : {
2026-05-03 13:26:31 +01:00
Authorization : ` Bearer ${ MISTRAL_API_KEY } ` ,
2026-04-19 01:42:00 +01:00
"Content-Type" : "application/json" ,
} ,
body : JSON.stringify ( body ) ,
} ) ;
if ( ! response . ok ) {
const details = await response . text ( ) ;
throw new Error (
2026-05-03 13:26:31 +01:00
` Mistral Vision API error ${ response . status } : ${ details . slice ( 0 , 300 ) } ` ,
2026-04-19 01:42:00 +01:00
) ;
}
const json = ( await response . json ( ) ) as {
choices? : Array < { message ? : { content? : string | null } } > ;
} ;
const text = json . choices ? . [ 0 ] ? . message ? . content ? . trim ( ) || "" ;
if ( ! text ) {
2026-05-03 13:26:31 +01:00
throw new Error ( "Empty response from Mistral Pixtral vision model." ) ;
2026-04-19 01:42:00 +01:00
}
2026-05-03 13:26:31 +01:00
console . log ( ` ✅ Mistral Pixtral vision analysis succeeded ` ) ;
2026-04-19 01:42:00 +01:00
return text ;
}
2026-05-03 13:26:31 +01:00
private static async generateWithMistralModelChain ( input : {
2026-04-19 01:42:00 +01:00
preferredModel? : string ;
prompt : string ;
systemPrompt? : string ;
responseAsJson : boolean ;
maxOutputTokens : number ;
temperature? : number ;
topP? : number ;
} ) : Promise < string > {
const candidates = Array . from (
new Set (
[
input . preferredModel ,
FALLBACK_ANALYSIS_MODEL ,
2026-05-03 13:26:31 +01:00
"mistral-large-latest" ,
"mistral-small-latest" ,
"open-mistral-nemo" ,
2026-04-19 01:42:00 +01:00
] . filter ( Boolean ) ,
) ,
) as string [ ] ;
let lastError : unknown = null ;
for ( const modelName of candidates ) {
try {
2026-05-03 13:26:31 +01:00
const text = await this . generateWithMistral ( {
2026-04-19 01:42:00 +01:00
model : modelName ,
prompt : input.prompt ,
systemPrompt : input.systemPrompt ,
responseAsJson : input.responseAsJson ,
maxOutputTokens : input.maxOutputTokens ,
temperature : input.temperature ,
topP : input.topP ,
} ) ;
if ( modelName !== ( input . preferredModel || FALLBACK_ANALYSIS_MODEL ) ) {
console . warn (
2026-05-03 13:26:31 +01:00
` Mistral switched to fallback model ${ modelName } after primary fallback model failed. ` ,
2026-04-19 01:42:00 +01:00
) ;
}
return text ;
} catch ( error ) {
lastError = error ;
console . warn (
2026-05-03 13:26:31 +01:00
` Mistral model ${ modelName } failed. Trying next fallback model. ` ,
2026-04-19 01:42:00 +01:00
error instanceof Error ? error.message : String ( error ) ,
) ;
}
}
throw lastError instanceof Error
? lastError
2026-05-03 13:26:31 +01:00
: new Error ( "All Mistral fallback models failed." ) ;
2026-04-19 01:42:00 +01:00
}
/ * *
2026-05-03 13:26:31 +01:00
* Build a Mistral - optimized system prompt that mirrors the Gemini behavior .
2026-04-19 01:42:00 +01:00
* This separates role & formatting rules from user content for better
* instruction adherence on open - source models .
2026-05-03 13:26:31 +01:00
*
* Unlike the Gemini prompt which sends examples with the file inline ,
* this prompt is designed to prevent hallucination by using explicit
* placeholder markers instead of realistic example values .
2026-04-19 01:42:00 +01:00
* /
2026-05-03 13:26:31 +01:00
private static buildMistralSystemPrompt ( ) : string {
2026-04-19 01:42:00 +01:00
return ` You are an expert contract analysis engine for the BFSI (Banking, Financial Services, and Insurance) sector.
2026-05-03 13:26:31 +01:00
You receive the full text content of a contract document and must extract structured information from it .
2026-04-19 01:42:00 +01:00
2026-05-03 13:26:31 +01:00
ABSOLUTE RULES — VIOLATION OF THESE IS A CRITICAL FAILURE :
2026-04-19 01:42:00 +01:00
1 . Return ONLY valid , parseable JSON — no markdown , no backticks , no explanations , no commentary .
2026-05-03 13:26:31 +01:00
2 . EVERY value you output MUST come directly from the document text provided to you .
3 . If a piece of information does NOT exist in the document text , you MUST use null ( for strings / numbers ) or [ ] ( for arrays ) . NEVER invent , assume , or guess data .
4 . Do NOT copy example values from the schema description — they are placeholders , not real data .
5 . The "extractedText" field MUST contain actual verbatim text from the document — not a summary , not examples .
JSON SCHEMA ( use exact field names ) :
{
"language" : "<ISO 639-1 code detected from document>" ,
"title" : "<exact contract title from document or null>" ,
"type" : "<one of: INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER>" ,
"provider" : "<company/institution name from document or null>" ,
"policyNumber" : "<policy/contract number from document or null>" ,
"startDate" : "<YYYY-MM-DD from document or null>" ,
"endDate" : "<YYYY-MM-DD from document or null>" ,
"premium" : < number from document or null — NO currency symbols > ,
"premiumCurrency" : "<currency code from document or null>" ,
"summary" : "<4-6 sentences summarizing the actual contract content>" ,
"keyPoints" : {
"guarantees" : [ "<actual guarantee from document>" ] ,
"exclusions" : [ "<actual exclusion from document>" ] ,
"franchise" : "<deductible/penalty from document or null>" ,
"importantDates" : [ "<actual date from document with description>" ] ,
"explainability" : [
{
"field" : "<field name>" ,
"why" : "<why this value was extracted>" ,
"sourceSnippet" : "<verbatim quote from document>" ,
"sourceHints" : { "page" : "<page or null>" , "section" : "<section or null>" , "confidence" : < 0 - 100 > }
}
]
} ,
"keyPeople" : [ { "name" : "<from document>" , "role" : "<from document or null>" , "email" : "<from document or null>" , "phone" : "<from document or null>" } ] ,
"contactInfo" : { "name" : "<from document or null>" , "email" : null , "phone" : null , "address" : null , "role" : null } ,
"importantContacts" : [ ] ,
"relevantDates" : [ { "date" : "<YYYY-MM-DD>" , "description" : "<from document>" , "type" : "<EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER>" } ] ,
"extractedText" : "<verbatim text from the document, max 12000 chars>" ,
"contractValidation" : {
"isValidContract" : true ,
"confidence" : < 0 - 100 reflecting how much data you actually found > ,
"reason" : null
}
}
FIELD RULES :
- All dates : ISO YYYY - MM - DD or null
- premium : positive number or null — NO currency symbols , NO text
- type : must be exactly one of the 8 values listed
- summary : 4 - 6 professional sentences about THIS specific contract . If no contract text is found , output "No contract data found in the document text."
- extractedText : must contain at least 30 characters of ACTUAL document content . If no text is found , output "No document text could be extracted. Please ensure the document is not a scanned image."
- explainability : at least 4 items with real sourceSnippets from the document
- confidence : reflects how much data you actually found ( not how confident the model is )
- Parse localized number formats correctly ( 1.234 , 56 vs 1 , 234.56 )
- Detect the contract language and set "language" accordingly
You are replacing a more capable multimodal model ( Gemini ) as a fallback . Your output quality MUST match production standards . ACCURACY is more important than completeness — it is better to return null than to guess . ` ;
2026-04-19 01:42:00 +01:00
}
2026-03-28 23:46:45 +01:00
private static async generateAnalysisWithFallback ( input : {
prompt : string ;
base64 : string ;
mimeType : string ;
2026-04-19 01:42:00 +01:00
forceFallbackModelTest? : boolean ;
2026-03-28 23:46:45 +01:00
} ) : Promise < string > {
let lastError : unknown = null ;
2026-04-19 01:42:00 +01:00
const forceFallback = Boolean ( input . forceFallbackModelTest ) ;
2026-05-03 13:26:31 +01:00
const buildGroundedMistralPrompt = async ( ) = > {
const groundingText = await this . extractMistralGroundingText ( {
2026-04-19 01:42:00 +01:00
base64 : input.base64 ,
mimeType : input.mimeType ,
} ) ;
2026-03-28 23:46:45 +01:00
2026-04-19 01:42:00 +01:00
if ( ! groundingText ) {
2026-05-03 13:26:31 +01:00
throw new Error (
"INVALID_CONTRACT:No extractable text found in this PDF after OCR fallback. Please verify the file is readable and not password-protected." ,
) ;
2026-04-19 01:42:00 +01:00
}
2026-05-03 13:26:31 +01:00
return ` --- BEGIN GROUNDED DOCUMENT TEXT (AUTHORITATIVE SOURCE) ---
$ { groundingText }
-- - END GROUNDED DOCUMENT TEXT -- -
MISTRAL FALLBACK RULES :
- Extract fields ONLY from the grounded document text above . This text is the full contract content .
- Do not invent , assume , or hallucinate any values not explicitly present in the above text .
- If a field ' s data is not found in the text , use null ( for strings / numbers ) or [ ] ( for arrays ) .
- Dates : convert any date format found in the text to YYYY - MM - DD .
- Numbers : parse localized formats ( comma vs period ) correctly before setting numeric fields .
- contractValidation . confidence should reflect how much data you could extract from the text . ` ;
2026-04-19 01:42:00 +01:00
} ;
if ( forceFallback ) {
console . warn (
2026-05-03 13:26:31 +01:00
` 🧪 Fallback test mode enabled. Skipping Gemini and forcing Mistral model ${ FALLBACK_ANALYSIS_MODEL } . ` ,
2026-04-19 01:42:00 +01:00
) ;
2026-05-03 13:26:31 +01:00
// For images: use Pixtral vision model directly (multimodal — no OCR bridge needed)
if ( input . mimeType . startsWith ( "image/" ) && this . isMistralConfigured ( ) ) {
return this . generateWithMistralVision ( {
systemPrompt : this.buildMistralSystemPrompt ( ) ,
prompt : ` TEST MODE: You are the forced fallback model. Return ONLY valid JSON and preserve the required schema exactly. Extract information from the provided image. ` ,
base64 : input.base64 ,
mimeType : input.mimeType ,
responseAsJson : true ,
maxOutputTokens : 16384 ,
} ) ;
}
const groundedPrompt = await buildGroundedMistralPrompt ( ) ;
return this . generateWithMistralModelChain ( {
2026-04-19 01:42:00 +01:00
preferredModel : FALLBACK_ANALYSIS_MODEL ,
2026-05-03 13:26:31 +01:00
systemPrompt : this.buildMistralSystemPrompt ( ) ,
2026-04-19 01:42:00 +01:00
prompt : ` ${ groundedPrompt } \ n \ nTEST MODE: You are the forced fallback model. Return ONLY valid JSON and preserve the required schema exactly. ` ,
responseAsJson : true ,
maxOutputTokens : 8192 ,
} ) ;
}
for ( const modelName of GEMINI_ANALYSIS_MODELS ) {
2026-03-28 23:46:45 +01:00
try {
2026-04-12 19:24:24 +01:00
return await keyManager . execute ( async ( genAI ) = > {
const model = genAI . getGenerativeModel ( {
model : modelName ,
generationConfig : {
temperature : 0 ,
topP : 0.95 ,
topK : 40 ,
maxOutputTokens : 16384 ,
responseMimeType : "application/json" ,
} ,
} ) ;
2026-03-25 13:52:45 +01:00
2026-04-12 19:24:24 +01:00
const result = await model . generateContent ( [
input . prompt ,
{
inlineData : {
data : input.base64 ,
mimeType : input.mimeType ,
} ,
2026-03-28 23:46:45 +01:00
} ,
2026-04-12 19:24:24 +01:00
] ) ;
2026-03-25 13:52:45 +01:00
2026-04-12 19:24:24 +01:00
const text = result . response . text ( ) ;
if ( text && text . trim ( ) . length > 0 ) {
console . log ( ` ✅ Analysis with model ${ modelName } succeeded ` ) ;
return text ;
}
throw new Error ( "Empty response" ) ;
} ) ;
} catch ( error : any ) {
2026-03-28 23:46:45 +01:00
lastError = error ;
console . warn (
` Analysis with model ${ modelName } failed. Trying next model. ` ,
error instanceof Error ? error.message : String ( error ) ,
) ;
}
2026-03-25 13:52:45 +01:00
}
2026-03-28 23:46:45 +01:00
// All primary models failed. Try with more lenient generation settings as last resort
console . warn (
"All standard models failed. Trying with lenient generation config..." ,
) ;
2026-03-25 13:52:45 +01:00
try {
2026-04-12 19:24:24 +01:00
return await keyManager . execute ( async ( genAI ) = > {
const fallbackModel = genAI . getGenerativeModel ( {
model : PRIMARY_ANALYSIS_MODEL ,
generationConfig : {
temperature : 0 ,
topP : 0.9 ,
topK : 20 ,
maxOutputTokens : 16384 ,
// Don't enforce JSON format; let model produce raw output
} ,
} ) ;
2026-03-25 13:52:45 +01:00
2026-04-12 19:24:24 +01:00
const result = await fallbackModel . generateContent ( [
input . prompt ,
{
inlineData : {
data : input.base64 ,
mimeType : input.mimeType ,
} ,
2026-03-28 23:46:45 +01:00
} ,
2026-04-12 19:24:24 +01:00
] ) ;
2026-03-28 23:46:45 +01:00
2026-04-12 19:24:24 +01:00
const text = result . response . text ( ) ;
if ( text && text . trim ( ) . length > 0 ) {
console . log ( "✅ Lenient generation succeeded" ) ;
return text ;
}
throw new Error ( "Empty response from fallback" ) ;
} ) ;
} catch ( error : any ) {
2026-03-28 23:46:45 +01:00
console . warn ( "Lenient generation also failed:" , error ) ;
}
2026-03-25 13:52:45 +01:00
2026-05-03 13:26:31 +01:00
// === Mistral AI fallback path ===
2026-04-19 01:42:00 +01:00
console . warn (
2026-05-03 13:26:31 +01:00
"All Gemini models exhausted. Activating Mistral AI fallback pipeline..." ,
2026-04-19 01:42:00 +01:00
) ;
try {
2026-05-03 13:26:31 +01:00
// For images: use Pixtral vision model directly (multimodal — no OCR bridge needed)
if ( input . mimeType . startsWith ( "image/" ) && this . isMistralConfigured ( ) ) {
const mistralText = await this . generateWithMistralVision ( {
systemPrompt : this.buildMistralSystemPrompt ( ) ,
prompt : ` IMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object. Extract data from the provided image. ` ,
base64 : input.base64 ,
mimeType : input.mimeType ,
responseAsJson : true ,
maxOutputTokens : 16384 ,
} ) ;
console . log (
` ✅ Analysis fallback with Mistral Pixtral vision succeeded ` ,
) ;
return mistralText ;
}
// For PDFs/text: extract text and use text-only Mistral
const groundedPrompt = await buildGroundedMistralPrompt ( ) ;
const mistralText = await this . generateWithMistralModelChain ( {
2026-04-19 01:42:00 +01:00
preferredModel : FALLBACK_ANALYSIS_MODEL ,
2026-05-03 13:26:31 +01:00
systemPrompt : this.buildMistralSystemPrompt ( ) ,
2026-04-19 01:42:00 +01:00
prompt : ` ${ groundedPrompt } \ n \ nIMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object. ` ,
responseAsJson : true ,
maxOutputTokens : 8192 ,
} ) ;
console . log (
2026-05-03 13:26:31 +01:00
` ✅ Analysis fallback with Mistral model ${ FALLBACK_ANALYSIS_MODEL } succeeded ` ,
) ;
return mistralText ;
} catch ( mistralError ) {
console . warn ( "Mistral analysis fallback failed:" , mistralError ) ;
lastError = new Error (
` Mistral fallback also failed: ${ mistralError instanceof Error ? mistralError.message : String ( mistralError ) } . Original error: ${ lastError instanceof Error ? lastError.message : String ( lastError ) } ` ,
2026-04-19 01:42:00 +01:00
) ;
}
2026-03-28 23:46:45 +01:00
throw lastError instanceof Error
? lastError
2026-04-19 01:42:00 +01:00
: new Error (
2026-05-03 13:26:31 +01:00
"All analysis models (Gemini + Mistral fallback) failed to generate content." ,
2026-04-19 01:42:00 +01:00
) ;
2026-03-28 23:46:45 +01:00
}
2026-03-25 13:52:45 +01:00
2026-03-28 23:46:45 +01:00
private static async repairMalformedJson (
malformedResponse : string ,
parseError : string ,
) : Promise < string | null > {
try {
2026-04-19 01:42:00 +01:00
const expectedSchema = {
language : "string|null" ,
title : "string" ,
type : "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER" ,
provider : "string|null" ,
policyNumber : "string|null" ,
startDate : "YYYY-MM-DD|null" ,
endDate : "YYYY-MM-DD|null" ,
premium : "number|null" ,
premiumCurrency : "string|null (ISO code like EUR/USD/TND or symbol)" ,
summary : "string (min 10 chars)" ,
extractedText : "string (min 30 chars)" ,
keyPoints : {
guarantees : "string[]" ,
exclusions : "string[]" ,
franchise : "string|null" ,
importantDates : "string[]" ,
explainability :
"[{ field, why, sourceSnippet, sourceHints:{ page|null, section|null, confidence|null } }]" ,
} ,
keyPeople : "[{ name, role|null, email|null, phone|null }]" ,
contactInfo :
"{ name|null, email|null, phone|null, address|null, role|null }" ,
importantContacts :
"[{ name|null, email|null, phone|null, address|null, role|null }]" ,
relevantDates :
"[{ date:'YYYY-MM-DD', description, type:'EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER' }]" ,
contractValidation : {
2026-03-28 23:46:45 +01:00
isValidContract : "boolean" ,
confidence : "number (0-100)" ,
reason : "string|null" ,
} ,
} ;
2026-03-25 13:52:45 +01:00
2026-03-28 23:46:45 +01:00
const repairPrompt = ` You are a JSON repair engine for contract analysis.
Fix the malformed JSON response below and return ONLY valid , parseable JSON conforming to this schema :
2026-03-25 13:52:45 +01:00
2026-03-28 23:46:45 +01:00
$ { JSON . stringify ( expectedSchema , null , 2 ) }
Rules :
1 . Return ONLY the JSON object , no markdown , no explanations .
2 . Preserve all values from the original response as accurately as possible .
3 . Fix structural issues : missing braces , unescaped quotes , trailing commas , unmatched brackets .
4 . For null / missing fields , use null value or empty array [ ] as appropriate .
5 . Ensure all required text fields ( title , summary , extractedText ) have content .
6 . All numeric values must be valid numbers .
7 . All dates must be in YYYY - MM - DD format .
8 . If type is unclear , use "OTHER" .
9 . Preserve explainability and evidence snippets when present .
Original parse error : $ { parseError }
Malformed response to fix :
$ { malformedResponse . slice ( 0 , 14000 ) } ` ;
2026-05-03 13:26:31 +01:00
const repairedText = await this . generateWithMistralModelChain ( {
2026-04-19 01:42:00 +01:00
preferredModel : FALLBACK_REPAIR_MODEL ,
prompt : repairPrompt ,
responseAsJson : true ,
maxOutputTokens : 6144 ,
} ) ;
2026-03-28 23:46:45 +01:00
if ( repairedText . length === 0 ) {
return null ;
2026-03-25 13:52:45 +01:00
}
2026-03-28 23:46:45 +01:00
if ( ! repairedText . includes ( "{" ) ) {
return null ;
2026-03-25 13:52:45 +01:00
}
2026-04-19 01:42:00 +01:00
try {
this . parseJsonResponse ( repairedText ) ;
} catch ( firstRepairParseError ) {
const secondPassPrompt = ` ${ repairPrompt } \ n \ nSECOND PASS CORRECTION: \ nYour previous repaired JSON was still invalid. \ nReason: ${ firstRepairParseError instanceof Error ? firstRepairParseError . message : "Invalid JSON" } . \ nReturn ONLY strict valid JSON. ` ;
2026-05-03 13:26:31 +01:00
const secondPass = await this . generateWithMistralModelChain ( {
2026-04-19 01:42:00 +01:00
preferredModel : FALLBACK_REPAIR_MODEL ,
prompt : secondPassPrompt ,
responseAsJson : true ,
maxOutputTokens : 6144 ,
} ) ;
this . parseJsonResponse ( secondPass ) ;
return secondPass ;
}
2026-03-28 23:46:45 +01:00
return repairedText ;
2026-04-12 19:24:24 +01:00
} catch ( error : any ) {
if ( error . message ? . includes ( "CRITICAL_KEY_EXHAUSTION" ) ) throw error ;
2026-03-28 23:46:45 +01:00
console . warn ( "JSON repair step failed:" , error ) ;
return null ;
}
}
2026-05-03 13:26:31 +01:00
/ * *
* Extract grounding text for Mistral text - only fallback .
* For PDFs : extracts text directly using pdf - parse ( local , no AI needed ) .
* For images : returns empty string — Pixtral vision handles images directly .
* /
private static async extractMistralGroundingText ( input : {
2026-04-19 01:42:00 +01:00
base64 : string ;
mimeType : string ;
} ) : Promise < string > {
// For PDFs: extract text directly using pdf-parse
if ( input . mimeType === "application/pdf" ) {
try {
const pdfBuffer = Buffer . from ( input . base64 , "base64" ) ;
2026-05-03 13:26:31 +01:00
// Handle Next.js Webpack/Turbopack CJS/ESM interop
let pdfParseModule : any ;
2026-04-19 01:42:00 +01:00
try {
2026-05-03 13:26:31 +01:00
pdfParseModule = require ( "pdf-parse" ) ;
} catch {
pdfParseModule = await import ( "pdf-parse" ) ;
}
const PDFParseClass =
pdfParseModule ? . PDFParse ||
pdfParseModule ? . default ? . PDFParse ||
( typeof pdfParseModule === "function" ? pdfParseModule : null ) ;
if ( ! PDFParseClass ) {
throw new Error (
"Could not resolve PDFParse constructor from pdf-parse module." ,
) ;
}
let parsed : { text? : string } ;
if (
typeof PDFParseClass === "function" &&
! PDFParseClass . prototype ? . getText
) {
// Fallback if it's actually the legacy function export
parsed = await PDFParseClass ( pdfBuffer ) ;
} else {
const parser = new PDFParseClass ( { data : pdfBuffer } ) ;
try {
parsed = await parser . getText ( ) ;
} finally {
if ( typeof parser . destroy === "function" ) {
await parser . destroy ( ) ;
}
}
2026-04-19 01:42:00 +01:00
}
const text = ( parsed ? . text || "" )
. replace ( /\r/g , "\n" )
. replace ( /\n{3,}/g , "\n\n" )
. trim ( ) ;
2026-05-03 13:26:31 +01:00
if ( text && text . length >= 10 ) {
2026-04-19 01:42:00 +01:00
console . log (
2026-05-03 13:26:31 +01:00
` 📄 Mistral grounding: extracted ${ text . length } chars from PDF ` ,
2026-04-19 01:42:00 +01:00
) ;
return text . slice ( 0 , 50000 ) ;
}
2026-05-03 13:26:31 +01:00
console . warn (
` 📄 Mistral grounding: native PDF text extraction too short (length: ${ text ? . length || 0 } ). Trying OCR fallback... ` ,
) ;
2026-04-19 01:42:00 +01:00
} catch ( error ) {
console . warn (
2026-05-03 13:26:31 +01:00
"📄 PDF grounding extraction failed for Mistral fallback:" ,
error instanceof Error ? error.message : error ,
2026-04-19 01:42:00 +01:00
) ;
}
2026-05-03 13:26:31 +01:00
// OCR fallback for scanned PDFs.
2026-04-19 01:42:00 +01:00
try {
2026-05-03 13:26:31 +01:00
const ocrText = await this . extractMistralPdfTextWithOcr ( input . base64 ) ;
if ( ocrText . length >= 10 ) {
2026-04-19 01:42:00 +01:00
console . log (
2026-05-03 13:26:31 +01:00
` 📄 Mistral grounding OCR: extracted ${ ocrText . length } chars from scanned PDF ` ,
2026-04-19 01:42:00 +01:00
) ;
return ocrText . slice ( 0 , 50000 ) ;
}
2026-05-03 13:26:31 +01:00
} catch ( ocrError ) {
console . warn (
"📄 PDF OCR fallback failed for Mistral grounding:" ,
ocrError instanceof Error ? ocrError.message : ocrError ,
) ;
2026-04-19 01:42:00 +01:00
}
}
2026-05-03 13:26:31 +01:00
// For images: Pixtral vision model handles images directly via
// generateWithMistralVision, so no grounding text extraction is needed.
// The calling code in generateAnalysisWithFallback routes images
// to the vision path instead of the text-only grounded path.
2026-04-19 01:42:00 +01:00
return "" ;
}
2026-05-03 13:26:31 +01:00
private static async extractMistralPdfTextWithOcr (
pdfBase64 : string ,
) : Promise < string > {
if ( ! this . isMistralConfigured ( ) ) {
return "" ;
}
const body = {
model : MISTRAL_OCR_MODEL ,
document : {
type : "document_url" ,
document_url : ` data:application/pdf;base64, ${ pdfBase64 } ` ,
} ,
include_image_base64 : false ,
} ;
const response = await fetch ( MISTRAL_OCR_API_URL , {
method : "POST" ,
headers : {
Authorization : ` Bearer ${ MISTRAL_API_KEY } ` ,
"Content-Type" : "application/json" ,
} ,
body : JSON.stringify ( body ) ,
} ) ;
if ( ! response . ok ) {
const details = await response . text ( ) ;
throw new Error (
` Mistral OCR API error ${ response . status } : ${ details . slice ( 0 , 300 ) } ` ,
) ;
}
const json = ( await response . json ( ) ) as {
text? : string ;
pages? : Array < {
text? : string ;
markdown? : string ;
content? : string ;
} > ;
output? : Array < {
text? : string ;
markdown? : string ;
content? : string ;
} > ;
} ;
const pageTexts = [
. . . ( Array . isArray ( json . pages ) ? json . pages : [ ] ) ,
. . . ( Array . isArray ( json . output ) ? json . output : [ ] ) ,
]
. map ( ( page ) = > page . markdown || page . text || page . content || "" )
. filter ( ( value ) = > value . trim ( ) . length > 0 ) ;
const merged = [ json . text || "" , . . . pageTexts ]
. join ( "\n\n" )
. replace ( /\r/g , "\n" )
. replace ( /\n{3,}/g , "\n\n" )
. trim ( ) ;
return merged ;
}
2026-03-28 23:46:45 +01:00
/ * *
* Emergency fallback : Extract key contract fields from raw text when JSON is completely malformed .
* Builds a minimal but valid JSON structure from pattern - matched fields .
* /
private static emergencyExtractFields ( rawText : string ) : string | null {
try {
const titleMatch = rawText . match (
/["']?title["']?\s*:\s*["']([^"']{5,200})/i ,
2026-03-25 13:52:45 +01:00
) ;
2026-03-28 23:46:45 +01:00
const summaryMatch = rawText . match (
/summary["']?\s*:\s*["']([^"']{10,500})/i ,
) ;
const extractedMatch = rawText . match (
/extractedText["']?\s*:\s*["']([^"']{30,})/i ,
) ;
if ( ! titleMatch || ! summaryMatch ) {
return null ;
}
const emergency = {
title : titleMatch [ 1 ] ? . slice ( 0 , 200 ) || "Contract" ,
type : "OTHER" ,
provider : null ,
policyNumber : null ,
startDate : null ,
endDate : null ,
premium : null ,
premiumCurrency : null ,
summary : summaryMatch [ 1 ] ? . slice ( 0 , 500 ) || "Contract analysis" ,
extractedText :
extractedMatch ? . [ 1 ] ? . slice ( 0 , 12000 ) || rawText . slice ( 0 , 12000 ) ,
keyPoints : {
guarantees : [ ] ,
exclusions : [ ] ,
franchise : null ,
importantDates : [ ] ,
} ,
contractValidation : {
isValidContract : true ,
confidence : 50 ,
reason : "Emergency partial extraction due to response malformation" ,
} ,
} ;
return JSON . stringify ( emergency ) ;
} catch {
return null ;
2026-03-25 13:52:45 +01:00
}
}
/ * *
* Lightweight contract validity pre - check .
*
* Goal : reject clearly invalid files quickly ( invoice / photo / blank / non - legal doc )
* before running heavier full extraction .
* /
private static async preValidateContract ( input : {
base64 : string ;
mimeType : string ;
fileName? : string ;
} ) : Promise < ContractPrecheckResult > {
2026-03-28 23:46:45 +01:00
const rawText = await this . generatePrevalidationWithFallback ( input ) ;
2026-03-25 13:52:45 +01:00
2026-04-12 19:24:24 +01:00
let raw : PrevalidationResponse ;
2026-03-28 23:46:45 +01:00
try {
2026-04-12 19:24:24 +01:00
raw = this . parseJsonResponse ( rawText || "{}" ) as PrevalidationResponse ;
} catch {
2026-03-28 23:46:45 +01:00
// If prevalidation JSON is malformed, assume it's a contract with moderate confidence
console . warn (
"Prevalidation JSON parse failed, assuming contract with moderate confidence" ,
) ;
return {
isValidContract : true ,
confidence : 60 ,
reason :
"Prevalidation response was malformed, but document appears contract-like" ,
} ;
}
2026-03-25 13:52:45 +01:00
const maybe = raw as Partial < ContractPrecheckResult > ;
const isValidContract = Boolean ( maybe . isValidContract ) ;
const confidence = Number . isFinite ( Number ( maybe . confidence ) )
? Math . max ( 0 , Math . min ( 100 , Math . round ( Number ( maybe . confidence ) ) ) )
: 0 ;
const reason =
typeof maybe . reason === "string" && maybe . reason . trim ( ) . length > 0
? maybe . reason . trim ( )
: null ;
return {
isValidContract ,
confidence ,
reason ,
} ;
}
2026-03-28 23:46:45 +01:00
private static async generatePrevalidationWithFallback ( input : {
base64 : string ;
mimeType : string ;
fileName? : string ;
} ) : Promise < string > {
let lastError : unknown = null ;
2026-04-19 01:42:00 +01:00
for ( const modelName of GEMINI_ANALYSIS_MODELS ) {
2026-03-28 23:46:45 +01:00
try {
2026-04-12 19:24:24 +01:00
return await keyManager . execute ( async ( genAI ) = > {
const model = genAI . getGenerativeModel ( {
model : modelName ,
generationConfig : {
temperature : 0 ,
topP : 0.9 ,
topK : 20 ,
maxOutputTokens : 350 ,
responseMimeType : "application/json" ,
} ,
} ) ;
2026-03-25 13:52:45 +01:00
2026-04-12 19:24:24 +01:00
const result = await model . generateContent ( [
buildPrevalidationPrompt ( input . fileName ) ,
{
inlineData : {
data : input.base64 ,
mimeType : input.mimeType ,
} ,
2026-03-28 23:46:45 +01:00
} ,
2026-04-12 19:24:24 +01:00
] ) ;
2026-03-25 13:52:45 +01:00
2026-04-12 19:24:24 +01:00
const text = result . response . text ( ) ;
if ( text && text . trim ( ) . length > 0 ) {
return text ;
}
throw new Error ( "Empty response" ) ;
} ) ;
} catch ( error : any ) {
if ( error . message ? . includes ( "CRITICAL_KEY_EXHAUSTION" ) ) throw error ;
2026-03-28 23:46:45 +01:00
lastError = error ;
console . warn (
` Pre-validation with model ${ modelName } failed. Trying next model. ` ,
) ;
}
2026-03-25 13:52:45 +01:00
}
2026-03-28 23:46:45 +01:00
throw lastError instanceof Error
? lastError
: new Error ( "All pre-validation models failed to generate content." ) ;
}
2026-03-25 13:52:45 +01:00
2026-04-12 19:24:24 +01:00
private static normalizeAnalysis ( input : unknown ) : NormalizedAnalysis {
2026-03-28 23:46:45 +01:00
return normalizeAiAnalysis ( input ) ;
2026-03-25 13:52:45 +01:00
}
private static async buildAdaptiveContext ( userId? : string ) : Promise < string > {
// No user context means no adaptation baseline.
if ( ! userId ) {
return "" ;
}
2026-04-12 19:24:24 +01:00
const examples : AdaptiveContractExample [ ] = await prisma . contract . findMany ( {
2026-03-25 13:52:45 +01:00
where : {
userId ,
status : "COMPLETED" ,
} ,
orderBy : {
updatedAt : "desc" ,
} ,
take : 12 ,
select : {
type : true ,
provider : true ,
policyNumber : true ,
summary : true ,
2026-03-28 23:46:45 +01:00
keyPoints : true ,
2026-03-25 13:52:45 +01:00
} ,
} ) ;
if ( examples . length < 2 ) {
return "" ;
}
// Small utility to get most frequent values from prior analyses.
const count = ( items : string [ ] ) = > {
const bucket = new Map < string , number > ( ) ;
for ( const item of items ) {
bucket . set ( item , ( bucket . get ( item ) ? ? 0 ) + 1 ) ;
}
return [ . . . bucket . entries ( ) ]
. sort ( ( a , b ) = > b [ 1 ] - a [ 1 ] )
. slice ( 0 , 4 )
. map ( ( [ value ] ) = > value ) ;
} ;
const topTypes = count (
examples
. map ( ( item ) = > item . type )
. filter ( ( value ) : value is NonNullable < typeof value > = > value !== null )
. map ( ( value ) = > String ( value ) ) ,
) ;
const topProviders = count (
examples
. map ( ( item ) = > item . provider )
. filter ( ( value ) : value is string = > Boolean ( value ) ) ,
) ;
const policyPatterns = examples
. map ( ( item ) = > item . policyNumber )
. filter ( ( value ) : value is string = > Boolean ( value ) )
. slice ( 0 , 4 )
. map ( ( value ) = > value . replace ( /[A-Za-z0-9]/g , "X" ) ) ;
2026-03-28 23:46:45 +01:00
const allExplainability = examples
. flatMap ( ( item ) = > {
2026-04-12 19:24:24 +01:00
const maybeExplainability = isAdaptiveKeyPoints ( item . keyPoints )
? item . keyPoints . explainability
: undefined ;
2026-03-28 23:46:45 +01:00
return Array . isArray ( maybeExplainability ) ? maybeExplainability : [ ] ;
} )
. slice ( 0 , 120 ) ;
const explainabilityByField = count (
allExplainability
2026-04-12 19:24:24 +01:00
. map ( ( entry ) = > String ( entry ? . field ? ? "" ) . trim ( ) )
2026-03-28 23:46:45 +01:00
. filter ( ( value : string ) = > value . length > 0 ) ,
) ;
const confidenceValues = allExplainability
2026-04-12 19:24:24 +01:00
. map ( ( entry ) = > Number ( entry ? . sourceHints ? . confidence ) )
2026-03-28 23:46:45 +01:00
. filter ( ( value : number ) = > Number . isFinite ( value ) ) ;
const avgEvidenceConfidence = confidenceValues . length
? Math . round (
confidenceValues . reduce (
( sum : number , value : number ) = > sum + value ,
0 ,
) / confidenceValues . length ,
)
: null ;
const learnedLanguages = count (
examples
2026-04-12 19:24:24 +01:00
. map ( ( item ) = >
isAdaptiveKeyPoints ( item . keyPoints )
? item . keyPoints . aiMeta ? . language
: null ,
)
2026-03-28 23:46:45 +01:00
. map ( ( value ) = > String ( value ? ? "" ) . trim ( ) )
. filter ( ( value : string ) = > value . length > 0 ) ,
) ;
const learnedKeyRoles = count (
examples
. flatMap ( ( item ) = > {
2026-04-12 19:24:24 +01:00
const people = isAdaptiveKeyPoints ( item . keyPoints )
? item . keyPoints . aiMeta ? . keyPeople
: undefined ;
2026-03-28 23:46:45 +01:00
return Array . isArray ( people ) ? people : [ ] ;
} )
2026-04-12 19:24:24 +01:00
. map ( ( person ) = > String ( person ? . role ? ? "" ) . trim ( ) )
2026-03-28 23:46:45 +01:00
. filter ( ( value : string ) = > value . length > 0 ) ,
) ;
2026-03-25 13:52:45 +01:00
const avgSummaryLength =
examples
. map ( ( item ) = > item . summary ? . length ? ? 0 )
. reduce ( ( sum , length ) = > sum + length , 0 ) / examples . length ;
return ` ADAPTIVE EXTRACTION CONTEXT FROM PREVIOUS DOCUMENTS:
- Frequent contract types in this workspace : $ { topTypes . join ( ", " ) || "N/A" }
- Frequent provider naming patterns : $ { topProviders . join ( ", " ) || "N/A" }
- Example policy number shape patterns : $ { policyPatterns . join ( ", " ) || "N/A" }
- Typical summary length target : around $ { Math . round ( avgSummaryLength ) } characters .
2026-03-28 23:46:45 +01:00
- Dominant learned languages : $ { learnedLanguages . join ( ", " ) || "N/A" }
- Most evidenced fields : $ { explainabilityByField . join ( ", " ) || "N/A" }
- Average evidence confidence : $ { avgEvidenceConfidence ? ? "N/A" }
- Frequent key roles identified : $ { learnedKeyRoles . join ( ", " ) || "N/A" }
2026-03-25 13:52:45 +01:00
Use this context only as formatting guidance . Do not force it if current document content differs . ` ;
}
/ * *
* Validate contract legitimacy .
*
* Rejection rules :
* - Model explicitly says document is not a contract
* - Model confidence for validity is critically low
* - Heuristic text signals suggest non - contract content
* /
private static assertValidContract (
2026-04-12 19:24:24 +01:00
raw : unknown ,
2026-03-25 13:52:45 +01:00
normalized : NormalizedAnalysis ,
) : void {
2026-04-12 19:24:24 +01:00
const validation = raw as ValidationEnvelope ;
const modelIsValid = validation . contractValidation ? . isValidContract ;
const confidenceRaw = Number ( validation . contractValidation ? . confidence ) ;
const modelReason = String (
validation . contractValidation ? . reason ? ? "" ,
) . trim ( ) ;
2026-03-25 13:52:45 +01:00
const legalSignalRegex =
2026-03-28 23:46:45 +01:00
/contract|agreement|policy|terms|clause|premium|coverage|insured|insurer|loan|borrower|credit|beneficiary|liability|lease|service|supplier|client|vendor|annex|appendix|signature|party|contrat|assurance|banque|credit|emprunteur|garantie|echeance|duree|clause/i ;
2026-03-25 13:52:45 +01:00
const hasLegalSignals = legalSignalRegex . test ( normalized . extractedText ) ;
const hasStructuredSignal =
Boolean ( normalized . provider ) ||
Boolean ( normalized . policyNumber ) ||
normalized . keyPoints . guarantees . length > 0 ||
normalized . keyPoints . exclusions . length > 0 ||
normalized . premium !== null ;
if ( modelIsValid === false ) {
throw new Error (
` INVALID_CONTRACT: ${ modelReason || "Uploaded file is not recognized as a contract." } ` ,
) ;
}
if ( Number . isFinite ( confidenceRaw ) && confidenceRaw < 45 ) {
throw new Error (
` INVALID_CONTRACT: ${ modelReason || "Contract confidence is too low. Please upload a clearer contract document." } ` ,
) ;
}
2026-03-28 23:46:45 +01:00
// For generic contracts mapped to OTHER, keep a lighter heuristic so valid non-BFSI contracts pass.
if ( normalized . type === "OTHER" ) {
if ( ! hasLegalSignals && normalized . extractedText . length < 120 ) {
throw new Error (
"INVALID_CONTRACT:Uploaded file does not contain enough contract-specific signals." ,
) ;
}
return ;
}
2026-03-25 13:52:45 +01:00
if ( ! hasLegalSignals && ! hasStructuredSignal ) {
throw new Error (
"INVALID_CONTRACT:Uploaded file does not contain enough contract-specific signals." ,
) ;
}
}
/ * *
* Validate that AI results have all required fields
* /
2026-04-12 19:24:24 +01:00
static validateAnalysis ( data : unknown ) : boolean {
2026-03-25 13:52:45 +01:00
try {
// Validation uses same normalizer used in production flow.
this . normalizeAnalysis ( data ) ;
return true ;
} catch {
return false ;
}
}
/ * *
* Parse date string to Date object
* /
static parseDate ( dateString : string | null | undefined ) : Date | undefined {
if ( ! dateString ) return undefined ;
try {
const date = new Date ( dateString ) ;
if ( isNaN ( date . getTime ( ) ) ) {
return undefined ;
}
return date ;
2026-04-12 19:24:24 +01:00
} catch {
2026-03-25 13:52:45 +01:00
return undefined ;
}
}
/ * *
* Format currency amount
* /
static formatCurrency ( amount : number | null | undefined ) : string {
if ( ! amount ) return "N/A" ;
return new Intl . NumberFormat ( "fr-FR" , {
style : "currency" ,
currency : "EUR" ,
} ) . format ( amount ) ;
}
static async askAboutContract ( input : {
question : string ;
2026-04-12 19:24:24 +01:00
ragChunks? : Array < { chunkIndex : number ; content : string ; score : number } > ;
2026-03-25 13:52:45 +01:00
contract : {
2026-04-12 19:24:24 +01:00
id : string ;
2026-03-25 13:52:45 +01:00
fileName : string ;
title? : string | null ;
type ? : string | null ;
provider? : string | null ;
policyNumber? : string | null ;
startDate? : Date | string | null ;
endDate? : Date | string | null ;
premium? : number | null ;
summary? : string | null ;
keyPoints? : Record < string , unknown > | null ;
extractedText? : string | null ;
2026-03-28 23:46:45 +01:00
language? : string | null ; // NEW: contract's detected language
2026-03-25 13:52:45 +01:00
} ;
} ) {
try {
2026-04-12 19:24:24 +01:00
// Retrieve best matching persisted chunks for grounded Q&A.
let ragChunks = input . ragChunks ? ? [ ] ;
if ( ragChunks . length === 0 ) {
try {
ragChunks = await RAGService . retrieveRelevantChunks ( {
contractId : input.contract.id ,
question : input.question ,
topK : 6 ,
} ) ;
} catch ( error ) {
console . warn (
"RAG chunk retrieval failed. Falling back to extracted snippet." ,
error ,
) ;
}
}
2026-03-25 13:52:45 +01:00
// Keep context bounded to avoid overlong prompts and token waste.
const extractedTextSnippet = ( input . contract . extractedText || "" )
2026-04-12 19:24:24 +01:00
. slice ( 0 , 5000 )
2026-03-25 13:52:45 +01:00
. trim ( ) ;
2026-04-12 19:24:24 +01:00
const ragContext =
ragChunks . length > 0
? RAGService . buildChunkContext ( ragChunks )
: extractedTextSnippet || "N/A" ;
2026-03-25 13:52:45 +01:00
const contractTypeGuidance = this . getContractTypeGuidance (
input . contract . type ,
) ;
2026-03-28 23:46:45 +01:00
// Detect contract language for multilingual response
const contractLanguage = input . contract . language || "en" ;
const languageName =
{
en : "English" ,
fr : "French" ,
de : "German" ,
es : "Spanish" ,
it : "Italian" ,
pt : "Portuguese" ,
nl : "Dutch" ,
pl : "Polish" ,
ja : "Japanese" ,
zh : "Chinese" ,
ar : "Arabic" ,
} [ contractLanguage ] || "English" ;
const prompt = ` You are a senior BFSI contract advisor. IMPORTANT: Respond entirely in ${ languageName } to match the contract language.
2026-03-25 13:52:45 +01:00
Contract metadata :
- File : $ { input . contract . fileName }
2026-03-28 23:46:45 +01:00
- Language : $ { languageName }
2026-03-25 13:52:45 +01:00
- Title : $ { input . contract . title ? ? "N/A" }
- Type : $ { input . contract . type ? ? "N/A" }
- Provider : $ { input . contract . provider ? ? "N/A" }
- Policy Number : $ { input . contract . policyNumber ? ? "N/A" }
- Start Date : $ { input . contract . startDate ? ? "N/A" }
- End Date : $ { input . contract . endDate ? ? "N/A" }
- Premium : $ { input . contract . premium ? ? "N/A" }
Summary :
$ { input . contract . summary ? ? "N/A" }
Key Points ( JSON ) :
$ { JSON . stringify ( input . contract . keyPoints ? ? { } , null , 2 ) }
2026-04-12 19:24:24 +01:00
Grounded RAG Context :
$ { ragContext }
2026-03-25 13:52:45 +01:00
2026-03-28 23:46:45 +01:00
User question ( $ { languageName } ) :
2026-03-25 13:52:45 +01:00
$ { input . question }
Instructions :
2026-03-28 23:46:45 +01:00
- RESPOND ENTIRELY IN $ { languageName } . This is critical .
2026-03-25 13:52:45 +01:00
- Write in clear , professional , business - oriented plain text .
2026-03-28 23:46:45 +01:00
- Do NOT use markdown or special formatting symbols , including : * * , __ , # , * , - , backticks with one exception : you can use | for separators if needed for clarity
2026-03-25 13:52:45 +01:00
- Do NOT quote large raw excerpts from extracted text unless strictly necessary .
- Synthesize and explain the implications in practical terms instead of copying file content .
- Base your answer ONLY on the provided contract content .
2026-04-12 19:24:24 +01:00
- Prioritize information from Grounded RAG Context over any assumptions .
2026-03-25 13:52:45 +01:00
- Adapt answer emphasis using this type guidance : $ { contractTypeGuidance }
- If information is missing , explicitly say : Information not found in the analyzed contract .
- If the question asks about legal consequences or non - compliance , provide general legal context for EU / USA at a high level only .
- For legal context , use wording like : "Under general EU/US legal principles..." and avoid citing specific article numbers unless explicitly present in the contract content .
- Never claim certainty where the contract text is ambiguous .
- Keep the answer concise , executive , and decision - oriented .
2026-03-28 23:46:45 +01:00
- Use the same language preference throughout ( $ { languageName } ) .
2026-04-12 19:24:24 +01:00
- Add one short evidence line at the end in this format : Source basis : Chunk X , Chunk Y ( or Source basis : extracted contract text ) .
2026-03-25 13:52:45 +01:00
2026-03-28 23:46:45 +01:00
Response structure ( in $ { languageName } ) :
2026-03-25 13:52:45 +01:00
1 ) Direct answer in one sentence .
2 ) Business impact in one to two sentences ( risk , cost , operational effect ) .
3 ) General legal context in one to two sentences when relevant .
4 ) Recommended next step in one sentence .
2026-03-28 23:46:45 +01:00
Compliance note ( in $ { languageName } ) :
2026-03-25 13:52:45 +01:00
Include one short disclaimer only when legal context is discussed : "This is general information, not formal legal advice." ` ;
2026-03-28 23:46:45 +01:00
// Execute completion with model fallback and sanitize styling artifacts.
let rawAnswer = "" ;
let lastError : unknown = null ;
2026-04-19 01:42:00 +01:00
for ( const modelName of GEMINI_ANALYSIS_MODELS ) {
2026-03-28 23:46:45 +01:00
try {
2026-04-12 19:24:24 +01:00
rawAnswer = await keyManager . execute ( async ( genAI ) = > {
const model = genAI . getGenerativeModel ( {
model : modelName ,
generationConfig : {
temperature : 0.2 ,
topP : 0.95 ,
topK : 40 ,
maxOutputTokens : 2048 ,
} ,
} ) ;
const result = await model . generateContent ( prompt ) ;
const text = result . response . text ( ) ? . trim ( ) || "" ;
if ( text ) {
console . log (
` ✅ Q&A with model ${ modelName } succeeded in ${ languageName } ` ,
) ;
return text ;
}
throw new Error ( "Empty response" ) ;
2026-03-28 23:46:45 +01:00
} ) ;
if ( rawAnswer ) {
break ;
}
2026-04-12 19:24:24 +01:00
} catch ( error : any ) {
if ( error . message ? . includes ( "CRITICAL_KEY_EXHAUSTION" ) ) throw error ;
2026-03-28 23:46:45 +01:00
lastError = error ;
console . warn (
` Q&A with model ${ modelName } failed. Trying next model. ` ,
) ;
}
}
2026-03-25 13:52:45 +01:00
2026-04-19 01:42:00 +01:00
if ( ! rawAnswer ) {
try {
2026-05-03 13:26:31 +01:00
rawAnswer = await this . generateWithMistralModelChain ( {
2026-04-19 01:42:00 +01:00
preferredModel : FALLBACK_ANALYSIS_MODEL ,
systemPrompt : ` You are a senior BFSI contract advisor. Answer questions about contracts accurately and professionally. Respond entirely in ${ languageName } . Use plain text only — no markdown, no bold, no headers, no bullet points. Base your answers ONLY on the provided contract content. If information is missing, say so. ` ,
prompt ,
responseAsJson : false ,
maxOutputTokens : 2048 ,
temperature : 0.2 ,
topP : 0.95 ,
} ) ;
console . log (
2026-05-03 13:26:31 +01:00
` ✅ Q&A fallback with Mistral model ${ FALLBACK_ANALYSIS_MODEL } succeeded in ${ languageName } ` ,
2026-04-19 01:42:00 +01:00
) ;
2026-05-03 13:26:31 +01:00
} catch ( mistralError ) {
lastError = mistralError ;
2026-04-19 01:42:00 +01:00
}
}
2026-03-25 13:52:45 +01:00
if ( ! rawAnswer ) {
2026-03-28 23:46:45 +01:00
if ( lastError instanceof Error ) {
throw lastError ;
}
2026-03-25 13:52:45 +01:00
throw new Error ( "No response generated" ) ;
}
const sanitizedAnswer = rawAnswer
. replace ( /\*\*/g , "" )
. replace ( /__/g , "" )
. replace ( /`/g , "" )
. replace ( /^\s*#{1,6}\s*/gm , "" )
. replace ( /^\s*[-*]\s+/gm , "" )
. replace ( /\n{3,}/g , "\n\n" )
. trim ( ) ;
return sanitizedAnswer ;
2026-04-12 19:24:24 +01:00
} catch ( error : unknown ) {
const errorMessage =
error instanceof Error ? error.message : String ( error ) ;
if ( errorMessage . includes ( "API key" ) ) {
2026-05-03 13:26:31 +01:00
throw new Error ( "Invalid or missing AI API key (Gemini/Mistral)." ) ;
2026-04-19 01:42:00 +01:00
}
2026-05-03 13:26:31 +01:00
if ( this . isTransientAIError ( errorMessage ) ) {
2026-04-19 01:42:00 +01:00
throw new Error (
2026-05-03 13:26:31 +01:00
` The AI providers (Gemini/Mistral) are temporarily overloaded for the configured Q&A models ( ${ ANALYSIS_MODELS . join ( ", " ) } ). Please try again in a few minutes. ` ,
2026-04-19 01:42:00 +01:00
) ;
2026-03-25 13:52:45 +01:00
}
2026-04-12 19:24:24 +01:00
throw new Error ( ` Error answering question: ${ errorMessage } ` ) ;
2026-03-25 13:52:45 +01:00
}
}
}