Release (Stable version)

This commit is contained in:
2026-04-12 19:24:24 +01:00
parent 9993bd232f
commit 185c680b37
18 changed files with 1771 additions and 485 deletions

View File

@@ -6,28 +6,19 @@ import {
ContractPrecheckResult,
NormalizedAnalysis,
} from "@/lib/services/ai/analysis.types";
import type { Prisma } from "@prisma/client";
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";
import { RAGService } from "@/lib/services/rag.service";
// Read API key from environment once at module load.
const API_KEY =
process.env.AI_API_KEY || process.env.AI_API_KEY2 || process.env.AI_API_KEY3;
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);
import { keyManager } from "@/lib/services/ai/key-manager";
const PRIMARY_ANALYSIS_MODEL =
process.env.AI_MODEL_PRIMARY || "gemini-2.5-flash";
process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview";
const FALLBACK_ANALYSIS_MODEL =
process.env.AI_MODEL_FALLBACK || "gemini-2.0-flash";
@@ -35,6 +26,51 @@ const ANALYSIS_MODELS = Array.from(
new Set([PRIMARY_ANALYSIS_MODEL, FALLBACK_ANALYSIS_MODEL]),
);
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);
};
export class AIService {
/**
* Domain-specific guidance for contract Q&A.
@@ -77,6 +113,7 @@ export class AIService {
* Supports both PDF and image files
*/
static async analyzeContract(fileUrl: string, options?: AnalyzeOptions) {
keyManager.resetKeys();
try {
const maxRetries = Math.min(3, Math.max(1, options?.maxRetries ?? 2));
@@ -191,10 +228,12 @@ export class AIService {
);
return normalized;
} catch (validationError: any) {
} catch (validationError: unknown) {
// If validation fails, keep reason and retry with correction guidance.
lastValidationError =
validationError?.message || "Failed to parse model output";
validationError instanceof Error
? validationError.message
: "Failed to parse model output";
if (attempt === maxRetries) {
throw new Error(lastValidationError);
}
@@ -202,51 +241,53 @@ export class AIService {
}
throw new Error("AI analysis failed after retries.");
} catch (error: any) {
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
// Better error messages
if (error.message?.includes("API key")) {
if (errorMessage.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)
} else if (errorMessage.includes("INVALID_CONTRACT:")) {
const reason = String(errorMessage)
.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")
errorMessage.includes("not found") ||
errorMessage.includes("404")
) {
throw new Error(
`Invalid Gemini model configuration. Current models: ${ANALYSIS_MODELS.join(", ")}. Check model availability in your Gemini account.`,
);
} else if (
error.message?.includes("fetch") &&
!error.message?.includes("generativelanguage")
errorMessage.includes("fetch") &&
!errorMessage.includes("generativelanguage")
) {
throw new Error(
"Download failed. Check if the file URL is correct and accessible.",
);
} else if (
error.message?.includes("JSON") ||
error.message?.includes("No complete JSON object") ||
error.message?.includes("parse failed")
errorMessage.includes("JSON") ||
errorMessage.includes("No complete JSON object") ||
errorMessage.includes("parse failed")
) {
console.error("❌ Raw response that failed to parse:", error);
console.error("Full error message:", error.message);
console.error("Full error message:", errorMessage);
// Help user understand what went wrong
if (error.message?.includes("escaped quotes")) {
if (errorMessage.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")) {
} else if (errorMessage.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")) {
} else if (errorMessage.includes("missing expected")) {
throw new Error(
"This doesn't appear to be a valid financial/insurance contract. Please upload a legitimate contract document.",
);
@@ -255,12 +296,12 @@ export class AIService {
"AI returned a malformed response format. Please retry analysis; if it fails again, the file may require OCR cleanup.",
);
}
} else if (error.message?.includes("quota")) {
} else if (errorMessage.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}`);
throw new Error(`Error analyzing contract: ${errorMessage}`);
}
}
}
@@ -318,33 +359,37 @@ export class AIService {
for (const modelName of ANALYSIS_MODELS) {
try {
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: {
temperature: 0.1,
topP: 0.95,
topK: 40,
maxOutputTokens: 16384,
responseMimeType: "application/json",
},
});
const result = await model.generateContent([
input.prompt,
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
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",
},
},
]);
});
const text = result.response.text();
if (text && text.trim().length > 0) {
console.log(`✅ Analysis with model ${modelName} succeeded`);
return text;
}
} catch (error) {
const result = await model.generateContent([
input.prompt,
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
},
},
]);
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;
lastError = error;
console.warn(
`Analysis with model ${modelName} failed. Trying next model.`,
@@ -358,33 +403,37 @@ export class AIService {
"All standard models failed. Trying with lenient generation config...",
);
try {
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
},
});
const result = await fallbackModel.generateContent([
input.prompt,
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
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
},
},
]);
});
const text = result.response.text();
if (text && text.trim().length > 0) {
console.log("✅ Lenient generation succeeded");
return text;
}
} catch (error) {
const result = await fallbackModel.generateContent([
input.prompt,
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
},
},
]);
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;
console.warn("Lenient generation also failed:", error);
}
@@ -398,46 +447,47 @@ export class AIService {
parseError: string,
): Promise<string | null> {
try {
const repairModelName = FALLBACK_ANALYSIS_MODEL;
const model = genAI.getGenerativeModel({
model: repairModelName,
generationConfig: {
temperature: 0,
topP: 0.9,
topK: 20,
maxOutputTokens: 16384,
responseMimeType: "application/json",
},
});
return await keyManager.execute(async (genAI) => {
const repairModelName = FALLBACK_ANALYSIS_MODEL;
const model = genAI.getGenerativeModel({
model: repairModelName,
generationConfig: {
temperature: 0,
topP: 0.9,
topK: 20,
maxOutputTokens: 16384,
responseMimeType: "application/json",
},
});
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: {
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: {
isValidContract: "boolean",
confidence: "number (0-100)",
reason: "string|null",
@@ -478,7 +528,9 @@ ${malformedResponse.slice(0, 14000)}`;
}
return repairedText;
} catch (error) {
});
} catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
console.warn("JSON repair step failed:", error);
return null;
}
@@ -548,10 +600,10 @@ ${malformedResponse.slice(0, 14000)}`;
}): Promise<ContractPrecheckResult> {
const rawText = await this.generatePrevalidationWithFallback(input);
let raw: any;
let raw: PrevalidationResponse;
try {
raw = this.parseJsonResponse(rawText || "{}");
} catch (error) {
raw = this.parseJsonResponse(rawText || "{}") as PrevalidationResponse;
} catch {
// If prevalidation JSON is malformed, assume it's a contract with moderate confidence
console.warn(
"Prevalidation JSON parse failed, assuming contract with moderate confidence",
@@ -591,32 +643,36 @@ ${malformedResponse.slice(0, 14000)}`;
for (const modelName of ANALYSIS_MODELS) {
try {
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: {
temperature: 0,
topP: 0.9,
topK: 20,
maxOutputTokens: 350,
responseMimeType: "application/json",
},
});
const result = await model.generateContent([
buildPrevalidationPrompt(input.fileName),
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
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",
},
},
]);
});
const text = result.response.text();
if (text && text.trim().length > 0) {
return text;
}
} catch (error) {
const result = await model.generateContent([
buildPrevalidationPrompt(input.fileName),
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
},
},
]);
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;
lastError = error;
console.warn(
`Pre-validation with model ${modelName} failed. Trying next model.`,
@@ -629,7 +685,7 @@ ${malformedResponse.slice(0, 14000)}`;
: new Error("All pre-validation models failed to generate content.");
}
private static normalizeAnalysis(input: any): NormalizedAnalysis {
private static normalizeAnalysis(input: unknown): NormalizedAnalysis {
return normalizeAiAnalysis(input);
}
@@ -639,7 +695,7 @@ ${malformedResponse.slice(0, 14000)}`;
return "";
}
const examples = await prisma.contract.findMany({
const examples: AdaptiveContractExample[] = await prisma.contract.findMany({
where: {
userId,
status: "COMPLETED",
@@ -693,19 +749,21 @@ ${malformedResponse.slice(0, 14000)}`;
const allExplainability = examples
.flatMap((item) => {
const maybeExplainability = (item.keyPoints as any)?.explainability;
const maybeExplainability = isAdaptiveKeyPoints(item.keyPoints)
? item.keyPoints.explainability
: undefined;
return Array.isArray(maybeExplainability) ? maybeExplainability : [];
})
.slice(0, 120);
const explainabilityByField = count(
allExplainability
.map((entry: any) => String(entry?.field ?? "").trim())
.map((entry) => String(entry?.field ?? "").trim())
.filter((value: string) => value.length > 0),
);
const confidenceValues = allExplainability
.map((entry: any) => Number(entry?.sourceHints?.confidence))
.map((entry) => Number(entry?.sourceHints?.confidence))
.filter((value: number) => Number.isFinite(value));
const avgEvidenceConfidence = confidenceValues.length
@@ -719,7 +777,11 @@ ${malformedResponse.slice(0, 14000)}`;
const learnedLanguages = count(
examples
.map((item) => (item.keyPoints as any)?.aiMeta?.language)
.map((item) =>
isAdaptiveKeyPoints(item.keyPoints)
? item.keyPoints.aiMeta?.language
: null,
)
.map((value) => String(value ?? "").trim())
.filter((value: string) => value.length > 0),
);
@@ -727,10 +789,12 @@ ${malformedResponse.slice(0, 14000)}`;
const learnedKeyRoles = count(
examples
.flatMap((item) => {
const people = (item.keyPoints as any)?.aiMeta?.keyPeople;
const people = isAdaptiveKeyPoints(item.keyPoints)
? item.keyPoints.aiMeta?.keyPeople
: undefined;
return Array.isArray(people) ? people : [];
})
.map((person: any) => String(person?.role ?? "").trim())
.map((person) => String(person?.role ?? "").trim())
.filter((value: string) => value.length > 0),
);
@@ -761,12 +825,15 @@ Use this context only as formatting guidance. Do not force it if current documen
* - Heuristic text signals suggest non-contract content
*/
private static assertValidContract(
raw: any,
raw: unknown,
normalized: NormalizedAnalysis,
): void {
const modelIsValid = raw?.contractValidation?.isValidContract;
const confidenceRaw = Number(raw?.contractValidation?.confidence);
const modelReason = String(raw?.contractValidation?.reason ?? "").trim();
const validation = raw as ValidationEnvelope;
const modelIsValid = validation.contractValidation?.isValidContract;
const confidenceRaw = Number(validation.contractValidation?.confidence);
const modelReason = String(
validation.contractValidation?.reason ?? "",
).trim();
const legalSignalRegex =
/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;
@@ -810,7 +877,7 @@ Use this context only as formatting guidance. Do not force it if current documen
/**
* Validate that AI results have all required fields
*/
static validateAnalysis(data: any): boolean {
static validateAnalysis(data: unknown): boolean {
try {
// Validation uses same normalizer used in production flow.
this.normalizeAnalysis(data);
@@ -832,7 +899,7 @@ Use this context only as formatting guidance. Do not force it if current documen
return undefined;
}
return date;
} catch (error) {
} catch {
return undefined;
}
}
@@ -850,7 +917,9 @@ Use this context only as formatting guidance. Do not force it if current documen
static async askAboutContract(input: {
question: string;
ragChunks?: Array<{ chunkIndex: number; content: string; score: number }>;
contract: {
id: string;
fileName: string;
title?: string | null;
type?: string | null;
@@ -866,10 +935,31 @@ Use this context only as formatting guidance. Do not force it if current documen
};
}) {
try {
// 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,
);
}
}
// Keep context bounded to avoid overlong prompts and token waste.
const extractedTextSnippet = (input.contract.extractedText || "")
.slice(0, 12000)
.slice(0, 5000)
.trim();
const ragContext =
ragChunks.length > 0
? RAGService.buildChunkContext(ragChunks)
: extractedTextSnippet || "N/A";
const contractTypeGuidance = this.getContractTypeGuidance(
input.contract.type,
);
@@ -910,8 +1000,8 @@ ${input.contract.summary ?? "N/A"}
Key Points (JSON):
${JSON.stringify(input.contract.keyPoints ?? {}, null, 2)}
Extracted Text:
${extractedTextSnippet || "N/A"}
Grounded RAG Context:
${ragContext}
User question (${languageName}):
${input.question}
@@ -923,6 +1013,7 @@ Instructions:
- 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.
- Prioritize information from Grounded RAG Context over any assumptions.
- 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.
@@ -930,6 +1021,7 @@ Instructions:
- Never claim certainty where the contract text is ambiguous.
- Keep the answer concise, executive, and decision-oriented.
- Use the same language preference throughout (${languageName}).
- Add one short evidence line at the end in this format: Source basis: Chunk X, Chunk Y (or Source basis: extracted contract text).
Response structure (in ${languageName}):
1) Direct answer in one sentence.
@@ -946,26 +1038,34 @@ Include one short disclaimer only when legal context is discussed: "This is gene
for (const modelName of ANALYSIS_MODELS) {
try {
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: {
temperature: 0.2,
topP: 0.95,
topK: 40,
maxOutputTokens: 2048,
},
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");
});
const result = await model.generateContent(prompt);
rawAnswer = result.response.text()?.trim() || "";
if (rawAnswer) {
console.log(
`✅ Q&A with model ${modelName} succeeded in ${languageName}`,
);
break;
}
} catch (error) {
} catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
lastError = error;
console.warn(
`Q&A with model ${modelName} failed. Trying next model.`,
@@ -990,11 +1090,13 @@ Include one short disclaimer only when legal context is discussed: "This is gene
.trim();
return sanitizedAnswer;
} catch (error: any) {
if (error.message?.includes("API key")) {
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes("API key")) {
throw new Error("Invalid or missing Gemini API key.");
}
throw new Error(`Error answering question: ${error.message}`);
throw new Error(`Error answering question: ${errorMessage}`);
}
}
}

View File

@@ -22,12 +22,12 @@ CRITICAL: Your response must be VALID, PARSEABLE JSON only. Do not include markd
"endDate": "2025-12-31",
"premium": 1200.50,
"premiumCurrency": "TND",
"summary": "Professional, comprehensive 4-6 sentence summary in the contract's language. Include: main parties, key obligations, coverage/benefits, exclusions, important deadlines, key contacts. Use **bold** for: names, numbers, dates, amounts, important terms.",
"summary": "Professional, comprehensive 4-6 sentence summary in the contract's language. Include: main parties, key obligations, coverage/benefits, exclusions, important deadlines, key contacts.",
"keyPoints": {
"guarantees": ["**Main Benefit 1**: Description", "**Main Benefit 2**: Description"],
"exclusions": ["**Exclusion 1**: Description with impact", "**Exclusion 2**: Description"],
"franchise": "**Deductible/Penalty**: €150 per claim or equivalent",
"importantDates": ["**Renewal Date**: 31 December annually", "**Payment Deadline**: 15th of each month"],
"guarantees": ["Main Benefit 1: Description", "Main Benefit 2: Description"],
"exclusions": ["Exclusion 1: Description with impact", "Exclusion 2: Description"],
"franchise": "Deductible/Penalty: €150 per claim or equivalent",
"importantDates": ["Renewal Date: 31 December annually", "Payment Deadline: 15th of each month"],
"explainability": [
{
"field": "endDate",
@@ -44,24 +44,24 @@ CRITICAL: Your response must be VALID, PARSEABLE JSON only. Do not include markd
]
},
"keyPeople": [
{"name": "**John Smith**", "role": "Policy Holder", "email": "john@example.com", "phone": "+33612345678"},
{"name": "**Jane Doe**", "role": "Insurance Agent", "email": "jane@insurer.com", "phone": "+33987654321"}
{"name": "John Smith", "role": "Policy Holder", "email": "john@example.com", "phone": "+33612345678"},
{"name": "Jane Doe", "role": "Insurance Agent", "email": "jane@insurer.com", "phone": "+33987654321"}
],
"contactInfo": {
"name": "**Policy Holder Name**",
"name": "Policy Holder Name",
"email": "holder@email.com",
"phone": "+33612345678",
"address": "123 Main Street, City, Postal Code",
"role": "Insured Person"
},
"importantContacts": [
{"name": "**Claims Department**", "email": "claims@insurer.com", "phone": "+33800000000"},
{"name": "**Customer Service**", "email": "support@insurer.com", "phone": "+33800111111"}
{"name": "Claims Department", "email": "claims@insurer.com", "phone": "+33800000000"},
{"name": "Customer Service", "email": "support@insurer.com", "phone": "+33800111111"}
],
"relevantDates": [
{"date": "2025-12-31", "description": "**Policy Expiration Date**", "type": "EXPIRATION"},
{"date": "2025-10-31", "description": "**Renewal Notice Deadline** (60 days before expiration)", "type": "RENEWAL"},
{"date": "1970-01-15", "description": "**Monthly Payment Due Date**", "type": "PAYMENT"}
{"date": "2025-12-31", "description": "Policy Expiration Date", "type": "EXPIRATION"},
{"date": "2025-10-31", "description": "Renewal Notice Deadline (60 days before expiration)", "type": "RENEWAL"},
{"date": "1970-01-15", "description": "Monthly Payment Due Date", "type": "PAYMENT"}
],
"extractedText": "Most relevant extracted text, preserving original structure and keywords. Include key clauses, definitions, obligations. Max 12000 chars.",
"contractValidation": {
@@ -78,18 +78,28 @@ CRITICAL FIELD EXTRACTION RULES:
1. **Language Detection**: Detect and return the contract's primary language (en, fr, de, es, it, pt, etc.). If mixed, return dominant language.
1.1 **Multi-language accuracy**:
- Preserve original character set (accents, Arabic script, umlauts, symbols) exactly in extractedText and sourceSnippet.
- Correctly parse dates in local formats (e.g., French, German, Spanish, Arabic locales) and normalize to YYYY-MM-DD.
- Correctly parse localized numbers (e.g., 1.234,56 and 1,234.56) before setting premium.
1.2 **Premium extraction priority**:
- Detect premium/amount clauses using nearby context words (premium, cotisation, prime, mensualite, annual, per claim, deductible).
- If multiple amounts exist, choose the one most clearly representing contract premium/payment obligation.
- If only percentage-based premium exists, set premium to null and mention the percentage in summary/keyPoints.
- premiumCurrency must reflect the contract currency exactly (ISO code if inferable).
2. **Summary (VERY IMPORTANT)**:
- Write 4-6 comprehensive sentences covering: parties involved, contract scope, key obligations, main coverage/benefits, critical exclusions, important deadlines
- Use **Party Name** for persons/entities mentioned
- Use **number** for all quantities, dates, amounts, percentages
- Use **YYYY-MM-DD** format for dates with **bold**
- Use plain text only (no markdown, no bold markers)
- Use YYYY-MM-DD format for explicit date mentions where possible
- Language: Professional business French, English, or contract's native language
- MUST be detailed enough that reader understands contract without opening PDF
3. **Key People Extraction**:
- Extract all named individuals: policy holders, insured parties, beneficiaries, signatories, agents, brokers
- Include roles, contact methods when visible in contract
- Use **bold** for names: {"name": "**John Smith**", ...}
- Use plain text only for names and labels
4. **Contact Information**:
- contactInfo: Details of PRIMARY policy holder or contract party
@@ -99,17 +109,17 @@ CRITICAL FIELD EXTRACTION RULES:
- Extract ALL dates with business meaning: expiration, renewal, payment due dates, review dates
- For recurring dates (monthly, annually): show pattern like "1970-01-15" for "15th of each month"
- Include type: EXPIRATION, RENEWAL, PAYMENT, REVIEW, or OTHER
- Each date must have clear **bold** description explaining its significance
- Each date must have a clear description explaining its significance
6. **Key Points**:
- Use **bold** for: benefit names, exclusion types, monetary amounts, coverage limits
- Example: "**Motor Coverage**: Collision and theft protection up to **€50,000**"
- Use concise plain text labels and include monetary amounts/limits when available
- Example: "Motor Coverage: Collision and theft protection up to €50,000"
- Make exclusions explicit and impactful
- Include franchise/deductible with bold currency and amount
- Include franchise/deductible with currency and amount when available
7. **Guarantees & Exclusions**:
- Be specific: "**Theft Coverage** includes keys, GPS, and aftermarket electronics"
- For exclusions, explain impact: "**Mechanical wear excluded** - means breakdowns in years 3+ not covered"
- Be specific: "Theft Coverage includes keys, GPS, and aftermarket electronics"
- For exclusions, explain impact: "Mechanical wear excluded - means breakdowns in years 3+ not covered"
8. **Email/Phone Extraction**: If present in contract, extract:
- Email addresses in format: contact@domain.com
@@ -127,6 +137,7 @@ CRITICAL FIELD EXTRACTION RULES:
- sourceHints.confidence: 0..100 confidence for that field extraction
- Keep sourceSnippet short (max 280 chars) but sufficiently specific to audit.
- Never invent snippet text not present in document.
- Prefer one snippet from each major section when available (header, financial clause, dates/terms, exclusions).
Field Type Rules:
- dates: ISO format YYYY-MM-DD or null. For recurring patterns, use canonical date (e.g., "0000-01-15" for "15th each month")

View File

@@ -0,0 +1,97 @@
import { GoogleGenerativeAI } from "@google/generative-ai";
export class ApiKeyManager {
private keys: string[];
private currentIndex: number = 0;
private genAIInstance: GoogleGenerativeAI;
constructor() {
// Collect all provided keys
const envKeys = [
process.env.AI_API_KEY1,
process.env.AI_API_KEY2,
process.env.AI_API_KEY3,
]
.map((key) => key?.trim())
.filter(Boolean) as string[];
this.keys = Array.from(new Set([...envKeys]));
if (this.keys.length === 0) {
console.error("❌ No AI API Keys are configured in the environment variables.");
throw new Error("No Gemini API keys configured. Set AI_API_KEY1, AI_API_KEY2, AI_API_KEY3 in your .env file.");
}
// Initialize with the first available key
this.genAIInstance = new GoogleGenerativeAI(this.keys[this.currentIndex]);
}
/**
* Reset to the first key. Call at the start of each new top-level request
* so that refreshed/renewed keys get a chance to be tried again.
*/
resetKeys() {
this.currentIndex = 0;
this.genAIInstance = new GoogleGenerativeAI(this.keys[0]);
}
private rotateKey() {
this.currentIndex++;
if (this.currentIndex >= this.keys.length) {
this.currentIndex = 0;
this.genAIInstance = new GoogleGenerativeAI(this.keys[0]);
throw new Error(
"CRITICAL_KEY_EXHAUSTION: All available API keys have failed, expired, or run out of quota."
);
}
console.warn(`⚠️ API Key failed. Swapping to backup key #${this.currentIndex + 1}...`);
this.genAIInstance = new GoogleGenerativeAI(this.keys[this.currentIndex]);
}
/**
* Wraps an SDK call. If it fails due to quota or auth errors, it automatically
* rotates the key and retries the operation transparently.
*/
async execute<T>(operation: (client: GoogleGenerativeAI) => Promise<T>): Promise<T> {
while (true) {
try {
return await operation(this.genAIInstance);
} catch (error: any) {
const msg = error?.message?.toLowerCase() || "";
const isAuthOrQuotaError =
msg.includes("429") ||
msg.includes("too many requests") ||
msg.includes("401") ||
msg.includes("403") ||
msg.includes("unauthorized") ||
msg.includes("forbidden") ||
msg.includes("api key not valid") ||
msg.includes("api_key_invalid") ||
msg.includes("quota") ||
msg.includes("exhausted") ||
msg.includes("resource has been exhausted") ||
msg.includes("limit exceeded") ||
msg.includes("rate limit") ||
msg.includes("permission denied") ||
msg.includes("billing") ||
msg.includes("exceeded your current quota") ||
error?.status === 429 ||
error?.status === 403 ||
error?.status === 401;
if (isAuthOrQuotaError) {
const failedKeyIndex = this.currentIndex;
const failedKeyHint = this.keys[failedKeyIndex]?.slice(0, 10) + "...";
console.warn(`⚠️ Key #${failedKeyIndex + 1} (${failedKeyHint}) failed: ${msg.slice(0, 120)}`);
this.rotateKey();
continue;
}
throw error;
}
}
}
}
// Export a robust singleton instance to be shared across services
export const keyManager = new ApiKeyManager();

View File

@@ -47,12 +47,15 @@ export async function saveContract(data: {
status: contract.status,
},
};
} catch (error: any) {
} catch (error: unknown) {
console.error("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.error("❌ SAVE CONTRACT ERROR");
console.error("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.error(error);
return { success: false, error: error.message };
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
@@ -248,6 +251,11 @@ export class ContractService {
email: true,
},
},
_count: {
select: {
ragChunks: true,
},
},
},
});
}
@@ -326,6 +334,38 @@ export class ContractService {
});
}
static async deleteAllForUser(userId: string): Promise<number> {
const contracts = await prisma.contract.findMany({
where: { userId },
select: {
id: true,
fileUrl: true,
},
});
if (contracts.length === 0) {
return 0;
}
const fileKeys = contracts
.map((contract) => this.extractFileKeyFromUrl(contract.fileUrl))
.filter((value): value is string => Boolean(value));
if (fileKeys.length > 0) {
try {
await utapi.deleteFiles(fileKeys);
} catch (error) {
console.error("Failed to bulk delete files from UploadThing:", error);
}
}
const deleted = await prisma.contract.deleteMany({
where: { userId },
});
return deleted.count;
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// HELPER: Extract file key from UploadThing URL
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

274
lib/services/rag.service.ts Normal file
View File

@@ -0,0 +1,274 @@
import { createHash } from "node:crypto";
import { GoogleGenerativeAI } from "@google/generative-ai";
import { prisma } from "@/lib/db/prisma";
type ChunkRecord = {
chunkIndex: number;
content: string;
contentHash: string;
embedding: number[];
};
type RetrievedChunk = {
chunkIndex: number;
content: string;
score: number;
};
const API_KEY =
process.env.AI_API_KEY1 || process.env.AI_API_KEY2 || process.env.AI_API_KEY3;
if (!API_KEY) {
throw new Error("AI_API_KEY is not configured");
}
const EMBEDDING_MODEL = process.env.AI_EMBEDDING_MODEL || "text-embedding-004";
const EMBEDDING_MODEL_FALLBACKS = [
EMBEDDING_MODEL,
"text-embedding-004",
"embedding-001",
];
const genAI = new GoogleGenerativeAI(API_KEY);
export class RAGService {
private static readonly MAX_CHUNK_CHARS = 1400;
private static readonly CHUNK_OVERLAP_CHARS = 220;
private static readonly MAX_CHUNKS_PER_CONTRACT = 120;
static async upsertContractChunks(input: {
contractId: string;
extractedText?: string | null;
summary?: string | null;
keyPoints?: Record<string, unknown> | null;
}): Promise<number> {
const sourceText = this.buildSourceText(input);
if (!sourceText.trim()) {
await prisma.contractRagChunk.deleteMany({
where: { contractId: input.contractId },
});
return 0;
}
const chunks = this.chunkText(sourceText);
if (chunks.length === 0) {
await prisma.contractRagChunk.deleteMany({
where: { contractId: input.contractId },
});
return 0;
}
const embeddedChunks: ChunkRecord[] = [];
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
const embedding = await this.embedText(chunk);
embeddedChunks.push({
chunkIndex: index,
content: chunk,
contentHash: this.hashChunk(chunk),
embedding,
});
}
await prisma.$transaction(async (tx) => {
await tx.contractRagChunk.deleteMany({
where: { contractId: input.contractId },
});
for (const chunk of embeddedChunks) {
await tx.contractRagChunk.create({
data: {
contractId: input.contractId,
chunkIndex: chunk.chunkIndex,
content: chunk.content,
contentHash: chunk.contentHash,
embedding: chunk.embedding,
},
});
}
});
return embeddedChunks.length;
}
static async retrieveRelevantChunks(input: {
contractId: string;
question: string;
topK?: number;
}): Promise<RetrievedChunk[]> {
const question = input.question.trim();
if (!question) return [];
const allChunks = await prisma.contractRagChunk.findMany({
where: { contractId: input.contractId },
orderBy: { chunkIndex: "asc" },
select: {
chunkIndex: true,
content: true,
embedding: true,
},
});
if (allChunks.length === 0) return [];
const queryEmbedding = await this.embedText(question);
const topK = Math.max(2, Math.min(12, input.topK ?? 6));
return allChunks
.map((chunk) => ({
chunkIndex: chunk.chunkIndex,
content: chunk.content,
score: this.cosineSimilarity(queryEmbedding, chunk.embedding),
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.filter((chunk) => Number.isFinite(chunk.score) && chunk.score > 0.12);
}
static buildChunkContext(chunks: RetrievedChunk[]): string {
if (chunks.length === 0) {
return "No RAG chunks available.";
}
return chunks
.map(
(chunk) =>
`[Chunk ${chunk.chunkIndex} | relevance=${chunk.score.toFixed(3)}]\n${chunk.content}`,
)
.join("\n\n");
}
private static buildSourceText(input: {
extractedText?: string | null;
summary?: string | null;
keyPoints?: Record<string, unknown> | null;
}): string {
const section: string[] = [];
const summary = String(input.summary ?? "").trim();
if (summary) {
section.push(`SUMMARY\n${summary}`);
}
const keyPoints = input.keyPoints ?? {};
const guarantees = Array.isArray(keyPoints.guarantees)
? keyPoints.guarantees.map((item) => String(item).trim()).filter(Boolean)
: [];
const exclusions = Array.isArray(keyPoints.exclusions)
? keyPoints.exclusions.map((item) => String(item).trim()).filter(Boolean)
: [];
const importantDates = Array.isArray(keyPoints.importantDates)
? keyPoints.importantDates
.map((item) => String(item).trim())
.filter(Boolean)
: [];
const franchise = String(keyPoints.franchise ?? "").trim();
const keyPointsLines: string[] = [];
if (guarantees.length > 0) {
keyPointsLines.push(`Guarantees: ${guarantees.join(" | ")}`);
}
if (exclusions.length > 0) {
keyPointsLines.push(`Exclusions: ${exclusions.join(" | ")}`);
}
if (franchise) {
keyPointsLines.push(`Franchise: ${franchise}`);
}
if (importantDates.length > 0) {
keyPointsLines.push(`ImportantDates: ${importantDates.join(" | ")}`);
}
if (keyPointsLines.length > 0) {
section.push(`KEY_POINTS\n${keyPointsLines.join("\n")}`);
}
const extractedText = String(input.extractedText ?? "").trim();
if (extractedText) {
section.push(`EXTRACTED_TEXT\n${extractedText}`);
}
return section.join("\n\n").slice(0, 45000);
}
private static chunkText(text: string): string[] {
const normalized = text.replace(/\r\n/g, "\n").trim();
if (!normalized) return [];
const chunks: string[] = [];
let cursor = 0;
const maxLen = this.MAX_CHUNK_CHARS;
const overlap = this.CHUNK_OVERLAP_CHARS;
while (
cursor < normalized.length &&
chunks.length < this.MAX_CHUNKS_PER_CONTRACT
) {
let end = Math.min(cursor + maxLen, normalized.length);
if (end < normalized.length) {
const window = normalized.slice(cursor, end);
const breakAt = Math.max(
window.lastIndexOf("\n\n"),
window.lastIndexOf(". "),
window.lastIndexOf("\n"),
);
if (breakAt > Math.floor(maxLen * 0.45)) {
end = cursor + breakAt + 1;
}
}
const chunk = normalized.slice(cursor, end).trim();
if (chunk.length > 40) {
chunks.push(chunk);
}
if (end >= normalized.length) break;
cursor = Math.max(end - overlap, cursor + 1);
}
return chunks;
}
private static hashChunk(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}
private static async embedText(text: string): Promise<number[]> {
let lastError: unknown = null;
for (const modelName of Array.from(new Set(EMBEDDING_MODEL_FALLBACKS))) {
try {
const model = genAI.getGenerativeModel({ model: modelName });
const result = await model.embedContent(text);
const values = result.embedding?.values;
if (values && Array.isArray(values) && values.length > 0) {
return values;
}
} catch (error) {
lastError = error;
}
}
const errorMessage =
lastError instanceof Error
? lastError.message
: "Failed to generate embedding vector.";
throw new Error(`Embedding generation failed: ${errorMessage}`);
}
private static cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length || a.length === 0) return -1;
let dot = 0;
let magA = 0;
let magB = 0;
for (let i = 0; i < a.length; i += 1) {
dot += a[i] * b[i];
magA += a[i] * a[i];
magB += b[i] * b[i];
}
if (magA === 0 || magB === 0) return -1;
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
}
}