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() { <>
- +
diff --git a/app/api/webhooks/clerk/route.ts b/app/api/webhooks/clerk/route.ts index cd8ccb2..0c173a8 100644 --- a/app/api/webhooks/clerk/route.ts +++ b/app/api/webhooks/clerk/route.ts @@ -140,7 +140,7 @@ export async function POST(req: Request) { try { // Delete user (CASCADE will delete all related contracts) - await prisma.user.delete({ + await prisma.user.deleteMany({ where: { clerkId: id }, }); diff --git a/components/layout/contacts-header.tsx b/components/layout/contacts-header.tsx index bdb8592..1b53393 100644 --- a/components/layout/contacts-header.tsx +++ b/components/layout/contacts-header.tsx @@ -2,40 +2,87 @@ import React from "react"; import Link from "next/link"; -import { ArrowLeft, ShieldCheck, Sparkles } from "lucide-react"; +import { + ArrowLeft, + ShieldCheck, + Sparkles, + FileText, + Network, +} from "lucide-react"; import { BackgroundBeams } from "@/components/ui/background-beams"; -export function ContactsHeader() { +export function ContractsHeader() { return ( -
- -
- - - Back to Dashboard - +
+ {/* Background Beams - Opacity bumped slightly for better visibility */} +
+ +
+
-
-

- Contracts Manager -

-

- Upload, review, and analyze your financial contracts with a focused - workspace built for speed and clarity. -

-
+
+
+ {/* Left Column: Typography & Badges */} +
+ + + Back to Dashboard + -
-
- - AI-powered review +
+

+ Contracts{" "} + + Manager + +

+

+ Upload, review, and analyze your financial contracts with speed, + transparency, and cryptographic security. +

+
+ + {/* Glassmorphic Badges - Sized up slightly from the compact version */} +
+
+ + AI-Powered +
+ +
+ + Bank-Grade +
+
-
- - Compliance-focused workflow + + {/* Right Column: Medium-Sized Graphic */} +
+ {/* Glowing backdrop */} +
+ + {/* Floating Medium Glass Cards */} +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
diff --git a/docs/sequence_diagrams.md b/docs/sequence_diagrams.md new file mode 100644 index 0000000..e69de29 diff --git a/features/contracts/api/contract.action.ts b/features/contracts/api/contract.action.ts index b006525..3bf62e1 100644 --- a/features/contracts/api/contract.action.ts +++ b/features/contracts/api/contract.action.ts @@ -26,7 +26,52 @@ import { saveContract as savePendingContract, } from "@/lib/services/contract.service"; import { AIService } from "@/lib/services/ai.service"; +import { RAGService } from "@/lib/services/rag.service"; import { NotificationService } from "@/lib/services/notification.service"; +import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types"; + +type ContractListItem = Awaited< + ReturnType +>[number] & { + _count?: { ragChunks?: number | null }; +}; + +type AnalysisWithMeta = NormalizedAnalysis & { + language?: string | null; + keyPeople?: Array<{ + name: string; + role?: string | null; + email?: string | null; + phone?: string | null; + }>; + contactInfo?: { + name?: string | null; + email?: string | null; + phone?: string | null; + address?: string | null; + role?: string | null; + }; + importantContacts?: Array<{ + name?: string | null; + email?: string | null; + phone?: string | null; + address?: string | null; + role?: string | null; + }>; + relevantDates?: Array<{ + date: string; + description: string; + type: "EXPIRATION" | "RENEWAL" | "PAYMENT" | "REVIEW" | "OTHER"; + }>; + premiumCurrency?: string | null; +}; + +type ContractKeyPoints = { + aiMeta?: { + language?: string | null; + premiumCurrency?: string | null; + }; +}; /** * Saves a new contract after UploadThing upload @@ -70,7 +115,7 @@ export async function saveContract(data: { userId: user.id, type: "SUCCESS", title: "๐Ÿ“„ Contract Uploaded", - message: `"${data.fileName}" has been uploaded successfully. Click "Analyze" to extract contract details.`, + message: `"${data.fileName}" has been uploaded successfully. AI analysis started automatically.`, contractId: result.contract.id, actionType: "UPLOAD_SUCCESS", icon: "FileCheck", @@ -78,6 +123,20 @@ export async function saveContract(data: { }); } + // Auto-run AI analysis immediately after upload. + const autoAnalysis = await analyzeContractAction(result.contract.id); + + if (!autoAnalysis.success) { + return { + success: true, + contract: result.contract, + analysisSuccess: false, + analysisError: + autoAnalysis.error || "Contract uploaded but AI analysis failed.", + errorCode: (autoAnalysis as { errorCode?: string }).errorCode, + }; + } + revalidatePath("/contacts"); revalidatePath("/dashboard"); } @@ -114,7 +173,7 @@ export async function getContracts(filters?: Record) { const contracts = await ContractService.getAll(filters); // Serialize contracts: convert Decimal to number, dates to ISO strings - const serializedContracts = contracts.map((contract: any) => ({ + const serializedContracts = contracts.map((contract: ContractListItem) => ({ id: contract.id, fileName: contract.fileName, fileSize: contract.fileSize, @@ -135,6 +194,8 @@ export async function getContracts(filters?: Record) { summary: contract.summary || null, keyPoints: contract.keyPoints || null, extractedText: contract.extractedText || null, + ragChunkCount: Number(contract?._count?.ragChunks ?? 0), + isRagged: Number(contract?._count?.ragChunks ?? 0) > 0, })); return { success: true, contracts: serializedContracts }; @@ -237,6 +298,47 @@ export async function deleteContract(id: string) { } } +export async function deleteAllContractsAction() { + try { + const { userId: clerkId } = await auth(); + if (!clerkId) { + return { success: false, error: "Unauthorized" }; + } + + const user = await ContractService.getUserByClerkId(clerkId); + if (!user) { + return { success: false, error: "User not found" }; + } + + const deletedCount = await ContractService.deleteAllForUser(user.id); + + await NotificationService.create({ + userId: user.id, + type: "SUCCESS", + title: "๐Ÿงน Contracts Cleared", + message: `All contracts were deleted successfully (${deletedCount}).`, + actionType: "DELETE_ALL_SUCCESS", + icon: "Trash2", + expiresIn: 24 * 60 * 60 * 1000, + }); + + revalidatePath("/contacts"); + revalidatePath("/dashboard"); + + return { + success: true, + deletedCount, + message: "All contracts deleted successfully", + }; + } catch (error: unknown) { + console.error("Delete all contracts error:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + /** * Retrieves dashboard statistics for the authenticated user * @@ -361,15 +463,16 @@ export async function analyzeContractAction(id: string) { // Persist AI learning metadata inside keyPoints JSON so future analyses can adapt // without requiring DB schema changes. + const aiAnalysis = aiResults as AnalysisWithMeta; const keyPointsWithLearning = { ...(aiResults.keyPoints ?? {}), aiMeta: { - language: (aiResults as any).language ?? null, - keyPeople: (aiResults as any).keyPeople ?? [], - contactInfo: (aiResults as any).contactInfo ?? null, - importantContacts: (aiResults as any).importantContacts ?? [], - relevantDates: (aiResults as any).relevantDates ?? [], - premiumCurrency: (aiResults as any).premiumCurrency ?? null, + language: aiAnalysis.language ?? null, + keyPeople: aiAnalysis.keyPeople ?? [], + contactInfo: aiAnalysis.contactInfo ?? null, + importantContacts: aiAnalysis.importantContacts ?? [], + relevantDates: aiAnalysis.relevantDates ?? [], + premiumCurrency: aiAnalysis.premiumCurrency ?? null, learnedAt: new Date().toISOString(), }, }; @@ -385,6 +488,14 @@ export async function analyzeContractAction(id: string) { premium: aiResults.premium ?? undefined, }); + // Build persistent RAG chunks for grounded contract Q&A. + await RAGService.upsertContractChunks({ + contractId: id, + extractedText: aiResults.extractedText, + summary: aiResults.summary, + keyPoints: keyPointsWithLearning, + }); + // Create success notification with extracted info const contractTitle = aiResults.title || "Contract"; const contractProvider = aiResults.provider || "Unknown Provider"; @@ -425,7 +536,6 @@ export async function analyzeContractAction(id: string) { // Create error notification if (user) { - const contract = await ContractService.getById(id); await NotificationService.create({ userId: user.id, type: "ERROR", @@ -515,10 +625,18 @@ export async function askContractQuestionAction(id: string, question: string) { }; } + const ragDiagnostics = await RAGService.retrieveRelevantChunks({ + contractId: contract.id, + question: trimmedQuestion, + topK: 6, + }); + // Ask AI about contract with full context const answer = await AIService.askAboutContract({ question: trimmedQuestion, + ragChunks: ragDiagnostics, contract: { + id: contract.id, fileName: contract.fileName, title: contract.title, type: contract.type, @@ -533,11 +651,21 @@ export async function askContractQuestionAction(id: string, question: string) { keyPoints: (contract.keyPoints as Record | null) ?? null, extractedText: contract.extractedText, - language: (contract.keyPoints as any)?.aiMeta?.language ?? null, + language: + (contract.keyPoints as ContractKeyPoints | null)?.aiMeta?.language ?? + null, }, }); - return { success: true, answer }; + return { + success: true, + answer, + ragDiagnostics: ragDiagnostics.map((chunk) => ({ + chunkIndex: chunk.chunkIndex, + score: Number(chunk.score.toFixed(4)), + preview: chunk.content.slice(0, 280), + })), + }; } catch (error: unknown) { console.error("Ask contract question error:", error); return { diff --git a/features/contracts/components/forms/contract-upload-form.tsx b/features/contracts/components/forms/contract-upload-form.tsx index d81c00e..4fd0a4f 100644 --- a/features/contracts/components/forms/contract-upload-form.tsx +++ b/features/contracts/components/forms/contract-upload-form.tsx @@ -1,7 +1,14 @@ "use client"; +import { useState } from "react"; import { UploadDropzone } from "@uploadthing/react"; -import { AlertCircle, Sparkles, Wand2, ShieldCheck } from "lucide-react"; +import { + AlertCircle, + Sparkles, + Wand2, + ShieldCheck, + Loader2, +} from "lucide-react"; import { Card } from "@/components/ui/card"; import { saveContract } from "@/features/contracts/api/contract.action"; import { toast } from "sonner"; @@ -14,6 +21,7 @@ export function ContractUploadForm({ onUploadSuccess: () => void; }) { const router = useRouter(); + const [isAutoAnalyzing, setIsAutoAnalyzing] = useState(false); const emitNotificationRefresh = () => { window.dispatchEvent(new Event("notifications:refresh")); @@ -77,25 +85,50 @@ export function ContractUploadForm({ } const file = res[0]; + setIsAutoAnalyzing(true); - // Save to database - const result = await saveContract({ - fileName: file.name, - fileUrl: file.url, - fileSize: file.size, - mimeType: file.type, - }); + try { + // Save to database + const result = await saveContract({ + fileName: file.name, + fileUrl: file.url, + fileSize: file.size, + mimeType: file.type, + }); - if (result.success) { - toast.success("Contract uploaded successfully!"); - emitNotificationRefresh(); - onUploadSuccess(); - router.refresh(); - } else { - toast.error(result.error || "Failed to save contract"); + if (result.success) { + if ( + (result as { analysisSuccess?: boolean }).analysisSuccess === + false + ) { + toast.warning( + (result as { analysisError?: string }).analysisError || + "Contract uploaded, but analysis failed.", + ); + } else { + toast.success("Contract uploaded and analyzed successfully!"); + } + + emitNotificationRefresh(); + onUploadSuccess(); + router.refresh(); + } else { + const fallbackError = + "error" in result ? result.error : "Failed to save contract"; + toast.error(fallbackError); + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Unexpected error during analysis", + ); + } finally { + setIsAutoAnalyzing(false); } }} onUploadError={(error: Error) => { + setIsAutoAnalyzing(false); toast.error(`Upload failed: ${error.message}`); }} appearance={{ @@ -126,7 +159,7 @@ export function ContractUploadForm({
AI Flow
-
Upload first, then click Analyze when ready
+
Upload starts instant AI analysis + RAG indexing
@@ -136,6 +169,53 @@ export function ContractUploadForm({ Extraction quality improves as more contracts are analyzed.
+ + {isAutoAnalyzing && ( +
+
+
+
+
+
+ +
+
+ +
+ +
+ +
+

+ Analyzing And Building RAG +

+

+ Your contract is being analyzed and indexed for chat... +

+
+ +
+
+ Processing + + + + + +
+ +
+
+
+
+ +

+ This may take up to 10 seconds +

+
+
+
+ )} ); } diff --git a/features/contracts/components/list/contracts-list.tsx b/features/contracts/components/list/contracts-list.tsx index 289cba4..fc812f5 100644 --- a/features/contracts/components/list/contracts-list.tsx +++ b/features/contracts/components/list/contracts-list.tsx @@ -2,24 +2,21 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import type { ReactNode } from "react"; +import type { Prisma } from "@prisma/client"; import { Download, Trash2, Eye, MoreVertical, Loader2, - Sparkles, FileText, + FileSpreadsheet, MessageSquare, - Send, - Scale, - Briefcase, - User, - Bot, AlertTriangle, X, Search, Info, + Network, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -50,12 +47,12 @@ import { import { deleteContract, getContracts, - analyzeContractAction, - askContractQuestionAction, + deleteAllContractsAction, } from "@/features/contracts/api/contract.action"; import { toast } from "sonner"; import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal"; import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal"; +import { stripMarkdown, exportToCSV, exportToPDF } from "@/features/contracts/utils/export.utils"; interface Contract { id: string; @@ -73,13 +70,10 @@ interface Contract { endDate?: string | null; premium?: number | null; summary?: string | null; - keyPoints?: Record | null; + keyPoints?: Prisma.JsonValue | null; extractedText?: string | null; -} - -interface ChatMessage { - role: "user" | "assistant"; - content: string; + ragChunkCount?: number; + isRagged?: boolean; } interface ExplainabilityEntry { @@ -93,6 +87,22 @@ interface ExplainabilityEntry { }; } +interface ContractKeyPoints { + guarantees?: string[]; + exclusions?: string[]; + franchise?: string | null; + importantDates?: string[]; + explainability?: ExplainabilityEntry[]; + aiMeta?: { + language?: string | null; + premiumCurrency?: string | null; + }; +} + +const isContractKeyPoints = (value: unknown): value is ContractKeyPoints => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { const emitNotificationRefresh = () => { window.dispatchEvent(new Event("notifications:refresh")); @@ -104,18 +114,18 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { const [contracts, setContracts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [deletingId, setDeletingId] = useState(null); - const [analyzingId, setAnalyzingId] = useState(null); + const [isDeletingAll, setIsDeletingAll] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); const [selectedContract, setSelectedContract] = useState( null, ); - const [isAnalyzing, setIsAnalyzing] = useState(false); const [askOpen, setAskOpen] = useState(false); const [chatContract, setChatContract] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [contractToDelete, setContractToDelete] = useState( null, ); + const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false); const [invalidContractDialogOpen, setInvalidContractDialogOpen] = useState(false); const [invalidContractReason, setInvalidContractReason] = useState(""); @@ -563,11 +573,13 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { const getExplainabilityItems = ( contract: Contract | null, ): ExplainabilityEntry[] => { - const raw = (contract?.keyPoints as any)?.explainability; + const raw = isContractKeyPoints(contract?.keyPoints) + ? contract.keyPoints.explainability + : undefined; if (!Array.isArray(raw)) return []; return raw - .map((item: any) => ({ + .map((item) => ({ field: String(item?.field ?? "").trim(), why: String(item?.why ?? "").trim(), sourceSnippet: String(item?.sourceSnippet ?? "").trim(), @@ -675,7 +687,9 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { if (!contract) return null; const fromMeta = String( - (contract.keyPoints as any)?.aiMeta?.premiumCurrency ?? "", + (isContractKeyPoints(contract.keyPoints) + ? contract.keyPoints.aiMeta?.premiumCurrency + : null) ?? "", ).trim(); if (fromMeta) return fromMeta; @@ -848,38 +862,26 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { setContractToDelete(null); }; - const handleAnalyze = async (id: string) => { - const selected = contracts.find((contract) => contract.id === id); - setAnalyzingId(id); - setIsAnalyzing(true); + const handleDeleteAll = async () => { + setIsDeletingAll(true); try { - const result = await analyzeContractAction(id); + const result = await deleteAllContractsAction(); if (result.success) { - // Reload contracts to get all AI analysis data - await loadContracts(); - toast.success("Contract analyzed successfully!"); + setContracts([]); + toast.success( + `Deleted ${result.deletedCount ?? 0} contract${(result.deletedCount ?? 0) === 1 ? "" : "s"}.`, + ); emitNotificationRefresh(); } else { - const errorCode = (result as { errorCode?: string }).errorCode; - if (errorCode === "INVALID_CONTRACT") { - const reason = - result.error || - "This uploaded file is not recognized as a valid contract."; - setInvalidContractReason(reason); - setInvalidContractFileName(selected?.fileName || "Unknown file"); - setInvalidContractDialogOpen(true); - toast.error("Invalid contract file detected"); - } else { - toast.error(result.error || "Failed to analyze contract"); - } + toast.error(result.error || "Failed to delete all contracts"); } } catch (error) { toast.error( error instanceof Error ? error.message : "Unknown error occurred", ); } finally { - setAnalyzingId(null); - setIsAnalyzing(false); + setIsDeletingAll(false); + setDeleteAllDialogOpen(false); } }; @@ -994,11 +996,28 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { className="pl-9" />
- {debouncedSearchQuery && ( -

- Showing results for: "{debouncedSearchQuery}" -

- )} +
+ {debouncedSearchQuery && ( +

+ Showing results for: "{debouncedSearchQuery}" +

+ )} + +
@@ -1023,6 +1042,12 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { > {contract.status} + {contract.isRagged && ( + + + RAG {contract.ragChunkCount ?? 0} + + )}
@@ -1070,21 +1095,6 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { - -

- {selectedContract.title || "N/A"} + {stripMarkdown(selectedContract.title) || "N/A"}

@@ -1259,7 +1283,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {

- {selectedContract.provider || "N/A"} + {stripMarkdown(selectedContract.provider) || "N/A"}

@@ -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} +

@@ -124,6 +173,43 @@ export function ContractChatModal({
+
+
+ +

+ RAG Diagnostics +

+
+ + {ragDiagnostics.length === 0 ? ( +

+ Ask a question to inspect top retrieved chunks and relevance + scores. +

+ ) : ( +
+ {ragDiagnostics.map((item) => ( +
+
+ + Chunk {item.chunkIndex} + + + score {item.score.toFixed(4)} + +
+

+ {item.preview} +

+
+ ))} +
+ )} +
+
{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: {