891 lines
29 KiB
TypeScript
891 lines
29 KiB
TypeScript
// src/lib/services/ai.service.ts
|
||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||
import { prisma } from "@/lib/db/prisma";
|
||
|
||
// Read API key from environment once at module load.
|
||
const API_KEY = process.env.AI_API_KEY;
|
||
|
||
if (!API_KEY) {
|
||
console.error("❌ AI_API_KEY is missing from environment variables");
|
||
console.error("Please add AI_API_KEY to your .env file");
|
||
throw new Error("AI_API_KEY is not configured");
|
||
}
|
||
|
||
// Initialize Gemini
|
||
const genAI = new GoogleGenerativeAI(API_KEY);
|
||
|
||
// Runtime options used by analysis.
|
||
type AnalyzeOptions = {
|
||
userId?: string;
|
||
fileName?: string;
|
||
maxRetries?: number;
|
||
};
|
||
|
||
// Canonical shape returned by this service after normalization and validation.
|
||
type NormalizedAnalysis = {
|
||
title: string;
|
||
type:
|
||
| "INSURANCE_AUTO"
|
||
| "INSURANCE_HOME"
|
||
| "INSURANCE_HEALTH"
|
||
| "INSURANCE_LIFE"
|
||
| "LOAN"
|
||
| "CREDIT_CARD"
|
||
| "INVESTMENT"
|
||
| "OTHER";
|
||
provider: string | null;
|
||
policyNumber: string | null;
|
||
startDate: string | null;
|
||
endDate: string | null;
|
||
premium: number | null;
|
||
summary: string;
|
||
keyPoints: {
|
||
guarantees: string[];
|
||
exclusions: string[];
|
||
franchise: string | null;
|
||
importantDates: string[];
|
||
};
|
||
extractedText: string;
|
||
};
|
||
|
||
type ContractPrecheckResult = {
|
||
isValidContract: boolean;
|
||
confidence: number;
|
||
reason: string | null;
|
||
};
|
||
|
||
export class AIService {
|
||
/**
|
||
* 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) {
|
||
try {
|
||
const maxRetries = Math.min(3, Math.max(1, options?.maxRetries ?? 2));
|
||
|
||
// 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 3: Configure model for deterministic, JSON-centric extraction.
|
||
const model = genAI.getGenerativeModel({
|
||
model: "gemini-2.5-flash",
|
||
generationConfig: {
|
||
temperature: 0.1, // Low for consistency
|
||
topP: 0.95,
|
||
topK: 40,
|
||
maxOutputTokens: 8192,
|
||
responseMimeType: "application/json",
|
||
},
|
||
});
|
||
|
||
// Step 4: Build adaptive extraction context from previously analyzed contracts.
|
||
const adaptiveContext = await this.buildAdaptiveContext(options?.userId);
|
||
const basePrompt = this.buildPrompt({
|
||
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.
|
||
const result = await model.generateContent([
|
||
`${basePrompt}${correctionHint}`,
|
||
{
|
||
inlineData: {
|
||
data: base64,
|
||
mimeType: mimeType,
|
||
},
|
||
},
|
||
]);
|
||
|
||
const text = result.response.text();
|
||
if (!text) {
|
||
lastValidationError = "No content in AI response";
|
||
continue;
|
||
}
|
||
|
||
previousRawResponse = text;
|
||
|
||
try {
|
||
// Step 6: Parse and normalize output into canonical structure.
|
||
const parsed = this.parseJsonResponse(text);
|
||
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;
|
||
} catch (validationError: any) {
|
||
// If validation fails, keep reason and retry with correction guidance.
|
||
lastValidationError =
|
||
validationError?.message || "Failed to parse model output";
|
||
if (attempt === maxRetries) {
|
||
throw new Error(lastValidationError);
|
||
}
|
||
}
|
||
}
|
||
|
||
throw new Error("AI analysis failed after retries.");
|
||
} catch (error: any) {
|
||
// Better error messages
|
||
if (error.message?.includes("API key")) {
|
||
throw new Error(
|
||
"Invalid or missing Gemini API key. Check AI_API_KEY in your .env file",
|
||
);
|
||
} else if (error.message?.includes("INVALID_CONTRACT:")) {
|
||
const reason = String(error.message)
|
||
.replace("INVALID_CONTRACT:", "")
|
||
.trim();
|
||
throw new Error(
|
||
reason || "Uploaded file is not recognized as a valid contract.",
|
||
);
|
||
} else if (
|
||
error.message?.includes("not found") ||
|
||
error.message?.includes("404")
|
||
) {
|
||
throw new Error(
|
||
"Invalid Gemini model. Ensure 'gemini-2.5-flash' is available in your Google Cloud project.",
|
||
);
|
||
} else if (
|
||
error.message?.includes("fetch") &&
|
||
!error.message?.includes("generativelanguage")
|
||
) {
|
||
throw new Error(
|
||
"Download failed. Check if the file URL is correct and accessible.",
|
||
);
|
||
} else if (error.message?.includes("JSON")) {
|
||
console.error("❌ Raw response that failed to parse:", error);
|
||
console.error("Full error message:", error.message);
|
||
|
||
// Help user understand what went wrong
|
||
if (error.message?.includes("escaped quotes")) {
|
||
throw new Error(
|
||
"The contract contains special characters that corrupted the analysis. Try uploading a cleaner version.",
|
||
);
|
||
} else if (error.message?.includes("incomplete")) {
|
||
throw new Error(
|
||
"AI analysis failed to complete properly. This might be a large or complex contract. Try a smaller contract first.",
|
||
);
|
||
} else if (error.message?.includes("missing expected")) {
|
||
throw new Error(
|
||
"This doesn't appear to be a valid financial/insurance contract. Please upload a legitimate contract document.",
|
||
);
|
||
} else {
|
||
throw new Error(
|
||
"Error parsing AI response. The response may not be valid JSON. Check console for details.",
|
||
);
|
||
}
|
||
} else if (error.message?.includes("quota")) {
|
||
throw new Error(
|
||
"Limit exceeded. Your Gemini API quota may be exhausted. Check your Google Cloud Console for usage details.",
|
||
);
|
||
} else {
|
||
throw new Error(`Error analyzing contract: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build extraction prompt with strict schema + anti-hallucination instructions.
|
||
*/
|
||
private static buildPrompt(input?: {
|
||
adaptiveContext?: string;
|
||
fileName?: string;
|
||
}): string {
|
||
return `You are an expert in BFSI contract analysis (Banking, Financial Services, Insurance).
|
||
|
||
Document name: ${input?.fileName ?? "Unknown"}
|
||
|
||
${input?.adaptiveContext ?? ""}
|
||
|
||
Analyze this contract document and extract ALL important information in the EXACT JSON format below:
|
||
|
||
{
|
||
"title": "Descriptive contract title (e.g., Allianz Car Insurance)",
|
||
"type": "INSURANCE_AUTO",
|
||
"provider": "Name of the company or financial institution",
|
||
"policyNumber": "Policy number or contract number",
|
||
"startDate": "2024-01-01",
|
||
"endDate": "2024-12-31",
|
||
"premium": 1200.50,
|
||
"summary": "Clear and concise summary of the contract in a maximum of 3–4 sentences, covering the main guarantees and conditions",
|
||
"keyPoints": {
|
||
"guarantees": ["List of main guarantees or coverages provided"],
|
||
"exclusions": ["List of important exclusions to be aware of"],
|
||
"franchise": "Deductible amount or description (e.g., €500)",
|
||
"importantDates": ["Key dates and important deadlines"]
|
||
},
|
||
"contractValidation": {
|
||
"isValidContract": true,
|
||
"confidence": 88,
|
||
"reason": "Short reason if invalid, otherwise null"
|
||
},
|
||
"extractedText": "Full text extracted from the document with all details"
|
||
}
|
||
|
||
CRITICAL INSTRUCTIONS:
|
||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
TYPE — Must be EXACTLY one of the following values:
|
||
|
||
INSURANCE_AUTO (car insurance)
|
||
|
||
INSURANCE_HOME (home insurance)
|
||
|
||
INSURANCE_HEALTH (health insurance/mutual)
|
||
|
||
INSURANCE_LIFE (life insurance)
|
||
|
||
LOAN (bank loan)
|
||
|
||
CREDIT_CARD (credit card)
|
||
|
||
INVESTMENT (investment account)
|
||
|
||
OTHER (other type)
|
||
|
||
DATES — Strict format YYYY-MM-DD (e.g., 2024-01-15)
|
||
|
||
PREMIUM — Decimal number only (e.g., 1200.50, no text)
|
||
|
||
NULL — If information does not exist, use null (not an empty string "")
|
||
|
||
CONTRACT VALIDATION — Determine whether this document is truly a contract/policy/loan agreement.
|
||
- contractValidation.isValidContract must be false for invoices, receipts, ID cards, blank scans, random photos, marketing flyers, or unrelated files.
|
||
- confidence must be an integer from 0 to 100.
|
||
- reason must explain why invalid when isValidContract is false.
|
||
|
||
EXTRACTED TEXT — Must contain ALL visible text from the document
|
||
|
||
SUMMARY — Maximum 4 sentences, clear and informative
|
||
|
||
RESPONSE — Respond ONLY with valid JSON, no text before or after, no markdown
|
||
|
||
QUALITY GUARDRAILS:
|
||
- Never invent provider names, policy numbers, dates, or premium values.
|
||
- If uncertain, use null for that field.
|
||
- Keep extractedText raw and faithful to the visible document content.
|
||
- For summary and key points, prioritize practical legal and business implications.
|
||
|
||
NOW ANALYZE THE DOCUMENT:`;
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
if (!text || typeof text !== "string" || text.trim().length === 0) {
|
||
throw new Error("AI response is empty or invalid.");
|
||
}
|
||
|
||
// Remove potential markdown wrappers, comments, and extra whitespace
|
||
let cleanJson = text
|
||
.replace(/```json[\s\n]*/, "") // Remove opening markdown
|
||
.replace(/```[\s\n]*$/, "") // Remove closing markdown
|
||
.replace(/\/\/.*$/gm, "") // Remove JavaScript comments
|
||
.trim();
|
||
|
||
// Check for common issues that indicate incomplete/corrupted response
|
||
const responsePreview = cleanJson.substring(0, 200);
|
||
console.log("🔍 AI Response preview:", responsePreview);
|
||
|
||
// Try direct parse first
|
||
try {
|
||
const result = JSON.parse(cleanJson);
|
||
console.log("✅ JSON parsed successfully on first attempt");
|
||
return result;
|
||
} catch (firstError) {
|
||
console.warn(
|
||
"⚠️ First JSON parse failed:",
|
||
(firstError as Error).message,
|
||
);
|
||
}
|
||
|
||
// Fallback 1: Try removing non-JSON text (explanations before/after JSON)
|
||
try {
|
||
const firstCurly = cleanJson.indexOf("{");
|
||
const lastCurly = cleanJson.lastIndexOf("}");
|
||
|
||
if (firstCurly === -1 || lastCurly === -1 || firstCurly >= lastCurly) {
|
||
throw new Error(
|
||
"No JSON object wrapper found (missing { or }). Response may be incomplete.",
|
||
);
|
||
}
|
||
|
||
// Ensure we get complete closing braces for nested objects
|
||
let braceCount = 0;
|
||
let endIndex = firstCurly;
|
||
|
||
for (let i = firstCurly; i < cleanJson.length; i++) {
|
||
if (cleanJson[i] === "{") braceCount++;
|
||
if (cleanJson[i] === "}") braceCount--;
|
||
if (braceCount === 0) {
|
||
endIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const jsonSlice = cleanJson.slice(firstCurly, endIndex + 1);
|
||
console.log("📝 Extracted JSON slice length:", jsonSlice.length);
|
||
|
||
const result = JSON.parse(jsonSlice);
|
||
console.log("✅ JSON parsed successfully after text removal");
|
||
return result;
|
||
} catch (fallbackError) {
|
||
console.error(
|
||
"❌ JSON fallback parsing failed:",
|
||
(fallbackError as Error).message,
|
||
);
|
||
console.error("Full raw response:", cleanJson.substring(0, 500));
|
||
|
||
// Last resort: Check for common formatting issues
|
||
if (cleanJson.includes('\\n"') || cleanJson.includes('\\"')) {
|
||
throw new Error(
|
||
"Response contains escaped quotes or newlines that couldn't be parsed. The contract may have corrupted text.",
|
||
);
|
||
}
|
||
|
||
if (!cleanJson.includes('"type"') && !cleanJson.includes('"title"')) {
|
||
throw new Error(
|
||
"Response is missing expected contract fields. It may not be a valid contract document.",
|
||
);
|
||
}
|
||
|
||
throw new Error(
|
||
`Failed to parse AI response as JSON: ${(fallbackError as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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> {
|
||
const model = genAI.getGenerativeModel({
|
||
model: "gemini-2.5-flash",
|
||
generationConfig: {
|
||
temperature: 0,
|
||
topP: 0.9,
|
||
topK: 20,
|
||
maxOutputTokens: 350,
|
||
responseMimeType: "application/json",
|
||
},
|
||
});
|
||
|
||
const result = await model.generateContent([
|
||
`You are validating whether an uploaded document is a legal/financial contract.
|
||
|
||
File name: ${input.fileName ?? "Unknown"}
|
||
|
||
Return ONLY JSON:
|
||
{
|
||
"isValidContract": true,
|
||
"confidence": 0,
|
||
"reason": null
|
||
}
|
||
|
||
Rules:
|
||
- isValidContract=false for invoices, receipts, identity cards, random photos/screenshots, blank pages, flyers, or unrelated files.
|
||
- confidence is an integer from 0 to 100.
|
||
- reason must be concise and user-friendly when invalid.
|
||
- If valid, reason can be null.
|
||
`,
|
||
{
|
||
inlineData: {
|
||
data: input.base64,
|
||
mimeType: input.mimeType,
|
||
},
|
||
},
|
||
]);
|
||
|
||
const raw = this.parseJsonResponse(result.response.text() || "{}");
|
||
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,
|
||
};
|
||
}
|
||
|
||
private static normalizeAnalysis(input: any): NormalizedAnalysis {
|
||
// Ensure contract type belongs to supported enum.
|
||
const validTypes = new Set([
|
||
"INSURANCE_AUTO",
|
||
"INSURANCE_HOME",
|
||
"INSURANCE_HEALTH",
|
||
"INSURANCE_LIFE",
|
||
"LOAN",
|
||
"CREDIT_CARD",
|
||
"INVESTMENT",
|
||
"OTHER",
|
||
]);
|
||
|
||
const type =
|
||
typeof input?.type === "string" && validTypes.has(input.type)
|
||
? input.type
|
||
: null;
|
||
|
||
if (!type) {
|
||
throw new Error("Contract type is missing or invalid.");
|
||
}
|
||
|
||
const title = String(input?.title || "").trim();
|
||
const summary = String(input?.summary || "").trim();
|
||
const extractedText = String(input?.extractedText || "").trim();
|
||
|
||
if (title.length < 3) {
|
||
throw new Error("Title is missing or too short.");
|
||
}
|
||
if (summary.length < 10) {
|
||
throw new Error("Summary is missing or too short.");
|
||
}
|
||
if (extractedText.length < 50) {
|
||
throw new Error("Extracted text is missing or too short.");
|
||
}
|
||
|
||
// Helper: normalize unknown primitive into string|null.
|
||
const toStringOrNull = (value: unknown): string | null => {
|
||
const normalized = String(value ?? "").trim();
|
||
return normalized.length > 0 ? normalized : null;
|
||
};
|
||
|
||
// Helper: accept only strict ISO date values.
|
||
const toDateOrNull = (value: unknown): string | null => {
|
||
const candidate = String(value ?? "").trim();
|
||
if (!candidate) return null;
|
||
|
||
const isIsoDate = /^\d{4}-\d{2}-\d{2}$/.test(candidate);
|
||
return isIsoDate ? candidate : null;
|
||
};
|
||
|
||
// Helper: sanitize array values into non-empty text list.
|
||
const toStringList = (value: unknown): string[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
return value
|
||
.map((item) => String(item ?? "").trim())
|
||
.filter((item) => item.length > 0);
|
||
};
|
||
|
||
// Premium must be numeric and non-negative.
|
||
const premiumValue =
|
||
input?.premium === null || input?.premium === undefined
|
||
? null
|
||
: Number(input.premium);
|
||
|
||
const premium =
|
||
premiumValue !== null &&
|
||
Number.isFinite(premiumValue) &&
|
||
premiumValue >= 0
|
||
? Number(premiumValue.toFixed(2))
|
||
: null;
|
||
|
||
return {
|
||
title,
|
||
type,
|
||
provider: toStringOrNull(input?.provider),
|
||
policyNumber: toStringOrNull(input?.policyNumber),
|
||
startDate: toDateOrNull(input?.startDate),
|
||
endDate: toDateOrNull(input?.endDate),
|
||
premium,
|
||
summary,
|
||
keyPoints: {
|
||
guarantees: toStringList(input?.keyPoints?.guarantees),
|
||
exclusions: toStringList(input?.keyPoints?.exclusions),
|
||
franchise: toStringOrNull(input?.keyPoints?.franchise),
|
||
importantDates: toStringList(input?.keyPoints?.importantDates),
|
||
},
|
||
extractedText,
|
||
};
|
||
}
|
||
|
||
private static async buildAdaptiveContext(userId?: string): Promise<string> {
|
||
// No user context means no adaptation baseline.
|
||
if (!userId) {
|
||
return "";
|
||
}
|
||
|
||
const examples = await prisma.contract.findMany({
|
||
where: {
|
||
userId,
|
||
status: "COMPLETED",
|
||
},
|
||
orderBy: {
|
||
updatedAt: "desc",
|
||
},
|
||
take: 12,
|
||
select: {
|
||
type: true,
|
||
provider: true,
|
||
policyNumber: true,
|
||
summary: true,
|
||
},
|
||
});
|
||
|
||
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"));
|
||
|
||
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.
|
||
|
||
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(
|
||
raw: any,
|
||
normalized: NormalizedAnalysis,
|
||
): void {
|
||
const modelIsValid = raw?.contractValidation?.isValidContract;
|
||
const confidenceRaw = Number(raw?.contractValidation?.confidence);
|
||
const modelReason = String(raw?.contractValidation?.reason ?? "").trim();
|
||
|
||
const legalSignalRegex =
|
||
/contract|agreement|policy|terms|clause|premium|coverage|insured|insurer|loan|borrower|credit|beneficiary|liability/i;
|
||
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."}`,
|
||
);
|
||
}
|
||
|
||
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
|
||
*/
|
||
static validateAnalysis(data: any): boolean {
|
||
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;
|
||
} catch (error) {
|
||
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;
|
||
contract: {
|
||
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;
|
||
};
|
||
}) {
|
||
try {
|
||
// Configure fast Q&A model tuned for concise answers.
|
||
const model = genAI.getGenerativeModel({
|
||
model: "gemini-2.5-flash",
|
||
generationConfig: {
|
||
temperature: 0.2,
|
||
topP: 0.95,
|
||
topK: 40,
|
||
maxOutputTokens: 2048,
|
||
},
|
||
});
|
||
|
||
// Keep context bounded to avoid overlong prompts and token waste.
|
||
const extractedTextSnippet = (input.contract.extractedText || "")
|
||
.slice(0, 12000)
|
||
.trim();
|
||
const contractTypeGuidance = this.getContractTypeGuidance(
|
||
input.contract.type,
|
||
);
|
||
|
||
const prompt = `You are a senior BFSI contract advisor.
|
||
|
||
Contract metadata:
|
||
- File: ${input.contract.fileName}
|
||
- 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)}
|
||
|
||
Extracted Text:
|
||
${extractedTextSnippet || "N/A"}
|
||
|
||
User question:
|
||
${input.question}
|
||
|
||
Instructions:
|
||
- Write in clear, professional, business-oriented plain text.
|
||
- Do NOT use markdown or special formatting symbols, including: **, __, #, *, -, backticks.
|
||
- 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.
|
||
- 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.
|
||
|
||
Response structure:
|
||
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.
|
||
|
||
Compliance note:
|
||
Include one short disclaimer only when legal context is discussed: "This is general information, not formal legal advice."`;
|
||
|
||
// Execute completion and sanitize styling artifacts from response.
|
||
const result = await model.generateContent(prompt);
|
||
const rawAnswer = result.response.text()?.trim();
|
||
|
||
if (!rawAnswer) {
|
||
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;
|
||
} catch (error: any) {
|
||
if (error.message?.includes("API key")) {
|
||
throw new Error("Invalid or missing Gemini API key.");
|
||
}
|
||
throw new Error(`Error answering question: ${error.message}`);
|
||
}
|
||
}
|
||
}
|