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 { EmptyContractsState } from "@/features/contracts/components/list/empty-contracts-state";
import { ContractsList } from "@/features/contracts/components/list/contracts-list";
import { ContactsHeader } from "@/components/layout/contacts-header";
import { ContractsHeader } from "@/components/layout/contacts-header";
import { useState, useEffect } from "react";
import { getContracts } from "@/features/contracts/api/contract.action";
import { Card } from "@/components/ui/card";
@@ -67,7 +67,7 @@ export default function ContactsPage() {
<>
<div className="min-h-screen bg-background text-foreground">
<main className="flex flex-col min-h-screen">
<ContactsHeader />
<ContractsHeader />
<div className="flex-1 overflow-auto">
<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 {
// Delete user (CASCADE will delete all related contracts)
await prisma.user.delete({
await prisma.user.deleteMany({
where: { clerkId: id },
});

View File

@@ -2,40 +2,87 @@
import React from "react";
import Link from "next/link";
import { ArrowLeft, ShieldCheck, Sparkles } from "lucide-react";
import {
ArrowLeft,
ShieldCheck,
Sparkles,
FileText,
Network,
} from "lucide-react";
import { BackgroundBeams } from "@/components/ui/background-beams";
export function ContactsHeader() {
export function ContractsHeader() {
return (
<div className="border-b border-border/50 bg-background/80 backdrop-blur-sm">
<BackgroundBeams className="opacity-80" />
<div className="max-w-7xl mx-auto px-6 py-8 space-y-6">
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Dashboard
</Link>
<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">
{/* Background Beams - Opacity bumped slightly for better visibility */}
<div className="absolute inset-0 z-0 pointer-events-none">
<BackgroundBeams className="opacity-80" />
<div className="absolute inset-0 bg-gradient-to-b from-background/10 via-background/50 to-background" />
</div>
<div className="space-y-3">
<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">
Contracts Manager
</h1>
<p className="max-w-3xl text-lg text-muted-foreground">
Upload, review, and analyze your financial contracts with a focused
workspace built for speed and clarity.
</p>
</div>
<div className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
{/* Left Column: Typography & Badges */}
<div className="flex-1 space-y-5 animate-in slide-in-from-bottom-4 fade-in duration-700">
<Link
href="/dashboard"
className="group inline-flex items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-primary"
>
<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="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-2 text-sm font-medium text-primary">
<Sparkles className="w-4 h-4" />
AI-powered review
<div className="space-y-2">
<h1 className="text-4xl font-extrabold tracking-tight text-foreground md:text-5xl">
Contracts{" "}
<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 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" />
Compliance-focused workflow
{/* Right Column: Medium-Sized Graphic */}
<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>

View File

View File

@@ -26,7 +26,52 @@ import {
saveContract as savePendingContract,
} from "@/lib/services/contract.service";
import { AIService } from "@/lib/services/ai.service";
import { RAGService } from "@/lib/services/rag.service";
import { NotificationService } from "@/lib/services/notification.service";
import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types";
type ContractListItem = Awaited<
ReturnType<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
@@ -70,7 +115,7 @@ export async function saveContract(data: {
userId: user.id,
type: "SUCCESS",
title: "📄 Contract Uploaded",
message: `"${data.fileName}" has been uploaded successfully. Click "Analyze" to extract contract details.`,
message: `"${data.fileName}" has been uploaded successfully. AI analysis started automatically.`,
contractId: result.contract.id,
actionType: "UPLOAD_SUCCESS",
icon: "FileCheck",
@@ -78,6 +123,20 @@ export async function saveContract(data: {
});
}
// Auto-run AI analysis immediately after upload.
const autoAnalysis = await analyzeContractAction(result.contract.id);
if (!autoAnalysis.success) {
return {
success: true,
contract: result.contract,
analysisSuccess: false,
analysisError:
autoAnalysis.error || "Contract uploaded but AI analysis failed.",
errorCode: (autoAnalysis as { errorCode?: string }).errorCode,
};
}
revalidatePath("/contacts");
revalidatePath("/dashboard");
}
@@ -114,7 +173,7 @@ export async function getContracts(filters?: Record<string, unknown>) {
const contracts = await ContractService.getAll(filters);
// Serialize contracts: convert Decimal to number, dates to ISO strings
const serializedContracts = contracts.map((contract: any) => ({
const serializedContracts = contracts.map((contract: ContractListItem) => ({
id: contract.id,
fileName: contract.fileName,
fileSize: contract.fileSize,
@@ -135,6 +194,8 @@ export async function getContracts(filters?: Record<string, unknown>) {
summary: contract.summary || null,
keyPoints: contract.keyPoints || null,
extractedText: contract.extractedText || null,
ragChunkCount: Number(contract?._count?.ragChunks ?? 0),
isRagged: Number(contract?._count?.ragChunks ?? 0) > 0,
}));
return { success: true, contracts: serializedContracts };
@@ -237,6 +298,47 @@ export async function deleteContract(id: string) {
}
}
export async function deleteAllContractsAction() {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return { success: false, error: "Unauthorized" };
}
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return { success: false, error: "User not found" };
}
const deletedCount = await ContractService.deleteAllForUser(user.id);
await NotificationService.create({
userId: user.id,
type: "SUCCESS",
title: "🧹 Contracts Cleared",
message: `All contracts were deleted successfully (${deletedCount}).`,
actionType: "DELETE_ALL_SUCCESS",
icon: "Trash2",
expiresIn: 24 * 60 * 60 * 1000,
});
revalidatePath("/contacts");
revalidatePath("/dashboard");
return {
success: true,
deletedCount,
message: "All contracts deleted successfully",
};
} catch (error: unknown) {
console.error("Delete all contracts error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Retrieves dashboard statistics for the authenticated user
*
@@ -361,15 +463,16 @@ export async function analyzeContractAction(id: string) {
// Persist AI learning metadata inside keyPoints JSON so future analyses can adapt
// without requiring DB schema changes.
const aiAnalysis = aiResults as AnalysisWithMeta;
const keyPointsWithLearning = {
...(aiResults.keyPoints ?? {}),
aiMeta: {
language: (aiResults as any).language ?? null,
keyPeople: (aiResults as any).keyPeople ?? [],
contactInfo: (aiResults as any).contactInfo ?? null,
importantContacts: (aiResults as any).importantContacts ?? [],
relevantDates: (aiResults as any).relevantDates ?? [],
premiumCurrency: (aiResults as any).premiumCurrency ?? null,
language: aiAnalysis.language ?? null,
keyPeople: aiAnalysis.keyPeople ?? [],
contactInfo: aiAnalysis.contactInfo ?? null,
importantContacts: aiAnalysis.importantContacts ?? [],
relevantDates: aiAnalysis.relevantDates ?? [],
premiumCurrency: aiAnalysis.premiumCurrency ?? null,
learnedAt: new Date().toISOString(),
},
};
@@ -385,6 +488,14 @@ export async function analyzeContractAction(id: string) {
premium: aiResults.premium ?? undefined,
});
// Build persistent RAG chunks for grounded contract Q&A.
await RAGService.upsertContractChunks({
contractId: id,
extractedText: aiResults.extractedText,
summary: aiResults.summary,
keyPoints: keyPointsWithLearning,
});
// Create success notification with extracted info
const contractTitle = aiResults.title || "Contract";
const contractProvider = aiResults.provider || "Unknown Provider";
@@ -425,7 +536,6 @@ export async function analyzeContractAction(id: string) {
// Create error notification
if (user) {
const contract = await ContractService.getById(id);
await NotificationService.create({
userId: user.id,
type: "ERROR",
@@ -515,10 +625,18 @@ export async function askContractQuestionAction(id: string, question: string) {
};
}
const ragDiagnostics = await RAGService.retrieveRelevantChunks({
contractId: contract.id,
question: trimmedQuestion,
topK: 6,
});
// Ask AI about contract with full context
const answer = await AIService.askAboutContract({
question: trimmedQuestion,
ragChunks: ragDiagnostics,
contract: {
id: contract.id,
fileName: contract.fileName,
title: contract.title,
type: contract.type,
@@ -533,11 +651,21 @@ export async function askContractQuestionAction(id: string, question: string) {
keyPoints:
(contract.keyPoints as Record<string, unknown> | null) ?? null,
extractedText: contract.extractedText,
language: (contract.keyPoints as any)?.aiMeta?.language ?? null,
language:
(contract.keyPoints as ContractKeyPoints | null)?.aiMeta?.language ??
null,
},
});
return { success: true, answer };
return {
success: true,
answer,
ragDiagnostics: ragDiagnostics.map((chunk) => ({
chunkIndex: chunk.chunkIndex,
score: Number(chunk.score.toFixed(4)),
preview: chunk.content.slice(0, 280),
})),
};
} catch (error: unknown) {
console.error("Ask contract question error:", error);
return {

View File

@@ -1,7 +1,14 @@
"use client";
import { useState } from "react";
import { UploadDropzone } from "@uploadthing/react";
import { AlertCircle, Sparkles, Wand2, ShieldCheck } from "lucide-react";
import {
AlertCircle,
Sparkles,
Wand2,
ShieldCheck,
Loader2,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { saveContract } from "@/features/contracts/api/contract.action";
import { toast } from "sonner";
@@ -14,6 +21,7 @@ export function ContractUploadForm({
onUploadSuccess: () => void;
}) {
const router = useRouter();
const [isAutoAnalyzing, setIsAutoAnalyzing] = useState(false);
const emitNotificationRefresh = () => {
window.dispatchEvent(new Event("notifications:refresh"));
@@ -77,25 +85,50 @@ export function ContractUploadForm({
}
const file = res[0];
setIsAutoAnalyzing(true);
// Save to database
const result = await saveContract({
fileName: file.name,
fileUrl: file.url,
fileSize: file.size,
mimeType: file.type,
});
try {
// Save to database
const result = await saveContract({
fileName: file.name,
fileUrl: file.url,
fileSize: file.size,
mimeType: file.type,
});
if (result.success) {
toast.success("Contract uploaded successfully!");
emitNotificationRefresh();
onUploadSuccess();
router.refresh();
} else {
toast.error(result.error || "Failed to save contract");
if (result.success) {
if (
(result as { analysisSuccess?: boolean }).analysisSuccess ===
false
) {
toast.warning(
(result as { analysisError?: string }).analysisError ||
"Contract uploaded, but analysis failed.",
);
} else {
toast.success("Contract uploaded and analyzed successfully!");
}
emitNotificationRefresh();
onUploadSuccess();
router.refresh();
} else {
const fallbackError =
"error" in result ? result.error : "Failed to save contract";
toast.error(fallbackError);
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Unexpected error during analysis",
);
} finally {
setIsAutoAnalyzing(false);
}
}}
onUploadError={(error: Error) => {
setIsAutoAnalyzing(false);
toast.error(`Upload failed: ${error.message}`);
}}
appearance={{
@@ -126,7 +159,7 @@ export function ContractUploadForm({
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
<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>
@@ -136,6 +169,53 @@ export function ContractUploadForm({
Extraction quality improves as more contracts are analyzed.
</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>
);
}

View File

@@ -2,24 +2,21 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import type { ReactNode } from "react";
import type { Prisma } from "@prisma/client";
import {
Download,
Trash2,
Eye,
MoreVertical,
Loader2,
Sparkles,
FileText,
FileSpreadsheet,
MessageSquare,
Send,
Scale,
Briefcase,
User,
Bot,
AlertTriangle,
X,
Search,
Info,
Network,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -50,12 +47,12 @@ import {
import {
deleteContract,
getContracts,
analyzeContractAction,
askContractQuestionAction,
deleteAllContractsAction,
} from "@/features/contracts/api/contract.action";
import { toast } from "sonner";
import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal";
import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal";
import { stripMarkdown, exportToCSV, exportToPDF } from "@/features/contracts/utils/export.utils";
interface Contract {
id: string;
@@ -73,13 +70,10 @@ interface Contract {
endDate?: string | null;
premium?: number | null;
summary?: string | null;
keyPoints?: Record<string, unknown> | null;
keyPoints?: Prisma.JsonValue | null;
extractedText?: string | null;
}
interface ChatMessage {
role: "user" | "assistant";
content: string;
ragChunkCount?: number;
isRagged?: boolean;
}
interface ExplainabilityEntry {
@@ -93,6 +87,22 @@ interface ExplainabilityEntry {
};
}
interface ContractKeyPoints {
guarantees?: string[];
exclusions?: string[];
franchise?: string | null;
importantDates?: string[];
explainability?: ExplainabilityEntry[];
aiMeta?: {
language?: string | null;
premiumCurrency?: string | null;
};
}
const isContractKeyPoints = (value: unknown): value is ContractKeyPoints => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
const emitNotificationRefresh = () => {
window.dispatchEvent(new Event("notifications:refresh"));
@@ -104,18 +114,18 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
const [contracts, setContracts] = useState<Contract[]>([]);
const [isLoading, setIsLoading] = useState(true);
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 [selectedContract, setSelectedContract] = useState<Contract | null>(
null,
);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [askOpen, setAskOpen] = useState(false);
const [chatContract, setChatContract] = useState<Contract | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [contractToDelete, setContractToDelete] = useState<Contract | null>(
null,
);
const [deleteAllDialogOpen, setDeleteAllDialogOpen] = useState(false);
const [invalidContractDialogOpen, setInvalidContractDialogOpen] =
useState(false);
const [invalidContractReason, setInvalidContractReason] = useState("");
@@ -563,11 +573,13 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
const getExplainabilityItems = (
contract: Contract | null,
): ExplainabilityEntry[] => {
const raw = (contract?.keyPoints as any)?.explainability;
const raw = isContractKeyPoints(contract?.keyPoints)
? contract.keyPoints.explainability
: undefined;
if (!Array.isArray(raw)) return [];
return raw
.map((item: any) => ({
.map((item) => ({
field: String(item?.field ?? "").trim(),
why: String(item?.why ?? "").trim(),
sourceSnippet: String(item?.sourceSnippet ?? "").trim(),
@@ -675,7 +687,9 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
if (!contract) return null;
const fromMeta = String(
(contract.keyPoints as any)?.aiMeta?.premiumCurrency ?? "",
(isContractKeyPoints(contract.keyPoints)
? contract.keyPoints.aiMeta?.premiumCurrency
: null) ?? "",
).trim();
if (fromMeta) return fromMeta;
@@ -848,38 +862,26 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
setContractToDelete(null);
};
const handleAnalyze = async (id: string) => {
const selected = contracts.find((contract) => contract.id === id);
setAnalyzingId(id);
setIsAnalyzing(true);
const handleDeleteAll = async () => {
setIsDeletingAll(true);
try {
const result = await analyzeContractAction(id);
const result = await deleteAllContractsAction();
if (result.success) {
// Reload contracts to get all AI analysis data
await loadContracts();
toast.success("Contract analyzed successfully!");
setContracts([]);
toast.success(
`Deleted ${result.deletedCount ?? 0} contract${(result.deletedCount ?? 0) === 1 ? "" : "s"}.`,
);
emitNotificationRefresh();
} else {
const errorCode = (result as { errorCode?: string }).errorCode;
if (errorCode === "INVALID_CONTRACT") {
const reason =
result.error ||
"This uploaded file is not recognized as a valid contract.";
setInvalidContractReason(reason);
setInvalidContractFileName(selected?.fileName || "Unknown file");
setInvalidContractDialogOpen(true);
toast.error("Invalid contract file detected");
} else {
toast.error(result.error || "Failed to analyze contract");
}
toast.error(result.error || "Failed to delete all contracts");
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Unknown error occurred",
);
} finally {
setAnalyzingId(null);
setIsAnalyzing(false);
setIsDeletingAll(false);
setDeleteAllDialogOpen(false);
}
};
@@ -994,11 +996,28 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
className="pl-9"
/>
</div>
{debouncedSearchQuery && (
<p className="text-xs text-muted-foreground">
Showing results for: "{debouncedSearchQuery}"
</p>
)}
<div className="flex items-center gap-2">
{debouncedSearchQuery && (
<p className="text-xs text-muted-foreground">
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>
<Card className="border-border/50 overflow-hidden">
@@ -1023,6 +1042,12 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
>
{contract.status}
</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 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" />
</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>
<DropdownMenuTrigger asChild>
<Button
@@ -1110,6 +1120,20 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
<FileText className="w-4 h-4 mr-2" />
Details
</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
onClick={() => requestDeleteContract(contract)}
disabled={deletingId === contract.id}
@@ -1238,7 +1262,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</button>
</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">
{selectedContract.title || "N/A"}
{stripMarkdown(selectedContract.title) || "N/A"}
</p>
</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">
@@ -1259,7 +1283,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</button>
</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">
{selectedContract.provider || "N/A"}
{stripMarkdown(selectedContract.provider) || "N/A"}
</p>
</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">
@@ -1283,7 +1307,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</button>
</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">
{selectedContract.policyNumber || "N/A"}
{stripMarkdown(selectedContract.policyNumber) || "N/A"}
</p>
</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">
@@ -1376,9 +1400,10 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
Key Points
</h3>
<div className="space-y-3 text-sm">
{(selectedContract.keyPoints as any)?.guarantees &&
{isContractKeyPoints(selectedContract.keyPoints) &&
selectedContract.keyPoints.guarantees &&
Array.isArray(
(selectedContract.keyPoints as any).guarantees,
selectedContract.keyPoints.guarantees,
) && (
<div>
<p className="text-muted-foreground font-medium">
@@ -1386,9 +1411,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</p>
<ul className="ml-1 space-y-2">
{(
(selectedContract.keyPoints as any)
.guarantees as string[]
).map((guarantee: string, idx: number) => (
selectedContract.keyPoints.guarantees ?? []
).map((guarantee, idx: number) => (
<li
key={idx}
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>
</div>
)}
{(selectedContract.keyPoints as any)?.exclusions &&
{isContractKeyPoints(selectedContract.keyPoints) &&
selectedContract.keyPoints.exclusions &&
Array.isArray(
(selectedContract.keyPoints as any).exclusions,
selectedContract.keyPoints.exclusions,
) && (
<div>
<p className="text-muted-foreground font-medium">
@@ -1412,9 +1437,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</p>
<ul className="ml-1 space-y-2">
{(
(selectedContract.keyPoints as any)
.exclusions as string[]
).map((exclusion: string, idx: number) => (
selectedContract.keyPoints.exclusions ?? []
).map((exclusion, idx: number) => (
<li
key={idx}
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>
</div>
)}
{(selectedContract.keyPoints as any)?.franchise && (
<div>
<p className="text-muted-foreground font-medium">
Deductible:
</p>
<div className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 whitespace-pre-wrap break-words">
{renderRichParagraphs(
String(
(selectedContract.keyPoints as any).franchise,
),
`franchise-${selectedContract.id}`,
)}
{isContractKeyPoints(selectedContract.keyPoints) &&
selectedContract.keyPoints.franchise && (
<div>
<p className="text-muted-foreground font-medium">
Deductible:
</p>
<div className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 whitespace-pre-wrap break-words">
{renderRichParagraphs(
String(selectedContract.keyPoints.franchise),
`franchise-${selectedContract.id}`,
)}
</div>
</div>
</div>
)}
)}
</div>
</div>
)}
@@ -1460,9 +1483,9 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
{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">
<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">
Click the Sparkles button to analyze this contract
Contract uploaded. AI analysis will start automatically.
</p>
</div>
)}
@@ -1526,6 +1549,30 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</AlertDialogContent>
</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
open={invalidContractDialogOpen}
onOpenChange={setInvalidContractDialogOpen}
@@ -1573,59 +1620,6 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</div>
</DialogContent>
</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";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MessageSquare, Briefcase, Scale, Bot, User, Loader2, Send } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
MessageSquare,
Briefcase,
Scale,
Bot,
User,
Loader2,
Send,
Network,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { askContractQuestionAction } from "@/features/contracts/api/contract.action";
@@ -17,6 +31,12 @@ interface ChatMessage {
content: string;
}
interface RagDiagnosticEntry {
chunkIndex: number;
score: number;
preview: string;
}
interface ContractChatModalProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@@ -32,10 +52,14 @@ export function ContractChatModal({
}: ContractChatModalProps) {
const [question, setQuestion] = useState("");
const [isAsking, setIsAsking] = useState(false);
const [ragDiagnostics, setRagDiagnostics] = useState<RagDiagnosticEntry[]>(
[],
);
const [messages, setMessages] = useState<ChatMessage[]>([
{
role: "assistant",
content: "Ask me anything about this contract. I will answer based on the file analysis.",
content:
"Ask me anything about this contract. I will answer based on the file analysis.",
},
]);
@@ -51,22 +75,45 @@ export function ContractChatModal({
const trimmedQuestion = question.trim();
if (!trimmedQuestion) return;
setMessages((prev) => [...prev, { role: "user", content: trimmedQuestion }]);
setMessages((prev) => [
...prev,
{ role: "user", content: trimmedQuestion },
]);
setQuestion("");
setIsAsking(true);
try {
const result = await askContractQuestionAction(contract.id, trimmedQuestion);
const result = await askContractQuestionAction(
contract.id,
trimmedQuestion,
);
if (result.success && result.answer) {
setMessages((prev) => [...prev, { role: "assistant", content: result.answer as string }]);
const diagnostics = Array.isArray(
(result as { ragDiagnostics?: RagDiagnosticEntry[] }).ragDiagnostics,
)
? ((result as { ragDiagnostics?: RagDiagnosticEntry[] })
.ragDiagnostics ?? [])
: [];
setRagDiagnostics(diagnostics);
setMessages((prev) => [
...prev,
{ role: "assistant", content: result.answer as string },
]);
} else {
const errorMessage = result.error || "Failed to get AI response";
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${errorMessage}` }]);
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${errorMessage}` },
]);
}
} catch (error) {
const fallbackMessage = error instanceof Error ? error.message : "Unknown error occurred";
setMessages((prev) => [...prev, { role: "assistant", content: `Error: ${fallbackMessage}` }]);
const fallbackMessage =
error instanceof Error ? error.message : "Unknown error occurred";
setMessages((prev) => [
...prev,
{ role: "assistant", content: `Error: ${fallbackMessage}` },
]);
} finally {
setIsAsking(false);
}
@@ -90,7 +137,9 @@ export function ContractChatModal({
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">
Contract Intelligence Assistant
</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 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">
@@ -124,6 +173,43 @@ export function ContractChatModal({
</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">
{messages.map((message, index) => (
<div
@@ -144,7 +230,10 @@ export function ContractChatModal({
}`}
>
{message.role === "assistant"
? renderRichParagraphs(message.content, `chat-assistant-${index}`)
? renderRichParagraphs(
message.content,
`chat-assistant-${index}`,
)
: message.content}
</div>
{message.role === "user" && (
@@ -177,7 +266,12 @@ export function ContractChatModal({
disabled={isAsking}
className="rounded-2xl border-white/20 dark:border-white/10 bg-background/50 backdrop-blur-md focus:bg-background/80 transition-all duration-300 shadow-inner"
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !isAsking && question.trim()) {
if (
event.key === "Enter" &&
!event.shiftKey &&
!isAsking &&
question.trim()
) {
event.preventDefault();
void handleAskQuestion();
}

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

View File

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

View File

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

View File

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

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

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

236
package-lock.json generated
View File

@@ -48,6 +48,8 @@
"dotenv": "^17.3.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.564.0",
"motion": "^12.34.0",
"next": "16.1.6",
@@ -297,6 +299,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -3478,6 +3489,19 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -3505,6 +3529,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -4434,6 +4465,16 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -4639,6 +4680,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -4745,6 +4806,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4760,6 +4833,16 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -5117,6 +5200,16 @@
"node": ">=0.10.0"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -5946,6 +6039,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
@@ -5962,6 +6066,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6435,6 +6545,20 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6499,6 +6623,12 @@
"node": ">=12"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -7031,6 +7161,32 @@
"json5": "lib/cli.js"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf-autotable": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
"integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
"license": "MIT",
"peerDependencies": {
"jspdf": "^2 || ^3 || ^4"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -7735,6 +7891,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7774,6 +7936,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8098,6 +8267,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@@ -8366,6 +8545,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -8435,6 +8621,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -8774,6 +8970,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
@@ -8986,6 +9192,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/svix": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
@@ -9128,6 +9344,16 @@
"node": ">=8.10.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -9600,6 +9826,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",

View File

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

View File

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

View File

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