Release (Stable version)

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

View File

@@ -3,7 +3,7 @@
import { ContractUploadForm } from "@/features/contracts/components/forms/contract-upload-form"; import { ContractUploadForm } from "@/features/contracts/components/forms/contract-upload-form";
import { EmptyContractsState } from "@/features/contracts/components/list/empty-contracts-state"; import { EmptyContractsState } from "@/features/contracts/components/list/empty-contracts-state";
import { ContractsList } from "@/features/contracts/components/list/contracts-list"; 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 { useState, useEffect } from "react";
import { getContracts } from "@/features/contracts/api/contract.action"; import { getContracts } from "@/features/contracts/api/contract.action";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -67,7 +67,7 @@ export default function ContactsPage() {
<> <>
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<main className="flex flex-col min-h-screen"> <main className="flex flex-col min-h-screen">
<ContactsHeader /> <ContractsHeader />
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8 space-y-8"> <div className="max-w-7xl mx-auto px-6 py-8 space-y-8">

View File

@@ -140,7 +140,7 @@ export async function POST(req: Request) {
try { try {
// Delete user (CASCADE will delete all related contracts) // Delete user (CASCADE will delete all related contracts)
await prisma.user.delete({ await prisma.user.deleteMany({
where: { clerkId: id }, where: { clerkId: id },
}); });

View File

@@ -2,40 +2,87 @@
import React from "react"; import React from "react";
import Link from "next/link"; 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"; import { BackgroundBeams } from "@/components/ui/background-beams";
export function ContactsHeader() { export function ContractsHeader() {
return ( return (
<div className="border-b border-border/50 bg-background/80 backdrop-blur-sm"> <div className="relative w-full overflow-hidden border-b border-border/40 bg-background/80 pt-8 pb-10 md:pt-10 md:pb-12 backdrop-blur-sm">
<BackgroundBeams className="opacity-80" /> {/* Background Beams - Opacity bumped slightly for better visibility */}
<div className="max-w-7xl mx-auto px-6 py-8 space-y-6"> <div className="absolute inset-0 z-0 pointer-events-none">
<Link <BackgroundBeams className="opacity-80" />
href="/dashboard" <div className="absolute inset-0 bg-gradient-to-b from-background/10 via-background/50 to-background" />
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors" </div>
>
<ArrowLeft className="w-4 h-4" />
Back to Dashboard
</Link>
<div className="space-y-3"> <div className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight bg-gradient-to-r from-primary via-accent to-secondary bg-clip-text text-transparent"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
Contracts Manager {/* Left Column: Typography & Badges */}
</h1> <div className="flex-1 space-y-5 animate-in slide-in-from-bottom-4 fade-in duration-700">
<p className="max-w-3xl text-lg text-muted-foreground"> <Link
Upload, review, and analyze your financial contracts with a focused href="/dashboard"
workspace built for speed and clarity. className="group inline-flex items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-primary"
</p> >
</div> <ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-1" />
Back to Dashboard
</Link>
<div className="flex flex-wrap gap-3 pt-1"> <div className="space-y-2">
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-2 text-sm font-medium text-primary"> <h1 className="text-4xl font-extrabold tracking-tight text-foreground md:text-5xl">
<Sparkles className="w-4 h-4" /> Contracts{" "}
AI-powered review <span className="bg-gradient-to-br from-primary via-accent to-secondary bg-clip-text text-transparent">
Manager
</span>
</h1>
<p className="max-w-2xl text-base text-muted-foreground md:text-lg">
Upload, review, and analyze your financial contracts with speed,
transparency, and cryptographic security.
</p>
</div>
{/* Glassmorphic Badges - Sized up slightly from the compact version */}
<div className="flex flex-wrap gap-3 pt-2">
<div className="group relative inline-flex items-center gap-2 overflow-hidden rounded-full border border-primary/20 bg-primary/5 px-3.5 py-1.5 text-sm font-medium text-primary backdrop-blur-md transition-all hover:bg-primary/10 cursor-default">
<Sparkles className="h-4 w-4" />
AI-Powered
</div>
<div className="group relative inline-flex items-center gap-2 overflow-hidden rounded-full border border-emerald-500/20 bg-emerald-500/5 px-3.5 py-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 backdrop-blur-md transition-all hover:bg-emerald-500/10 cursor-default">
<ShieldCheck className="h-4 w-4" />
Bank-Grade
</div>
</div>
</div> </div>
<div className="flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-4 py-2 text-sm font-medium text-emerald-500">
<ShieldCheck className="w-4 h-4" /> {/* Right Column: Medium-Sized Graphic */}
Compliance-focused workflow <div className="hidden md:block animate-in zoom-in-95 fade-in duration-1000 delay-150 relative h-40 w-64 shrink-0">
{/* Glowing backdrop */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-32 w-32 rounded-full bg-primary/20 blur-3xl" />
{/* Floating Medium Glass Cards */}
<div className="absolute left-2 top-2 flex h-20 w-32 flex-col justify-between rounded-xl border border-white/10 bg-white/5 p-3 shadow-xl backdrop-blur-md dark:border-slate-700/50 dark:bg-slate-800/50 transform -rotate-6 animate-[bounce_5s_infinite_ease-in-out]">
<FileText className="h-5 w-5 text-muted-foreground" />
<div className="space-y-1.5">
<div className="h-1 w-full rounded-full bg-slate-200 dark:bg-slate-700" />
<div className="h-1 w-2/3 rounded-full bg-slate-200 dark:bg-slate-700" />
</div>
</div>
<div className="absolute right-2 bottom-2 flex h-20 w-32 flex-col justify-between rounded-xl border border-white/10 bg-white/5 p-3 shadow-xl backdrop-blur-md dark:border-slate-700/50 dark:bg-slate-800/50 transform rotate-6 animate-[bounce_6s_infinite_ease-in-out_reverse]">
<Network className="h-5 w-5 text-primary" />
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
<div className="h-1 w-1/2 rounded-full bg-slate-200 dark:bg-slate-700" />
</div>
<div className="h-1 w-full rounded-full bg-slate-200 dark:bg-slate-700" />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

View File

@@ -26,7 +26,52 @@ import {
saveContract as savePendingContract, saveContract as savePendingContract,
} from "@/lib/services/contract.service"; } from "@/lib/services/contract.service";
import { AIService } from "@/lib/services/ai.service"; import { AIService } from "@/lib/services/ai.service";
import { RAGService } from "@/lib/services/rag.service";
import { NotificationService } from "@/lib/services/notification.service"; import { NotificationService } from "@/lib/services/notification.service";
import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types";
type ContractListItem = Awaited<
ReturnType<typeof ContractService.getAll>
>[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 * Saves a new contract after UploadThing upload
@@ -70,7 +115,7 @@ export async function saveContract(data: {
userId: user.id, userId: user.id,
type: "SUCCESS", type: "SUCCESS",
title: "📄 Contract Uploaded", 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, contractId: result.contract.id,
actionType: "UPLOAD_SUCCESS", actionType: "UPLOAD_SUCCESS",
icon: "FileCheck", 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("/contacts");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
} }
@@ -114,7 +173,7 @@ export async function getContracts(filters?: Record<string, unknown>) {
const contracts = await ContractService.getAll(filters); const contracts = await ContractService.getAll(filters);
// Serialize contracts: convert Decimal to number, dates to ISO strings // 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, id: contract.id,
fileName: contract.fileName, fileName: contract.fileName,
fileSize: contract.fileSize, fileSize: contract.fileSize,
@@ -135,6 +194,8 @@ export async function getContracts(filters?: Record<string, unknown>) {
summary: contract.summary || null, summary: contract.summary || null,
keyPoints: contract.keyPoints || null, keyPoints: contract.keyPoints || null,
extractedText: contract.extractedText || null, extractedText: contract.extractedText || null,
ragChunkCount: Number(contract?._count?.ragChunks ?? 0),
isRagged: Number(contract?._count?.ragChunks ?? 0) > 0,
})); }));
return { success: true, contracts: serializedContracts }; 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 * 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 // Persist AI learning metadata inside keyPoints JSON so future analyses can adapt
// without requiring DB schema changes. // without requiring DB schema changes.
const aiAnalysis = aiResults as AnalysisWithMeta;
const keyPointsWithLearning = { const keyPointsWithLearning = {
...(aiResults.keyPoints ?? {}), ...(aiResults.keyPoints ?? {}),
aiMeta: { aiMeta: {
language: (aiResults as any).language ?? null, language: aiAnalysis.language ?? null,
keyPeople: (aiResults as any).keyPeople ?? [], keyPeople: aiAnalysis.keyPeople ?? [],
contactInfo: (aiResults as any).contactInfo ?? null, contactInfo: aiAnalysis.contactInfo ?? null,
importantContacts: (aiResults as any).importantContacts ?? [], importantContacts: aiAnalysis.importantContacts ?? [],
relevantDates: (aiResults as any).relevantDates ?? [], relevantDates: aiAnalysis.relevantDates ?? [],
premiumCurrency: (aiResults as any).premiumCurrency ?? null, premiumCurrency: aiAnalysis.premiumCurrency ?? null,
learnedAt: new Date().toISOString(), learnedAt: new Date().toISOString(),
}, },
}; };
@@ -385,6 +488,14 @@ export async function analyzeContractAction(id: string) {
premium: aiResults.premium ?? undefined, 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 // Create success notification with extracted info
const contractTitle = aiResults.title || "Contract"; const contractTitle = aiResults.title || "Contract";
const contractProvider = aiResults.provider || "Unknown Provider"; const contractProvider = aiResults.provider || "Unknown Provider";
@@ -425,7 +536,6 @@ export async function analyzeContractAction(id: string) {
// Create error notification // Create error notification
if (user) { if (user) {
const contract = await ContractService.getById(id);
await NotificationService.create({ await NotificationService.create({
userId: user.id, userId: user.id,
type: "ERROR", 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 // Ask AI about contract with full context
const answer = await AIService.askAboutContract({ const answer = await AIService.askAboutContract({
question: trimmedQuestion, question: trimmedQuestion,
ragChunks: ragDiagnostics,
contract: { contract: {
id: contract.id,
fileName: contract.fileName, fileName: contract.fileName,
title: contract.title, title: contract.title,
type: contract.type, type: contract.type,
@@ -533,11 +651,21 @@ export async function askContractQuestionAction(id: string, question: string) {
keyPoints: keyPoints:
(contract.keyPoints as Record<string, unknown> | null) ?? null, (contract.keyPoints as Record<string, unknown> | null) ?? null,
extractedText: contract.extractedText, 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) { } catch (error: unknown) {
console.error("Ask contract question error:", error); console.error("Ask contract question error:", error);
return { return {

View File

@@ -1,7 +1,14 @@
"use client"; "use client";
import { useState } from "react";
import { UploadDropzone } from "@uploadthing/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 { Card } from "@/components/ui/card";
import { saveContract } from "@/features/contracts/api/contract.action"; import { saveContract } from "@/features/contracts/api/contract.action";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -14,6 +21,7 @@ export function ContractUploadForm({
onUploadSuccess: () => void; onUploadSuccess: () => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const [isAutoAnalyzing, setIsAutoAnalyzing] = useState(false);
const emitNotificationRefresh = () => { const emitNotificationRefresh = () => {
window.dispatchEvent(new Event("notifications:refresh")); window.dispatchEvent(new Event("notifications:refresh"));
@@ -77,25 +85,50 @@ export function ContractUploadForm({
} }
const file = res[0]; const file = res[0];
setIsAutoAnalyzing(true);
// Save to database try {
const result = await saveContract({ // Save to database
fileName: file.name, const result = await saveContract({
fileUrl: file.url, fileName: file.name,
fileSize: file.size, fileUrl: file.url,
mimeType: file.type, fileSize: file.size,
}); mimeType: file.type,
});
if (result.success) { if (result.success) {
toast.success("Contract uploaded successfully!"); if (
emitNotificationRefresh(); (result as { analysisSuccess?: boolean }).analysisSuccess ===
onUploadSuccess(); false
router.refresh(); ) {
} else { toast.warning(
toast.error(result.error || "Failed to save contract"); (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) => { onUploadError={(error: Error) => {
setIsAutoAnalyzing(false);
toast.error(`Upload failed: ${error.message}`); toast.error(`Upload failed: ${error.message}`);
}} }}
appearance={{ appearance={{
@@ -126,7 +159,7 @@ export function ContractUploadForm({
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" /> <AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
<div> <div>
<div className="mb-1 font-semibold text-foreground">AI Flow</div> <div className="mb-1 font-semibold text-foreground">AI Flow</div>
<div>Upload first, then click Analyze when ready</div> <div>Upload starts instant AI analysis + RAG indexing</div>
</div> </div>
</div> </div>
</div> </div>
@@ -136,6 +169,53 @@ export function ContractUploadForm({
Extraction quality improves as more contracts are analyzed. Extraction quality improves as more contracts are analyzed.
</div> </div>
</div> </div>
{isAutoAnalyzing && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 backdrop-blur-sm animate-in fade-in duration-300">
<div className="mx-4 max-w-md rounded-3xl border border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.22),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.16),transparent_45%),hsl(var(--background))] p-8 shadow-2xl md:p-10 zoom-in-95 animate-in duration-300">
<div className="flex flex-col items-center text-center space-y-6">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-primary/30 blur-xl animate-pulse"></div>
<div className="relative rounded-full bg-gradient-to-br from-primary to-accent p-4">
<Sparkles className="h-8 w-8 animate-pulse text-white" />
</div>
</div>
<div className="relative">
<Loader2 className="h-11 w-11 animate-spin text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold text-foreground">
Analyzing And Building RAG
</h3>
<p className="text-sm text-muted-foreground">
Your contract is being analyzed and indexed for chat...
</p>
</div>
<div className="w-full space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Processing</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce"></span>
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full w-full rounded-full bg-gradient-to-r from-primary to-accent animate-progress-loading origin-left"></div>
</div>
</div>
<p className="text-xs text-muted-foreground italic">
This may take up to 10 seconds
</p>
</div>
</div>
</div>
)}
</Card> </Card>
); );
} }

View File

@@ -2,24 +2,21 @@
import { useState, useEffect, useCallback, useMemo } from "react"; import { useState, useEffect, useCallback, useMemo } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Prisma } from "@prisma/client";
import { import {
Download, Download,
Trash2, Trash2,
Eye, Eye,
MoreVertical, MoreVertical,
Loader2, Loader2,
Sparkles,
FileText, FileText,
FileSpreadsheet,
MessageSquare, MessageSquare,
Send,
Scale,
Briefcase,
User,
Bot,
AlertTriangle, AlertTriangle,
X, X,
Search, Search,
Info, Info,
Network,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -50,12 +47,12 @@ import {
import { import {
deleteContract, deleteContract,
getContracts, getContracts,
analyzeContractAction, deleteAllContractsAction,
askContractQuestionAction,
} from "@/features/contracts/api/contract.action"; } from "@/features/contracts/api/contract.action";
import { toast } from "sonner"; import { toast } from "sonner";
import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal"; import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal";
import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal"; import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal";
import { stripMarkdown, exportToCSV, exportToPDF } from "@/features/contracts/utils/export.utils";
interface Contract { interface Contract {
id: string; id: string;
@@ -73,13 +70,10 @@ interface Contract {
endDate?: string | null; endDate?: string | null;
premium?: number | null; premium?: number | null;
summary?: string | null; summary?: string | null;
keyPoints?: Record<string, unknown> | null; keyPoints?: Prisma.JsonValue | null;
extractedText?: string | null; extractedText?: string | null;
} ragChunkCount?: number;
isRagged?: boolean;
interface ChatMessage {
role: "user" | "assistant";
content: string;
} }
interface ExplainabilityEntry { 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 }) { export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
const emitNotificationRefresh = () => { const emitNotificationRefresh = () => {
window.dispatchEvent(new Event("notifications:refresh")); window.dispatchEvent(new Event("notifications:refresh"));
@@ -104,18 +114,18 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
const [contracts, setContracts] = useState<Contract[]>([]); const [contracts, setContracts] = useState<Contract[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [analyzingId, setAnalyzingId] = useState<string | null>(null); const [isDeletingAll, setIsDeletingAll] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedContract, setSelectedContract] = useState<Contract | null>( const [selectedContract, setSelectedContract] = useState<Contract | null>(
null, null,
); );
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [askOpen, setAskOpen] = useState(false); const [askOpen, setAskOpen] = useState(false);
const [chatContract, setChatContract] = useState<Contract | null>(null); const [chatContract, setChatContract] = useState<Contract | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [contractToDelete, setContractToDelete] = useState<Contract | null>( const [contractToDelete, setContractToDelete] = useState<Contract | null>(
null, null,
); );
const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false);
const [invalidContractDialogOpen, setInvalidContractDialogOpen] = const [invalidContractDialogOpen, setInvalidContractDialogOpen] =
useState(false); useState(false);
const [invalidContractReason, setInvalidContractReason] = useState(""); const [invalidContractReason, setInvalidContractReason] = useState("");
@@ -563,11 +573,13 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
const getExplainabilityItems = ( const getExplainabilityItems = (
contract: Contract | null, contract: Contract | null,
): ExplainabilityEntry[] => { ): ExplainabilityEntry[] => {
const raw = (contract?.keyPoints as any)?.explainability; const raw = isContractKeyPoints(contract?.keyPoints)
? contract.keyPoints.explainability
: undefined;
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw return raw
.map((item: any) => ({ .map((item) => ({
field: String(item?.field ?? "").trim(), field: String(item?.field ?? "").trim(),
why: String(item?.why ?? "").trim(), why: String(item?.why ?? "").trim(),
sourceSnippet: String(item?.sourceSnippet ?? "").trim(), sourceSnippet: String(item?.sourceSnippet ?? "").trim(),
@@ -675,7 +687,9 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
if (!contract) return null; if (!contract) return null;
const fromMeta = String( const fromMeta = String(
(contract.keyPoints as any)?.aiMeta?.premiumCurrency ?? "", (isContractKeyPoints(contract.keyPoints)
? contract.keyPoints.aiMeta?.premiumCurrency
: null) ?? "",
).trim(); ).trim();
if (fromMeta) return fromMeta; if (fromMeta) return fromMeta;
@@ -848,38 +862,26 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
setContractToDelete(null); setContractToDelete(null);
}; };
const handleAnalyze = async (id: string) => { const handleDeleteAll = async () => {
const selected = contracts.find((contract) => contract.id === id); setIsDeletingAll(true);
setAnalyzingId(id);
setIsAnalyzing(true);
try { try {
const result = await analyzeContractAction(id); const result = await deleteAllContractsAction();
if (result.success) { if (result.success) {
// Reload contracts to get all AI analysis data setContracts([]);
await loadContracts(); toast.success(
toast.success("Contract analyzed successfully!"); `Deleted ${result.deletedCount ?? 0} contract${(result.deletedCount ?? 0) === 1 ? "" : "s"}.`,
);
emitNotificationRefresh(); emitNotificationRefresh();
} else { } else {
const errorCode = (result as { errorCode?: string }).errorCode; toast.error(result.error || "Failed to delete all contracts");
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");
}
} }
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "Unknown error occurred", error instanceof Error ? error.message : "Unknown error occurred",
); );
} finally { } finally {
setAnalyzingId(null); setIsDeletingAll(false);
setIsAnalyzing(false); setDeleteAllDialogOpen(false);
} }
}; };
@@ -994,11 +996,28 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
className="pl-9" className="pl-9"
/> />
</div> </div>
{debouncedSearchQuery && ( <div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground"> {debouncedSearchQuery && (
Showing results for: "{debouncedSearchQuery}" <p className="text-xs text-muted-foreground">
</p> Showing results for: &quot;{debouncedSearchQuery}&quot;
)} </p>
)}
<Button
type="button"
variant="destructive"
size="sm"
disabled={contracts.length === 0 || isDeletingAll}
onClick={() => setDeleteAllDialogOpen(true)}
className="gap-2"
>
{isDeletingAll ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Delete All
</Button>
</div>
</div> </div>
<Card className="border-border/50 overflow-hidden"> <Card className="border-border/50 overflow-hidden">
@@ -1023,6 +1042,12 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
> >
{contract.status} {contract.status}
</span> </span>
{contract.isRagged && (
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-cyan-700 dark:text-cyan-300">
<Network className="h-3 w-3" />
RAG {contract.ragChunkCount ?? 0}
</span>
)}
</div> </div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground flex-wrap"> <div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground flex-wrap">
@@ -1070,21 +1095,6 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
className="hover:bg-primary/10"
title="Analyze with AI"
disabled={analyzingId === contract.id}
onClick={() => handleAnalyze(contract.id)}
>
{analyzingId === contract.id ? (
<Loader2 className="w-4 h-4 animate-spin text-primary" />
) : (
<Sparkles className="w-4 h-4" />
)}
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -1110,6 +1120,20 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
<FileText className="w-4 h-4 mr-2" /> <FileText className="w-4 h-4 mr-2" />
Details Details
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => exportToPDF(contract as any)}
className="cursor-pointer"
>
<FileText className="w-4 h-4 mr-2" />
Export Analysis (PDF)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => exportToCSV(contract as any)}
className="cursor-pointer"
>
<FileSpreadsheet className="w-4 h-4 mr-2" />
Export Analysis (CSV)
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => requestDeleteContract(contract)} onClick={() => requestDeleteContract(contract)}
disabled={deletingId === contract.id} disabled={deletingId === contract.id}
@@ -1238,7 +1262,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</button> </button>
</div> </div>
<p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner"> <p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner">
{selectedContract.title || "N/A"} {stripMarkdown(selectedContract.title) || "N/A"}
</p> </p>
</div> </div>
<div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30"> <div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30">
@@ -1259,7 +1283,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</button> </button>
</div> </div>
<p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner"> <p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner">
{selectedContract.provider || "N/A"} {stripMarkdown(selectedContract.provider) || "N/A"}
</p> </p>
</div> </div>
<div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30"> <div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30">
@@ -1283,7 +1307,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</button> </button>
</div> </div>
<p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner"> <p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner">
{selectedContract.policyNumber || "N/A"} {stripMarkdown(selectedContract.policyNumber) || "N/A"}
</p> </p>
</div> </div>
<div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30"> <div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30">
@@ -1376,9 +1400,10 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
Key Points Key Points
</h3> </h3>
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
{(selectedContract.keyPoints as any)?.guarantees && {isContractKeyPoints(selectedContract.keyPoints) &&
selectedContract.keyPoints.guarantees &&
Array.isArray( Array.isArray(
(selectedContract.keyPoints as any).guarantees, selectedContract.keyPoints.guarantees,
) && ( ) && (
<div> <div>
<p className="text-muted-foreground font-medium"> <p className="text-muted-foreground font-medium">
@@ -1386,9 +1411,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</p> </p>
<ul className="ml-1 space-y-2"> <ul className="ml-1 space-y-2">
{( {(
(selectedContract.keyPoints as any) selectedContract.keyPoints.guarantees ?? []
.guarantees as string[] ).map((guarantee, idx: number) => (
).map((guarantee: string, idx: number) => (
<li <li
key={idx} key={idx}
className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2" className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
@@ -1402,9 +1426,10 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</ul> </ul>
</div> </div>
)} )}
{(selectedContract.keyPoints as any)?.exclusions && {isContractKeyPoints(selectedContract.keyPoints) &&
selectedContract.keyPoints.exclusions &&
Array.isArray( Array.isArray(
(selectedContract.keyPoints as any).exclusions, selectedContract.keyPoints.exclusions,
) && ( ) && (
<div> <div>
<p className="text-muted-foreground font-medium"> <p className="text-muted-foreground font-medium">
@@ -1412,9 +1437,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</p> </p>
<ul className="ml-1 space-y-2"> <ul className="ml-1 space-y-2">
{( {(
(selectedContract.keyPoints as any) selectedContract.keyPoints.exclusions ?? []
.exclusions as string[] ).map((exclusion, idx: number) => (
).map((exclusion: string, idx: number) => (
<li <li
key={idx} key={idx}
className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2" className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
@@ -1428,21 +1452,20 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</ul> </ul>
</div> </div>
)} )}
{(selectedContract.keyPoints as any)?.franchise && ( {isContractKeyPoints(selectedContract.keyPoints) &&
<div> selectedContract.keyPoints.franchise && (
<p className="text-muted-foreground font-medium"> <div>
Deductible: <p className="text-muted-foreground font-medium">
</p> Deductible:
<div className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 whitespace-pre-wrap break-words"> </p>
{renderRichParagraphs( <div className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 whitespace-pre-wrap break-words">
String( {renderRichParagraphs(
(selectedContract.keyPoints as any).franchise, String(selectedContract.keyPoints.franchise),
), `franchise-${selectedContract.id}`,
`franchise-${selectedContract.id}`, )}
)} </div>
</div> </div>
</div> )}
)}
</div> </div>
</div> </div>
)} )}
@@ -1460,9 +1483,9 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
{selectedContract.status === "UPLOADED" && ( {selectedContract.status === "UPLOADED" && (
<div className="flex items-center gap-2 rounded-xl border border-amber-200/40 bg-amber-50/60 p-4 dark:border-amber-800/40 dark:bg-amber-950/30"> <div className="flex items-center gap-2 rounded-xl border border-amber-200/40 bg-amber-50/60 p-4 dark:border-amber-800/40 dark:bg-amber-950/30">
<Sparkles className="w-5 h-5 text-amber-500" /> <Loader2 className="w-5 h-5 text-amber-500 animate-spin" />
<p className="text-sm text-amber-700 dark:text-amber-300"> <p className="text-sm text-amber-700 dark:text-amber-300">
Click the Sparkles button to analyze this contract Contract uploaded. AI analysis will start automatically.
</p> </p>
</div> </div>
)} )}
@@ -1526,6 +1549,30 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog
open={deleteAllDialogOpen}
onOpenChange={setDeleteAllDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete all contracts?</AlertDialogTitle>
<AlertDialogDescription>
This action permanently removes all contracts and related files
for your account. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => void handleDeleteAll()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeletingAll ? "Deleting..." : "Delete All"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog <Dialog
open={invalidContractDialogOpen} open={invalidContractDialogOpen}
onOpenChange={setInvalidContractDialogOpen} onOpenChange={setInvalidContractDialogOpen}
@@ -1573,59 +1620,6 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* AI Analysis Loading Overlay */}
{isAnalyzing && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 backdrop-blur-sm animate-in fade-in duration-300">
<div className="mx-4 max-w-md rounded-3xl border border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.22),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.16),transparent_45%),hsl(var(--background))] p-8 shadow-2xl md:p-10 zoom-in-95 animate-in duration-300">
<div className="flex flex-col items-center text-center space-y-6">
{/* Glow Effect */}
<div className="relative">
<div className="absolute inset-0 rounded-full bg-primary/30 blur-xl animate-pulse"></div>
<div className="relative rounded-full bg-gradient-to-br from-primary to-accent p-4">
<Sparkles className="h-8 w-8 animate-pulse text-white" />
</div>
</div>
{/* Spinner */}
<div className="relative">
<Loader2 className="h-11 w-11 animate-spin text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold text-foreground">
Analyzing Contract
</h3>
<p className="text-sm text-muted-foreground">
Our AI is carefully reviewing your document...
</p>
</div>
{/* Progress Section */}
<div className="w-full space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Processing</span>
<span className="flex items-center gap-1.5">
{/* Use inline styles for delays if they aren't in your config */}
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-bounce"></span>
</span>
</div>
{/* Moving Progress Bar */}
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full w-full rounded-full bg-gradient-to-r from-primary to-accent animate-progress-loading origin-left"></div>
</div>
</div>
<p className="text-xs text-muted-foreground italic">
This may take up to 10 seconds
</p>
</div>
</div>
</div>
)}
</> </>
); );
} }

View File

@@ -1,8 +1,22 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import {
import { MessageSquare, Briefcase, Scale, Bot, User, Loader2, Send } from "lucide-react"; 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 { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { askContractQuestionAction } from "@/features/contracts/api/contract.action"; import { askContractQuestionAction } from "@/features/contracts/api/contract.action";
@@ -17,6 +31,12 @@ interface ChatMessage {
content: string; content: string;
} }
interface RagDiagnosticEntry {
chunkIndex: number;
score: number;
preview: string;
}
interface ContractChatModalProps { interface ContractChatModalProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -32,10 +52,14 @@ export function ContractChatModal({
}: ContractChatModalProps) { }: ContractChatModalProps) {
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const [isAsking, setIsAsking] = useState(false); const [isAsking, setIsAsking] = useState(false);
const [ragDiagnostics, setRagDiagnostics] = useState<RagDiagnosticEntry[]>(
[],
);
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
{ {
role: "assistant", 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(); const trimmedQuestion = question.trim();
if (!trimmedQuestion) return; if (!trimmedQuestion) return;
setMessages((prev) => [...prev, { role: "user", content: trimmedQuestion }]); setMessages((prev) => [
...prev,
{ role: "user", content: trimmedQuestion },
]);
setQuestion(""); setQuestion("");
setIsAsking(true); setIsAsking(true);
try { try {
const result = await askContractQuestionAction(contract.id, trimmedQuestion); const result = await askContractQuestionAction(
contract.id,
trimmedQuestion,
);
if (result.success && result.answer) { 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 { } else {
const errorMessage = result.error || "Failed to get AI response"; 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) { } catch (error) {
const fallbackMessage = error instanceof Error ? error.message : "Unknown error occurred"; const fallbackMessage =
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${fallbackMessage}` }]); error instanceof Error ? error.message : "Unknown error occurred";
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${fallbackMessage}` },
]);
} finally { } finally {
setIsAsking(false); setIsAsking(false);
} }
@@ -90,7 +137,9 @@ export function ContractChatModal({
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">
Contract Intelligence Assistant Contract Intelligence Assistant
</p> </p>
<p className="text-sm font-medium truncate mt-1">{contract.fileName}</p> <p className="text-sm font-medium truncate mt-1">
{contract.fileName}
</p>
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/30 px-2 py-1"> <span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/30 px-2 py-1">
@@ -124,6 +173,43 @@ export function ContractChatModal({
</div> </div>
</div> </div>
<div className="rounded-2xl border border-cyan-500/20 bg-cyan-500/5 p-3">
<div className="mb-2 flex items-center gap-2">
<Network className="h-4 w-4 text-cyan-600 dark:text-cyan-300" />
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-700 dark:text-cyan-300">
RAG Diagnostics
</p>
</div>
{ragDiagnostics.length === 0 ? (
<p className="text-xs text-muted-foreground">
Ask a question to inspect top retrieved chunks and relevance
scores.
</p>
) : (
<div className="space-y-2">
{ragDiagnostics.map((item) => (
<div
key={`${item.chunkIndex}-${item.score}`}
className="rounded-xl border border-border/50 bg-background/70 p-2"
>
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="font-medium text-foreground">
Chunk {item.chunkIndex}
</span>
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-700 dark:text-cyan-300">
score {item.score.toFixed(4)}
</span>
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{item.preview}
</p>
</div>
))}
</div>
)}
</div>
<div className="h-80 space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-black/5 dark:bg-white/5 p-4 shadow-inner backdrop-blur-md"> <div className="h-80 space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-black/5 dark:bg-white/5 p-4 shadow-inner backdrop-blur-md">
{messages.map((message, index) => ( {messages.map((message, index) => (
<div <div
@@ -144,7 +230,10 @@ export function ContractChatModal({
}`} }`}
> >
{message.role === "assistant" {message.role === "assistant"
? renderRichParagraphs(message.content, `chat-assistant-${index}`) ? renderRichParagraphs(
message.content,
`chat-assistant-${index}`,
)
: message.content} : message.content}
</div> </div>
{message.role === "user" && ( {message.role === "user" && (
@@ -177,7 +266,12 @@ export function ContractChatModal({
disabled={isAsking} 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" 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) => { onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !isAsking && question.trim()) { if (
event.key === "Enter" &&
!event.shiftKey &&
!isAsking &&
question.trim()
) {
event.preventDefault(); event.preventDefault();
void handleAskQuestion(); void handleAskQuestion();
} }

View File

@@ -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`);
};

View File

@@ -6,28 +6,19 @@ import {
ContractPrecheckResult, ContractPrecheckResult,
NormalizedAnalysis, NormalizedAnalysis,
} from "@/lib/services/ai/analysis.types"; } from "@/lib/services/ai/analysis.types";
import type { Prisma } from "@prisma/client";
import { import {
buildAnalysisPrompt, buildAnalysisPrompt,
buildPrevalidationPrompt, buildPrevalidationPrompt,
} from "@/lib/services/ai/analysis.prompt"; } from "@/lib/services/ai/analysis.prompt";
import { parseJsonResponse as parseAiJsonResponse } from "@/lib/services/ai/analysis.parser"; import { parseJsonResponse as parseAiJsonResponse } from "@/lib/services/ai/analysis.parser";
import { normalizeAnalysis as normalizeAiAnalysis } from "@/lib/services/ai/analysis.normalizer"; 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. import { keyManager } from "@/lib/services/ai/key-manager";
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);
const PRIMARY_ANALYSIS_MODEL = 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 = const FALLBACK_ANALYSIS_MODEL =
process.env.AI_MODEL_FALLBACK || "gemini-2.0-flash"; 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]), 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 { export class AIService {
/** /**
* Domain-specific guidance for contract Q&A. * Domain-specific guidance for contract Q&A.
@@ -77,6 +113,7 @@ export class AIService {
* Supports both PDF and image files * Supports both PDF and image files
*/ */
static async analyzeContract(fileUrl: string, options?: AnalyzeOptions) { static async analyzeContract(fileUrl: string, options?: AnalyzeOptions) {
keyManager.resetKeys();
try { try {
const maxRetries = Math.min(3, Math.max(1, options?.maxRetries ?? 2)); const maxRetries = Math.min(3, Math.max(1, options?.maxRetries ?? 2));
@@ -191,10 +228,12 @@ export class AIService {
); );
return normalized; return normalized;
} catch (validationError: any) { } catch (validationError: unknown) {
// If validation fails, keep reason and retry with correction guidance. // If validation fails, keep reason and retry with correction guidance.
lastValidationError = lastValidationError =
validationError?.message || "Failed to parse model output"; validationError instanceof Error
? validationError.message
: "Failed to parse model output";
if (attempt === maxRetries) { if (attempt === maxRetries) {
throw new Error(lastValidationError); throw new Error(lastValidationError);
} }
@@ -202,51 +241,53 @@ export class AIService {
} }
throw new Error("AI analysis failed after retries."); 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 // Better error messages
if (error.message?.includes("API key")) { if (errorMessage.includes("API key")) {
throw new Error( throw new Error(
"Invalid or missing Gemini API key. Check AI_API_KEY in your .env file", "Invalid or missing Gemini API key. Check AI_API_KEY in your .env file",
); );
} else if (error.message?.includes("INVALID_CONTRACT:")) { } else if (errorMessage.includes("INVALID_CONTRACT:")) {
const reason = String(error.message) const reason = String(errorMessage)
.replace("INVALID_CONTRACT:", "") .replace("INVALID_CONTRACT:", "")
.trim(); .trim();
throw new Error( throw new Error(
reason || "Uploaded file is not recognized as a valid contract.", reason || "Uploaded file is not recognized as a valid contract.",
); );
} else if ( } else if (
error.message?.includes("not found") || errorMessage.includes("not found") ||
error.message?.includes("404") errorMessage.includes("404")
) { ) {
throw new Error( throw new Error(
`Invalid Gemini model configuration. Current models: ${ANALYSIS_MODELS.join(", ")}. Check model availability in your Gemini account.`, `Invalid Gemini model configuration. Current models: ${ANALYSIS_MODELS.join(", ")}. Check model availability in your Gemini account.`,
); );
} else if ( } else if (
error.message?.includes("fetch") && errorMessage.includes("fetch") &&
!error.message?.includes("generativelanguage") !errorMessage.includes("generativelanguage")
) { ) {
throw new Error( throw new Error(
"Download failed. Check if the file URL is correct and accessible.", "Download failed. Check if the file URL is correct and accessible.",
); );
} else if ( } else if (
error.message?.includes("JSON") || errorMessage.includes("JSON") ||
error.message?.includes("No complete JSON object") || errorMessage.includes("No complete JSON object") ||
error.message?.includes("parse failed") errorMessage.includes("parse failed")
) { ) {
console.error("❌ Raw response that failed to parse:", error); 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 // Help user understand what went wrong
if (error.message?.includes("escaped quotes")) { if (errorMessage.includes("escaped quotes")) {
throw new Error( throw new Error(
"The contract contains special characters that corrupted the analysis. Try uploading a cleaner version.", "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( throw new Error(
"AI analysis failed to complete properly. This might be a large or complex contract. Try a smaller contract first.", "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( throw new Error(
"This doesn't appear to be a valid financial/insurance contract. Please upload a legitimate contract document.", "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.", "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( throw new Error(
"Limit exceeded. Your Gemini API quota may be exhausted. Check your Google Cloud Console for usage details.", "Limit exceeded. Your Gemini API quota may be exhausted. Check your Google Cloud Console for usage details.",
); );
} else { } 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) { for (const modelName of ANALYSIS_MODELS) {
try { try {
const model = genAI.getGenerativeModel({ return await keyManager.execute(async (genAI) => {
model: modelName, const model = genAI.getGenerativeModel({
generationConfig: { model: modelName,
temperature: 0.1, generationConfig: {
topP: 0.95, temperature: 0,
topK: 40, topP: 0.95,
maxOutputTokens: 16384, topK: 40,
responseMimeType: "application/json", maxOutputTokens: 16384,
}, responseMimeType: "application/json",
});
const result = await model.generateContent([
input.prompt,
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
}, },
}, });
]);
const text = result.response.text(); const result = await model.generateContent([
if (text && text.trim().length > 0) { input.prompt,
console.log(`✅ Analysis with model ${modelName} succeeded`); {
return text; inlineData: {
} data: input.base64,
} catch (error) { 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; lastError = error;
console.warn( console.warn(
`Analysis with model ${modelName} failed. Trying next model.`, `Analysis with model ${modelName} failed. Trying next model.`,
@@ -358,33 +403,37 @@ export class AIService {
"All standard models failed. Trying with lenient generation config...", "All standard models failed. Trying with lenient generation config...",
); );
try { try {
const fallbackModel = genAI.getGenerativeModel({ return await keyManager.execute(async (genAI) => {
model: PRIMARY_ANALYSIS_MODEL, const fallbackModel = genAI.getGenerativeModel({
generationConfig: { model: PRIMARY_ANALYSIS_MODEL,
temperature: 0, generationConfig: {
topP: 0.9, temperature: 0,
topK: 20, topP: 0.9,
maxOutputTokens: 16384, topK: 20,
// Don't enforce JSON format; let model produce raw output 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,
}, },
}, });
]);
const text = result.response.text(); const result = await fallbackModel.generateContent([
if (text && text.trim().length > 0) { input.prompt,
console.log("✅ Lenient generation succeeded"); {
return text; inlineData: {
} data: input.base64,
} catch (error) { 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); console.warn("Lenient generation also failed:", error);
} }
@@ -398,46 +447,47 @@ export class AIService {
parseError: string, parseError: string,
): Promise<string | null> { ): Promise<string | null> {
try { try {
const repairModelName = FALLBACK_ANALYSIS_MODEL; return await keyManager.execute(async (genAI) => {
const model = genAI.getGenerativeModel({ const repairModelName = FALLBACK_ANALYSIS_MODEL;
model: repairModelName, const model = genAI.getGenerativeModel({
generationConfig: { model: repairModelName,
temperature: 0, generationConfig: {
topP: 0.9, temperature: 0,
topK: 20, topP: 0.9,
maxOutputTokens: 16384, topK: 20,
responseMimeType: "application/json", maxOutputTokens: 16384,
}, responseMimeType: "application/json",
}); },
});
const expectedSchema = { const expectedSchema = {
language: "string|null", language: "string|null",
title: "string", title: "string",
type: "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER", type: "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER",
provider: "string|null", provider: "string|null",
policyNumber: "string|null", policyNumber: "string|null",
startDate: "YYYY-MM-DD|null", startDate: "YYYY-MM-DD|null",
endDate: "YYYY-MM-DD|null", endDate: "YYYY-MM-DD|null",
premium: "number|null", premium: "number|null",
premiumCurrency: "string|null (ISO code like EUR/USD/TND or symbol)", premiumCurrency: "string|null (ISO code like EUR/USD/TND or symbol)",
summary: "string (min 10 chars)", summary: "string (min 10 chars)",
extractedText: "string (min 30 chars)", extractedText: "string (min 30 chars)",
keyPoints: { keyPoints: {
guarantees: "string[]", guarantees: "string[]",
exclusions: "string[]", exclusions: "string[]",
franchise: "string|null", franchise: "string|null",
importantDates: "string[]", importantDates: "string[]",
explainability: explainability:
"[{ field, why, sourceSnippet, sourceHints:{ page|null, section|null, confidence|null } }]", "[{ field, why, sourceSnippet, sourceHints:{ page|null, section|null, confidence|null } }]",
}, },
keyPeople: "[{ name, role|null, email|null, phone|null }]", keyPeople: "[{ name, role|null, email|null, phone|null }]",
contactInfo: contactInfo:
"{ name|null, email|null, phone|null, address|null, role|null }", "{ name|null, email|null, phone|null, address|null, role|null }",
importantContacts: importantContacts:
"[{ name|null, email|null, phone|null, address|null, role|null }]", "[{ name|null, email|null, phone|null, address|null, role|null }]",
relevantDates: relevantDates:
"[{ date:'YYYY-MM-DD', description, type:'EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER' }]", "[{ date:'YYYY-MM-DD', description, type:'EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER' }]",
contractValidation: { contractValidation: {
isValidContract: "boolean", isValidContract: "boolean",
confidence: "number (0-100)", confidence: "number (0-100)",
reason: "string|null", reason: "string|null",
@@ -478,7 +528,9 @@ ${malformedResponse.slice(0, 14000)}`;
} }
return repairedText; return repairedText;
} catch (error) { });
} catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
console.warn("JSON repair step failed:", error); console.warn("JSON repair step failed:", error);
return null; return null;
} }
@@ -548,10 +600,10 @@ ${malformedResponse.slice(0, 14000)}`;
}): Promise<ContractPrecheckResult> { }): Promise<ContractPrecheckResult> {
const rawText = await this.generatePrevalidationWithFallback(input); const rawText = await this.generatePrevalidationWithFallback(input);
let raw: any; let raw: PrevalidationResponse;
try { try {
raw = this.parseJsonResponse(rawText || "{}"); raw = this.parseJsonResponse(rawText || "{}") as PrevalidationResponse;
} catch (error) { } catch {
// If prevalidation JSON is malformed, assume it's a contract with moderate confidence // If prevalidation JSON is malformed, assume it's a contract with moderate confidence
console.warn( console.warn(
"Prevalidation JSON parse failed, assuming contract with moderate confidence", "Prevalidation JSON parse failed, assuming contract with moderate confidence",
@@ -591,32 +643,36 @@ ${malformedResponse.slice(0, 14000)}`;
for (const modelName of ANALYSIS_MODELS) { for (const modelName of ANALYSIS_MODELS) {
try { try {
const model = genAI.getGenerativeModel({ return await keyManager.execute(async (genAI) => {
model: modelName, const model = genAI.getGenerativeModel({
generationConfig: { model: modelName,
temperature: 0, generationConfig: {
topP: 0.9, temperature: 0,
topK: 20, topP: 0.9,
maxOutputTokens: 350, topK: 20,
responseMimeType: "application/json", maxOutputTokens: 350,
}, responseMimeType: "application/json",
});
const result = await model.generateContent([
buildPrevalidationPrompt(input.fileName),
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
}, },
}, });
]);
const text = result.response.text(); const result = await model.generateContent([
if (text && text.trim().length > 0) { buildPrevalidationPrompt(input.fileName),
return text; {
} inlineData: {
} catch (error) { 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; lastError = error;
console.warn( console.warn(
`Pre-validation with model ${modelName} failed. Trying next model.`, `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."); : 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); return normalizeAiAnalysis(input);
} }
@@ -639,7 +695,7 @@ ${malformedResponse.slice(0, 14000)}`;
return ""; return "";
} }
const examples = await prisma.contract.findMany({ const examples: AdaptiveContractExample[] = await prisma.contract.findMany({
where: { where: {
userId, userId,
status: "COMPLETED", status: "COMPLETED",
@@ -693,19 +749,21 @@ ${malformedResponse.slice(0, 14000)}`;
const allExplainability = examples const allExplainability = examples
.flatMap((item) => { .flatMap((item) => {
const maybeExplainability = (item.keyPoints as any)?.explainability; const maybeExplainability = isAdaptiveKeyPoints(item.keyPoints)
? item.keyPoints.explainability
: undefined;
return Array.isArray(maybeExplainability) ? maybeExplainability : []; return Array.isArray(maybeExplainability) ? maybeExplainability : [];
}) })
.slice(0, 120); .slice(0, 120);
const explainabilityByField = count( const explainabilityByField = count(
allExplainability allExplainability
.map((entry: any) => String(entry?.field ?? "").trim()) .map((entry) => String(entry?.field ?? "").trim())
.filter((value: string) => value.length > 0), .filter((value: string) => value.length > 0),
); );
const confidenceValues = allExplainability const confidenceValues = allExplainability
.map((entry: any) => Number(entry?.sourceHints?.confidence)) .map((entry) => Number(entry?.sourceHints?.confidence))
.filter((value: number) => Number.isFinite(value)); .filter((value: number) => Number.isFinite(value));
const avgEvidenceConfidence = confidenceValues.length const avgEvidenceConfidence = confidenceValues.length
@@ -719,7 +777,11 @@ ${malformedResponse.slice(0, 14000)}`;
const learnedLanguages = count( const learnedLanguages = count(
examples examples
.map((item) => (item.keyPoints as any)?.aiMeta?.language) .map((item) =>
isAdaptiveKeyPoints(item.keyPoints)
? item.keyPoints.aiMeta?.language
: null,
)
.map((value) => String(value ?? "").trim()) .map((value) => String(value ?? "").trim())
.filter((value: string) => value.length > 0), .filter((value: string) => value.length > 0),
); );
@@ -727,10 +789,12 @@ ${malformedResponse.slice(0, 14000)}`;
const learnedKeyRoles = count( const learnedKeyRoles = count(
examples examples
.flatMap((item) => { .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 : []; return Array.isArray(people) ? people : [];
}) })
.map((person: any) => String(person?.role ?? "").trim()) .map((person) => String(person?.role ?? "").trim())
.filter((value: string) => value.length > 0), .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 * - Heuristic text signals suggest non-contract content
*/ */
private static assertValidContract( private static assertValidContract(
raw: any, raw: unknown,
normalized: NormalizedAnalysis, normalized: NormalizedAnalysis,
): void { ): void {
const modelIsValid = raw?.contractValidation?.isValidContract; const validation = raw as ValidationEnvelope;
const confidenceRaw = Number(raw?.contractValidation?.confidence); const modelIsValid = validation.contractValidation?.isValidContract;
const modelReason = String(raw?.contractValidation?.reason ?? "").trim(); const confidenceRaw = Number(validation.contractValidation?.confidence);
const modelReason = String(
validation.contractValidation?.reason ?? "",
).trim();
const legalSignalRegex = 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; /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 * Validate that AI results have all required fields
*/ */
static validateAnalysis(data: any): boolean { static validateAnalysis(data: unknown): boolean {
try { try {
// Validation uses same normalizer used in production flow. // Validation uses same normalizer used in production flow.
this.normalizeAnalysis(data); this.normalizeAnalysis(data);
@@ -832,7 +899,7 @@ Use this context only as formatting guidance. Do not force it if current documen
return undefined; return undefined;
} }
return date; return date;
} catch (error) { } catch {
return undefined; return undefined;
} }
} }
@@ -850,7 +917,9 @@ Use this context only as formatting guidance. Do not force it if current documen
static async askAboutContract(input: { static async askAboutContract(input: {
question: string; question: string;
ragChunks?: Array<{ chunkIndex: number; content: string; score: number }>;
contract: { contract: {
id: string;
fileName: string; fileName: string;
title?: string | null; title?: string | null;
type?: string | null; type?: string | null;
@@ -866,10 +935,31 @@ Use this context only as formatting guidance. Do not force it if current documen
}; };
}) { }) {
try { 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. // Keep context bounded to avoid overlong prompts and token waste.
const extractedTextSnippet = (input.contract.extractedText || "") const extractedTextSnippet = (input.contract.extractedText || "")
.slice(0, 12000) .slice(0, 5000)
.trim(); .trim();
const ragContext =
ragChunks.length > 0
? RAGService.buildChunkContext(ragChunks)
: extractedTextSnippet || "N/A";
const contractTypeGuidance = this.getContractTypeGuidance( const contractTypeGuidance = this.getContractTypeGuidance(
input.contract.type, input.contract.type,
); );
@@ -910,8 +1000,8 @@ ${input.contract.summary ?? "N/A"}
Key Points (JSON): Key Points (JSON):
${JSON.stringify(input.contract.keyPoints ?? {}, null, 2)} ${JSON.stringify(input.contract.keyPoints ?? {}, null, 2)}
Extracted Text: Grounded RAG Context:
${extractedTextSnippet || "N/A"} ${ragContext}
User question (${languageName}): User question (${languageName}):
${input.question} ${input.question}
@@ -923,6 +1013,7 @@ Instructions:
- Do NOT quote large raw excerpts from extracted text unless strictly necessary. - 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. - Synthesize and explain the implications in practical terms instead of copying file content.
- Base your answer ONLY on the provided contract 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} - Adapt answer emphasis using this type guidance: ${contractTypeGuidance}
- If information is missing, explicitly say: Information not found in the analyzed contract. - 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. - 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. - Never claim certainty where the contract text is ambiguous.
- Keep the answer concise, executive, and decision-oriented. - Keep the answer concise, executive, and decision-oriented.
- Use the same language preference throughout (${languageName}). - 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}): Response structure (in ${languageName}):
1) Direct answer in one sentence. 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) { for (const modelName of ANALYSIS_MODELS) {
try { try {
const model = genAI.getGenerativeModel({ rawAnswer = await keyManager.execute(async (genAI) => {
model: modelName, const model = genAI.getGenerativeModel({
generationConfig: { model: modelName,
temperature: 0.2, generationConfig: {
topP: 0.95, temperature: 0.2,
topK: 40, topP: 0.95,
maxOutputTokens: 2048, 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) { if (rawAnswer) {
console.log(
`✅ Q&A with model ${modelName} succeeded in ${languageName}`,
);
break; break;
} }
} catch (error) { } catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
lastError = error; lastError = error;
console.warn( console.warn(
`Q&A with model ${modelName} failed. Trying next model.`, `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(); .trim();
return sanitizedAnswer; return sanitizedAnswer;
} catch (error: any) { } catch (error: unknown) {
if (error.message?.includes("API key")) { 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("Invalid or missing Gemini API key.");
} }
throw new Error(`Error answering question: ${error.message}`); throw new Error(`Error answering question: ${errorMessage}`);
} }
} }
} }

View File

@@ -22,12 +22,12 @@ CRITICAL: Your response must be VALID, PARSEABLE JSON only. Do not include markd
"endDate": "2025-12-31", "endDate": "2025-12-31",
"premium": 1200.50, "premium": 1200.50,
"premiumCurrency": "TND", "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": { "keyPoints": {
"guarantees": ["**Main Benefit 1**: Description", "**Main Benefit 2**: Description"], "guarantees": ["Main Benefit 1: Description", "Main Benefit 2: Description"],
"exclusions": ["**Exclusion 1**: Description with impact", "**Exclusion 2**: Description"], "exclusions": ["Exclusion 1: Description with impact", "Exclusion 2: Description"],
"franchise": "**Deductible/Penalty**: €150 per claim or equivalent", "franchise": "Deductible/Penalty: €150 per claim or equivalent",
"importantDates": ["**Renewal Date**: 31 December annually", "**Payment Deadline**: 15th of each month"], "importantDates": ["Renewal Date: 31 December annually", "Payment Deadline: 15th of each month"],
"explainability": [ "explainability": [
{ {
"field": "endDate", "field": "endDate",
@@ -44,24 +44,24 @@ CRITICAL: Your response must be VALID, PARSEABLE JSON only. Do not include markd
] ]
}, },
"keyPeople": [ "keyPeople": [
{"name": "**John Smith**", "role": "Policy Holder", "email": "john@example.com", "phone": "+33612345678"}, {"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": "Jane Doe", "role": "Insurance Agent", "email": "jane@insurer.com", "phone": "+33987654321"}
], ],
"contactInfo": { "contactInfo": {
"name": "**Policy Holder Name**", "name": "Policy Holder Name",
"email": "holder@email.com", "email": "holder@email.com",
"phone": "+33612345678", "phone": "+33612345678",
"address": "123 Main Street, City, Postal Code", "address": "123 Main Street, City, Postal Code",
"role": "Insured Person" "role": "Insured Person"
}, },
"importantContacts": [ "importantContacts": [
{"name": "**Claims Department**", "email": "claims@insurer.com", "phone": "+33800000000"}, {"name": "Claims Department", "email": "claims@insurer.com", "phone": "+33800000000"},
{"name": "**Customer Service**", "email": "support@insurer.com", "phone": "+33800111111"} {"name": "Customer Service", "email": "support@insurer.com", "phone": "+33800111111"}
], ],
"relevantDates": [ "relevantDates": [
{"date": "2025-12-31", "description": "**Policy Expiration Date**", "type": "EXPIRATION"}, {"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": "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": "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.", "extractedText": "Most relevant extracted text, preserving original structure and keywords. Include key clauses, definitions, obligations. Max 12000 chars.",
"contractValidation": { "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. **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)**: 2. **Summary (VERY IMPORTANT)**:
- Write 4-6 comprehensive sentences covering: parties involved, contract scope, key obligations, main coverage/benefits, critical exclusions, important deadlines - 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 plain text only (no markdown, no bold markers)
- Use **number** for all quantities, dates, amounts, percentages - Use YYYY-MM-DD format for explicit date mentions where possible
- Use **YYYY-MM-DD** format for dates with **bold**
- Language: Professional business French, English, or contract's native language - Language: Professional business French, English, or contract's native language
- MUST be detailed enough that reader understands contract without opening PDF - MUST be detailed enough that reader understands contract without opening PDF
3. **Key People Extraction**: 3. **Key People Extraction**:
- Extract all named individuals: policy holders, insured parties, beneficiaries, signatories, agents, brokers - Extract all named individuals: policy holders, insured parties, beneficiaries, signatories, agents, brokers
- Include roles, contact methods when visible in contract - 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**: 4. **Contact Information**:
- contactInfo: Details of PRIMARY policy holder or contract party - 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 - 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" - For recurring dates (monthly, annually): show pattern like "1970-01-15" for "15th of each month"
- Include type: EXPIRATION, RENEWAL, PAYMENT, REVIEW, or OTHER - 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**: 6. **Key Points**:
- Use **bold** for: benefit names, exclusion types, monetary amounts, coverage limits - Use concise plain text labels and include monetary amounts/limits when available
- Example: "**Motor Coverage**: Collision and theft protection up to **€50,000**" - Example: "Motor Coverage: Collision and theft protection up to €50,000"
- Make exclusions explicit and impactful - 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**: 7. **Guarantees & Exclusions**:
- Be specific: "**Theft Coverage** includes keys, GPS, and aftermarket electronics" - Be specific: "Theft Coverage includes keys, GPS, and aftermarket electronics"
- For exclusions, explain impact: "**Mechanical wear excluded** - means breakdowns in years 3+ not covered" - For exclusions, explain impact: "Mechanical wear excluded - means breakdowns in years 3+ not covered"
8. **Email/Phone Extraction**: If present in contract, extract: 8. **Email/Phone Extraction**: If present in contract, extract:
- Email addresses in format: contact@domain.com - Email addresses in format: contact@domain.com
@@ -127,6 +137,7 @@ CRITICAL FIELD EXTRACTION RULES:
- sourceHints.confidence: 0..100 confidence for that field extraction - sourceHints.confidence: 0..100 confidence for that field extraction
- Keep sourceSnippet short (max 280 chars) but sufficiently specific to audit. - Keep sourceSnippet short (max 280 chars) but sufficiently specific to audit.
- Never invent snippet text not present in document. - 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: 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") - dates: ISO format YYYY-MM-DD or null. For recurring patterns, use canonical date (e.g., "0000-01-15" for "15th each month")

View File

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

View File

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

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

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

236
package-lock.json generated
View File

@@ -48,6 +48,8 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"motion": "^12.34.0", "motion": "^12.34.0",
"next": "16.1.6", "next": "16.1.6",
@@ -297,6 +299,15 @@
"node": ">=6.0.0" "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": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -3478,6 +3489,19 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -3505,6 +3529,13 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "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": "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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -4639,6 +4680,26 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -4745,6 +4806,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4760,6 +4833,16 @@
"node": ">= 8" "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": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -5117,6 +5200,16 @@
"node": ">=0.10.0" "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": { "node_modules/dotenv": {
"version": "17.3.1", "version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -5946,6 +6039,17 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-sha256": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
@@ -5962,6 +6066,12 @@
"reusify": "^1.0.4" "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": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6435,6 +6545,20 @@
"hermes-estree": "0.25.1" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6499,6 +6623,12 @@
"node": ">=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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -7031,6 +7161,32 @@
"json5": "lib/cli.js" "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": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "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" "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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7774,6 +7936,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8098,6 +8267,16 @@
], ],
"license": "MIT" "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": { "node_modules/rc9": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@@ -8366,6 +8545,13 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -8435,6 +8621,16 @@
"node": ">=0.10.0" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -8774,6 +8970,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/standardwebhooks": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
@@ -8986,6 +9192,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/svix": {
"version": "1.86.0", "version": "1.86.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz", "resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
@@ -9128,6 +9344,16 @@
"node": ">=8.10.0" "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": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -9600,6 +9826,16 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/uuid": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",

View File

@@ -49,6 +49,8 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"motion": "^12.34.0", "motion": "^12.34.0",
"next": "16.1.6", "next": "16.1.6",

View File

@@ -7,11 +7,10 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
clerkId String @unique clerkId String @unique
email String @unique email String @unique
firstName String? firstName String?
lastName String? lastName String?
imageUrl String? imageUrl String?
@@ -19,8 +18,8 @@ model User {
contracts Contract[] contracts Contract[]
notifications Notification[] notifications Notification[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([clerkId]) @@index([clerkId])
@@index([email]) @@index([email])
@@ -32,35 +31,36 @@ model Contract {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// File info (user uploads) // File info (user uploads)
fileName String fileName String
fileUrl String fileUrl String
fileSize Int fileSize Int
mimeType String mimeType String
// AI-determined fields (filled automatically) // AI-determined fields (filled automatically)
title String? title String?
type ContractType? type ContractType?
provider String? provider String?
policyNumber String? policyNumber String?
startDate DateTime? startDate DateTime?
endDate DateTime? endDate DateTime?
premium Decimal? @db.Decimal(10, 2) premium Decimal? @db.Decimal(10, 2)
// Processing pipeline // Processing pipeline
status ContractStatus @default(UPLOADED) status ContractStatus @default(UPLOADED)
// AI results // AI results
extractedText String? @db.Text extractedText String? @db.Text
summary String? @db.Text summary String? @db.Text
keyPoints Json? keyPoints Json?
// Blockchain (later) // Blockchain (later)
documentHash String? documentHash String?
txHash String? txHash String?
ipfsUrl String? ipfsUrl String?
// Notifications for this contract // Notifications for this contract
notifications Notification[] notifications Notification[]
ragChunks ContractRagChunk[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -71,27 +71,46 @@ model Contract {
@@index([endDate]) @@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 { model Notification {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contractId String? contractId String?
contract Contract? @relation(fields: [contractId], references: [id], onDelete: SetNull) contract Contract? @relation(fields: [contractId], references: [id], onDelete: SetNull)
// Notification metadata // Notification metadata
type NotificationType type NotificationType
title String title String
message String message String
icon String? // Icon type for UI icon String? // Icon type for UI
// Action metadata // Action metadata
actionType String? // e.g., "RENEWAL_REMINDER", "UPLOAD_SUCCESS", "ANALYSIS_COMPLETE" actionType String? // e.g., "RENEWAL_REMINDER", "UPLOAD_SUCCESS", "ANALYSIS_COMPLETE"
actionData Json? // Additional data for the action actionData Json? // Additional data for the action
// Status tracking // Status tracking
read Boolean @default(false) read Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiresAt DateTime? // Notification expiration time expiresAt DateTime? // Notification expiration time
@@index([userId]) @@index([userId])
@@ -102,11 +121,11 @@ model Notification {
} }
enum NotificationType { enum NotificationType {
SUCCESS // Successful action SUCCESS // Successful action
WARNING // Warning/Alert WARNING // Warning/Alert
ERROR // Error ERROR // Error
INFO // Informational INFO // Informational
DEADLINE // Deadline approaching DEADLINE // Deadline approaching
} }
enum ContractType { enum ContractType {
@@ -121,10 +140,8 @@ enum ContractType {
} }
enum ContractStatus { enum ContractStatus {
UPLOADED // Just uploaded, waiting for processing UPLOADED // Just uploaded, waiting for processing
PROCESSING // AI is analyzing PROCESSING // AI is analyzing
COMPLETED // Everything done COMPLETED // Everything done
FAILED // Processing failed FAILED // Processing failed
} }

View File

@@ -7,6 +7,7 @@ const config: Config = {
"./app/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}",
"./pages/**/*.{ts,tsx}", "./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}",
"./features/**/*.{ts,tsx}",
], ],
theme: { theme: {
extend: { extend: {