diff --git a/app/(dashboard)/contacts/page.tsx b/app/(dashboard)/contacts/page.tsx
index bdcb3b4..8a0e5ad 100644
--- a/app/(dashboard)/contacts/page.tsx
+++ b/app/(dashboard)/contacts/page.tsx
@@ -3,7 +3,7 @@
import { ContractUploadForm } from "@/features/contracts/components/forms/contract-upload-form";
import { EmptyContractsState } from "@/features/contracts/components/list/empty-contracts-state";
import { ContractsList } from "@/features/contracts/components/list/contracts-list";
-import { ContactsHeader } from "@/components/layout/contacts-header";
+import { ContractsHeader } from "@/components/layout/contacts-header";
import { useState, useEffect } from "react";
import { getContracts } from "@/features/contracts/api/contract.action";
import { Card } from "@/components/ui/card";
@@ -67,7 +67,7 @@ export default function ContactsPage() {
<>
@@ -1283,7 +1307,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
- {selectedContract.policyNumber || "N/A"}
+ {stripMarkdown(selectedContract.policyNumber) || "N/A"}
@@ -1376,9 +1400,10 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
Key Points
- {(selectedContract.keyPoints as any)?.guarantees &&
+ {isContractKeyPoints(selectedContract.keyPoints) &&
+ selectedContract.keyPoints.guarantees &&
Array.isArray(
- (selectedContract.keyPoints as any).guarantees,
+ selectedContract.keyPoints.guarantees,
) && (
@@ -1386,9 +1411,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
{(
- (selectedContract.keyPoints as any)
- .guarantees as string[]
- ).map((guarantee: string, idx: number) => (
+ selectedContract.keyPoints.guarantees ?? []
+ ).map((guarantee, idx: number) => (
-
)}
- {(selectedContract.keyPoints as any)?.exclusions &&
+ {isContractKeyPoints(selectedContract.keyPoints) &&
+ selectedContract.keyPoints.exclusions &&
Array.isArray(
- (selectedContract.keyPoints as any).exclusions,
+ selectedContract.keyPoints.exclusions,
) && (
@@ -1412,9 +1437,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
{(
- (selectedContract.keyPoints as any)
- .exclusions as string[]
- ).map((exclusion: string, idx: number) => (
+ selectedContract.keyPoints.exclusions ?? []
+ ).map((exclusion, idx: number) => (
-
)}
- {(selectedContract.keyPoints as any)?.franchise && (
-
-
- Deductible:
-
-
- {renderRichParagraphs(
- String(
- (selectedContract.keyPoints as any).franchise,
- ),
- `franchise-${selectedContract.id}`,
- )}
+ {isContractKeyPoints(selectedContract.keyPoints) &&
+ selectedContract.keyPoints.franchise && (
+
+
+ Deductible:
+
+
+ {renderRichParagraphs(
+ String(selectedContract.keyPoints.franchise),
+ `franchise-${selectedContract.id}`,
+ )}
+
-
- )}
+ )}
)}
@@ -1460,9 +1483,9 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
{selectedContract.status === "UPLOADED" && (
-
+
- Click the Sparkles button to analyze this contract
+ Contract uploaded. AI analysis will start automatically.
)}
@@ -1526,6 +1549,30 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
+
+
+
+ Delete all contracts?
+
+ This action permanently removes all contracts and related files
+ for your account. This cannot be undone.
+
+
+
+ Cancel
+ void handleDeleteAll()}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeletingAll ? "Deleting..." : "Delete All"}
+
+
+
+
+
-
- {/* AI Analysis Loading Overlay */}
- {isAnalyzing && (
-
-
-
- {/* Glow Effect */}
-
-
- {/* Spinner */}
-
-
-
-
-
-
- Analyzing Contract
-
-
- Our AI is carefully reviewing your document...
-
-
-
- {/* Progress Section */}
-
-
- Processing
-
- {/* Use inline styles for delays if they aren't in your config */}
-
-
-
-
-
-
- {/* Moving Progress Bar */}
-
-
-
-
- This may take up to 10 seconds
-
-
-
-
- )}
>
);
}
diff --git a/features/contracts/components/modals/contract-chat-modal.tsx b/features/contracts/components/modals/contract-chat-modal.tsx
index e7de499..79bf01c 100644
--- a/features/contracts/components/modals/contract-chat-modal.tsx
+++ b/features/contracts/components/modals/contract-chat-modal.tsx
@@ -1,8 +1,22 @@
"use client";
import { useState } from "react";
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { MessageSquare, Briefcase, Scale, Bot, User, Loader2, Send } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ MessageSquare,
+ Briefcase,
+ Scale,
+ Bot,
+ User,
+ Loader2,
+ Send,
+ Network,
+} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { askContractQuestionAction } from "@/features/contracts/api/contract.action";
@@ -17,6 +31,12 @@ interface ChatMessage {
content: string;
}
+interface RagDiagnosticEntry {
+ chunkIndex: number;
+ score: number;
+ preview: string;
+}
+
interface ContractChatModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@@ -32,10 +52,14 @@ export function ContractChatModal({
}: ContractChatModalProps) {
const [question, setQuestion] = useState("");
const [isAsking, setIsAsking] = useState(false);
+ const [ragDiagnostics, setRagDiagnostics] = useState
(
+ [],
+ );
const [messages, setMessages] = useState([
{
role: "assistant",
- content: "Ask me anything about this contract. I will answer based on the file analysis.",
+ content:
+ "Ask me anything about this contract. I will answer based on the file analysis.",
},
]);
@@ -51,22 +75,45 @@ export function ContractChatModal({
const trimmedQuestion = question.trim();
if (!trimmedQuestion) return;
- setMessages((prev) => [...prev, { role: "user", content: trimmedQuestion }]);
+ setMessages((prev) => [
+ ...prev,
+ { role: "user", content: trimmedQuestion },
+ ]);
setQuestion("");
setIsAsking(true);
try {
- const result = await askContractQuestionAction(contract.id, trimmedQuestion);
+ const result = await askContractQuestionAction(
+ contract.id,
+ trimmedQuestion,
+ );
if (result.success && result.answer) {
- setMessages((prev) => [...prev, { role: "assistant", content: result.answer as string }]);
+ const diagnostics = Array.isArray(
+ (result as { ragDiagnostics?: RagDiagnosticEntry[] }).ragDiagnostics,
+ )
+ ? ((result as { ragDiagnostics?: RagDiagnosticEntry[] })
+ .ragDiagnostics ?? [])
+ : [];
+ setRagDiagnostics(diagnostics);
+ setMessages((prev) => [
+ ...prev,
+ { role: "assistant", content: result.answer as string },
+ ]);
} else {
const errorMessage = result.error || "Failed to get AI response";
- setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${errorMessage}` }]);
+ setMessages((prev) => [
+ ...prev,
+ { role: "assistant", content: `Error: ${errorMessage}` },
+ ]);
}
} catch (error) {
- const fallbackMessage = error instanceof Error ? error.message : "Unknown error occurred";
- setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${fallbackMessage}` }]);
+ const fallbackMessage =
+ error instanceof Error ? error.message : "Unknown error occurred";
+ setMessages((prev) => [
+ ...prev,
+ { role: "assistant", content: `Error: ${fallbackMessage}` },
+ ]);
} finally {
setIsAsking(false);
}
@@ -90,7 +137,9 @@ export function ContractChatModal({
Contract Intelligence Assistant
- {contract.fileName}
+
+ {contract.fileName}
+
{messages.map((message, index) => (
{message.role === "assistant"
- ? renderRichParagraphs(message.content, `chat-assistant-${index}`)
+ ? renderRichParagraphs(
+ message.content,
+ `chat-assistant-${index}`,
+ )
: message.content}
{message.role === "user" && (
@@ -177,7 +266,12 @@ export function ContractChatModal({
disabled={isAsking}
className="rounded-2xl border-white/20 dark:border-white/10 bg-background/50 backdrop-blur-md focus:bg-background/80 transition-all duration-300 shadow-inner"
onKeyDown={(event) => {
- if (event.key === "Enter" && !event.shiftKey && !isAsking && question.trim()) {
+ if (
+ event.key === "Enter" &&
+ !event.shiftKey &&
+ !isAsking &&
+ question.trim()
+ ) {
event.preventDefault();
void handleAskQuestion();
}
diff --git a/features/contracts/utils/export.utils.ts b/features/contracts/utils/export.utils.ts
new file mode 100644
index 0000000..b229db2
--- /dev/null
+++ b/features/contracts/utils/export.utils.ts
@@ -0,0 +1,163 @@
+import jsPDF from "jspdf";
+import autoTable from "jspdf-autotable";
+import { type Contract, type Prisma } from "@prisma/client";
+
+interface ContractKeyPoints {
+ guarantees?: string[];
+ exclusions?: string[];
+ franchise?: string | number | null;
+ [key: string]: any;
+}
+
+export const isContractKeyPoints = (
+ val: Prisma.JsonValue | null | undefined,
+): val is ContractKeyPoints => {
+ if (!val || typeof val !== "object" || Array.isArray(val)) return false;
+ return true;
+};
+
+export const stripMarkdown = (text: string | null | undefined): string => {
+ if (!text) return "";
+ // Strip ** bold tags, __ italic tags, # headers, โข bullets
+ return text
+ .replace(/\*\*/g, "")
+ .replace(/__/g, "")
+ .replace(/^#+\s+/gm, "")
+ .replace(/โข\s+/g, "- ")
+ // replace any remaining markdown stars
+ .replace(/\*/g, "");
+};
+
+const formatValue = (val: any): string => {
+ if (val === null || val === undefined) return "N/A";
+ if (val instanceof Date) return val.toLocaleDateString();
+ if (Array.isArray(val)) {
+ return val.map((v) => stripMarkdown(String(v))).join("\n");
+ }
+ return stripMarkdown(String(val));
+};
+
+export const exportToCSV = (contract: Contract) => {
+ let guarantees = "N/A";
+ let exclusions = "N/A";
+ let franchise = "N/A";
+
+ if (isContractKeyPoints(contract.keyPoints)) {
+ if (Array.isArray(contract.keyPoints.guarantees)) {
+ guarantees = contract.keyPoints.guarantees.map(stripMarkdown).join("; ");
+ }
+ if (Array.isArray(contract.keyPoints.exclusions)) {
+ exclusions = contract.keyPoints.exclusions.map(stripMarkdown).join("; ");
+ }
+ if (contract.keyPoints.franchise) {
+ franchise = stripMarkdown(String(contract.keyPoints.franchise));
+ }
+ }
+
+ const exportData = [
+ ["Field", "Value"],
+ ["Title", formatValue(contract.title)],
+ ["Provider", formatValue(contract.provider)],
+ ["Policy Number", formatValue(contract.policyNumber)],
+ ["Start Date", formatValue(contract.startDate)],
+ ["End Date", formatValue(contract.endDate)],
+ ["Status", formatValue(contract.status)],
+ ["Summary", formatValue(contract.summary).replace(/\n/g, " ")],
+ ["Guarantees", guarantees],
+ ["Exclusions", exclusions],
+ ["Deductible", franchise],
+ ];
+
+ const csvContent = exportData
+ .map((row) =>
+ row
+ .map((cell) => {
+ const stringCell = String(cell);
+ if (stringCell.includes(",") || stringCell.includes("\"") || stringCell.includes("\n")) {
+ return `"${stringCell.replace(/"/g, "\"\"")}"`;
+ }
+ return stringCell;
+ })
+ .join(","),
+ )
+ .join("\n");
+
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
+ const downloadUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = downloadUrl;
+ link.download = `Analysis_${contract.fileName || "Contract"}.csv`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(downloadUrl);
+};
+
+export const exportToPDF = (contract: Contract) => {
+ const doc = new jsPDF();
+
+ // Title
+ doc.setFontSize(18);
+ doc.setTextColor(33, 43, 54);
+ doc.text("AI Contract Analysis", 14, 22);
+
+ // Subtitle
+ doc.setFontSize(11);
+ doc.setTextColor(100);
+ doc.text(`Filename: ${contract.fileName}`, 14, 30);
+ doc.text(`Exported: ${new Date().toLocaleDateString()}`, 14, 36);
+
+ let guarantees = "N/A";
+ let exclusions = "N/A";
+ let franchise = "N/A";
+
+ if (isContractKeyPoints(contract.keyPoints)) {
+ if (Array.isArray(contract.keyPoints.guarantees)) {
+ guarantees = contract.keyPoints.guarantees.map(stripMarkdown).join("\nโข ");
+ if (guarantees) guarantees = "โข " + guarantees;
+ }
+ if (Array.isArray(contract.keyPoints.exclusions)) {
+ exclusions = contract.keyPoints.exclusions.map(stripMarkdown).join("\nโข ");
+ if (exclusions) exclusions = "โข " + exclusions;
+ }
+ if (contract.keyPoints.franchise) {
+ franchise = stripMarkdown(String(contract.keyPoints.franchise));
+ }
+ }
+
+ const tableData = [
+ ["Title", formatValue(contract.title)],
+ ["Provider", formatValue(contract.provider)],
+ ["Policy Number", formatValue(contract.policyNumber)],
+ ["Start Date", formatValue(contract.startDate)],
+ ["End Date", formatValue(contract.endDate)],
+ ["Summary", formatValue(contract.summary)],
+ ["Guarantees", guarantees],
+ ["Exclusions", exclusions],
+ ["Deductible", franchise],
+ ];
+
+ autoTable(doc, {
+ startY: 45,
+ head: [["Information Field", "Extracted Detail"]],
+ body: tableData,
+ theme: "grid",
+ headStyles: {
+ fillColor: [30, 41, 59],
+ textColor: 255,
+ fontStyle: "bold",
+ },
+ styles: {
+ fontSize: 10,
+ cellPadding: 6,
+ overflow: "linebreak",
+ cellWidth: "wrap"
+ },
+ columnStyles: {
+ 0: { cellWidth: 40, fontStyle: "bold", textColor: [50, 50, 50] },
+ 1: { cellWidth: 140 }
+ },
+ });
+
+ doc.save(`Analysis_${contract.fileName || "Contract"}.pdf`);
+};
diff --git a/lib/services/ai.service.ts b/lib/services/ai.service.ts
index 2a73df9..c30b102 100644
--- a/lib/services/ai.service.ts
+++ b/lib/services/ai.service.ts
@@ -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
{
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 {
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}`);
}
}
}
diff --git a/lib/services/ai/analysis.prompt.ts b/lib/services/ai/analysis.prompt.ts
index a0f6e93..808a9ee 100644
--- a/lib/services/ai/analysis.prompt.ts
+++ b/lib/services/ai/analysis.prompt.ts
@@ -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")
diff --git a/lib/services/ai/key-manager.ts b/lib/services/ai/key-manager.ts
new file mode 100644
index 0000000..e3e7895
--- /dev/null
+++ b/lib/services/ai/key-manager.ts
@@ -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(operation: (client: GoogleGenerativeAI) => Promise): Promise {
+ 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();
diff --git a/lib/services/contract.service.ts b/lib/services/contract.service.ts
index 6023cd6..4e5514d 100644
--- a/lib/services/contract.service.ts
+++ b/lib/services/contract.service.ts
@@ -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 {
+ 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
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/lib/services/rag.service.ts b/lib/services/rag.service.ts
new file mode 100644
index 0000000..cc82aca
--- /dev/null
+++ b/lib/services/rag.service.ts
@@ -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 | null;
+ }): Promise {
+ 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 {
+ 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 | 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 {
+ 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));
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index e07b053..1b55588 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -48,6 +48,8 @@
"dotenv": "^17.3.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
+ "jspdf": "^4.2.1",
+ "jspdf-autotable": "^5.0.7",
"lucide-react": "^0.564.0",
"motion": "^12.34.0",
"next": "16.1.6",
@@ -297,6 +299,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -3478,6 +3489,19 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/pako": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
+ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+ "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -3505,6 +3529,13 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -4434,6 +4465,16 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -4639,6 +4680,26 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/canvg": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
+ "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.8.3",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -4745,6 +4806,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4760,6 +4833,16 @@
"node": ">= 8"
}
},
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -5117,6 +5200,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dompurify": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
+ "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optional": true,
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -5946,6 +6039,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-png": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
+ "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/pako": "^2.0.3",
+ "iobuffer": "^5.3.2",
+ "pako": "^2.1.0"
+ }
+ },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
@@ -5962,6 +6066,12 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6435,6 +6545,20 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6499,6 +6623,12 @@
"node": ">=12"
}
},
+ "node_modules/iobuffer": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
+ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
+ "license": "MIT"
+ },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -7031,6 +7161,32 @@
"json5": "lib/cli.js"
}
},
+ "node_modules/jspdf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
+ "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "fast-png": "^6.2.0",
+ "fflate": "^0.8.1"
+ },
+ "optionalDependencies": {
+ "canvg": "^3.0.11",
+ "core-js": "^3.6.0",
+ "dompurify": "^3.3.1",
+ "html2canvas": "^1.0.0-rc.5"
+ }
+ },
+ "node_modules/jspdf-autotable": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
+ "integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "jspdf": "^2 || ^3 || ^4"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -7735,6 +7891,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pako": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7774,6 +7936,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
+ "node_modules/performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8098,6 +8267,16 @@
],
"license": "MIT"
},
+ "node_modules/raf": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "performance-now": "^2.1.0"
+ }
+ },
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@@ -8366,6 +8545,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -8435,6 +8621,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.8.15"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -8774,6 +8970,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.14"
+ }
+ },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
@@ -8986,6 +9192,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/svg-pathdata": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/svix": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
@@ -9128,6 +9344,16 @@
"node": ">=8.10.0"
}
},
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -9600,6 +9826,16 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
diff --git a/package.json b/package.json
index 4a2be63..481d737 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,8 @@
"dotenv": "^17.3.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
+ "jspdf": "^4.2.1",
+ "jspdf-autotable": "^5.0.7",
"lucide-react": "^0.564.0",
"motion": "^12.34.0",
"next": "16.1.6",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6be1859..acdd915 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -7,21 +7,20 @@ datasource db {
url = env("DATABASE_URL")
}
-
model User {
- id String @id @default(cuid())
- clerkId String @unique
- email String @unique
+ id String @id @default(cuid())
+ clerkId String @unique
+ email String @unique
firstName String?
lastName String?
imageUrl String?
-
+
contracts Contract[]
notifications Notification[]
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
@@index([clerkId])
@@index([email])
}
@@ -30,70 +29,90 @@ model Contract {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
+
// File info (user uploads)
- fileName String
- fileUrl String
- fileSize Int
- mimeType String
-
+ fileName String
+ fileUrl String
+ fileSize Int
+ mimeType String
+
// AI-determined fields (filled automatically)
- title String?
- type ContractType?
- provider String?
- policyNumber String?
- startDate DateTime?
- endDate DateTime?
- premium Decimal? @db.Decimal(10, 2)
-
+ title String?
+ type ContractType?
+ provider String?
+ policyNumber String?
+ startDate DateTime?
+ endDate DateTime?
+ premium Decimal? @db.Decimal(10, 2)
+
// Processing pipeline
- status ContractStatus @default(UPLOADED)
-
+ status ContractStatus @default(UPLOADED)
+
// AI results
- extractedText String? @db.Text
- summary String? @db.Text
- keyPoints Json?
-
+ extractedText String? @db.Text
+ summary String? @db.Text
+ keyPoints Json?
+
// Blockchain (later)
- documentHash String?
- txHash String?
- ipfsUrl String?
-
+ documentHash String?
+ txHash String?
+ ipfsUrl String?
+
// Notifications for this contract
- notifications Notification[]
-
+ notifications Notification[]
+ ragChunks ContractRagChunk[]
+
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
-
+
@@index([userId])
@@index([status])
@@index([type])
@@index([endDate])
}
+model ContractRagChunk {
+ id String @id @default(cuid())
+ contractId String
+ contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
+
+ chunkIndex Int
+ content String
+ contentHash String
+ embedding Float[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([contractId, chunkIndex])
+ @@index([contractId])
+ @@index([contentHash])
+ @@index([chunkIndex])
+}
+
model Notification {
- id String @id @default(cuid())
- userId String
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
-
+ id String @id @default(cuid())
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
contractId String?
contract Contract? @relation(fields: [contractId], references: [id], onDelete: SetNull)
-
+
// Notification metadata
- type NotificationType
- title String
- message String
- icon String? // Icon type for UI
-
+ type NotificationType
+ title String
+ message String
+ icon String? // Icon type for UI
+
// Action metadata
- actionType String? // e.g., "RENEWAL_REMINDER", "UPLOAD_SUCCESS", "ANALYSIS_COMPLETE"
- actionData Json? // Additional data for the action
-
+ actionType String? // e.g., "RENEWAL_REMINDER", "UPLOAD_SUCCESS", "ANALYSIS_COMPLETE"
+ actionData Json? // Additional data for the action
+
// Status tracking
- read Boolean @default(false)
- createdAt DateTime @default(now())
+ read Boolean @default(false)
+ createdAt DateTime @default(now())
expiresAt DateTime? // Notification expiration time
-
+
@@index([userId])
@@index([contractId])
@@index([type])
@@ -102,11 +121,11 @@ model Notification {
}
enum NotificationType {
- SUCCESS // Successful action
- WARNING // Warning/Alert
- ERROR // Error
- INFO // Informational
- DEADLINE // Deadline approaching
+ SUCCESS // Successful action
+ WARNING // Warning/Alert
+ ERROR // Error
+ INFO // Informational
+ DEADLINE // Deadline approaching
}
enum ContractType {
@@ -121,10 +140,8 @@ enum ContractType {
}
enum ContractStatus {
- UPLOADED // Just uploaded, waiting for processing
- PROCESSING // AI is analyzing
- COMPLETED // Everything done
- FAILED // Processing failed
+ UPLOADED // Just uploaded, waiting for processing
+ PROCESSING // AI is analyzing
+ COMPLETED // Everything done
+ FAILED // Processing failed
}
-
-
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 6afd964..fce8b58 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -7,6 +7,7 @@ const config: Config = {
"./app/**/*.{ts,tsx}",
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
+ "./features/**/*.{ts,tsx}",
],
theme: {
extend: {