Release (Stable version)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
0
docs/sequence_diagrams.md
Normal file
0
docs/sequence_diagrams.md
Normal 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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "{debouncedSearchQuery}"
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
163
features/contracts/utils/export.utils.ts
Normal file
163
features/contracts/utils/export.utils.ts
Normal 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`);
|
||||
};
|
||||
@@ -6,28 +6,19 @@ import {
|
||||
ContractPrecheckResult,
|
||||
NormalizedAnalysis,
|
||||
} from "@/lib/services/ai/analysis.types";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
buildAnalysisPrompt,
|
||||
buildPrevalidationPrompt,
|
||||
} from "@/lib/services/ai/analysis.prompt";
|
||||
import { parseJsonResponse as parseAiJsonResponse } from "@/lib/services/ai/analysis.parser";
|
||||
import { normalizeAnalysis as normalizeAiAnalysis } from "@/lib/services/ai/analysis.normalizer";
|
||||
import { RAGService } from "@/lib/services/rag.service";
|
||||
|
||||
// Read API key from environment once at module load.
|
||||
const API_KEY =
|
||||
process.env.AI_API_KEY || process.env.AI_API_KEY2 || process.env.AI_API_KEY3;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("❌ AI_API_KEY is missing from environment variables");
|
||||
console.error("Please add AI_API_KEY to your .env file");
|
||||
throw new Error("AI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
// Initialize Gemini
|
||||
const genAI = new GoogleGenerativeAI(API_KEY);
|
||||
import { keyManager } from "@/lib/services/ai/key-manager";
|
||||
|
||||
const PRIMARY_ANALYSIS_MODEL =
|
||||
process.env.AI_MODEL_PRIMARY || "gemini-2.5-flash";
|
||||
process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview";
|
||||
const FALLBACK_ANALYSIS_MODEL =
|
||||
process.env.AI_MODEL_FALLBACK || "gemini-2.0-flash";
|
||||
|
||||
@@ -35,6 +26,51 @@ const ANALYSIS_MODELS = Array.from(
|
||||
new Set([PRIMARY_ANALYSIS_MODEL, FALLBACK_ANALYSIS_MODEL]),
|
||||
);
|
||||
|
||||
type ValidationEnvelope = {
|
||||
contractValidation?: {
|
||||
isValidContract?: boolean;
|
||||
confidence?: number;
|
||||
reason?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type PrevalidationResponse = {
|
||||
isValidContract?: boolean;
|
||||
confidence?: number;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
type AdaptiveExplainability = {
|
||||
field?: string;
|
||||
sourceHints?: {
|
||||
confidence?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type AdaptiveAiMeta = {
|
||||
language?: string | null;
|
||||
keyPeople?: Array<{ role?: string | null }>;
|
||||
};
|
||||
|
||||
type AdaptiveKeyPoints = {
|
||||
explainability?: AdaptiveExplainability[];
|
||||
aiMeta?: AdaptiveAiMeta;
|
||||
};
|
||||
|
||||
type AdaptiveContractExample = {
|
||||
type?: string | null;
|
||||
provider?: string | null;
|
||||
policyNumber?: string | null;
|
||||
summary?: string | null;
|
||||
keyPoints?: Prisma.JsonValue | null;
|
||||
};
|
||||
|
||||
const isAdaptiveKeyPoints = (
|
||||
value: Prisma.JsonValue | null | undefined,
|
||||
): value is AdaptiveKeyPoints => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
export class AIService {
|
||||
/**
|
||||
* Domain-specific guidance for contract Q&A.
|
||||
@@ -77,6 +113,7 @@ export class AIService {
|
||||
* Supports both PDF and image files
|
||||
*/
|
||||
static async analyzeContract(fileUrl: string, options?: AnalyzeOptions) {
|
||||
keyManager.resetKeys();
|
||||
try {
|
||||
const maxRetries = Math.min(3, Math.max(1, options?.maxRetries ?? 2));
|
||||
|
||||
@@ -191,10 +228,12 @@ export class AIService {
|
||||
);
|
||||
|
||||
return normalized;
|
||||
} catch (validationError: any) {
|
||||
} catch (validationError: unknown) {
|
||||
// If validation fails, keep reason and retry with correction guidance.
|
||||
lastValidationError =
|
||||
validationError?.message || "Failed to parse model output";
|
||||
validationError instanceof Error
|
||||
? validationError.message
|
||||
: "Failed to parse model output";
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(lastValidationError);
|
||||
}
|
||||
@@ -202,51 +241,53 @@ export class AIService {
|
||||
}
|
||||
|
||||
throw new Error("AI analysis failed after retries.");
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
// Better error messages
|
||||
if (error.message?.includes("API key")) {
|
||||
if (errorMessage.includes("API key")) {
|
||||
throw new Error(
|
||||
"Invalid or missing Gemini API key. Check AI_API_KEY in your .env file",
|
||||
);
|
||||
} else if (error.message?.includes("INVALID_CONTRACT:")) {
|
||||
const reason = String(error.message)
|
||||
} else if (errorMessage.includes("INVALID_CONTRACT:")) {
|
||||
const reason = String(errorMessage)
|
||||
.replace("INVALID_CONTRACT:", "")
|
||||
.trim();
|
||||
throw new Error(
|
||||
reason || "Uploaded file is not recognized as a valid contract.",
|
||||
);
|
||||
} else if (
|
||||
error.message?.includes("not found") ||
|
||||
error.message?.includes("404")
|
||||
errorMessage.includes("not found") ||
|
||||
errorMessage.includes("404")
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid Gemini model configuration. Current models: ${ANALYSIS_MODELS.join(", ")}. Check model availability in your Gemini account.`,
|
||||
);
|
||||
} else if (
|
||||
error.message?.includes("fetch") &&
|
||||
!error.message?.includes("generativelanguage")
|
||||
errorMessage.includes("fetch") &&
|
||||
!errorMessage.includes("generativelanguage")
|
||||
) {
|
||||
throw new Error(
|
||||
"Download failed. Check if the file URL is correct and accessible.",
|
||||
);
|
||||
} else if (
|
||||
error.message?.includes("JSON") ||
|
||||
error.message?.includes("No complete JSON object") ||
|
||||
error.message?.includes("parse failed")
|
||||
errorMessage.includes("JSON") ||
|
||||
errorMessage.includes("No complete JSON object") ||
|
||||
errorMessage.includes("parse failed")
|
||||
) {
|
||||
console.error("❌ Raw response that failed to parse:", error);
|
||||
console.error("Full error message:", error.message);
|
||||
console.error("Full error message:", errorMessage);
|
||||
|
||||
// Help user understand what went wrong
|
||||
if (error.message?.includes("escaped quotes")) {
|
||||
if (errorMessage.includes("escaped quotes")) {
|
||||
throw new Error(
|
||||
"The contract contains special characters that corrupted the analysis. Try uploading a cleaner version.",
|
||||
);
|
||||
} else if (error.message?.includes("incomplete")) {
|
||||
} else if (errorMessage.includes("incomplete")) {
|
||||
throw new Error(
|
||||
"AI analysis failed to complete properly. This might be a large or complex contract. Try a smaller contract first.",
|
||||
);
|
||||
} else if (error.message?.includes("missing expected")) {
|
||||
} else if (errorMessage.includes("missing expected")) {
|
||||
throw new Error(
|
||||
"This doesn't appear to be a valid financial/insurance contract. Please upload a legitimate contract document.",
|
||||
);
|
||||
@@ -255,12 +296,12 @@ export class AIService {
|
||||
"AI returned a malformed response format. Please retry analysis; if it fails again, the file may require OCR cleanup.",
|
||||
);
|
||||
}
|
||||
} else if (error.message?.includes("quota")) {
|
||||
} else if (errorMessage.includes("quota")) {
|
||||
throw new Error(
|
||||
"Limit exceeded. Your Gemini API quota may be exhausted. Check your Google Cloud Console for usage details.",
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Error analyzing contract: ${error.message}`);
|
||||
throw new Error(`Error analyzing contract: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,33 +359,37 @@ export class AIService {
|
||||
|
||||
for (const modelName of ANALYSIS_MODELS) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0.1,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
maxOutputTokens: 16384,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await model.generateContent([
|
||||
input.prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
return await keyManager.execute(async (genAI) => {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
maxOutputTokens: 16384,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
console.log(`✅ Analysis with model ${modelName} succeeded`);
|
||||
return text;
|
||||
}
|
||||
} catch (error) {
|
||||
const result = await model.generateContent([
|
||||
input.prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
console.log(`✅ Analysis with model ${modelName} succeeded`);
|
||||
return text;
|
||||
}
|
||||
throw new Error("Empty response");
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
|
||||
lastError = error;
|
||||
console.warn(
|
||||
`Analysis with model ${modelName} failed. Trying next model.`,
|
||||
@@ -358,33 +403,37 @@ export class AIService {
|
||||
"All standard models failed. Trying with lenient generation config...",
|
||||
);
|
||||
try {
|
||||
const fallbackModel = genAI.getGenerativeModel({
|
||||
model: PRIMARY_ANALYSIS_MODEL,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 16384,
|
||||
// Don't enforce JSON format; let model produce raw output
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fallbackModel.generateContent([
|
||||
input.prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
return await keyManager.execute(async (genAI) => {
|
||||
const fallbackModel = genAI.getGenerativeModel({
|
||||
model: PRIMARY_ANALYSIS_MODEL,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 16384,
|
||||
// Don't enforce JSON format; let model produce raw output
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
console.log("✅ Lenient generation succeeded");
|
||||
return text;
|
||||
}
|
||||
} catch (error) {
|
||||
const result = await fallbackModel.generateContent([
|
||||
input.prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
console.log("✅ Lenient generation succeeded");
|
||||
return text;
|
||||
}
|
||||
throw new Error("Empty response from fallback");
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
|
||||
console.warn("Lenient generation also failed:", error);
|
||||
}
|
||||
|
||||
@@ -398,46 +447,47 @@ export class AIService {
|
||||
parseError: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const repairModelName = FALLBACK_ANALYSIS_MODEL;
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: repairModelName,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 16384,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
return await keyManager.execute(async (genAI) => {
|
||||
const repairModelName = FALLBACK_ANALYSIS_MODEL;
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: repairModelName,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 16384,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const expectedSchema = {
|
||||
language: "string|null",
|
||||
title: "string",
|
||||
type: "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER",
|
||||
provider: "string|null",
|
||||
policyNumber: "string|null",
|
||||
startDate: "YYYY-MM-DD|null",
|
||||
endDate: "YYYY-MM-DD|null",
|
||||
premium: "number|null",
|
||||
premiumCurrency: "string|null (ISO code like EUR/USD/TND or symbol)",
|
||||
summary: "string (min 10 chars)",
|
||||
extractedText: "string (min 30 chars)",
|
||||
keyPoints: {
|
||||
guarantees: "string[]",
|
||||
exclusions: "string[]",
|
||||
franchise: "string|null",
|
||||
importantDates: "string[]",
|
||||
explainability:
|
||||
"[{ field, why, sourceSnippet, sourceHints:{ page|null, section|null, confidence|null } }]",
|
||||
},
|
||||
keyPeople: "[{ name, role|null, email|null, phone|null }]",
|
||||
contactInfo:
|
||||
"{ name|null, email|null, phone|null, address|null, role|null }",
|
||||
importantContacts:
|
||||
"[{ name|null, email|null, phone|null, address|null, role|null }]",
|
||||
relevantDates:
|
||||
"[{ date:'YYYY-MM-DD', description, type:'EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER' }]",
|
||||
contractValidation: {
|
||||
const expectedSchema = {
|
||||
language: "string|null",
|
||||
title: "string",
|
||||
type: "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER",
|
||||
provider: "string|null",
|
||||
policyNumber: "string|null",
|
||||
startDate: "YYYY-MM-DD|null",
|
||||
endDate: "YYYY-MM-DD|null",
|
||||
premium: "number|null",
|
||||
premiumCurrency: "string|null (ISO code like EUR/USD/TND or symbol)",
|
||||
summary: "string (min 10 chars)",
|
||||
extractedText: "string (min 30 chars)",
|
||||
keyPoints: {
|
||||
guarantees: "string[]",
|
||||
exclusions: "string[]",
|
||||
franchise: "string|null",
|
||||
importantDates: "string[]",
|
||||
explainability:
|
||||
"[{ field, why, sourceSnippet, sourceHints:{ page|null, section|null, confidence|null } }]",
|
||||
},
|
||||
keyPeople: "[{ name, role|null, email|null, phone|null }]",
|
||||
contactInfo:
|
||||
"{ name|null, email|null, phone|null, address|null, role|null }",
|
||||
importantContacts:
|
||||
"[{ name|null, email|null, phone|null, address|null, role|null }]",
|
||||
relevantDates:
|
||||
"[{ date:'YYYY-MM-DD', description, type:'EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER' }]",
|
||||
contractValidation: {
|
||||
isValidContract: "boolean",
|
||||
confidence: "number (0-100)",
|
||||
reason: "string|null",
|
||||
@@ -478,7 +528,9 @@ ${malformedResponse.slice(0, 14000)}`;
|
||||
}
|
||||
|
||||
return repairedText;
|
||||
} catch (error) {
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
|
||||
console.warn("JSON repair step failed:", error);
|
||||
return null;
|
||||
}
|
||||
@@ -548,10 +600,10 @@ ${malformedResponse.slice(0, 14000)}`;
|
||||
}): Promise<ContractPrecheckResult> {
|
||||
const rawText = await this.generatePrevalidationWithFallback(input);
|
||||
|
||||
let raw: any;
|
||||
let raw: PrevalidationResponse;
|
||||
try {
|
||||
raw = this.parseJsonResponse(rawText || "{}");
|
||||
} catch (error) {
|
||||
raw = this.parseJsonResponse(rawText || "{}") as PrevalidationResponse;
|
||||
} catch {
|
||||
// If prevalidation JSON is malformed, assume it's a contract with moderate confidence
|
||||
console.warn(
|
||||
"Prevalidation JSON parse failed, assuming contract with moderate confidence",
|
||||
@@ -591,32 +643,36 @@ ${malformedResponse.slice(0, 14000)}`;
|
||||
|
||||
for (const modelName of ANALYSIS_MODELS) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 350,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await model.generateContent([
|
||||
buildPrevalidationPrompt(input.fileName),
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
return await keyManager.execute(async (genAI) => {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 350,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
return text;
|
||||
}
|
||||
} catch (error) {
|
||||
const result = await model.generateContent([
|
||||
buildPrevalidationPrompt(input.fileName),
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
return text;
|
||||
}
|
||||
throw new Error("Empty response");
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
|
||||
lastError = error;
|
||||
console.warn(
|
||||
`Pre-validation with model ${modelName} failed. Trying next model.`,
|
||||
@@ -629,7 +685,7 @@ ${malformedResponse.slice(0, 14000)}`;
|
||||
: new Error("All pre-validation models failed to generate content.");
|
||||
}
|
||||
|
||||
private static normalizeAnalysis(input: any): NormalizedAnalysis {
|
||||
private static normalizeAnalysis(input: unknown): NormalizedAnalysis {
|
||||
return normalizeAiAnalysis(input);
|
||||
}
|
||||
|
||||
@@ -639,7 +695,7 @@ ${malformedResponse.slice(0, 14000)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
const examples = await prisma.contract.findMany({
|
||||
const examples: AdaptiveContractExample[] = await prisma.contract.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: "COMPLETED",
|
||||
@@ -693,19 +749,21 @@ ${malformedResponse.slice(0, 14000)}`;
|
||||
|
||||
const allExplainability = examples
|
||||
.flatMap((item) => {
|
||||
const maybeExplainability = (item.keyPoints as any)?.explainability;
|
||||
const maybeExplainability = isAdaptiveKeyPoints(item.keyPoints)
|
||||
? item.keyPoints.explainability
|
||||
: undefined;
|
||||
return Array.isArray(maybeExplainability) ? maybeExplainability : [];
|
||||
})
|
||||
.slice(0, 120);
|
||||
|
||||
const explainabilityByField = count(
|
||||
allExplainability
|
||||
.map((entry: any) => String(entry?.field ?? "").trim())
|
||||
.map((entry) => String(entry?.field ?? "").trim())
|
||||
.filter((value: string) => value.length > 0),
|
||||
);
|
||||
|
||||
const confidenceValues = allExplainability
|
||||
.map((entry: any) => Number(entry?.sourceHints?.confidence))
|
||||
.map((entry) => Number(entry?.sourceHints?.confidence))
|
||||
.filter((value: number) => Number.isFinite(value));
|
||||
|
||||
const avgEvidenceConfidence = confidenceValues.length
|
||||
@@ -719,7 +777,11 @@ ${malformedResponse.slice(0, 14000)}`;
|
||||
|
||||
const learnedLanguages = count(
|
||||
examples
|
||||
.map((item) => (item.keyPoints as any)?.aiMeta?.language)
|
||||
.map((item) =>
|
||||
isAdaptiveKeyPoints(item.keyPoints)
|
||||
? item.keyPoints.aiMeta?.language
|
||||
: null,
|
||||
)
|
||||
.map((value) => String(value ?? "").trim())
|
||||
.filter((value: string) => value.length > 0),
|
||||
);
|
||||
@@ -727,10 +789,12 @@ ${malformedResponse.slice(0, 14000)}`;
|
||||
const learnedKeyRoles = count(
|
||||
examples
|
||||
.flatMap((item) => {
|
||||
const people = (item.keyPoints as any)?.aiMeta?.keyPeople;
|
||||
const people = isAdaptiveKeyPoints(item.keyPoints)
|
||||
? item.keyPoints.aiMeta?.keyPeople
|
||||
: undefined;
|
||||
return Array.isArray(people) ? people : [];
|
||||
})
|
||||
.map((person: any) => String(person?.role ?? "").trim())
|
||||
.map((person) => String(person?.role ?? "").trim())
|
||||
.filter((value: string) => value.length > 0),
|
||||
);
|
||||
|
||||
@@ -761,12 +825,15 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
* - Heuristic text signals suggest non-contract content
|
||||
*/
|
||||
private static assertValidContract(
|
||||
raw: any,
|
||||
raw: unknown,
|
||||
normalized: NormalizedAnalysis,
|
||||
): void {
|
||||
const modelIsValid = raw?.contractValidation?.isValidContract;
|
||||
const confidenceRaw = Number(raw?.contractValidation?.confidence);
|
||||
const modelReason = String(raw?.contractValidation?.reason ?? "").trim();
|
||||
const validation = raw as ValidationEnvelope;
|
||||
const modelIsValid = validation.contractValidation?.isValidContract;
|
||||
const confidenceRaw = Number(validation.contractValidation?.confidence);
|
||||
const modelReason = String(
|
||||
validation.contractValidation?.reason ?? "",
|
||||
).trim();
|
||||
|
||||
const legalSignalRegex =
|
||||
/contract|agreement|policy|terms|clause|premium|coverage|insured|insurer|loan|borrower|credit|beneficiary|liability|lease|service|supplier|client|vendor|annex|appendix|signature|party|contrat|assurance|banque|credit|emprunteur|garantie|echeance|duree|clause/i;
|
||||
@@ -810,7 +877,7 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
/**
|
||||
* Validate that AI results have all required fields
|
||||
*/
|
||||
static validateAnalysis(data: any): boolean {
|
||||
static validateAnalysis(data: unknown): boolean {
|
||||
try {
|
||||
// Validation uses same normalizer used in production flow.
|
||||
this.normalizeAnalysis(data);
|
||||
@@ -832,7 +899,7 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
return undefined;
|
||||
}
|
||||
return date;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -850,7 +917,9 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
|
||||
static async askAboutContract(input: {
|
||||
question: string;
|
||||
ragChunks?: Array<{ chunkIndex: number; content: string; score: number }>;
|
||||
contract: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
title?: string | null;
|
||||
type?: string | null;
|
||||
@@ -866,10 +935,31 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
};
|
||||
}) {
|
||||
try {
|
||||
// Retrieve best matching persisted chunks for grounded Q&A.
|
||||
let ragChunks = input.ragChunks ?? [];
|
||||
if (ragChunks.length === 0) {
|
||||
try {
|
||||
ragChunks = await RAGService.retrieveRelevantChunks({
|
||||
contractId: input.contract.id,
|
||||
question: input.question,
|
||||
topK: 6,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"RAG chunk retrieval failed. Falling back to extracted snippet.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep context bounded to avoid overlong prompts and token waste.
|
||||
const extractedTextSnippet = (input.contract.extractedText || "")
|
||||
.slice(0, 12000)
|
||||
.slice(0, 5000)
|
||||
.trim();
|
||||
const ragContext =
|
||||
ragChunks.length > 0
|
||||
? RAGService.buildChunkContext(ragChunks)
|
||||
: extractedTextSnippet || "N/A";
|
||||
const contractTypeGuidance = this.getContractTypeGuidance(
|
||||
input.contract.type,
|
||||
);
|
||||
@@ -910,8 +1000,8 @@ ${input.contract.summary ?? "N/A"}
|
||||
Key Points (JSON):
|
||||
${JSON.stringify(input.contract.keyPoints ?? {}, null, 2)}
|
||||
|
||||
Extracted Text:
|
||||
${extractedTextSnippet || "N/A"}
|
||||
Grounded RAG Context:
|
||||
${ragContext}
|
||||
|
||||
User question (${languageName}):
|
||||
${input.question}
|
||||
@@ -923,6 +1013,7 @@ Instructions:
|
||||
- Do NOT quote large raw excerpts from extracted text unless strictly necessary.
|
||||
- Synthesize and explain the implications in practical terms instead of copying file content.
|
||||
- Base your answer ONLY on the provided contract content.
|
||||
- Prioritize information from Grounded RAG Context over any assumptions.
|
||||
- Adapt answer emphasis using this type guidance: ${contractTypeGuidance}
|
||||
- If information is missing, explicitly say: Information not found in the analyzed contract.
|
||||
- If the question asks about legal consequences or non-compliance, provide general legal context for EU/USA at a high level only.
|
||||
@@ -930,6 +1021,7 @@ Instructions:
|
||||
- Never claim certainty where the contract text is ambiguous.
|
||||
- Keep the answer concise, executive, and decision-oriented.
|
||||
- Use the same language preference throughout (${languageName}).
|
||||
- Add one short evidence line at the end in this format: Source basis: Chunk X, Chunk Y (or Source basis: extracted contract text).
|
||||
|
||||
Response structure (in ${languageName}):
|
||||
1) Direct answer in one sentence.
|
||||
@@ -946,26 +1038,34 @@ Include one short disclaimer only when legal context is discussed: "This is gene
|
||||
|
||||
for (const modelName of ANALYSIS_MODELS) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
maxOutputTokens: 2048,
|
||||
},
|
||||
rawAnswer = await keyManager.execute(async (genAI) => {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
maxOutputTokens: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const text = result.response.text()?.trim() || "";
|
||||
|
||||
if (text) {
|
||||
console.log(
|
||||
`✅ Q&A with model ${modelName} succeeded in ${languageName}`,
|
||||
);
|
||||
return text;
|
||||
}
|
||||
throw new Error("Empty response");
|
||||
});
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
rawAnswer = result.response.text()?.trim() || "";
|
||||
|
||||
if (rawAnswer) {
|
||||
console.log(
|
||||
`✅ Q&A with model ${modelName} succeeded in ${languageName}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
|
||||
lastError = error;
|
||||
console.warn(
|
||||
`Q&A with model ${modelName} failed. Trying next model.`,
|
||||
@@ -990,11 +1090,13 @@ Include one short disclaimer only when legal context is discussed: "This is gene
|
||||
.trim();
|
||||
|
||||
return sanitizedAnswer;
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("API key")) {
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes("API key")) {
|
||||
throw new Error("Invalid or missing Gemini API key.");
|
||||
}
|
||||
throw new Error(`Error answering question: ${error.message}`);
|
||||
throw new Error(`Error answering question: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ CRITICAL: Your response must be VALID, PARSEABLE JSON only. Do not include markd
|
||||
"endDate": "2025-12-31",
|
||||
"premium": 1200.50,
|
||||
"premiumCurrency": "TND",
|
||||
"summary": "Professional, comprehensive 4-6 sentence summary in the contract's language. Include: main parties, key obligations, coverage/benefits, exclusions, important deadlines, key contacts. Use **bold** for: names, numbers, dates, amounts, important terms.",
|
||||
"summary": "Professional, comprehensive 4-6 sentence summary in the contract's language. Include: main parties, key obligations, coverage/benefits, exclusions, important deadlines, key contacts.",
|
||||
"keyPoints": {
|
||||
"guarantees": ["**Main Benefit 1**: Description", "**Main Benefit 2**: Description"],
|
||||
"exclusions": ["**Exclusion 1**: Description with impact", "**Exclusion 2**: Description"],
|
||||
"franchise": "**Deductible/Penalty**: €150 per claim or equivalent",
|
||||
"importantDates": ["**Renewal Date**: 31 December annually", "**Payment Deadline**: 15th of each month"],
|
||||
"guarantees": ["Main Benefit 1: Description", "Main Benefit 2: Description"],
|
||||
"exclusions": ["Exclusion 1: Description with impact", "Exclusion 2: Description"],
|
||||
"franchise": "Deductible/Penalty: €150 per claim or equivalent",
|
||||
"importantDates": ["Renewal Date: 31 December annually", "Payment Deadline: 15th of each month"],
|
||||
"explainability": [
|
||||
{
|
||||
"field": "endDate",
|
||||
@@ -44,24 +44,24 @@ CRITICAL: Your response must be VALID, PARSEABLE JSON only. Do not include markd
|
||||
]
|
||||
},
|
||||
"keyPeople": [
|
||||
{"name": "**John Smith**", "role": "Policy Holder", "email": "john@example.com", "phone": "+33612345678"},
|
||||
{"name": "**Jane Doe**", "role": "Insurance Agent", "email": "jane@insurer.com", "phone": "+33987654321"}
|
||||
{"name": "John Smith", "role": "Policy Holder", "email": "john@example.com", "phone": "+33612345678"},
|
||||
{"name": "Jane Doe", "role": "Insurance Agent", "email": "jane@insurer.com", "phone": "+33987654321"}
|
||||
],
|
||||
"contactInfo": {
|
||||
"name": "**Policy Holder Name**",
|
||||
"name": "Policy Holder Name",
|
||||
"email": "holder@email.com",
|
||||
"phone": "+33612345678",
|
||||
"address": "123 Main Street, City, Postal Code",
|
||||
"role": "Insured Person"
|
||||
},
|
||||
"importantContacts": [
|
||||
{"name": "**Claims Department**", "email": "claims@insurer.com", "phone": "+33800000000"},
|
||||
{"name": "**Customer Service**", "email": "support@insurer.com", "phone": "+33800111111"}
|
||||
{"name": "Claims Department", "email": "claims@insurer.com", "phone": "+33800000000"},
|
||||
{"name": "Customer Service", "email": "support@insurer.com", "phone": "+33800111111"}
|
||||
],
|
||||
"relevantDates": [
|
||||
{"date": "2025-12-31", "description": "**Policy Expiration Date**", "type": "EXPIRATION"},
|
||||
{"date": "2025-10-31", "description": "**Renewal Notice Deadline** (60 days before expiration)", "type": "RENEWAL"},
|
||||
{"date": "1970-01-15", "description": "**Monthly Payment Due Date**", "type": "PAYMENT"}
|
||||
{"date": "2025-12-31", "description": "Policy Expiration Date", "type": "EXPIRATION"},
|
||||
{"date": "2025-10-31", "description": "Renewal Notice Deadline (60 days before expiration)", "type": "RENEWAL"},
|
||||
{"date": "1970-01-15", "description": "Monthly Payment Due Date", "type": "PAYMENT"}
|
||||
],
|
||||
"extractedText": "Most relevant extracted text, preserving original structure and keywords. Include key clauses, definitions, obligations. Max 12000 chars.",
|
||||
"contractValidation": {
|
||||
@@ -78,18 +78,28 @@ CRITICAL FIELD EXTRACTION RULES:
|
||||
|
||||
1. **Language Detection**: Detect and return the contract's primary language (en, fr, de, es, it, pt, etc.). If mixed, return dominant language.
|
||||
|
||||
1.1 **Multi-language accuracy**:
|
||||
- Preserve original character set (accents, Arabic script, umlauts, symbols) exactly in extractedText and sourceSnippet.
|
||||
- Correctly parse dates in local formats (e.g., French, German, Spanish, Arabic locales) and normalize to YYYY-MM-DD.
|
||||
- Correctly parse localized numbers (e.g., 1.234,56 and 1,234.56) before setting premium.
|
||||
|
||||
1.2 **Premium extraction priority**:
|
||||
- Detect premium/amount clauses using nearby context words (premium, cotisation, prime, mensualite, annual, per claim, deductible).
|
||||
- If multiple amounts exist, choose the one most clearly representing contract premium/payment obligation.
|
||||
- If only percentage-based premium exists, set premium to null and mention the percentage in summary/keyPoints.
|
||||
- premiumCurrency must reflect the contract currency exactly (ISO code if inferable).
|
||||
|
||||
2. **Summary (VERY IMPORTANT)**:
|
||||
- Write 4-6 comprehensive sentences covering: parties involved, contract scope, key obligations, main coverage/benefits, critical exclusions, important deadlines
|
||||
- Use **Party Name** for persons/entities mentioned
|
||||
- Use **number** for all quantities, dates, amounts, percentages
|
||||
- Use **YYYY-MM-DD** format for dates with **bold**
|
||||
- Use plain text only (no markdown, no bold markers)
|
||||
- Use YYYY-MM-DD format for explicit date mentions where possible
|
||||
- Language: Professional business French, English, or contract's native language
|
||||
- MUST be detailed enough that reader understands contract without opening PDF
|
||||
|
||||
3. **Key People Extraction**:
|
||||
- Extract all named individuals: policy holders, insured parties, beneficiaries, signatories, agents, brokers
|
||||
- Include roles, contact methods when visible in contract
|
||||
- Use **bold** for names: {"name": "**John Smith**", ...}
|
||||
- Use plain text only for names and labels
|
||||
|
||||
4. **Contact Information**:
|
||||
- contactInfo: Details of PRIMARY policy holder or contract party
|
||||
@@ -99,17 +109,17 @@ CRITICAL FIELD EXTRACTION RULES:
|
||||
- Extract ALL dates with business meaning: expiration, renewal, payment due dates, review dates
|
||||
- For recurring dates (monthly, annually): show pattern like "1970-01-15" for "15th of each month"
|
||||
- Include type: EXPIRATION, RENEWAL, PAYMENT, REVIEW, or OTHER
|
||||
- Each date must have clear **bold** description explaining its significance
|
||||
- Each date must have a clear description explaining its significance
|
||||
|
||||
6. **Key Points**:
|
||||
- Use **bold** for: benefit names, exclusion types, monetary amounts, coverage limits
|
||||
- Example: "**Motor Coverage**: Collision and theft protection up to **€50,000**"
|
||||
- Use concise plain text labels and include monetary amounts/limits when available
|
||||
- Example: "Motor Coverage: Collision and theft protection up to €50,000"
|
||||
- Make exclusions explicit and impactful
|
||||
- Include franchise/deductible with bold currency and amount
|
||||
- Include franchise/deductible with currency and amount when available
|
||||
|
||||
7. **Guarantees & Exclusions**:
|
||||
- Be specific: "**Theft Coverage** includes keys, GPS, and aftermarket electronics"
|
||||
- For exclusions, explain impact: "**Mechanical wear excluded** - means breakdowns in years 3+ not covered"
|
||||
- Be specific: "Theft Coverage includes keys, GPS, and aftermarket electronics"
|
||||
- For exclusions, explain impact: "Mechanical wear excluded - means breakdowns in years 3+ not covered"
|
||||
|
||||
8. **Email/Phone Extraction**: If present in contract, extract:
|
||||
- Email addresses in format: contact@domain.com
|
||||
@@ -127,6 +137,7 @@ CRITICAL FIELD EXTRACTION RULES:
|
||||
- sourceHints.confidence: 0..100 confidence for that field extraction
|
||||
- Keep sourceSnippet short (max 280 chars) but sufficiently specific to audit.
|
||||
- Never invent snippet text not present in document.
|
||||
- Prefer one snippet from each major section when available (header, financial clause, dates/terms, exclusions).
|
||||
|
||||
Field Type Rules:
|
||||
- dates: ISO format YYYY-MM-DD or null. For recurring patterns, use canonical date (e.g., "0000-01-15" for "15th each month")
|
||||
|
||||
97
lib/services/ai/key-manager.ts
Normal file
97
lib/services/ai/key-manager.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
|
||||
export class ApiKeyManager {
|
||||
private keys: string[];
|
||||
private currentIndex: number = 0;
|
||||
private genAIInstance: GoogleGenerativeAI;
|
||||
|
||||
constructor() {
|
||||
// Collect all provided keys
|
||||
const envKeys = [
|
||||
process.env.AI_API_KEY1,
|
||||
process.env.AI_API_KEY2,
|
||||
process.env.AI_API_KEY3,
|
||||
]
|
||||
.map((key) => key?.trim())
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
this.keys = Array.from(new Set([...envKeys]));
|
||||
|
||||
if (this.keys.length === 0) {
|
||||
console.error("❌ No AI API Keys are configured in the environment variables.");
|
||||
throw new Error("No Gemini API keys configured. Set AI_API_KEY1, AI_API_KEY2, AI_API_KEY3 in your .env file.");
|
||||
}
|
||||
|
||||
// Initialize with the first available key
|
||||
this.genAIInstance = new GoogleGenerativeAI(this.keys[this.currentIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to the first key. Call at the start of each new top-level request
|
||||
* so that refreshed/renewed keys get a chance to be tried again.
|
||||
*/
|
||||
resetKeys() {
|
||||
this.currentIndex = 0;
|
||||
this.genAIInstance = new GoogleGenerativeAI(this.keys[0]);
|
||||
}
|
||||
|
||||
private rotateKey() {
|
||||
this.currentIndex++;
|
||||
if (this.currentIndex >= this.keys.length) {
|
||||
this.currentIndex = 0;
|
||||
this.genAIInstance = new GoogleGenerativeAI(this.keys[0]);
|
||||
throw new Error(
|
||||
"CRITICAL_KEY_EXHAUSTION: All available API keys have failed, expired, or run out of quota."
|
||||
);
|
||||
}
|
||||
console.warn(`⚠️ API Key failed. Swapping to backup key #${this.currentIndex + 1}...`);
|
||||
this.genAIInstance = new GoogleGenerativeAI(this.keys[this.currentIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an SDK call. If it fails due to quota or auth errors, it automatically
|
||||
* rotates the key and retries the operation transparently.
|
||||
*/
|
||||
async execute<T>(operation: (client: GoogleGenerativeAI) => Promise<T>): Promise<T> {
|
||||
while (true) {
|
||||
try {
|
||||
return await operation(this.genAIInstance);
|
||||
} catch (error: any) {
|
||||
const msg = error?.message?.toLowerCase() || "";
|
||||
const isAuthOrQuotaError =
|
||||
msg.includes("429") ||
|
||||
msg.includes("too many requests") ||
|
||||
msg.includes("401") ||
|
||||
msg.includes("403") ||
|
||||
msg.includes("unauthorized") ||
|
||||
msg.includes("forbidden") ||
|
||||
msg.includes("api key not valid") ||
|
||||
msg.includes("api_key_invalid") ||
|
||||
msg.includes("quota") ||
|
||||
msg.includes("exhausted") ||
|
||||
msg.includes("resource has been exhausted") ||
|
||||
msg.includes("limit exceeded") ||
|
||||
msg.includes("rate limit") ||
|
||||
msg.includes("permission denied") ||
|
||||
msg.includes("billing") ||
|
||||
msg.includes("exceeded your current quota") ||
|
||||
error?.status === 429 ||
|
||||
error?.status === 403 ||
|
||||
error?.status === 401;
|
||||
|
||||
if (isAuthOrQuotaError) {
|
||||
const failedKeyIndex = this.currentIndex;
|
||||
const failedKeyHint = this.keys[failedKeyIndex]?.slice(0, 10) + "...";
|
||||
console.warn(`⚠️ Key #${failedKeyIndex + 1} (${failedKeyHint}) failed: ${msg.slice(0, 120)}`);
|
||||
this.rotateKey();
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a robust singleton instance to be shared across services
|
||||
export const keyManager = new ApiKeyManager();
|
||||
@@ -47,12 +47,15 @@ export async function saveContract(data: {
|
||||
status: contract.status,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.error("❌ SAVE CONTRACT ERROR");
|
||||
console.error("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.error(error);
|
||||
return { success: false, error: error.message };
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +251,11 @@ export class ContractService {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
ragChunks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -326,6 +334,38 @@ export class ContractService {
|
||||
});
|
||||
}
|
||||
|
||||
static async deleteAllForUser(userId: string): Promise<number> {
|
||||
const contracts = await prisma.contract.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
fileUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (contracts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const fileKeys = contracts
|
||||
.map((contract) => this.extractFileKeyFromUrl(contract.fileUrl))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
if (fileKeys.length > 0) {
|
||||
try {
|
||||
await utapi.deleteFiles(fileKeys);
|
||||
} catch (error) {
|
||||
console.error("Failed to bulk delete files from UploadThing:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const deleted = await prisma.contract.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
return deleted.count;
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// HELPER: Extract file key from UploadThing URL
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
274
lib/services/rag.service.ts
Normal file
274
lib/services/rag.service.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
type ChunkRecord = {
|
||||
chunkIndex: number;
|
||||
content: string;
|
||||
contentHash: string;
|
||||
embedding: number[];
|
||||
};
|
||||
|
||||
type RetrievedChunk = {
|
||||
chunkIndex: number;
|
||||
content: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
const API_KEY =
|
||||
process.env.AI_API_KEY1 || process.env.AI_API_KEY2 || process.env.AI_API_KEY3;
|
||||
|
||||
if (!API_KEY) {
|
||||
throw new Error("AI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
const EMBEDDING_MODEL = process.env.AI_EMBEDDING_MODEL || "text-embedding-004";
|
||||
const EMBEDDING_MODEL_FALLBACKS = [
|
||||
EMBEDDING_MODEL,
|
||||
"text-embedding-004",
|
||||
"embedding-001",
|
||||
];
|
||||
const genAI = new GoogleGenerativeAI(API_KEY);
|
||||
|
||||
export class RAGService {
|
||||
private static readonly MAX_CHUNK_CHARS = 1400;
|
||||
private static readonly CHUNK_OVERLAP_CHARS = 220;
|
||||
private static readonly MAX_CHUNKS_PER_CONTRACT = 120;
|
||||
|
||||
static async upsertContractChunks(input: {
|
||||
contractId: string;
|
||||
extractedText?: string | null;
|
||||
summary?: string | null;
|
||||
keyPoints?: Record<string, unknown> | null;
|
||||
}): Promise<number> {
|
||||
const sourceText = this.buildSourceText(input);
|
||||
if (!sourceText.trim()) {
|
||||
await prisma.contractRagChunk.deleteMany({
|
||||
where: { contractId: input.contractId },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
const chunks = this.chunkText(sourceText);
|
||||
if (chunks.length === 0) {
|
||||
await prisma.contractRagChunk.deleteMany({
|
||||
where: { contractId: input.contractId },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
const embeddedChunks: ChunkRecord[] = [];
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunk = chunks[index];
|
||||
const embedding = await this.embedText(chunk);
|
||||
embeddedChunks.push({
|
||||
chunkIndex: index,
|
||||
content: chunk,
|
||||
contentHash: this.hashChunk(chunk),
|
||||
embedding,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.contractRagChunk.deleteMany({
|
||||
where: { contractId: input.contractId },
|
||||
});
|
||||
for (const chunk of embeddedChunks) {
|
||||
await tx.contractRagChunk.create({
|
||||
data: {
|
||||
contractId: input.contractId,
|
||||
chunkIndex: chunk.chunkIndex,
|
||||
content: chunk.content,
|
||||
contentHash: chunk.contentHash,
|
||||
embedding: chunk.embedding,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return embeddedChunks.length;
|
||||
}
|
||||
|
||||
static async retrieveRelevantChunks(input: {
|
||||
contractId: string;
|
||||
question: string;
|
||||
topK?: number;
|
||||
}): Promise<RetrievedChunk[]> {
|
||||
const question = input.question.trim();
|
||||
if (!question) return [];
|
||||
|
||||
const allChunks = await prisma.contractRagChunk.findMany({
|
||||
where: { contractId: input.contractId },
|
||||
orderBy: { chunkIndex: "asc" },
|
||||
select: {
|
||||
chunkIndex: true,
|
||||
content: true,
|
||||
embedding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (allChunks.length === 0) return [];
|
||||
|
||||
const queryEmbedding = await this.embedText(question);
|
||||
const topK = Math.max(2, Math.min(12, input.topK ?? 6));
|
||||
|
||||
return allChunks
|
||||
.map((chunk) => ({
|
||||
chunkIndex: chunk.chunkIndex,
|
||||
content: chunk.content,
|
||||
score: this.cosineSimilarity(queryEmbedding, chunk.embedding),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, topK)
|
||||
.filter((chunk) => Number.isFinite(chunk.score) && chunk.score > 0.12);
|
||||
}
|
||||
|
||||
static buildChunkContext(chunks: RetrievedChunk[]): string {
|
||||
if (chunks.length === 0) {
|
||||
return "No RAG chunks available.";
|
||||
}
|
||||
|
||||
return chunks
|
||||
.map(
|
||||
(chunk) =>
|
||||
`[Chunk ${chunk.chunkIndex} | relevance=${chunk.score.toFixed(3)}]\n${chunk.content}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
private static buildSourceText(input: {
|
||||
extractedText?: string | null;
|
||||
summary?: string | null;
|
||||
keyPoints?: Record<string, unknown> | null;
|
||||
}): string {
|
||||
const section: string[] = [];
|
||||
|
||||
const summary = String(input.summary ?? "").trim();
|
||||
if (summary) {
|
||||
section.push(`SUMMARY\n${summary}`);
|
||||
}
|
||||
|
||||
const keyPoints = input.keyPoints ?? {};
|
||||
const guarantees = Array.isArray(keyPoints.guarantees)
|
||||
? keyPoints.guarantees.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
const exclusions = Array.isArray(keyPoints.exclusions)
|
||||
? keyPoints.exclusions.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
const importantDates = Array.isArray(keyPoints.importantDates)
|
||||
? keyPoints.importantDates
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const franchise = String(keyPoints.franchise ?? "").trim();
|
||||
|
||||
const keyPointsLines: string[] = [];
|
||||
if (guarantees.length > 0) {
|
||||
keyPointsLines.push(`Guarantees: ${guarantees.join(" | ")}`);
|
||||
}
|
||||
if (exclusions.length > 0) {
|
||||
keyPointsLines.push(`Exclusions: ${exclusions.join(" | ")}`);
|
||||
}
|
||||
if (franchise) {
|
||||
keyPointsLines.push(`Franchise: ${franchise}`);
|
||||
}
|
||||
if (importantDates.length > 0) {
|
||||
keyPointsLines.push(`ImportantDates: ${importantDates.join(" | ")}`);
|
||||
}
|
||||
if (keyPointsLines.length > 0) {
|
||||
section.push(`KEY_POINTS\n${keyPointsLines.join("\n")}`);
|
||||
}
|
||||
|
||||
const extractedText = String(input.extractedText ?? "").trim();
|
||||
if (extractedText) {
|
||||
section.push(`EXTRACTED_TEXT\n${extractedText}`);
|
||||
}
|
||||
|
||||
return section.join("\n\n").slice(0, 45000);
|
||||
}
|
||||
|
||||
private static chunkText(text: string): string[] {
|
||||
const normalized = text.replace(/\r\n/g, "\n").trim();
|
||||
if (!normalized) return [];
|
||||
|
||||
const chunks: string[] = [];
|
||||
let cursor = 0;
|
||||
const maxLen = this.MAX_CHUNK_CHARS;
|
||||
const overlap = this.CHUNK_OVERLAP_CHARS;
|
||||
|
||||
while (
|
||||
cursor < normalized.length &&
|
||||
chunks.length < this.MAX_CHUNKS_PER_CONTRACT
|
||||
) {
|
||||
let end = Math.min(cursor + maxLen, normalized.length);
|
||||
|
||||
if (end < normalized.length) {
|
||||
const window = normalized.slice(cursor, end);
|
||||
const breakAt = Math.max(
|
||||
window.lastIndexOf("\n\n"),
|
||||
window.lastIndexOf(". "),
|
||||
window.lastIndexOf("\n"),
|
||||
);
|
||||
|
||||
if (breakAt > Math.floor(maxLen * 0.45)) {
|
||||
end = cursor + breakAt + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const chunk = normalized.slice(cursor, end).trim();
|
||||
if (chunk.length > 40) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
if (end >= normalized.length) break;
|
||||
cursor = Math.max(end - overlap, cursor + 1);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static hashChunk(content: string): string {
|
||||
return createHash("sha256").update(content, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
private static async embedText(text: string): Promise<number[]> {
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const modelName of Array.from(new Set(EMBEDDING_MODEL_FALLBACKS))) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({ model: modelName });
|
||||
const result = await model.embedContent(text);
|
||||
const values = result.embedding?.values;
|
||||
|
||||
if (values && Array.isArray(values) && values.length > 0) {
|
||||
return values;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
lastError instanceof Error
|
||||
? lastError.message
|
||||
: "Failed to generate embedding vector.";
|
||||
throw new Error(`Embedding generation failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
private static cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length !== b.length || a.length === 0) return -1;
|
||||
|
||||
let dot = 0;
|
||||
let magA = 0;
|
||||
let magB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
dot += a[i] * b[i];
|
||||
magA += a[i] * a[i];
|
||||
magB += b[i] * b[i];
|
||||
}
|
||||
|
||||
if (magA === 0 || magB === 0) return -1;
|
||||
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
||||
}
|
||||
}
|
||||
236
package-lock.json
generated
236
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,11 +7,10 @@ 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?
|
||||
@@ -19,8 +18,8 @@ model User {
|
||||
contracts Contract[]
|
||||
notifications Notification[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([clerkId])
|
||||
@@index([email])
|
||||
@@ -32,35 +31,36 @@ model Contract {
|
||||
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
|
||||
@@ -71,27 +71,46 @@ model Contract {
|
||||
@@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])
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const config: Config = {
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./features/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
Reference in New Issue
Block a user