Files
LexiChain/lib/services/ai.service.ts

1458 lines
50 KiB
TypeScript
Raw Normal View History

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 =
process.env.AI_MODEL_SECONDARY_GEMINI || "";
2026-03-28 23:46:45 +01:00
const FALLBACK_ANALYSIS_MODEL =
2026-04-19 01:42:00 +01:00
process.env.AI_MODEL_FALLBACK || "llama-3.3-70b-versatile";
const FALLBACK_REPAIR_MODEL =
process.env.AI_MODEL_FALLBACK_REPAIR || "llama-3.3-70b-versatile";
const GROQ_API_KEY =
process.env.GROQ_API_KEY?.trim() || process.env.AI_GROQ_API_KEY?.trim() || "";
const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions";
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-04-19 01:42:00 +01:00
new Set([...GEMINI_ANALYSIS_MODELS, `groq:${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-04-19 01:42:00 +01:00
private static isTransientGeminiError(message: string): boolean {
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-04-19 01:42:00 +01:00
"Invalid or missing AI API key. Check AI_API_KEY1/2/3 for Gemini and GROQ_API_KEY for Groq 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-04-19 01:42:00 +01:00
} else if (this.isTransientGeminiError(errorMessage)) {
throw new Error(
`Gemini is temporarily overloaded for the configured analysis models (${ANALYSIS_MODELS.join(", ")}). The app retried automatically, but both models are still busy. Please try again in a few minutes.`,
);
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-04-19 01:42:00 +01:00
"Limit exceeded. Gemini or Groq 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-04-19 01:42:00 +01:00
private static isGroqConfigured(): boolean {
return GROQ_API_KEY.length > 0;
}
private static async generateWithGroq(input: {
model?: string;
prompt: string;
systemPrompt?: string;
responseAsJson: boolean;
maxOutputTokens: number;
temperature?: number;
topP?: number;
}): Promise<string> {
if (!this.isGroqConfigured()) {
throw new Error(
"Groq fallback is not configured. Set GROQ_API_KEY (or AI_GROQ_API_KEY).",
);
}
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)
const responseFormat: Record<string, unknown> | undefined = input.responseAsJson
? { type: "json_object" as const }
: undefined;
const body: Record<string, unknown> = {
model: modelName,
temperature: input.temperature ?? 0,
top_p: input.topP ?? 0.95,
max_tokens: input.maxOutputTokens,
response_format: responseFormat,
messages,
};
const response = await fetch(GROQ_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${GROQ_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const details = await response.text();
throw new Error(
`Groq 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 Groq fallback model.");
}
return text;
}
private static async generateWithGroqModelChain(input: {
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,
"llama-3.3-70b-versatile",
"qwen-2.5-32b",
"llama-3.1-8b-instant",
].filter(Boolean),
),
) as string[];
let lastError: unknown = null;
for (const modelName of candidates) {
try {
const text = await this.generateWithGroq({
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(
`Groq switched to fallback model ${modelName} after primary fallback model failed.`,
);
}
return text;
} catch (error) {
lastError = error;
console.warn(
`Groq model ${modelName} failed. Trying next fallback model.`,
error instanceof Error ? error.message : String(error),
);
}
}
throw lastError instanceof Error
? lastError
: new Error("All Groq fallback models failed.");
}
/**
* Build a Groq-optimized system prompt that mirrors the Gemini behavior.
* This separates role & formatting rules from user content for better
* instruction adherence on open-source models.
*/
private static buildGroqSystemPrompt(): string {
return `You are an expert contract analysis engine for the BFSI (Banking, Financial Services, and Insurance) sector.
You receive the full text content of a contract document below and must extract structured information from it.
CRITICAL OUTPUT RULES:
1. Return ONLY valid, parseable JSON no markdown, no backticks, no explanations, no commentary.
2. Your JSON must conform EXACTLY to the schema specified in the user prompt.
3. Every required field MUST be present. Use null for missing strings/numbers and [] for missing arrays.
4. All dates MUST be in ISO YYYY-MM-DD format or null.
5. The "premium" field must be a positive number or null NO currency symbols.
6. The "type" field MUST be one of: INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER.
7. Do NOT hallucinate or invent data that is not present in the document.
8. Preserve original language in extractedText and sourceSnippet fields (accents, special characters).
9. The "summary" must be 4-6 professional sentences covering parties, obligations, coverage, exclusions, and deadlines.
10. The "extractedText" must contain at least 30 characters of actual document content.
11. The "keyPoints.explainability" array must have at least 4 items for critical fields when data is available.
12. contractValidation.confidence must reflect actual extraction certainty (0-100).
13. When uncertain about a value, use null and set a lower confidence never guess.
14. Parse localized number formats correctly (e.g., 1.234,56 vs 1,234.56).
15. Detect the contract language and set the "language" field accordingly (ISO 639-1).
You are replacing a more capable multimodal model (Gemini) as a fallback. Your output quality MUST match production standards.`;
}
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);
const buildGroundedGroqPrompt = async (basePrompt: string) => {
const groundingText = await this.extractGroqGroundingText({
base64: input.base64,
mimeType: input.mimeType,
});
2026-03-28 23:46:45 +01:00
2026-04-19 01:42:00 +01:00
if (!groundingText) {
return `${basePrompt}\n\nGROQ FALLBACK RULES:\n- You do not have direct binary file access in this fallback path.\n- Do not hallucinate values; use null/empty arrays when data is missing.\n- Keep contractValidation conservative when uncertain.\n- Set contractValidation.confidence to at most 60 when no grounding text is available.`;
}
return `${basePrompt}\n\n--- BEGIN GROUNDED DOCUMENT TEXT (AUTHORITATIVE SOURCE) ---\n${groundingText}\n--- END GROUNDED DOCUMENT TEXT ---\n\nGROQ FALLBACK RULES:\n- Extract fields ONLY from the grounded document text above. This text is the full contract content.\n- Do not invent, assume, or hallucinate any values not explicitly present in the above text.\n- If a field's data is not found in the text, use null (for strings/numbers) or [] (for arrays).\n- Dates: convert any date format found in the text to YYYY-MM-DD.\n- Numbers: parse localized formats (comma vs period) correctly before setting numeric fields.\n- contractValidation.confidence should reflect how much data you could extract from the text.`;
};
if (forceFallback) {
console.warn(
`🧪 Fallback test mode enabled. Skipping Gemini and forcing Groq model ${FALLBACK_ANALYSIS_MODEL}.`,
);
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt);
return this.generateWithGroqModelChain({
preferredModel: FALLBACK_ANALYSIS_MODEL,
systemPrompt: this.buildGroqSystemPrompt(),
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) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
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) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
2026-03-28 23:46:45 +01:00
console.warn("Lenient generation also failed:", error);
}
2026-03-25 13:52:45 +01:00
2026-04-19 01:42:00 +01:00
// === Groq fallback path ===
console.warn(
"All Gemini models exhausted. Activating Groq fallback pipeline...",
);
try {
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt);
const groqText = await this.generateWithGroqModelChain({
preferredModel: FALLBACK_ANALYSIS_MODEL,
systemPrompt: this.buildGroqSystemPrompt(),
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(
`✅ Analysis fallback with Groq model ${FALLBACK_ANALYSIS_MODEL} succeeded`,
);
return groqText;
} catch (groqError) {
console.warn("Groq analysis fallback failed:", groqError);
}
2026-03-28 23:46:45 +01:00
throw lastError instanceof Error
? lastError
2026-04-19 01:42:00 +01:00
: new Error(
"All analysis models (Gemini + Groq fallback) failed to generate content.",
);
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-04-19 01:42:00 +01:00
const repairedText = await this.generateWithGroqModelChain({
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.`;
const secondPass = await this.generateWithGroqModelChain({
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-04-19 01:42:00 +01:00
private static async extractGroqGroundingText(input: {
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");
const { PDFParse } = await import("pdf-parse");
const parser = new PDFParse({ data: pdfBuffer });
let parsed: { text?: string };
try {
parsed = await parser.getText();
} finally {
await parser.destroy();
}
const text = (parsed?.text || "")
.replace(/\r/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (text && text.length > 50) {
console.log(
`📄 Groq grounding: extracted ${text.length} chars from PDF`,
);
return text.slice(0, 50000);
}
} catch (error) {
console.warn(
"PDF grounding extraction failed for Groq fallback.",
error,
);
}
}
// For images: try to extract text using Gemini OCR as grounding bridge.
// This gives Groq the text content it needs since it can't read images.
if (input.mimeType.startsWith("image/")) {
try {
const ocrText = await keyManager.execute(async (genAI) => {
const model = genAI.getGenerativeModel({
model: PRIMARY_ANALYSIS_MODEL,
generationConfig: {
temperature: 0,
maxOutputTokens: 8192,
},
});
const result = await model.generateContent([
"Extract ALL text from this document image exactly as it appears. Preserve structure, formatting, and all content. Return ONLY the raw text, no JSON, no commentary.",
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
},
},
]);
return result.response.text()?.trim() || "";
});
if (ocrText && ocrText.length > 50) {
console.log(
`🖼️ Groq grounding: extracted ${ocrText.length} chars from image via Gemini OCR bridge`,
);
return ocrText.slice(0, 50000);
}
} catch (error: any) {
// Gemini OCR bridge failed (likely key exhaustion), continue without
if (!error.message?.includes("CRITICAL_KEY_EXHAUSTION")) {
console.warn(
"Image grounding via Gemini OCR failed for Groq fallback; continuing without grounded text.",
error,
);
}
}
}
return "";
}
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 {
rawAnswer = await this.generateWithGroqModelChain({
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(
`✅ Q&A fallback with Groq model ${FALLBACK_ANALYSIS_MODEL} succeeded in ${languageName}`,
);
} catch (groqError) {
lastError = groqError;
}
}
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-04-19 01:42:00 +01:00
throw new Error("Invalid or missing AI API key (Gemini/Groq).");
}
if (this.isTransientGeminiError(errorMessage)) {
throw new Error(
`Gemini is temporarily overloaded for the configured Q&A models (${ANALYSIS_MODELS.join(", ")}). Please try again in a few minutes.`,
);
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
}
}
}