Release (Stable version)
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
97
lib/services/ai/key-manager.ts
Normal file
97
lib/services/ai/key-manager.ts
Normal 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();
|
||||
@@ -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
274
lib/services/rag.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user