Pre-Final Backup
This commit is contained in:
@@ -65,17 +65,19 @@ The AI subsystem is centered on:
|
|||||||
|
|
||||||
- Primary model: gemini-2.5-flash
|
- Primary model: gemini-2.5-flash
|
||||||
- Optional secondary Gemini model: AI_MODEL_SECONDARY_GEMINI
|
- Optional secondary Gemini model: AI_MODEL_SECONDARY_GEMINI
|
||||||
- Fallback model provider: Groq (default: llama-3.3-70b-versatile)
|
- Fallback model provider: Mistral AI (default: mistral-large-latest, vision: pixtral-large-latest)
|
||||||
- Gemini models are de-duplicated and iterated in order before Groq fallback
|
- Gemini models are de-duplicated and iterated in order before Mistral fallback
|
||||||
- Groq extraction fallback currently applies to image inputs in this pipeline; JSON repair and Q&A fallback are text-based
|
- Mistral extraction fallback supports both text and image inputs via Pixtral vision; JSON repair and Q&A fallback are text-based
|
||||||
|
|
||||||
### 2.3 Environment Variables
|
### 2.3 Environment Variables
|
||||||
|
|
||||||
- AI_API_KEY (or AI_API_KEY2 / AI_API_KEY3 fallback)
|
- AI_API_KEY (or AI_API_KEY2 / AI_API_KEY3 fallback)
|
||||||
- AI_MODEL_PRIMARY (optional override)
|
- AI_MODEL_PRIMARY (optional override)
|
||||||
- AI_MODEL_SECONDARY_GEMINI (optional override)
|
- AI_MODEL_SECONDARY_GEMINI (optional override)
|
||||||
|
- AI_MODEL_SECONDARY (legacy alias supported for compatibility)
|
||||||
- AI_MODEL_FALLBACK (optional override)
|
- AI_MODEL_FALLBACK (optional override)
|
||||||
- GROQ_API_KEY (or AI_GROQ_API_KEY)
|
- MISTRAL_API_KEY
|
||||||
|
- AI_MODEL_MISTRAL_VISION (optional, default: pixtral-large-latest)
|
||||||
|
|
||||||
## 3. AI Capability Matrix
|
## 3. AI Capability Matrix
|
||||||
|
|
||||||
@@ -206,7 +208,7 @@ sequenceDiagram
|
|||||||
participant AIS as AIService
|
participant AIS as AIService
|
||||||
participant GP as Gemini Primary
|
participant GP as Gemini Primary
|
||||||
participant GS as Gemini Secondary (optional)
|
participant GS as Gemini Secondary (optional)
|
||||||
participant GR as Groq Fallback
|
participant MR as Mistral AI Fallback
|
||||||
|
|
||||||
AIS->>GP: generate analysis (strict JSON)
|
AIS->>GP: generate analysis (strict JSON)
|
||||||
alt GP success with usable output
|
alt GP success with usable output
|
||||||
@@ -220,17 +222,17 @@ sequenceDiagram
|
|||||||
alt lenient success
|
alt lenient success
|
||||||
GP-->>AIS: raw text
|
GP-->>AIS: raw text
|
||||||
else lenient fails
|
else lenient fails
|
||||||
AIS->>GR: generate analysis (strict JSON)
|
AIS->>MR: generate analysis (strict JSON)
|
||||||
GR-->>AIS: text
|
MR-->>AIS: text
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
AIS->>AIS: parseJsonResponse
|
AIS->>AIS: parseJsonResponse
|
||||||
alt parse failed
|
alt parse failed
|
||||||
AIS->>GR: repairMalformedJson(originalText, parseError)
|
AIS->>MR: repairMalformedJson(originalText, parseError)
|
||||||
alt repair success
|
alt repair success
|
||||||
GR-->>AIS: repaired JSON text
|
MR-->>AIS: repaired JSON text
|
||||||
AIS->>AIS: parse repaired JSON
|
AIS->>AIS: parse repaired JSON
|
||||||
else repair failed
|
else repair failed
|
||||||
AIS->>AIS: emergencyExtractFields(rawText)
|
AIS->>AIS: emergencyExtractFields(rawText)
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ import { ContractsList } from "@/features/contracts/components/list/contracts-li
|
|||||||
import { ContractsHeader } from "@/components/layout/contacts-header";
|
import { ContractsHeader } from "@/components/layout/contacts-header";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getContracts } from "@/features/contracts/api/contract.action";
|
import { getContracts } from "@/features/contracts/api/contract.action";
|
||||||
import { Card } from "@/components/ui/card";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
FileText,
|
||||||
|
Sparkles,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function ContactsPage() {
|
export default function ContactsPage() {
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
@@ -42,69 +51,217 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
if (isChecking) {
|
if (isChecking) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-background text-foreground relative overflow-hidden">
|
||||||
<div className="min-h-screen bg-background text-foreground overflow-hidden">
|
{/* Ambient loading background */}
|
||||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-3xl animate-blob"></div>
|
<div className="absolute top-1/4 left-1/3 w-[500px] h-[500px] bg-primary/10 rounded-full blur-[120px] animate-pulse" />
|
||||||
<div className="absolute top-1/2 right-1/4 w-96 h-96 bg-accent/20 rounded-full blur-3xl animate-blob animation-delay-2000"></div>
|
<div className="absolute bottom-1/4 right-1/3 w-[400px] h-[400px] bg-violet-500/10 rounded-full blur-[100px] animate-pulse delay-700" />
|
||||||
<div className="absolute -bottom-8 right-1/3 w-96 h-96 bg-secondary/20 rounded-full blur-3xl animate-blob animation-delay-4000"></div>
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:64px_64px] [mask-image:radial-gradient(ellipse_60%_60%_at_50%_50%,#000_30%,transparent_100%)]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="relative z-10 flex flex-col h-screen overflow-auto items-center justify-center">
|
<main className="relative z-10 flex flex-col h-screen items-center justify-center">
|
||||||
<div className="text-center">
|
<motion.div
|
||||||
<div className="mb-4 inline-block p-4 bg-background dark:bg-card rounded-full border border-border/50">
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
<div className="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin"></div>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="text-center space-y-6"
|
||||||
|
>
|
||||||
|
<div className="relative inline-flex">
|
||||||
|
<div className="absolute inset-0 bg-primary/30 blur-2xl rounded-full" />
|
||||||
|
<div className="relative p-6 bg-background/80 dark:bg-card/80 rounded-3xl border border-border/50 backdrop-blur-xl shadow-2xl">
|
||||||
|
<Loader2 className="w-10 h-10 text-primary animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">Loading...</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-lg font-semibold text-foreground">
|
||||||
|
Loading workspace
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Preparing your contract environment...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-background text-foreground relative overflow-hidden">
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
{/* Ambient Background */}
|
||||||
<main className="flex flex-col min-h-screen">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-[-10%] left-[-5%] w-[600px] h-[600px] bg-primary/5 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute top-[20%] right-[-10%] w-[500px] h-[500px] bg-violet-500/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute bottom-[-10%] left-[20%] w-[400px] h-[400px] bg-emerald-500/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:64px_64px] [mask-image:radial-gradient(ellipse_80%_80%_at_50%_50%,#000_20%,transparent_100%)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ContractsHeader />
|
<ContractsHeader />
|
||||||
|
|
||||||
|
<main className="relative z-10 flex flex-col min-h-[calc(100vh-64px)]">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-8 space-y-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8 py-10 space-y-10">
|
||||||
<Card className="rounded-2xl border-border/60 p-6 md:p-8">
|
{/* Upload Section */}
|
||||||
<div className="mb-6">
|
<motion.section
|
||||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-5">
|
||||||
|
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
|
||||||
|
<Upload className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">
|
||||||
Upload Contract
|
Upload Contract
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm md:text-base text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Add PDF contracts and let the AI pipeline extract summary,
|
PDF documents supported
|
||||||
key points, and legal-business insights.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 via-violet-500/20 to-primary/20 rounded-2xl blur opacity-30 group-hover:opacity-50 transition duration-500" />
|
||||||
|
<div className="relative rounded-2xl border border-border/60 bg-background/60 backdrop-blur-2xl p-6 md:p-8 shadow-xl shadow-black/5">
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
New Document
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-md">
|
||||||
|
Our AI pipeline will automatically extract summaries,
|
||||||
|
key clauses, risk factors, and generate actionable
|
||||||
|
business insights.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5 text-[10px] font-medium px-3 py-1.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
AI Ready
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ContractUploadForm onUploadSuccess={handleUploadSuccess} />
|
<ContractUploadForm onUploadSuccess={handleUploadSuccess} />
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
<Card className="rounded-2xl border-border/60 p-6 md:p-8">
|
{/* Contracts List Section */}
|
||||||
<div className="mb-6">
|
<motion.section
|
||||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||||||
|
<FileText className="w-4 h-4 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">
|
||||||
Your Contracts
|
Your Contracts
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm md:text-base text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Review contract lifecycle, trigger analysis, and ask AI
|
Manage, analyze, and query your document library
|
||||||
questions per file.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showContracts ? (
|
{showContracts && (
|
||||||
<ContractsList refreshTrigger={refreshTrigger} />
|
<motion.button
|
||||||
) : (
|
initial={{ opacity: 0 }}
|
||||||
<EmptyContractsState />
|
animate={{ opacity: 1 }}
|
||||||
|
onClick={() => setRefreshTrigger((prev) => prev + 1)}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5 rounded-lg hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Refresh
|
||||||
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative rounded-2xl border border-border/40 bg-background/40 backdrop-blur-2xl overflow-hidden shadow-xl shadow-black/5 min-h-[400px]">
|
||||||
|
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{showContracts ? (
|
||||||
|
<motion.div
|
||||||
|
key="list"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="p-6 md:p-8"
|
||||||
|
>
|
||||||
|
<ContractsList refreshTrigger={refreshTrigger} />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="empty"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="p-6 md:p-8"
|
||||||
|
>
|
||||||
|
<EmptyContractsState />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
{/* Bottom Info Cards */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-3 gap-4 pb-8"
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: <Shield className="w-4 h-4" />,
|
||||||
|
title: "Secure Processing",
|
||||||
|
desc: "Documents are encrypted in transit and at rest. Your data never leaves your infrastructure.",
|
||||||
|
color: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Sparkles className="w-4 h-4" />,
|
||||||
|
title: "AI Extraction",
|
||||||
|
desc: "Advanced NLP models identify parties, obligations, risks, and key dates automatically.",
|
||||||
|
color: "primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap className="w-4 h-4" />,
|
||||||
|
title: "Instant Insights",
|
||||||
|
desc: "Get executive summaries and red-flag alerts within seconds of upload completion.",
|
||||||
|
color: "violet",
|
||||||
|
},
|
||||||
|
].map((feature, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={feature.title}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.4 + i * 0.05 }}
|
||||||
|
className="group relative rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-5 hover:bg-background/60 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-lg bg-${feature.color}-500/10 border border-${feature.color}-500/20 w-fit mb-3 text-${feature.color}-500`}
|
||||||
|
>
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-semibold mb-1">
|
||||||
|
{feature.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{feature.desc}
|
||||||
|
</p>
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground/30 absolute bottom-5 right-5 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
5
app/(dashboard)/not-found.tsx
Normal file
5
app/(dashboard)/not-found.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InvalidRouteScreen } from "@/components/layout/invalid-route-screen";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return <InvalidRouteScreen />;
|
||||||
|
}
|
||||||
53
app/api/contracts/[id]/download/route.ts
Normal file
53
app/api/contracts/[id]/download/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { ContractService } from "@/lib/services/contract.service";
|
||||||
|
|
||||||
|
const sanitizeFilename = (fileName: string): string => {
|
||||||
|
const cleaned = fileName.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||||
|
return cleaned || "contract";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId: clerkId } = await auth();
|
||||||
|
if (!clerkId) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!id) {
|
||||||
|
return new Response("Missing contract ID", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await ContractService.getById(id);
|
||||||
|
|
||||||
|
const upstream = await fetch(contract.fileUrl);
|
||||||
|
if (!upstream.ok) {
|
||||||
|
return new Response("Unable to fetch source file", { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await upstream.arrayBuffer();
|
||||||
|
const contentType =
|
||||||
|
contract.mimeType ||
|
||||||
|
upstream.headers.get("content-type") ||
|
||||||
|
"application/octet-stream";
|
||||||
|
const fileName = sanitizeFilename(contract.fileName);
|
||||||
|
const encodedFileName = encodeURIComponent(fileName);
|
||||||
|
|
||||||
|
return new Response(bytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": `attachment; filename="${fileName}"; filename*=UTF-8''${encodedFileName}`,
|
||||||
|
"Cache-Control": "private, no-store, no-cache, must-revalidate",
|
||||||
|
Pragma: "no-cache",
|
||||||
|
Expires: "0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Contract download error:", error);
|
||||||
|
return new Response("Failed to download contract", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/not-found.tsx
Normal file
5
app/not-found.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InvalidRouteScreen } from "@/components/layout/invalid-route-screen";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return <InvalidRouteScreen />;
|
||||||
|
}
|
||||||
93
components/layout/invalid-route-screen.tsx
Normal file
93
components/layout/invalid-route-screen.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Home, Compass } from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function InvalidRouteScreen() {
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen overflow-hidden bg-background text-foreground flex items-center justify-center">
|
||||||
|
{/* Ambient Background */}
|
||||||
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] animate-pulse" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] bg-violet-500/5 rounded-full blur-[100px] animate-pulse delay-1000" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-emerald-500/3 rounded-full blur-[140px]" />
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:64px_64px] [mask-image:radial-gradient(ellipse_80%_80%_at_50%_50%,#000_20%,transparent_100%)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="relative z-10 flex flex-col items-center justify-center px-6 py-16 text-center max-w-lg mx-auto">
|
||||||
|
{/* 404 Code */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8, filter: "blur(10px)" }}
|
||||||
|
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||||
|
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
className="relative mb-6"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-primary/30 via-violet-500/20 to-primary/30 blur-3xl rounded-full scale-150 animate-pulse" />
|
||||||
|
<h1 className="relative text-9xl sm:text-[10rem] font-bold tracking-tighter leading-none bg-gradient-to-b from-foreground via-foreground to-muted-foreground/20 bg-clip-text text-transparent select-none">
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.25, duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-border/50 bg-background/50 backdrop-blur-xl px-4 py-1.5 text-[11px] font-bold uppercase tracking-[0.25em] text-muted-foreground">
|
||||||
|
<Compass className="w-3.5 h-3.5" />
|
||||||
|
Page not found
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground">
|
||||||
|
This page doesn't exist
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm mx-auto">
|
||||||
|
The URL you entered doesn't match any known route. Double-check
|
||||||
|
the address or return to the homepage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.45, duration: 0.5 }}
|
||||||
|
className="pt-2"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="rounded-xl gap-2 h-11 px-6 shadow-lg shadow-primary/15 hover:shadow-primary/25 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Link href="/">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Decorative footer */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.7, duration: 0.8 }}
|
||||||
|
className="pt-16 flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<div className="h-px w-10 bg-gradient-to-r from-transparent to-border" />
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.3em] font-semibold text-muted-foreground/40">
|
||||||
|
LexiChain
|
||||||
|
</span>
|
||||||
|
<div className="h-px w-10 bg-gradient-to-l from-transparent to-border" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
### The Problem (in simple terms)
|
### The Problem (in simple terms)
|
||||||
|
|
||||||
When a client sends an insurance claim or uploads a contract, they need **proof** that they submitted it on a specific date. Without proof:
|
When a client sends an insurance claim or uploads a contract, they need **proof** that they submitted it on a specific date. Without proof:
|
||||||
|
|
||||||
- The insurance company could claim "we never received it"
|
- The insurance company could claim "we never received it"
|
||||||
- Deadlines could be disputed
|
- Deadlines could be disputed
|
||||||
- There's no transparency
|
- There's no transparency
|
||||||
@@ -40,6 +41,7 @@ When a client sends an insurance claim or uploads a contract, they need **proof*
|
|||||||
A **blockchain** is like a public, tamper-proof notebook. Once you write something in it, **nobody can erase or modify it** — not even the person who wrote it.
|
A **blockchain** is like a public, tamper-proof notebook. Once you write something in it, **nobody can erase or modify it** — not even the person who wrote it.
|
||||||
|
|
||||||
We use the blockchain as a **digital notary**:
|
We use the blockchain as a **digital notary**:
|
||||||
|
|
||||||
1. We take the uploaded contract PDF
|
1. We take the uploaded contract PDF
|
||||||
2. We create a unique **fingerprint** (hash) of that file
|
2. We create a unique **fingerprint** (hash) of that file
|
||||||
3. We write that fingerprint into the blockchain with a timestamp
|
3. We write that fingerprint into the blockchain with a timestamp
|
||||||
@@ -50,11 +52,13 @@ We use the blockchain as a **digital notary**:
|
|||||||
### What is a Smart Contract?
|
### What is a Smart Contract?
|
||||||
|
|
||||||
A **smart contract** is a program that runs on the blockchain. Think of it as a vending machine:
|
A **smart contract** is a program that runs on the blockchain. Think of it as a vending machine:
|
||||||
|
|
||||||
- You put in a coin (send a transaction)
|
- You put in a coin (send a transaction)
|
||||||
- The machine executes its programmed logic
|
- The machine executes its programmed logic
|
||||||
- The result is permanent and visible to everyone
|
- The result is permanent and visible to everyone
|
||||||
|
|
||||||
Our smart contract (`DocumentRegistry.sol`) has two main functions:
|
Our smart contract (`DocumentRegistry.sol`) has two main functions:
|
||||||
|
|
||||||
- **Register**: Store a document fingerprint with a timestamp
|
- **Register**: Store a document fingerprint with a timestamp
|
||||||
- **Verify**: Check if a fingerprint exists and when it was stored
|
- **Verify**: Check if a fingerprint exists and when it was stored
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@ Our smart contract (`DocumentRegistry.sol`) has two main functions:
|
|||||||
### Features Implemented
|
### Features Implemented
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
| ------------------------- | --------------------------------------------------------------------------- |
|
||||||
| **Auto-Registration** | After AI analyzes a contract, its hash is automatically registered on-chain |
|
| **Auto-Registration** | After AI analyzes a contract, its hash is automatically registered on-chain |
|
||||||
| **Manual Registration** | Users can register unregistered contracts via the Blockchain Explorer |
|
| **Manual Registration** | Users can register unregistered contracts via the Blockchain Explorer |
|
||||||
| **Document Verification** | Paste any document hash to check if it exists on-chain |
|
| **Document Verification** | Paste any document hash to check if it exists on-chain |
|
||||||
@@ -80,6 +84,7 @@ User uploads PDF → AI analyzes it → Blockchain registers the hash
|
|||||||
```
|
```
|
||||||
|
|
||||||
The entire flow is automatic. The user doesn't need:
|
The entire flow is automatic. The user doesn't need:
|
||||||
|
|
||||||
- ❌ MetaMask or any wallet
|
- ❌ MetaMask or any wallet
|
||||||
- ❌ Cryptocurrency knowledge
|
- ❌ Cryptocurrency knowledge
|
||||||
- ❌ To pay anything
|
- ❌ To pay anything
|
||||||
@@ -130,7 +135,7 @@ flowchart TD
|
|||||||
### Network Modes
|
### Network Modes
|
||||||
|
|
||||||
| Mode | When | URL | Cost |
|
| Mode | When | URL | Cost |
|
||||||
|------|------|-----|------|
|
| ----------- | ----------------- | ----------------------- | -------------- |
|
||||||
| **Hardhat** | Development | `http://127.0.0.1:8545` | Free (local) |
|
| **Hardhat** | Development | `http://127.0.0.1:8545` | Free (local) |
|
||||||
| **Sepolia** | Demo/Presentation | Via Alchemy/Infura RPC | Free (testnet) |
|
| **Sepolia** | Demo/Presentation | Via Alchemy/Infura RPC | Free (testnet) |
|
||||||
|
|
||||||
@@ -178,12 +183,14 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### `registerDocument(bytes32 _docHash)`
|
#### `registerDocument(bytes32 _docHash)`
|
||||||
|
|
||||||
- **Purpose**: Store a document hash on-chain
|
- **Purpose**: Store a document hash on-chain
|
||||||
- **Access**: Only the contract owner (our server wallet)
|
- **Access**: Only the contract owner (our server wallet)
|
||||||
- **Guard**: Prevents duplicate registration (same hash can't be registered twice)
|
- **Guard**: Prevents duplicate registration (same hash can't be registered twice)
|
||||||
- **Event**: Emits `DocumentRegistered` for off-chain indexing
|
- **Event**: Emits `DocumentRegistered` for off-chain indexing
|
||||||
|
|
||||||
#### `verifyDocument(bytes32 _docHash)`
|
#### `verifyDocument(bytes32 _docHash)`
|
||||||
|
|
||||||
- **Purpose**: Check if a hash exists and get its details
|
- **Purpose**: Check if a hash exists and get its details
|
||||||
- **Cost**: Free (read-only, no gas)
|
- **Cost**: Free (read-only, no gas)
|
||||||
- **Returns**: `(exists, timestamp, depositor)`
|
- **Returns**: `(exists, timestamp, depositor)`
|
||||||
@@ -231,6 +238,7 @@ flowchart LR
|
|||||||
### Why Server-Side?
|
### Why Server-Side?
|
||||||
|
|
||||||
Most blockchain dApps require users to install MetaMask and sign transactions. This is bad UX for a BFSI enterprise platform because:
|
Most blockchain dApps require users to install MetaMask and sign transactions. This is bad UX for a BFSI enterprise platform because:
|
||||||
|
|
||||||
- Users shouldn't need crypto knowledge
|
- Users shouldn't need crypto knowledge
|
||||||
- The platform manages documents, not individual users
|
- The platform manages documents, not individual users
|
||||||
- Server-side signing is more reliable
|
- Server-side signing is more reliable
|
||||||
@@ -261,7 +269,7 @@ sequenceDiagram
|
|||||||
### Key Methods
|
### Key Methods
|
||||||
|
|
||||||
| Method | Purpose | Gas Cost |
|
| Method | Purpose | Gas Cost |
|
||||||
|--------|---------|----------|
|
| ------------------------------------ | ------------------------------- | ---------------- |
|
||||||
| `hashDocument(fileUrl)` | Download file + compute SHA-256 | None (off-chain) |
|
| `hashDocument(fileUrl)` | Download file + compute SHA-256 | None (off-chain) |
|
||||||
| `registerOnChain(hash, fileName)` | Send tx to smart contract | ~50,000 gas |
|
| `registerOnChain(hash, fileName)` | Send tx to smart contract | ~50,000 gas |
|
||||||
| `verifyOnChain(hash)` | Read-only check | Free |
|
| `verifyOnChain(hash)` | Read-only check | Free |
|
||||||
@@ -424,6 +432,7 @@ sequenceDiagram
|
|||||||
participant SA as Server Action
|
participant SA as Server Action
|
||||||
participant AI as AI Service
|
participant AI as AI Service
|
||||||
participant BS as BlockchainService
|
participant BS as BlockchainService
|
||||||
|
participant ES as EmailService
|
||||||
participant SC as Smart Contract
|
participant SC as Smart Contract
|
||||||
participant DB as PostgreSQL
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
@@ -446,6 +455,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
SA->>DB: Save txHash, blockNumber, etc.
|
SA->>DB: Save txHash, blockNumber, etc.
|
||||||
SA->>DB: Create BlockchainTransaction
|
SA->>DB: Create BlockchainTransaction
|
||||||
|
SA->>ES: Send analysis + blockchain proof email
|
||||||
|
ES-->>U: Email received (or Ethereal preview in dev)
|
||||||
SA-->>UI: Success!
|
SA-->>UI: Success!
|
||||||
|
|
||||||
Note over U,UI: User visits /blockchain
|
Note over U,UI: User visits /blockchain
|
||||||
@@ -470,6 +481,7 @@ sequenceDiagram
|
|||||||
## 10. How to Run Locally
|
## 10. How to Run Locally
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js installed
|
- Node.js installed
|
||||||
- The Next.js app running (`npm run dev`)
|
- The Next.js app running (`npm run dev`)
|
||||||
|
|
||||||
@@ -562,6 +574,7 @@ npx hardhat run scripts/deploy.ts --network sepolia
|
|||||||
### Step 5: Verify on Etherscan
|
### Step 5: Verify on Etherscan
|
||||||
|
|
||||||
After deploying, transactions will have real Etherscan links:
|
After deploying, transactions will have real Etherscan links:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://sepolia.etherscan.io/tx/0x...
|
https://sepolia.etherscan.io/tx/0x...
|
||||||
```
|
```
|
||||||
@@ -571,7 +584,7 @@ https://sepolia.etherscan.io/tx/0x...
|
|||||||
## 12. Technology Choices & Rationale
|
## 12. Technology Choices & Rationale
|
||||||
|
|
||||||
| Technology | Why We Chose It |
|
| Technology | Why We Chose It |
|
||||||
|-----------|----------------|
|
| ------------------------ | ----------------------------------------------------------------- |
|
||||||
| **Solidity 0.8.24** | Latest stable version with built-in overflow protection |
|
| **Solidity 0.8.24** | Latest stable version with built-in overflow protection |
|
||||||
| **Hardhat** | Industry standard for Solidity development, free local blockchain |
|
| **Hardhat** | Industry standard for Solidity development, free local blockchain |
|
||||||
| **ethers.js v6** | Modern, lightweight, TypeScript-native Ethereum library |
|
| **ethers.js v6** | Modern, lightweight, TypeScript-native Ethereum library |
|
||||||
@@ -583,6 +596,7 @@ https://sepolia.etherscan.io/tx/0x...
|
|||||||
### Why NOT Web3j / Java?
|
### Why NOT Web3j / Java?
|
||||||
|
|
||||||
The original project spec suggested Web3j (Java library). We chose ethers.js instead because:
|
The original project spec suggested Web3j (Java library). We chose ethers.js instead because:
|
||||||
|
|
||||||
1. Our backend is **Next.js/TypeScript**, not Spring Boot
|
1. Our backend is **Next.js/TypeScript**, not Spring Boot
|
||||||
2. ethers.js has **better TypeScript support** and is more actively maintained
|
2. ethers.js has **better TypeScript support** and is more actively maintained
|
||||||
3. Both libraries do the same job — interact with Ethereum — but ethers.js is native to our stack
|
3. Both libraries do the same job — interact with Ethereum — but ethers.js is native to our stack
|
||||||
@@ -592,8 +606,9 @@ The original project spec suggested Web3j (Java library). We chose ethers.js ins
|
|||||||
## 13. File Reference
|
## 13. File Reference
|
||||||
|
|
||||||
### Smart Contract Layer
|
### Smart Contract Layer
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| ------------------------------------------- | ----------------------- |
|
||||||
| `blockchain/contracts/DocumentRegistry.sol` | Solidity smart contract |
|
| `blockchain/contracts/DocumentRegistry.sol` | Solidity smart contract |
|
||||||
| `blockchain/test/DocumentRegistry.test.ts` | 14 comprehensive tests |
|
| `blockchain/test/DocumentRegistry.test.ts` | 14 comprehensive tests |
|
||||||
| `blockchain/scripts/deploy.ts` | Deployment script |
|
| `blockchain/scripts/deploy.ts` | Deployment script |
|
||||||
@@ -601,32 +616,37 @@ The original project spec suggested Web3j (Java library). We chose ethers.js ins
|
|||||||
| `blockchain/package.json` | Hardhat dependencies |
|
| `blockchain/package.json` | Hardhat dependencies |
|
||||||
|
|
||||||
### Service Layer
|
### Service Layer
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| ------------------------------------ | ---------------------------- |
|
||||||
| `lib/services/blockchain.service.ts` | Core blockchain interactions |
|
| `lib/services/blockchain.service.ts` | Core blockchain interactions |
|
||||||
| `lib/services/blockchain.types.ts` | TypeScript type definitions |
|
| `lib/services/blockchain.types.ts` | TypeScript type definitions |
|
||||||
|
|
||||||
### Server Actions
|
### Server Actions
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| ---------------------------------------------- | ------------------------------ |
|
||||||
| `features/blockchain/api/blockchain.action.ts` | Blockchain server actions |
|
| `features/blockchain/api/blockchain.action.ts` | Blockchain server actions |
|
||||||
| `features/contracts/api/contract.action.ts` | Updated with auto-registration |
|
| `features/contracts/api/contract.action.ts` | Updated with auto-registration |
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| --------------------------------------- | ---------------------------- |
|
||||||
| `app/(dashboard)/blockchain/page.tsx` | Blockchain Explorer page |
|
| `app/(dashboard)/blockchain/page.tsx` | Blockchain Explorer page |
|
||||||
| `app/(dashboard)/blockchain/layout.tsx` | Page metadata |
|
| `app/(dashboard)/blockchain/layout.tsx` | Page metadata |
|
||||||
| `components/layout/navigation.tsx` | Updated with blockchain link |
|
| `components/layout/navigation.tsx` | Updated with blockchain link |
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| ---------------------- | ------------------------------ |
|
||||||
| `prisma/schema.prisma` | Updated with blockchain fields |
|
| `prisma/schema.prisma` | Updated with blockchain fields |
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| -------------- | ----------------------------- |
|
||||||
| `.env` | Blockchain env vars |
|
| `.env` | Blockchain env vars |
|
||||||
| `.env.example` | Template for new developers |
|
| `.env.example` | Template for new developers |
|
||||||
| `.gitignore` | Blockchain artifacts excluded |
|
| `.gitignore` | Blockchain artifacts excluded |
|
||||||
@@ -636,7 +656,7 @@ The original project spec suggested Web3j (Java library). We chose ethers.js ins
|
|||||||
## Glossary
|
## Glossary
|
||||||
|
|
||||||
| Term | Definition |
|
| Term | Definition |
|
||||||
|------|-----------|
|
| -------------------- | -------------------------------------------------------------------- |
|
||||||
| **Hash** | A fixed-size fingerprint of data. Same input → same output. |
|
| **Hash** | A fixed-size fingerprint of data. Same input → same output. |
|
||||||
| **SHA-256** | A specific hash algorithm producing 256-bit (32-byte) outputs |
|
| **SHA-256** | A specific hash algorithm producing 256-bit (32-byte) outputs |
|
||||||
| **Smart Contract** | A program stored on the blockchain that executes automatically |
|
| **Smart Contract** | A program stored on the blockchain that executes automatically |
|
||||||
|
|||||||
80
docs/lexichain-full-system.md
Normal file
80
docs/lexichain-full-system.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# LexiChain: Intelligent BFSI Contract Management System
|
||||||
|
|
||||||
|
## 🌟 The Platform Vision
|
||||||
|
**LexiChain** is a state-of-the-art enterprise platform designed specifically for the **BFSI** (Banking, Financial Services, and Insurance) sector. The core objective is to solve the historical problem of "Information Silos" and "Trust Gaps" in contract management.
|
||||||
|
|
||||||
|
In the traditional world, insurance policies and bank loans are long, complex, and opaque. LexiChain uses **Generative AI** to make these documents "conversational" and **Blockchain Technology** to make them "tamper-proof."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ System Architecture
|
||||||
|
The platform follows a **Modular Full-Stack Architecture** designed for scalability, security, and high performance. It is divided into three distinct layers:
|
||||||
|
|
||||||
|
### 1. The Presentation Layer (Frontend)
|
||||||
|
Built with **React and Next.js**, the interface provides a "Premium Executive" experience. It is fully responsive, theme-aware, and designed for high-density information display. It uses **Server Components** for fast loading and **Client Components** for interactive elements like the AI Chat and Blockchain Explorer.
|
||||||
|
|
||||||
|
### 2. The Intelligence & Processing Layer (Backend)
|
||||||
|
This is the "Brain" of LexiChain. It handles:
|
||||||
|
* **Authentication**: Managed by **Clerk**, providing enterprise-grade security and multi-factor authentication.
|
||||||
|
* **File Orchestration**: Securely handling document uploads and cloud storage.
|
||||||
|
* **AI Pipeline**: Converting raw PDF data into structured knowledge.
|
||||||
|
* **Blockchain Bridge**: Acting as a middleware between the web app and the decentralized network.
|
||||||
|
|
||||||
|
### 3. The Persistence Layer (Database)
|
||||||
|
We use a **PostgreSQL** database managed by **Prisma ORM**. This stores all user metadata, contract details, and the historical "Audit Trail" of blockchain transactions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Core Pillar 1: AI & Retrieval-Augmented Generation (RAG)
|
||||||
|
LexiChain doesn't just "read" your contracts; it "understands" them. We implement a pattern called **RAG (Retrieval-Augmented Generation)**.
|
||||||
|
|
||||||
|
### How it works:
|
||||||
|
1. **Ingestion & Parsing**: When a contract is uploaded, our AI service (powered by **Google Gemini**) breaks the document down into small "semantic chunks."
|
||||||
|
2. **Vector Indexing**: These chunks are indexed based on their meaning.
|
||||||
|
3. **Contextual Retrieval**: When you ask a question like *"Does this policy cover water damage?"*, the system doesn't search for keywords. It searches for **Concepts**.
|
||||||
|
4. **Informed Response**: The AI retrieves the relevant sections of your contract and uses them as "facts" to generate a precise, grounded answer. This eliminates "hallucinations" and ensures 100% accuracy based on your actual document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Core Pillar 2: The Blockchain Trust Layer
|
||||||
|
In the BFSI industry, "when" and "what" was signed is everything. LexiChain uses an **Ethereum-based Smart Contract** to establish absolute trust.
|
||||||
|
|
||||||
|
### The Problem it Solves:
|
||||||
|
If a user and a bank have a dispute, the bank could theoretically change the digital contract in their database. LexiChain prevents this through **Immutable Proof-of-Deposit**.
|
||||||
|
|
||||||
|
### Key Concepts Implemented:
|
||||||
|
* **Cryptographic Fingerprinting (Hashing)**: We generate a unique SHA-256 hash of the contract. This fingerprint is mathematically tied to every single character in the document.
|
||||||
|
* **Smart Contract Execution**: The platform automatically sends this fingerprint to a **Solidity Smart Contract** on the blockchain.
|
||||||
|
* **Immutable Timestamping**: Once the transaction is "mined," it is given a permanent timestamp by the network. This provides an **indisputable proof** that the document existed in that exact state on that specific date.
|
||||||
|
* **Decentralized Verification**: Anyone with the file can verify it against the blockchain record. If even one comma is changed in the PDF, the verification will fail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 The Integrated Workflow (The App Journey)
|
||||||
|
1. **Upload**: The user securely uploads a contract (Insurance policy, Loan agreement, etc.).
|
||||||
|
2. **AI Extraction**: The AI immediately extracts key data points (Expiration date, Total value, Involved parties) to populate the dashboard.
|
||||||
|
3. **Semantic Indexing**: The document is prepared for the RAG-based Chat interface.
|
||||||
|
4. **On-Chain Registration**: Simultaneously, the system computes the document's hash and registers it on the blockchain.
|
||||||
|
5. **Interaction**: The user can now "Chat" with their document or verify its "Blockchain Status" via the Explorer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ The Technology Stack
|
||||||
|
* **Frontend/Backend Framework**: Next.js 15+ (App Router).
|
||||||
|
* **Styling**: TailwindCSS with Custom Framer Motion animations.
|
||||||
|
* **Database**: PostgreSQL with Prisma ORM.
|
||||||
|
* **AI Engine**: Google Gemini Pro (Vision & Text).
|
||||||
|
* **Blockchain Environment**: Hardhat (Local) & Sepolia (Public Testnet).
|
||||||
|
* **Smart Contract Language**: Solidity 0.8.24.
|
||||||
|
* **Blockchain Integration**: Ethers.js v6.
|
||||||
|
* **File Storage**: UploadThing.
|
||||||
|
* **Security/Auth**: Clerk Auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Software Engineering Principles Used
|
||||||
|
* **Separation of Concerns**: The AI, Blockchain, and Core Business logic are kept in separate services to prevent "God Objects."
|
||||||
|
* **Idempotency**: Blockchain registrations are designed to be idempotent (you can't register the same hash twice).
|
||||||
|
* **Graceful Degradation**: If the blockchain network is down, the AI and Core App features continue to work normally.
|
||||||
|
* **Data Integrity**: Using SHA-256 ensures that the data being audited is exactly the data that was signed.
|
||||||
|
* **Scalability**: The RAG architecture allows the system to handle thousands of documents without slowing down the AI responses.
|
||||||
85
docs/project-technical-overview.md
Normal file
85
docs/project-technical-overview.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# LexiChain — Technical Platform Overview
|
||||||
|
|
||||||
|
## 1. Executive Summary: What is LexiChain?
|
||||||
|
**LexiChain** is an advanced intelligence platform specifically designed for the **BFSI** (Banking, Financial Services, and Insurance) sector. It transforms complex, opaque legal documents into interactive, actionable data using a combination of **Generative AI** and **Blockchain Technology**.
|
||||||
|
|
||||||
|
The core mission of LexiChain is to solve the "Black Box" problem in contracts: where clients and institutions often sign long documents without fully understanding the hidden risks, obligations, or deadlines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The Core Problem & Solution
|
||||||
|
### The Problem
|
||||||
|
* **Cognitive Overload**: Insurance and banking contracts are filled with "Legalese"—dense, technical language that is difficult for non-experts to parse.
|
||||||
|
* **Lack of Trust**: There is no easy way to prove that a document hasn't been modified after signing.
|
||||||
|
* **Static Data**: Traditional PDFs are "dead" files. You cannot ask a PDF a question like *"What happens if I miss a payment by 3 days?"*
|
||||||
|
|
||||||
|
### The LexiChain Solution
|
||||||
|
LexiChain creates a **"Living Document"** environment. It uses AI to extract meaning and Blockchain to guarantee integrity, allowing users to converse with their contracts in natural language.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. System Architecture
|
||||||
|
LexiChain is built using a modern **Distributed Architecture** composed of four primary layers:
|
||||||
|
|
||||||
|
### A. The Client Layer (Frontend)
|
||||||
|
Built with **Next.js 15** and **Tailwind CSS**. It focuses on **User Experience (UX)**, providing a dashboard that works seamlessly on both desktop and mobile. It handles the secure transmission of files to the backend.
|
||||||
|
|
||||||
|
### B. The Application Layer (Backend)
|
||||||
|
This is the "Brain" of the system, powered by **Next.js Server Actions**. It coordinates the flow of data between the user, the database, the AI models, and the blockchain network. It manages authentication, file storage, and the processing pipeline.
|
||||||
|
|
||||||
|
### C. The Intelligence Layer (AI & RAG)
|
||||||
|
This layer uses **Gemini 1.5 Pro** and **Mistral AI** for high-speed analysis. Instead of just "reading" text, it uses a **Vector Database** to perform Retrieval-Augmented Generation (RAG), ensuring the AI answers only based on the specific facts found in the uploaded contract.
|
||||||
|
|
||||||
|
### D. The Trust Layer (Blockchain)
|
||||||
|
A decentralized layer powered by **Ethereum/Hardhat**. It creates a unique cryptographic "fingerprint" (hash) for every contract. Once recorded, this fingerprint becomes an immutable proof of the document's existence and original state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. How the Application Works (The Pipeline)
|
||||||
|
|
||||||
|
1. **Intake**: The user uploads a contract (PDF/Image).
|
||||||
|
2. **OCR & Parsing**: The system converts the document into machine-readable text.
|
||||||
|
3. **Semantic Chunking**: The text is broken down into small "concepts" or chunks.
|
||||||
|
4. **AI Analysis**: The AI extracts key metadata (Dates, Parties, Obligations, Risks).
|
||||||
|
5. **Blockchain Certification**: The document hash is sent to a Smart Contract to lock in the "Proof of Deposit."
|
||||||
|
6. **RAG Indexing**: The chunks are stored in a specialized index for the Chat interface.
|
||||||
|
7. **Interaction**: The user can now ask questions, view the blockchain proof, or check their dashboard for upcoming contract deadlines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Deep Dive: RAG (Retrieval-Augmented Generation)
|
||||||
|
### What is it?
|
||||||
|
In simple terms, RAG is like giving the AI a **"Open Book Exam."**
|
||||||
|
|
||||||
|
Most AI models rely on what they learned during training (which might be old or generic). With RAG, when you ask a question, the system first **searches** your specific contract for the relevant paragraphs, **retrieves** them, and then **gives** them to the AI to summarize.
|
||||||
|
|
||||||
|
### Why use it in BFSI?
|
||||||
|
* **Zero Hallucination**: The AI is forbidden from "guessing." If the answer isn't in your contract, it says "I don't know."
|
||||||
|
* **Contextual Accuracy**: It understands the difference between a "Home Loan" in 2010 vs. a "Car Insurance" in 2024 because it only looks at the specific context of your file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deep Dive: Blockchain & Trust
|
||||||
|
### The Digital Notary
|
||||||
|
In the BFSI world, dates and integrity are everything. If a claim is denied because of a "deadline," the user needs proof that they held the document on time.
|
||||||
|
|
||||||
|
### How it works technically:
|
||||||
|
1. **Hashing**: We turn your PDF into a 64-character string called a "Hash." Even changing a single comma in the PDF would result in a completely different hash.
|
||||||
|
2. **Immutability**: Once this hash is written into our **Solidity Smart Contract**, it can never be deleted or changed by anyone—not even the platform administrators.
|
||||||
|
3. **Verification**: At any time, a user can "Verify" their document. The system re-hashes the file and compares it to the blockchain. If they match, the document is **Genuine**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. The Technology Stack (Summary)
|
||||||
|
|
||||||
|
* **Frontend**: Next.js (React), Tailwind CSS, Lucide Icons, Framer Motion.
|
||||||
|
* **Backend**: TypeScript, Prisma ORM, Server Actions.
|
||||||
|
* **Database**: PostgreSQL (Neon) for metadata, Vector Storage for AI.
|
||||||
|
* **AI**: Google Gemini (Large Language Model), Mistral AI (Fallback with Pixtral Vision).
|
||||||
|
* **Blockchain**: Solidity (Smart Contracts), Hardhat (Local Node), Ethers.js (Integration).
|
||||||
|
* **Storage**: UploadThing (Secure File Hosting).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Conclusion
|
||||||
|
LexiChain is not just a document viewer; it is a **Decision Support System**. By combining the analytical power of AI with the structural trust of Blockchain, it bridges the gap between complex legal documents and clear, verifiable human understanding.
|
||||||
@@ -23,23 +23,57 @@ type StatusData = Array<{ name: string; count: number }>;
|
|||||||
|
|
||||||
const PIE_COLORS: Record<string, string> = {
|
const PIE_COLORS: Record<string, string> = {
|
||||||
Uploaded: "hsl(38 92% 50%)",
|
Uploaded: "hsl(38 92% 50%)",
|
||||||
Processing: "hsl(var(--primary))",
|
Processing: "hsl(217 91% 60%)",
|
||||||
Analyzed: "hsl(160 84% 39%)",
|
Analyzed: "hsl(160 84% 39%)",
|
||||||
Failed: "hsl(var(--destructive))",
|
Failed: "hsl(0 84% 60%)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK_COLORS = [
|
const FALLBACK_COLORS = [
|
||||||
"hsl(var(--primary))",
|
"hsl(217 91% 60%)",
|
||||||
"hsl(var(--secondary))",
|
"hsl(260 89% 65%)",
|
||||||
"hsl(var(--accent))",
|
"hsl(190 85% 50%)",
|
||||||
"hsl(var(--destructive))",
|
"hsl(340 82% 52%)",
|
||||||
];
|
];
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
backgroundColor: "hsl(var(--background))",
|
backgroundColor: "hsl(var(--background) / 0.95)",
|
||||||
border: "1px solid hsl(var(--border))",
|
border: "1px solid hsl(var(--border) / 0.6)",
|
||||||
borderRadius: "12px",
|
borderRadius: "16px",
|
||||||
color: "hsl(var(--foreground))",
|
color: "hsl(var(--foreground))",
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
|
||||||
|
padding: "12px 16px",
|
||||||
|
fontSize: "13px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={tooltipStyle} className="space-y-1.5 min-w-[140px]">
|
||||||
|
{label && (
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground border-b border-border/40 pb-1.5 mb-1.5">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{payload.map((entry: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-foreground tabular-nums">
|
||||||
|
{typeof entry.value === "number"
|
||||||
|
? entry.value.toLocaleString()
|
||||||
|
: entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TrendChart({ data }: { data: TrendData }) {
|
export function TrendChart({ data }: { data: TrendData }) {
|
||||||
@@ -72,64 +106,82 @@ export function TrendChart({ data }: { data: TrendData }) {
|
|||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="0%"
|
||||||
stopColor="hsl(var(--primary))"
|
stopColor="hsl(217 91% 60%)"
|
||||||
stopOpacity={0.65}
|
stopOpacity={0.5}
|
||||||
/>
|
/>
|
||||||
<stop
|
<stop
|
||||||
offset="95%"
|
offset="60%"
|
||||||
stopColor="hsl(var(--primary))"
|
stopColor="hsl(217 91% 60%)"
|
||||||
stopOpacity={0.05}
|
stopOpacity={0.15}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor="hsl(217 91% 60%)"
|
||||||
|
stopOpacity={0.02}
|
||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="trendStroke" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="hsl(217 91% 60%)" />
|
||||||
|
<stop offset="100%" stopColor="hsl(260 89% 65%)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="avgStroke" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="hsl(260 89% 65%)" />
|
||||||
|
<stop offset="100%" stopColor="hsl(190 85% 50%)" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="coloredBlur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 6"
|
||||||
stroke="hsl(var(--border))"
|
stroke="hsl(var(--border) / 0.4)"
|
||||||
vertical={false}
|
vertical={false}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--muted-foreground) / 0.5)"
|
||||||
interval={xAxisInterval}
|
interval={xAxisInterval}
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 11, fontWeight: 500 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--muted-foreground) / 0.5)"
|
||||||
allowDecimals={false}
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11, fontWeight: 500 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip content={<CustomTooltip />} />
|
||||||
contentStyle={tooltipStyle}
|
|
||||||
formatter={(
|
|
||||||
value: number | string | undefined,
|
|
||||||
name: string | number | undefined,
|
|
||||||
) => {
|
|
||||||
const numericValue = Number(value ?? 0);
|
|
||||||
if (name === "movingAverage") {
|
|
||||||
return [numericValue.toFixed(1), "7-day avg"];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [numericValue, "Uploads"];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
stroke="hsl(var(--primary))"
|
stroke="url(#trendStroke)"
|
||||||
strokeWidth={2.25}
|
strokeWidth={2.5}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
fill="url(#trendFill)"
|
fill="url(#trendFill)"
|
||||||
activeDot={{ r: 5 }}
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
stroke: "hsl(var(--background))",
|
||||||
|
strokeWidth: 3,
|
||||||
|
fill: "hsl(217 91% 60%)",
|
||||||
|
filter: "url(#glow)",
|
||||||
|
}}
|
||||||
|
dot={false}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="movingAverage"
|
dataKey="movingAverage"
|
||||||
stroke="hsl(var(--secondary))"
|
stroke="url(#avgStroke)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
strokeDasharray="6 4"
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -152,43 +204,65 @@ export function ContractTypeChart({ data }: { data: TypeData }) {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||||
>
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="barGradient" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="hsl(217 91% 60%)" />
|
||||||
|
<stop offset="100%" stopColor="hsl(260 89% 65%)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="barGradient2" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="hsl(260 89% 65%)" />
|
||||||
|
<stop offset="100%" stopColor="hsl(190 85% 50%)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="barGradient3" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="hsl(190 85% 50%)" />
|
||||||
|
<stop offset="100%" stopColor="hsl(340 82% 52%)" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="barGradient4" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor="hsl(340 82% 52%)" />
|
||||||
|
<stop offset="100%" stopColor="hsl(38 92% 50%)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 6"
|
||||||
stroke="hsl(var(--border))"
|
stroke="hsl(var(--border) / 0.4)"
|
||||||
horizontal={false}
|
horizontal={false}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--muted-foreground) / 0.5)"
|
||||||
allowDecimals={false}
|
allowDecimals={false}
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 11, fontWeight: 500 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="type"
|
dataKey="type"
|
||||||
width={128}
|
width={128}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--muted-foreground) / 0.5)"
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 11, fontWeight: 600 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={tooltipStyle}
|
content={<CustomTooltip />}
|
||||||
cursor={false}
|
cursor={{ fill: "hsl(var(--muted) / 0.15)", radius: 8 }}
|
||||||
formatter={(value: number | string | undefined) => [
|
|
||||||
Number(value ?? 0),
|
|
||||||
"Files",
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="count" radius={[0, 8, 8, 0]}>
|
<Bar dataKey="count" radius={[0, 10, 10, 0]} maxBarSize={32}>
|
||||||
{sortedData.map((item, index) => {
|
{sortedData.map((item, index) => {
|
||||||
const opacity = Math.max(0.35, 0.95 - index * 0.12);
|
const gradients = [
|
||||||
|
"url(#barGradient)",
|
||||||
|
"url(#barGradient2)",
|
||||||
|
"url(#barGradient3)",
|
||||||
|
"url(#barGradient4)",
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<Cell
|
<Cell
|
||||||
key={`${item.type}-${index}`}
|
key={`${item.type}-${index}`}
|
||||||
fill={`hsl(var(--primary) / ${opacity})`}
|
fill={gradients[index % gradients.length]}
|
||||||
|
className="transition-all duration-300 hover:opacity-80"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -210,16 +284,26 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
|
|||||||
<div className="h-[76%] w-full">
|
<div className="h-[76%] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
|
<defs>
|
||||||
|
<filter id="pieGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="4" result="coloredBlur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="coloredBlur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={62}
|
innerRadius={58}
|
||||||
outerRadius={94}
|
outerRadius={88}
|
||||||
paddingAngle={3}
|
paddingAngle={4}
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
stroke="hsl(var(--background))"
|
stroke="hsl(var(--background))"
|
||||||
strokeWidth={2}
|
strokeWidth={3}
|
||||||
|
cornerRadius={6}
|
||||||
>
|
>
|
||||||
{data.map((entry, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
@@ -228,44 +312,40 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
|
|||||||
PIE_COLORS[entry.name] ??
|
PIE_COLORS[entry.name] ??
|
||||||
FALLBACK_COLORS[index % FALLBACK_COLORS.length]
|
FALLBACK_COLORS[index % FALLBACK_COLORS.length]
|
||||||
}
|
}
|
||||||
|
className="transition-all duration-300 hover:opacity-90"
|
||||||
|
style={{ filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.1))" }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<text
|
<text
|
||||||
x="50%"
|
x="50%"
|
||||||
y="50%"
|
y="48%"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
>
|
>
|
||||||
<tspan
|
<tspan
|
||||||
x="50%"
|
x="50%"
|
||||||
y="50%"
|
y="48%"
|
||||||
className="fill-foreground text-base font-semibold"
|
className="fill-foreground text-xl font-bold tracking-tight"
|
||||||
>
|
>
|
||||||
{total}
|
{total.toLocaleString()}
|
||||||
</tspan>
|
</tspan>
|
||||||
<tspan
|
<tspan
|
||||||
x="50%"
|
x="50%"
|
||||||
dy="16"
|
dy="18"
|
||||||
className="fill-muted-foreground text-[11px]"
|
className="fill-muted-foreground text-[11px] font-medium uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Files
|
Files
|
||||||
</tspan>
|
</tspan>
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
<Tooltip
|
<Tooltip content={<CustomTooltip />} />
|
||||||
contentStyle={tooltipStyle}
|
|
||||||
formatter={(value: number | string | undefined) => [
|
|
||||||
Number(value ?? 0),
|
|
||||||
"Files",
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
<div className="grid grid-cols-2 gap-2.5 pt-2">
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
const color =
|
const color =
|
||||||
PIE_COLORS[item.name] ??
|
PIE_COLORS[item.name] ??
|
||||||
@@ -274,16 +354,16 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${item.name}-legend`}
|
key={`${item.name}-legend`}
|
||||||
className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 px-2.5 py-1.5"
|
className="group flex items-center gap-2.5 rounded-xl border border-border/40 bg-background/40 backdrop-blur-md px-3 py-2 hover:bg-background/60 hover:border-border/60 transition-all cursor-default"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
className="h-2.5 w-2.5 rounded-full ring-2 ring-offset-1 ring-offset-background"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color, "--tw-ring-color": color } as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-muted-foreground truncate">
|
<span className="text-[11px] text-muted-foreground truncate font-medium">
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto text-[11px] font-medium text-foreground">
|
<span className="ml-auto text-[11px] font-bold text-foreground tabular-nums">
|
||||||
{item.count}
|
{item.count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
import { clerkClient } from "@clerk/nextjs/server";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
ContractService,
|
ContractService,
|
||||||
@@ -29,6 +30,7 @@ import { AIService } from "@/lib/services/ai.service";
|
|||||||
import { RAGService } from "@/lib/services/rag.service";
|
import { RAGService } from "@/lib/services/rag.service";
|
||||||
import { NotificationService } from "@/lib/services/notification.service";
|
import { NotificationService } from "@/lib/services/notification.service";
|
||||||
import { BlockchainService } from "@/lib/services/blockchain.service";
|
import { BlockchainService } from "@/lib/services/blockchain.service";
|
||||||
|
import { EmailService } from "@/lib/services/email.service";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types";
|
import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types";
|
||||||
|
|
||||||
@@ -209,7 +211,9 @@ export async function getContracts(filters?: Record<string, unknown>) {
|
|||||||
documentHash: contract.documentHash || null,
|
documentHash: contract.documentHash || null,
|
||||||
txHash: contract.txHash || null,
|
txHash: contract.txHash || null,
|
||||||
blockNumber: contract.blockNumber || null,
|
blockNumber: contract.blockNumber || null,
|
||||||
blockTimestamp: contract.blockTimestamp ? contract.blockTimestamp.toISOString() : null,
|
blockTimestamp: contract.blockTimestamp
|
||||||
|
? contract.blockTimestamp.toISOString()
|
||||||
|
: null,
|
||||||
blockchainNetwork: contract.blockchainNetwork || null,
|
blockchainNetwork: contract.blockchainNetwork || null,
|
||||||
contractAddress: contract.contractAddress || null,
|
contractAddress: contract.contractAddress || null,
|
||||||
}));
|
}));
|
||||||
@@ -517,6 +521,16 @@ export async function analyzeContractAction(id: string) {
|
|||||||
keyPoints: keyPointsWithLearning,
|
keyPoints: keyPointsWithLearning,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let blockchainEmailData: {
|
||||||
|
documentHash: string;
|
||||||
|
txHash: string;
|
||||||
|
blockNumber: number;
|
||||||
|
blockTimestamp: Date;
|
||||||
|
network: string;
|
||||||
|
contractAddress: string;
|
||||||
|
explorerUrl: string | null;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
// BLOCKCHAIN: Auto-register document on-chain
|
// BLOCKCHAIN: Auto-register document on-chain
|
||||||
// This is non-blocking — if blockchain fails, analysis still succeeds
|
// This is non-blocking — if blockchain fails, analysis still succeeds
|
||||||
@@ -525,7 +539,7 @@ export async function analyzeContractAction(id: string) {
|
|||||||
if (BlockchainService.isConfigured()) {
|
if (BlockchainService.isConfigured()) {
|
||||||
const proof = await BlockchainService.hashAndRegister(
|
const proof = await BlockchainService.hashAndRegister(
|
||||||
contract.fileUrl,
|
contract.fileUrl,
|
||||||
contract.fileName
|
contract.fileName,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save blockchain proof to the contract record
|
// Save blockchain proof to the contract record
|
||||||
@@ -556,7 +570,19 @@ export async function analyzeContractAction(id: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`🔗 Blockchain proof stored: ${proof.txHash.slice(0, 16)}...`);
|
blockchainEmailData = {
|
||||||
|
documentHash: proof.documentHash,
|
||||||
|
txHash: proof.txHash,
|
||||||
|
blockNumber: proof.blockNumber,
|
||||||
|
blockTimestamp: proof.blockTimestamp,
|
||||||
|
network: proof.network,
|
||||||
|
contractAddress: proof.contractAddress,
|
||||||
|
explorerUrl: proof.explorerUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🔗 Blockchain proof stored: ${proof.txHash.slice(0, 16)}...`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (blockchainError) {
|
} catch (blockchainError) {
|
||||||
// Blockchain failure should NOT fail the analysis
|
// Blockchain failure should NOT fail the analysis
|
||||||
@@ -581,6 +607,71 @@ export async function analyzeContractAction(id: string) {
|
|||||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Email summary + blockchain proof (non-blocking)
|
||||||
|
try {
|
||||||
|
let recipientEmail = user.email;
|
||||||
|
|
||||||
|
if (!recipientEmail) {
|
||||||
|
const clerk = await clerkClient();
|
||||||
|
const clerkUser = await clerk.users.getUser(clerkId);
|
||||||
|
recipientEmail =
|
||||||
|
clerkUser.emailAddresses.find(
|
||||||
|
(address) => address.id === clerkUser.primaryEmailAddressId,
|
||||||
|
)?.emailAddress ??
|
||||||
|
clerkUser.emailAddresses[0]?.emailAddress ??
|
||||||
|
"";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipientEmail) {
|
||||||
|
const premiumValue =
|
||||||
|
aiResults.premium === null || aiResults.premium === undefined
|
||||||
|
? null
|
||||||
|
: aiResults.premium;
|
||||||
|
|
||||||
|
const keyPointsRecord =
|
||||||
|
typeof keyPointsWithLearning === "object" &&
|
||||||
|
keyPointsWithLearning !== null
|
||||||
|
? (keyPointsWithLearning as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await EmailService.sendContractAnalysisCompletedEmail({
|
||||||
|
to: recipientEmail,
|
||||||
|
userDisplayName:
|
||||||
|
`${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || null,
|
||||||
|
contractId: id,
|
||||||
|
contractFileName: contract.fileName,
|
||||||
|
contractTitle: aiResults.title,
|
||||||
|
blueprint: {
|
||||||
|
type: aiResults.type,
|
||||||
|
provider: aiResults.provider ?? null,
|
||||||
|
policyNumber: aiResults.policyNumber ?? null,
|
||||||
|
startDate: aiResults.startDate ?? null,
|
||||||
|
endDate: aiResults.endDate ?? null,
|
||||||
|
premium: premiumValue,
|
||||||
|
premiumCurrency:
|
||||||
|
aiAnalysis.premiumCurrency ??
|
||||||
|
(keyPointsRecord?.aiMeta &&
|
||||||
|
typeof keyPointsRecord.aiMeta === "object" &&
|
||||||
|
keyPointsRecord.aiMeta !== null &&
|
||||||
|
"premiumCurrency" in keyPointsRecord.aiMeta
|
||||||
|
? String(
|
||||||
|
(keyPointsRecord.aiMeta as Record<string, unknown>)
|
||||||
|
.premiumCurrency ?? "",
|
||||||
|
) || null
|
||||||
|
: null),
|
||||||
|
summary: aiResults.summary,
|
||||||
|
},
|
||||||
|
blockchain: blockchainEmailData,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ Contract analysis email skipped: no recipient email found for user ${user.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (emailError) {
|
||||||
|
console.warn("⚠️ Contract analysis email skipped:", emailError);
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/contacts");
|
revalidatePath("/contacts");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Info,
|
Info,
|
||||||
Network,
|
Network,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
FileIcon,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar,
|
||||||
|
HardDrive,
|
||||||
|
Tag,
|
||||||
|
FileType,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -57,6 +65,7 @@ import {
|
|||||||
exportToCSV,
|
exportToCSV,
|
||||||
exportToPDF,
|
exportToPDF,
|
||||||
} from "@/features/contracts/utils/export.utils";
|
} from "@/features/contracts/utils/export.utils";
|
||||||
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
|
|
||||||
interface Contract {
|
interface Contract {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -918,37 +927,77 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
|
|
||||||
const getFileIcon = (mimeType: string) => {
|
const getFileIcon = (mimeType: string) => {
|
||||||
if (mimeType.startsWith("image/")) {
|
if (mimeType.startsWith("image/")) {
|
||||||
return "🖼️";
|
return <FileIcon className="w-5 h-5 text-violet-500" />;
|
||||||
}
|
}
|
||||||
if (mimeType === "application/pdf") {
|
if (mimeType === "application/pdf") {
|
||||||
return "📄";
|
return <FileText className="w-5 h-5 text-red-500" />;
|
||||||
}
|
}
|
||||||
return "📋";
|
return <FileIcon className="w-5 h-5 text-blue-500" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getFileIconBg = (mimeType: string) => {
|
||||||
|
if (mimeType.startsWith("image/")) {
|
||||||
|
return "bg-violet-500/10 border-violet-500/20";
|
||||||
|
}
|
||||||
|
if (mimeType === "application/pdf") {
|
||||||
|
return "bg-red-500/10 border-red-500/20";
|
||||||
|
}
|
||||||
|
return "bg-blue-500/10 border-blue-500/20";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusConfig = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "COMPLETED":
|
case "COMPLETED":
|
||||||
return "text-green-500 dark:text-green-400 bg-green-50 dark:bg-green-950/30";
|
return {
|
||||||
|
dot: "bg-emerald-500",
|
||||||
|
bg: "bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-300",
|
||||||
|
label: "Completed",
|
||||||
|
};
|
||||||
case "PROCESSING":
|
case "PROCESSING":
|
||||||
return "text-blue-500 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/30";
|
return {
|
||||||
|
dot: "bg-blue-500",
|
||||||
|
bg: "bg-blue-500/10 border-blue-500/20 text-blue-700 dark:text-blue-300",
|
||||||
|
label: "Processing",
|
||||||
|
};
|
||||||
case "UPLOADED":
|
case "UPLOADED":
|
||||||
return "text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30";
|
return {
|
||||||
|
dot: "bg-amber-500",
|
||||||
|
bg: "bg-amber-500/10 border-amber-500/20 text-amber-700 dark:text-amber-300",
|
||||||
|
label: "Uploaded",
|
||||||
|
};
|
||||||
case "FAILED":
|
case "FAILED":
|
||||||
return "text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30";
|
return {
|
||||||
|
dot: "bg-red-500",
|
||||||
|
bg: "bg-red-500/10 border-red-500/20 text-red-700 dark:text-red-300",
|
||||||
|
label: "Failed",
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return "text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-950/30";
|
return {
|
||||||
|
dot: "bg-gray-500",
|
||||||
|
bg: "bg-gray-500/10 border-gray-500/20 text-gray-700 dark:text-gray-300",
|
||||||
|
label: status,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-border/50">
|
<div className="space-y-3">
|
||||||
<div className="p-12 flex items-center justify-center">
|
{[1, 2, 3].map((i) => (
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-primary mr-3" />
|
<div
|
||||||
<span className="text-muted-foreground">Loading contracts...</span>
|
key={i}
|
||||||
|
className="rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-5 animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-muted" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-muted rounded-lg w-1/3" />
|
||||||
|
<div className="h-3 bg-muted rounded-lg w-1/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,15 +1008,21 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{invalidContractReason && (
|
{invalidContractReason && (
|
||||||
<Card className="mb-4 border border-destructive/30 bg-destructive/5">
|
<motion.div
|
||||||
<div className="flex items-start justify-between gap-3 p-4">
|
initial={{ opacity: 0, y: -10 }}
|
||||||
<div className="flex items-start gap-2">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 text-destructive" />
|
className="mb-5 rounded-2xl border border-red-500/20 bg-gradient-to-r from-red-500/10 via-red-500/5 to-transparent p-4 backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-1.5 rounded-lg bg-red-500/20 mt-0.5">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
Invalid contract upload detected
|
Invalid contract upload detected
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||||
{invalidContractFileName
|
{invalidContractFileName
|
||||||
? `${invalidContractFileName}: `
|
? `${invalidContractFileName}: `
|
||||||
: ""}
|
: ""}
|
||||||
@@ -978,7 +1033,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-8 w-8 rounded-lg hover:bg-red-500/10 hover:text-red-500"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setInvalidContractReason("");
|
setInvalidContractReason("");
|
||||||
setInvalidContractFileName("");
|
setInvalidContractFileName("");
|
||||||
@@ -987,32 +1042,37 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
{/* Toolbar */}
|
||||||
<div className="relative w-full sm:max-w-md">
|
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<div className="relative w-full sm:max-w-md group">
|
||||||
|
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 via-violet-500/20 to-primary/20 rounded-2xl blur opacity-0 group-focus-within:opacity-100 transition duration-500" />
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => setSearchQuery(event.target.value)}
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
placeholder="Search by contract title or provider..."
|
placeholder="Search by contract title or provider..."
|
||||||
className="pl-9"
|
className="pl-10 pr-4 h-11 rounded-xl border-border/60 bg-background/60 backdrop-blur-xl focus:bg-background/80 focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{debouncedSearchQuery && (
|
{debouncedSearchQuery && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground hidden sm:block">
|
||||||
Showing results for: "{debouncedSearchQuery}"
|
{contracts.length} result{contracts.length !== 1 ? "s" : ""} for
|
||||||
|
"{debouncedSearchQuery}"
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={contracts.length === 0 || isDeletingAll}
|
disabled={contracts.length === 0 || isDeletingAll}
|
||||||
onClick={() => setDeleteAllDialogOpen(true)}
|
onClick={() => setDeleteAllDialogOpen(true)}
|
||||||
className="gap-2"
|
className="gap-2 rounded-xl border-border/60 hover:border-red-500/30 hover:bg-red-500/5 hover:text-red-600 transition-all"
|
||||||
>
|
>
|
||||||
{isDeletingAll ? (
|
{isDeletingAll ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -1024,49 +1084,71 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-border/50 overflow-hidden">
|
{/* Contract Cards */}
|
||||||
<div className="divide-y divide-border/50">
|
<div className="space-y-3">
|
||||||
{contracts.map((contract) => (
|
<AnimatePresence mode="popLayout">
|
||||||
<div
|
{contracts.map((contract, idx) => {
|
||||||
|
const status = getStatusConfig(contract.status);
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
key={contract.id}
|
key={contract.id}
|
||||||
className="p-4 md:p-6 hover:bg-primary/2 dark:hover:bg-primary/5 transition-colors duration-200 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"
|
layout
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
|
transition={{ delay: idx * 0.03 }}
|
||||||
|
className="group relative rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-4 md:p-5 hover:bg-background/60 hover:border-primary/20 hover:shadow-lg hover:shadow-primary/5 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4 flex-1 min-w-0 w-full">
|
||||||
|
<div
|
||||||
|
className={`p-2.5 rounded-xl border ${getFileIconBg(contract.mimeType)} shrink-0`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 flex-1 min-w-0 w-full sm:w-auto">
|
|
||||||
<div className="text-2xl flex-shrink-0">
|
|
||||||
{getFileIcon(contract.mimeType)}
|
{getFileIcon(contract.mimeType)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-medium text-foreground truncate">
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||||
{contract.fileName}
|
{contract.fileName}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span
|
||||||
className={`text-xs px-2.5 py-1 rounded-full font-medium whitespace-nowrap ${getStatusColor(contract.status)}`}
|
className={`inline-flex items-center gap-1.5 text-[10px] font-bold px-2.5 py-1 rounded-full border uppercase tracking-wider ${status.bg}`}
|
||||||
>
|
>
|
||||||
{contract.status}
|
<span
|
||||||
|
className={`relative flex h-1.5 w-1.5 rounded-full ${status.dot} ${contract.status === "PROCESSING" || contract.status === "UPLOADED" ? "animate-pulse" : ""}`}
|
||||||
|
/>
|
||||||
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
{contract.isRagged && (
|
{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">
|
<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-bold uppercase tracking-wider text-cyan-700 dark:text-cyan-300">
|
||||||
<Network className="h-3 w-3" />
|
<Network className="h-3 w-3" />
|
||||||
RAG {contract.ragChunkCount ?? 0}
|
RAG {contract.ragChunkCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span>{formatFileSize(contract.fileSize)}</span>
|
<span className="flex items-center gap-1">
|
||||||
<span>•</span>
|
<HardDrive className="w-3 h-3" />
|
||||||
<span>{formatDate(contract.createdAt)}</span>
|
{formatFileSize(contract.fileSize)}
|
||||||
|
</span>
|
||||||
|
<span className="w-px h-3 bg-border" />
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{formatDate(contract.createdAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 w-full sm:w-auto justify-end">
|
<div className="flex items-center gap-1 flex-shrink-0 w-full sm:w-auto justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hover:bg-primary/10"
|
className="h-9 w-9 rounded-xl hover:bg-primary/10 hover:text-primary transition-colors"
|
||||||
title="View contract"
|
title="View contract"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (contract.fileUrl) {
|
if (contract.fileUrl) {
|
||||||
@@ -1080,16 +1162,16 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hover:bg-primary/10"
|
className="h-9 w-9 rounded-xl hover:bg-primary/10 hover:text-primary transition-colors"
|
||||||
title="Download contract"
|
title="Download contract"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (contract.fileUrl) {
|
if (contract.id) {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = contract.fileUrl;
|
link.href = `/api/contracts/${contract.id}/download`;
|
||||||
link.download =
|
link.setAttribute(
|
||||||
contract.fileUrl.split("/").pop() || "contract";
|
"download",
|
||||||
link.target = "_blank";
|
contract.fileName || "contract",
|
||||||
link.rel = "noopener noreferrer";
|
);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
@@ -1104,44 +1186,47 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="hover:bg-primary/10"
|
className="h-9 w-9 rounded-xl hover:bg-primary/10 hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-4 h-4" />
|
<MoreVertical className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="rounded-xl border-border/60 backdrop-blur-xl"
|
||||||
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleOpenAsk(contract)}
|
onClick={() => handleOpenAsk(contract)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer rounded-lg focus:bg-primary/10"
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-4 h-4 mr-2 text-primary" />
|
||||||
Ask about this file
|
Ask about this file
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleOpenDetails(contract)}
|
onClick={() => handleOpenDetails(contract)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer rounded-lg focus:bg-primary/10"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-2 text-primary" />
|
||||||
Details
|
Details
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => exportToPDF(contract as any)}
|
onClick={() => exportToPDF(contract as any)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer rounded-lg focus:bg-primary/10"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-2 text-primary" />
|
||||||
Export Analysis (PDF)
|
Export Analysis (PDF)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => exportToCSV(contract as any)}
|
onClick={() => exportToCSV(contract as any)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer rounded-lg focus:bg-primary/10"
|
||||||
>
|
>
|
||||||
<FileSpreadsheet className="w-4 h-4 mr-2" />
|
<FileSpreadsheet className="w-4 h-4 mr-2 text-primary" />
|
||||||
Export Analysis (CSV)
|
Export Analysis (CSV)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => requestDeleteContract(contract)}
|
onClick={() => requestDeleteContract(contract)}
|
||||||
disabled={deletingId === contract.id}
|
disabled={deletingId === contract.id}
|
||||||
className="text-destructive focus:text-destructive cursor-pointer"
|
className="text-destructive focus:text-destructive cursor-pointer rounded-lg focus:bg-destructive/10"
|
||||||
>
|
>
|
||||||
{deletingId === contract.id ? (
|
{deletingId === contract.id ? (
|
||||||
<>
|
<>
|
||||||
@@ -1159,27 +1244,41 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{contracts.length === 0 && debouncedSearchQuery && (
|
{contracts.length === 0 && debouncedSearchQuery && (
|
||||||
<div className="p-10 text-center">
|
<motion.div
|
||||||
<p className="text-sm font-medium text-foreground">
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-12 text-center"
|
||||||
|
>
|
||||||
|
<div className="relative inline-flex mb-4">
|
||||||
|
<div className="absolute inset-0 bg-primary/20 blur-xl rounded-full" />
|
||||||
|
<Search className="w-10 h-10 text-muted-foreground relative z-10" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
No contracts found
|
No contracts found
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Try different keywords from the title or provider name.
|
Try different keywords from the title or provider name.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Details Modal */}
|
{/* Details Modal */}
|
||||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||||
<DialogContent className="max-h-[92vh] max-w-6xl overflow-y-auto border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.14),transparent_38%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.12),transparent_42%)]">
|
<DialogContent className="max-h-[92vh] max-w-6xl overflow-y-auto border-border/40 bg-background/80 backdrop-blur-2xl shadow-2xl">
|
||||||
<DialogHeader>
|
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<FileText className="w-5 h-5" />
|
<DialogHeader className="pb-2">
|
||||||
|
<DialogTitle className="flex items-center gap-3 text-xl">
|
||||||
|
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
|
||||||
|
<FileText className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
Contract Details
|
Contract Details
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogClose />
|
<DialogClose />
|
||||||
@@ -1187,210 +1286,158 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
|
|
||||||
{selectedContract && (
|
{selectedContract && (
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
<div className="rounded-3xl border border-white/20 dark:border-white/10 bg-background/40 p-5 shadow-2xl backdrop-blur-2xl ring-1 ring-black/5 dark:ring-white/5 md:p-6 transition-all duration-500 hover:shadow-primary/5 hover:border-primary/20">
|
{/* Document Profile */}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="relative overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-br from-primary/5 via-background to-violet-500/5 p-6 backdrop-blur-xl">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-primary/10 to-transparent rounded-full blur-2xl" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-wrap items-start justify-between gap-3 mb-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">
|
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground mb-1">
|
||||||
Document Profile
|
Document Profile
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 truncate text-base font-semibold text-foreground">
|
<p className="truncate text-lg font-bold text-foreground">
|
||||||
{selectedContract.fileName}
|
{selectedContract.fileName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`text-xs px-2.5 py-1 rounded-full font-medium whitespace-nowrap ${getStatusColor(selectedContract.status)}`}
|
className={`inline-flex items-center gap-1.5 text-[10px] font-bold px-3 py-1.5 rounded-full border uppercase tracking-wider ${getStatusConfig(selectedContract.status).bg}`}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${getStatusConfig(selectedContract.status).dot}`}
|
||||||
|
/>
|
||||||
{selectedContract.status}
|
{selectedContract.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid auto-rows-fr gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
<div className="relative z-10 grid auto-rows-fr gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="min-h-[94px] rounded-xl border border-border/30 bg-muted/20 px-3 py-2 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-md hover:-translate-y-0.5">
|
{[
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
{
|
||||||
File Size
|
label: "File Size",
|
||||||
</p>
|
value: formatFileSize(selectedContract.fileSize),
|
||||||
<p className="mt-1 font-medium text-foreground">
|
icon: <HardDrive className="w-3.5 h-3.5" />,
|
||||||
{formatFileSize(selectedContract.fileSize)}
|
},
|
||||||
|
{
|
||||||
|
label: "Mime Type",
|
||||||
|
value: selectedContract.mimeType,
|
||||||
|
icon: <FileType className="w-3.5 h-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Uploaded",
|
||||||
|
value: formatDate(selectedContract.createdAt),
|
||||||
|
icon: <Calendar className="w-3.5 h-3.5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Category",
|
||||||
|
value: selectedContract.type || "Pending analysis",
|
||||||
|
icon: <Tag className="w-3.5 h-3.5" />,
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="min-h-[90px] rounded-xl border border-border/30 bg-background/50 px-4 py-3 backdrop-blur-md transition-all duration-300 hover:bg-background/80 hover:shadow-md hover:-translate-y-0.5 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
||||||
|
<span className="text-primary/70">{item.icon}</span>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider">
|
||||||
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-[94px] rounded-xl border border-border/30 bg-muted/20 px-3 py-2 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-md hover:-translate-y-0.5">
|
<p className="font-semibold text-foreground text-sm truncate">
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
{item.value}
|
||||||
Mime Type
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-medium text-foreground truncate">
|
|
||||||
{selectedContract.mimeType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-[94px] rounded-xl border border-border/30 bg-muted/20 px-3 py-2 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-md hover:-translate-y-0.5">
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
||||||
Uploaded
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-medium text-foreground">
|
|
||||||
{formatDate(selectedContract.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-[94px] rounded-xl border border-border/30 bg-muted/20 px-3 py-2 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-md hover:-translate-y-0.5">
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
||||||
Category
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-medium text-foreground">
|
|
||||||
{selectedContract.type || "Pending analysis"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Analysis Results */}
|
{/* AI Analysis Results */}
|
||||||
{selectedContract.status === "COMPLETED" && (
|
{selectedContract.status === "COMPLETED" && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4 rounded-3xl border border-border/60 bg-background/85 p-5 shadow-sm backdrop-blur-sm md:p-6">
|
<div className="space-y-4 rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-6">
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary" />
|
||||||
|
<h3 className="text-base font-bold text-foreground">
|
||||||
Extracted Contract Information
|
Extracted Contract Information
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
<div className="grid auto-rows-fr gap-3 text-sm sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid auto-rows-fr gap-3 text-sm sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30">
|
{[
|
||||||
<div className="flex items-center justify-between gap-2">
|
{
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
key: "title",
|
||||||
Title
|
label: "Title",
|
||||||
</p>
|
value: stripMarkdown(selectedContract.title) || "N/A",
|
||||||
<button
|
},
|
||||||
type="button"
|
{
|
||||||
onClick={() =>
|
key: "provider",
|
||||||
handleOpenFieldProof("title", "Title")
|
label: "Provider",
|
||||||
}
|
value:
|
||||||
className="rounded-lg border border-transparent p-1 text-muted-foreground transition-colors hover:border-primary/20 hover:bg-primary/10 hover:text-primary"
|
stripMarkdown(selectedContract.provider) || "N/A",
|
||||||
aria-label="Show title proof"
|
},
|
||||||
title="Show proof"
|
{
|
||||||
>
|
key: "policyNumber",
|
||||||
<Info className="h-3.5 w-3.5" />
|
label: "Policy Number",
|
||||||
</button>
|
value:
|
||||||
</div>
|
stripMarkdown(selectedContract.policyNumber) ||
|
||||||
<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">
|
"N/A",
|
||||||
{stripMarkdown(selectedContract.title) || "N/A"}
|
},
|
||||||
</p>
|
{
|
||||||
</div>
|
key: "startDate",
|
||||||
<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">
|
label: "Start Date",
|
||||||
<div className="flex items-center justify-between gap-2">
|
value: selectedContract.startDate
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
||||||
Provider
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleOpenFieldProof("provider", "Provider")
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-transparent p-1 text-muted-foreground transition-colors hover:border-primary/20 hover:bg-primary/10 hover:text-primary"
|
|
||||||
aria-label="Show provider proof"
|
|
||||||
title="Show proof"
|
|
||||||
>
|
|
||||||
<Info className="h-3.5 w-3.5" />
|
|
||||||
</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">
|
|
||||||
{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">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
||||||
Policy Number
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleOpenFieldProof(
|
|
||||||
"policyNumber",
|
|
||||||
"Policy Number",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-transparent p-1 text-muted-foreground transition-colors hover:border-primary/20 hover:bg-primary/10 hover:text-primary"
|
|
||||||
aria-label="Show policy number proof"
|
|
||||||
title="Show proof"
|
|
||||||
>
|
|
||||||
<Info className="h-3.5 w-3.5" />
|
|
||||||
</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">
|
|
||||||
{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">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
||||||
Start Date
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleOpenFieldProof("startDate", "Start Date")
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-transparent p-1 text-muted-foreground transition-colors hover:border-primary/20 hover:bg-primary/10 hover:text-primary"
|
|
||||||
aria-label="Show start date proof"
|
|
||||||
title="Show proof"
|
|
||||||
>
|
|
||||||
<Info className="h-3.5 w-3.5" />
|
|
||||||
</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.startDate
|
|
||||||
? formatDate(selectedContract.startDate)
|
? formatDate(selectedContract.startDate)
|
||||||
: "N/A"}
|
: "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">
|
key: "endDate",
|
||||||
<div className="flex items-center justify-between gap-2">
|
label: "End Date",
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
value: selectedContract.endDate
|
||||||
End Date
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleOpenFieldProof("endDate", "End Date")
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-transparent p-1 text-muted-foreground transition-colors hover:border-primary/20 hover:bg-primary/10 hover:text-primary"
|
|
||||||
aria-label="Show end date proof"
|
|
||||||
title="Show proof"
|
|
||||||
>
|
|
||||||
<Info className="h-3.5 w-3.5" />
|
|
||||||
</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.endDate
|
|
||||||
? formatDate(selectedContract.endDate)
|
? formatDate(selectedContract.endDate)
|
||||||
: "N/A"}
|
: "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">
|
key: "premium",
|
||||||
<div className="flex items-center justify-between gap-2">
|
label: "Premium",
|
||||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
value:
|
||||||
Premium
|
formatPremiumWithSourceCurrency(selectedContract),
|
||||||
|
},
|
||||||
|
].map((field) => (
|
||||||
|
<div
|
||||||
|
key={field.key}
|
||||||
|
className="flex min-h-[120px] flex-col rounded-xl border border-border/30 bg-background/50 px-4 py-3 backdrop-blur-md transition-all duration-300 hover:bg-background/80 hover:shadow-lg hover:-translate-y-1 hover:border-primary/20 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleOpenFieldProof("premium", "Premium")
|
handleOpenFieldProof(field.key, field.label)
|
||||||
}
|
}
|
||||||
className="rounded-lg border border-transparent p-1 text-muted-foreground transition-colors hover:border-primary/20 hover:bg-primary/10 hover:text-primary"
|
className="rounded-lg border border-transparent p-1.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-all hover:border-primary/20 hover:bg-primary/10 hover:text-primary"
|
||||||
aria-label="Show premium proof"
|
aria-label={`Show ${field.label.toLowerCase()} proof`}
|
||||||
title="Show proof"
|
title="Show proof"
|
||||||
>
|
>
|
||||||
<Info className="h-3.5 w-3.5" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner">
|
<p className="mt-auto rounded-lg border border-white/10 dark:border-white/5 bg-muted/30 px-3 py-2.5 font-semibold text-foreground whitespace-pre-wrap break-words text-sm">
|
||||||
{formatPremiumWithSourceCurrency(selectedContract)}
|
{field.value}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedContract.summary && (
|
{selectedContract.summary && (
|
||||||
<div className="space-y-2 rounded-3xl border border-border/60 bg-background/80 p-5 shadow-sm backdrop-blur-sm md:p-6">
|
<div className="space-y-3 rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-6">
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-primary" />
|
||||||
|
<h3 className="text-base font-bold text-foreground">
|
||||||
Summary
|
Summary
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2 rounded-xl border border-border/50 bg-muted/20 p-3 text-sm whitespace-pre-wrap break-words">
|
</div>
|
||||||
|
<div className="space-y-2 rounded-xl border border-border/30 bg-background/50 p-4 text-sm whitespace-pre-wrap break-words">
|
||||||
{renderRichParagraphs(
|
{renderRichParagraphs(
|
||||||
selectedContract.summary,
|
selectedContract.summary,
|
||||||
`summary-${selectedContract.id}`,
|
`summary-${selectedContract.id}`,
|
||||||
@@ -1400,35 +1447,38 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedContract.keyPoints && (
|
{selectedContract.keyPoints && (
|
||||||
<div className="space-y-2 rounded-3xl border border-border/60 bg-background/80 p-5 shadow-sm backdrop-blur-sm md:p-6">
|
<div className="space-y-3 rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-6">
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-primary" />
|
||||||
|
<h3 className="text-base font-bold text-foreground">
|
||||||
Key Points
|
Key Points
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3 text-sm">
|
</div>
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
{isContractKeyPoints(selectedContract.keyPoints) &&
|
{isContractKeyPoints(selectedContract.keyPoints) &&
|
||||||
selectedContract.keyPoints.guarantees &&
|
selectedContract.keyPoints.guarantees &&
|
||||||
Array.isArray(
|
Array.isArray(
|
||||||
selectedContract.keyPoints.guarantees,
|
selectedContract.keyPoints.guarantees,
|
||||||
) && (
|
) && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground font-medium">
|
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
Guarantees:
|
Guarantees
|
||||||
</p>
|
</p>
|
||||||
<ul className="ml-1 space-y-2">
|
<div className="space-y-2">
|
||||||
{(
|
{(
|
||||||
selectedContract.keyPoints.guarantees ?? []
|
selectedContract.keyPoints.guarantees ?? []
|
||||||
).map((guarantee, idx: number) => (
|
).map((guarantee, idx: number) => (
|
||||||
<li
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
|
className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-4 py-3"
|
||||||
>
|
>
|
||||||
{renderRichParagraphs(
|
{renderRichParagraphs(
|
||||||
guarantee,
|
guarantee,
|
||||||
`guarantee-${selectedContract.id}-${idx}`,
|
`guarantee-${selectedContract.id}-${idx}`,
|
||||||
)}
|
)}
|
||||||
</li>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isContractKeyPoints(selectedContract.keyPoints) &&
|
{isContractKeyPoints(selectedContract.keyPoints) &&
|
||||||
@@ -1437,33 +1487,33 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
selectedContract.keyPoints.exclusions,
|
selectedContract.keyPoints.exclusions,
|
||||||
) && (
|
) && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground font-medium">
|
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
Exclusions:
|
Exclusions
|
||||||
</p>
|
</p>
|
||||||
<ul className="ml-1 space-y-2">
|
<div className="space-y-2">
|
||||||
{(
|
{(
|
||||||
selectedContract.keyPoints.exclusions ?? []
|
selectedContract.keyPoints.exclusions ?? []
|
||||||
).map((exclusion, idx: number) => (
|
).map((exclusion, idx: number) => (
|
||||||
<li
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
|
className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-3"
|
||||||
>
|
>
|
||||||
{renderRichParagraphs(
|
{renderRichParagraphs(
|
||||||
exclusion,
|
exclusion,
|
||||||
`exclusion-${selectedContract.id}-${idx}`,
|
`exclusion-${selectedContract.id}-${idx}`,
|
||||||
)}
|
)}
|
||||||
</li>
|
</div>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isContractKeyPoints(selectedContract.keyPoints) &&
|
{isContractKeyPoints(selectedContract.keyPoints) &&
|
||||||
selectedContract.keyPoints.franchise && (
|
selectedContract.keyPoints.franchise && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground font-medium">
|
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
Deductible:
|
Deductible
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-lg border border-border/50 bg-muted/20 px-3 py-2 whitespace-pre-wrap break-words">
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 whitespace-pre-wrap break-words">
|
||||||
{renderRichParagraphs(
|
{renderRichParagraphs(
|
||||||
String(selectedContract.keyPoints.franchise),
|
String(selectedContract.keyPoints.franchise),
|
||||||
`franchise-${selectedContract.id}`,
|
`franchise-${selectedContract.id}`,
|
||||||
@@ -1478,29 +1528,46 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedContract.status === "PROCESSING" && (
|
{selectedContract.status === "PROCESSING" && (
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-blue-200/40 bg-blue-50/60 p-4 dark:border-blue-800/40 dark:bg-blue-950/30">
|
<div className="flex items-center gap-3 rounded-xl border border-blue-500/20 bg-blue-500/5 p-5 backdrop-blur-xl">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-500/20">
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
<Loader2 className="w-5 h-5 animate-spin text-blue-500" />
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
</div>
|
||||||
AI analysis is in progress...
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-blue-700 dark:text-blue-300">
|
||||||
|
AI analysis is in progress
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-blue-600/70 dark:text-blue-400/70">
|
||||||
|
Extracting entities, clauses, and generating insights...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedContract.status === "UPLOADED" && (
|
{selectedContract.status === "UPLOADED" && (
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-amber-200/40 bg-amber-50/60 p-4 dark:border-amber-800/40 dark:bg-amber-950/30">
|
<div className="flex items-center gap-3 rounded-xl border border-amber-500/20 bg-amber-500/5 p-5 backdrop-blur-xl">
|
||||||
|
<div className="p-2 rounded-lg bg-amber-500/20">
|
||||||
<Loader2 className="w-5 h-5 text-amber-500 animate-spin" />
|
<Loader2 className="w-5 h-5 text-amber-500 animate-spin" />
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
</div>
|
||||||
Contract uploaded. AI analysis will start automatically.
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">
|
||||||
|
Contract uploaded successfully
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-amber-600/70 dark:text-amber-400/70">
|
||||||
|
AI analysis will begin automatically momentarily
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedContract.status === "FAILED" && (
|
{selectedContract.status === "FAILED" && (
|
||||||
<div className="space-y-2 rounded-xl border border-red-200/40 bg-red-50/60 p-4 dark:border-red-800/40 dark:bg-red-950/30">
|
<div className="space-y-2 rounded-xl border border-red-500/20 bg-red-500/5 p-5 backdrop-blur-xl">
|
||||||
<p className="text-sm font-semibold text-red-700 dark:text-red-300">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||||
|
<p className="text-sm font-bold text-red-700 dark:text-red-300">
|
||||||
Analysis failed
|
Analysis failed
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-red-700/90 dark:text-red-300/90 leading-relaxed">
|
</div>
|
||||||
|
<p className="text-sm text-red-700/80 dark:text-red-300/80 leading-relaxed">
|
||||||
{selectedContract.summary ||
|
{selectedContract.summary ||
|
||||||
"The uploaded file could not be processed as a valid contract. Please upload a clearer contract document and try again."}
|
"The uploaded file could not be processed as a valid contract. Please upload a clearer contract document and try again."}
|
||||||
</p>
|
</p>
|
||||||
@@ -1525,13 +1592,22 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent className="border-border/40 bg-background/80 backdrop-blur-2xl">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete this contract?</AlertDialogTitle>
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
<AlertDialogDescription>
|
<Trash2 className="w-5 h-5 text-destructive" />
|
||||||
|
Delete this contract?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-muted-foreground">
|
||||||
This action permanently removes the selected contract and its
|
This action permanently removes the selected contract and its
|
||||||
associated file.
|
associated file.
|
||||||
{contractToDelete ? `\n\nFile: ${contractToDelete.fileName}` : ""}
|
{contractToDelete ? (
|
||||||
|
<span className="block mt-2 p-3 rounded-lg bg-muted/50 border border-border/30 font-mono text-xs">
|
||||||
|
{contractToDelete.fileName}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -1539,12 +1615,13 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContractToDelete(null);
|
setContractToDelete(null);
|
||||||
}}
|
}}
|
||||||
|
className="rounded-xl"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => void confirmDeleteContract()}
|
onClick={() => void confirmDeleteContract()}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-xl"
|
||||||
>
|
>
|
||||||
{contractToDelete && deletingId === contractToDelete.id
|
{contractToDelete && deletingId === contractToDelete.id
|
||||||
? "Deleting..."
|
? "Deleting..."
|
||||||
@@ -1558,19 +1635,22 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
open={deleteAllDialogOpen}
|
open={deleteAllDialogOpen}
|
||||||
onOpenChange={setDeleteAllDialogOpen}
|
onOpenChange={setDeleteAllDialogOpen}
|
||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent className="border-border/40 bg-background/80 backdrop-blur-2xl">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete all contracts?</AlertDialogTitle>
|
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Delete all contracts?
|
||||||
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action permanently removes all contracts and related files
|
This action permanently removes all contracts and related files
|
||||||
for your account. This cannot be undone.
|
for your account. This cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => void handleDeleteAll()}
|
onClick={() => void handleDeleteAll()}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-xl"
|
||||||
>
|
>
|
||||||
{isDeletingAll ? "Deleting..." : "Delete All"}
|
{isDeletingAll ? "Deleting..." : "Delete All"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
@@ -1582,7 +1662,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
open={invalidContractDialogOpen}
|
open={invalidContractDialogOpen}
|
||||||
onOpenChange={setInvalidContractDialogOpen}
|
onOpenChange={setInvalidContractDialogOpen}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-md border-border/70">
|
<DialogContent className="max-w-md border-border/40 bg-background/80 backdrop-blur-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
<AlertTriangle className="h-5 w-5" />
|
<AlertTriangle className="h-5 w-5" />
|
||||||
@@ -1594,9 +1674,11 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The AI could not validate this file as a real contract.
|
The AI could not validate this file as a real contract.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl border border-destructive/25 bg-destructive/5 p-3">
|
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-4">
|
||||||
<p className="text-xs font-semibold text-foreground">Reason</p>
|
<p className="text-xs font-bold uppercase tracking-wider text-destructive mb-1">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
Reason
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
{invalidContractReason ||
|
{invalidContractReason ||
|
||||||
"This uploaded file does not appear to be a valid contract."}
|
"This uploaded file does not appear to be a valid contract."}
|
||||||
</p>
|
</p>
|
||||||
@@ -1604,12 +1686,12 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
|
|||||||
{invalidContractFileName && (
|
{invalidContractFileName && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
File:{" "}
|
File:{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground font-mono">
|
||||||
{invalidContractFileName}
|
{invalidContractFileName}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
Please upload a contract or policy document with readable legal
|
Please upload a contract or policy document with readable legal
|
||||||
terms and agreement details.
|
terms and agreement details.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -20,14 +20,18 @@ import { keyManager } from "@/lib/services/ai/key-manager";
|
|||||||
const PRIMARY_ANALYSIS_MODEL =
|
const PRIMARY_ANALYSIS_MODEL =
|
||||||
process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview";
|
process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview";
|
||||||
const GEMINI_SECONDARY_ANALYSIS_MODEL =
|
const GEMINI_SECONDARY_ANALYSIS_MODEL =
|
||||||
process.env.AI_MODEL_SECONDARY_GEMINI || "";
|
process.env.AI_MODEL_SECONDARY_GEMINI || process.env.AI_MODEL_SECONDARY || "";
|
||||||
const FALLBACK_ANALYSIS_MODEL =
|
const FALLBACK_ANALYSIS_MODEL =
|
||||||
process.env.AI_MODEL_FALLBACK || "llama-3.3-70b-versatile";
|
process.env.AI_MODEL_FALLBACK || "mistral-large-latest";
|
||||||
const FALLBACK_REPAIR_MODEL =
|
const FALLBACK_REPAIR_MODEL =
|
||||||
process.env.AI_MODEL_FALLBACK_REPAIR || "llama-3.3-70b-versatile";
|
process.env.AI_MODEL_FALLBACK_REPAIR || "mistral-large-latest";
|
||||||
const GROQ_API_KEY =
|
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY?.trim() || "";
|
||||||
process.env.GROQ_API_KEY?.trim() || process.env.AI_GROQ_API_KEY?.trim() || "";
|
const MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions";
|
||||||
const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions";
|
const MISTRAL_OCR_API_URL = "https://api.mistral.ai/v1/ocr";
|
||||||
|
const MISTRAL_VISION_MODEL =
|
||||||
|
process.env.AI_MODEL_MISTRAL_VISION || "pixtral-large-latest";
|
||||||
|
const MISTRAL_OCR_MODEL =
|
||||||
|
process.env.AI_MODEL_MISTRAL_OCR || "mistral-ocr-latest";
|
||||||
|
|
||||||
const GEMINI_ANALYSIS_MODELS = Array.from(
|
const GEMINI_ANALYSIS_MODELS = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -36,7 +40,7 @@ const GEMINI_ANALYSIS_MODELS = Array.from(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ANALYSIS_MODELS = Array.from(
|
const ANALYSIS_MODELS = Array.from(
|
||||||
new Set([...GEMINI_ANALYSIS_MODELS, `groq:${FALLBACK_ANALYSIS_MODEL}`]),
|
new Set([...GEMINI_ANALYSIS_MODELS, `mistral:${FALLBACK_ANALYSIS_MODEL}`]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const FORCE_FALLBACK_TEST =
|
const FORCE_FALLBACK_TEST =
|
||||||
@@ -89,7 +93,7 @@ const isAdaptiveKeyPoints = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class AIService {
|
export class AIService {
|
||||||
private static isTransientGeminiError(message: string): boolean {
|
private static isTransientAIError(message: string): boolean {
|
||||||
const normalized = message.toLowerCase();
|
const normalized = message.toLowerCase();
|
||||||
return (
|
return (
|
||||||
normalized.includes("503") ||
|
normalized.includes("503") ||
|
||||||
@@ -282,7 +286,7 @@ export class AIService {
|
|||||||
// Better error messages
|
// Better error messages
|
||||||
if (errorMessage.includes("API key")) {
|
if (errorMessage.includes("API key")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid or missing AI API key. Check AI_API_KEY1/2/3 for Gemini and GROQ_API_KEY for Groq fallback.",
|
"Invalid or missing AI API key. Check AI_API_KEY1/2/3 for Gemini and MISTRAL_API_KEY for Mistral fallback.",
|
||||||
);
|
);
|
||||||
} else if (errorMessage.includes("INVALID_CONTRACT:")) {
|
} else if (errorMessage.includes("INVALID_CONTRACT:")) {
|
||||||
const reason = String(errorMessage)
|
const reason = String(errorMessage)
|
||||||
@@ -291,9 +295,9 @@ export class AIService {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
reason || "Uploaded file is not recognized as a valid contract.",
|
reason || "Uploaded file is not recognized as a valid contract.",
|
||||||
);
|
);
|
||||||
} else if (this.isTransientGeminiError(errorMessage)) {
|
} else if (this.isTransientAIError(errorMessage)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Gemini is temporarily overloaded for the configured analysis models (${ANALYSIS_MODELS.join(", ")}). The app retried automatically, but both models are still busy. Please try again in a few minutes.`,
|
`The AI providers (Gemini/Mistral) are temporarily overloaded for the configured analysis models (${ANALYSIS_MODELS.join(", ")}). The app retried automatically, but both providers are still busy. Please try again in a few minutes.`,
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
errorMessage.includes("not found") ||
|
errorMessage.includes("not found") ||
|
||||||
@@ -337,7 +341,7 @@ export class AIService {
|
|||||||
}
|
}
|
||||||
} else if (errorMessage.includes("quota")) {
|
} else if (errorMessage.includes("quota")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Limit exceeded. Gemini or Groq quota may be exhausted. Check your provider dashboards for usage and limits.",
|
"Limit exceeded. Gemini or Mistral quota may be exhausted. Check your provider dashboards for usage and limits.",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Error analyzing contract: ${errorMessage}`);
|
throw new Error(`Error analyzing contract: ${errorMessage}`);
|
||||||
@@ -389,11 +393,11 @@ export class AIService {
|
|||||||
return parseAiJsonResponse(text);
|
return parseAiJsonResponse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static isGroqConfigured(): boolean {
|
private static isMistralConfigured(): boolean {
|
||||||
return GROQ_API_KEY.length > 0;
|
return MISTRAL_API_KEY.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async generateWithGroq(input: {
|
private static async generateWithMistral(input: {
|
||||||
model?: string;
|
model?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
@@ -402,9 +406,9 @@ export class AIService {
|
|||||||
temperature?: number;
|
temperature?: number;
|
||||||
topP?: number;
|
topP?: number;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
if (!this.isGroqConfigured()) {
|
if (!this.isMistralConfigured()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Groq fallback is not configured. Set GROQ_API_KEY (or AI_GROQ_API_KEY).",
|
"Mistral fallback is not configured. Set MISTRAL_API_KEY.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,23 +422,25 @@ export class AIService {
|
|||||||
messages.push({ role: "user", content: input.prompt });
|
messages.push({ role: "user", content: input.prompt });
|
||||||
|
|
||||||
// Use json_object mode (compatible with all models)
|
// Use json_object mode (compatible with all models)
|
||||||
const responseFormat: Record<string, unknown> | undefined = input.responseAsJson
|
const responseFormat: Record<string, unknown> | undefined =
|
||||||
? { type: "json_object" as const }
|
input.responseAsJson ? { type: "json_object" as const } : undefined;
|
||||||
: undefined;
|
|
||||||
|
const temperature = input.temperature ?? 0;
|
||||||
|
const top_p = temperature === 0 ? 1 : (input.topP ?? 0.95);
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model: modelName,
|
model: modelName,
|
||||||
temperature: input.temperature ?? 0,
|
temperature,
|
||||||
top_p: input.topP ?? 0.95,
|
top_p,
|
||||||
max_tokens: input.maxOutputTokens,
|
max_tokens: input.maxOutputTokens,
|
||||||
response_format: responseFormat,
|
response_format: responseFormat,
|
||||||
messages,
|
messages,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(GROQ_API_URL, {
|
const response = await fetch(MISTRAL_API_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${GROQ_API_KEY}`,
|
Authorization: `Bearer ${MISTRAL_API_KEY}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -443,7 +449,7 @@ export class AIService {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const details = await response.text();
|
const details = await response.text();
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Groq API error ${response.status}: ${details.slice(0, 300)}`,
|
`Mistral API error ${response.status}: ${details.slice(0, 300)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,13 +459,92 @@ export class AIService {
|
|||||||
const text = json.choices?.[0]?.message?.content?.trim() || "";
|
const text = json.choices?.[0]?.message?.content?.trim() || "";
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
throw new Error("Empty response from Groq fallback model.");
|
throw new Error("Empty response from Mistral fallback model.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async generateWithGroqModelChain(input: {
|
/**
|
||||||
|
* Multimodal analysis using Mistral Pixtral vision model.
|
||||||
|
* Sends base64-encoded images directly to Pixtral for analysis,
|
||||||
|
* eliminating the need for a separate OCR bridge when Gemini is down.
|
||||||
|
*/
|
||||||
|
private static async generateWithMistralVision(input: {
|
||||||
|
prompt: string;
|
||||||
|
base64: string;
|
||||||
|
mimeType: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
responseAsJson?: boolean;
|
||||||
|
maxOutputTokens?: number;
|
||||||
|
}): Promise<string> {
|
||||||
|
if (!this.isMistralConfigured()) {
|
||||||
|
throw new Error(
|
||||||
|
"Mistral fallback is not configured. Set MISTRAL_API_KEY.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: Array<{ role: string; content: unknown }> = [];
|
||||||
|
if (input.systemPrompt) {
|
||||||
|
messages.push({ role: "system", content: input.systemPrompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI-compatible multimodal content format for Pixtral vision
|
||||||
|
messages.push({
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: input.prompt },
|
||||||
|
{
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: `data:${input.mimeType};base64,${input.base64}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseFormat: Record<string, unknown> | undefined =
|
||||||
|
input.responseAsJson ? { type: "json_object" as const } : undefined;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
model: MISTRAL_VISION_MODEL,
|
||||||
|
temperature: 0,
|
||||||
|
top_p: 1,
|
||||||
|
max_tokens: input.maxOutputTokens ?? 16384,
|
||||||
|
response_format: responseFormat,
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(MISTRAL_API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${MISTRAL_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const details = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`Mistral Vision API error ${response.status}: ${details.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as {
|
||||||
|
choices?: Array<{ message?: { content?: string | null } }>;
|
||||||
|
};
|
||||||
|
const text = json.choices?.[0]?.message?.content?.trim() || "";
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
throw new Error("Empty response from Mistral Pixtral vision model.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Mistral Pixtral vision analysis succeeded`);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async generateWithMistralModelChain(input: {
|
||||||
preferredModel?: string;
|
preferredModel?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
@@ -473,9 +558,9 @@ export class AIService {
|
|||||||
[
|
[
|
||||||
input.preferredModel,
|
input.preferredModel,
|
||||||
FALLBACK_ANALYSIS_MODEL,
|
FALLBACK_ANALYSIS_MODEL,
|
||||||
"llama-3.3-70b-versatile",
|
"mistral-large-latest",
|
||||||
"qwen-2.5-32b",
|
"mistral-small-latest",
|
||||||
"llama-3.1-8b-instant",
|
"open-mistral-nemo",
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
),
|
),
|
||||||
) as string[];
|
) as string[];
|
||||||
@@ -484,7 +569,7 @@ export class AIService {
|
|||||||
|
|
||||||
for (const modelName of candidates) {
|
for (const modelName of candidates) {
|
||||||
try {
|
try {
|
||||||
const text = await this.generateWithGroq({
|
const text = await this.generateWithMistral({
|
||||||
model: modelName,
|
model: modelName,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
systemPrompt: input.systemPrompt,
|
systemPrompt: input.systemPrompt,
|
||||||
@@ -495,14 +580,14 @@ export class AIService {
|
|||||||
});
|
});
|
||||||
if (modelName !== (input.preferredModel || FALLBACK_ANALYSIS_MODEL)) {
|
if (modelName !== (input.preferredModel || FALLBACK_ANALYSIS_MODEL)) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Groq switched to fallback model ${modelName} after primary fallback model failed.`,
|
`Mistral switched to fallback model ${modelName} after primary fallback model failed.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
console.warn(
|
console.warn(
|
||||||
`Groq model ${modelName} failed. Trying next fallback model.`,
|
`Mistral model ${modelName} failed. Trying next fallback model.`,
|
||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -510,36 +595,79 @@ export class AIService {
|
|||||||
|
|
||||||
throw lastError instanceof Error
|
throw lastError instanceof Error
|
||||||
? lastError
|
? lastError
|
||||||
: new Error("All Groq fallback models failed.");
|
: new Error("All Mistral fallback models failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Groq-optimized system prompt that mirrors the Gemini behavior.
|
* Build a Mistral-optimized system prompt that mirrors the Gemini behavior.
|
||||||
* This separates role & formatting rules from user content for better
|
* This separates role & formatting rules from user content for better
|
||||||
* instruction adherence on open-source models.
|
* instruction adherence on open-source models.
|
||||||
|
*
|
||||||
|
* Unlike the Gemini prompt which sends examples with the file inline,
|
||||||
|
* this prompt is designed to prevent hallucination by using explicit
|
||||||
|
* placeholder markers instead of realistic example values.
|
||||||
*/
|
*/
|
||||||
private static buildGroqSystemPrompt(): string {
|
private static buildMistralSystemPrompt(): string {
|
||||||
return `You are an expert contract analysis engine for the BFSI (Banking, Financial Services, and Insurance) sector.
|
return `You are an expert contract analysis engine for the BFSI (Banking, Financial Services, and Insurance) sector.
|
||||||
You receive the full text content of a contract document below and must extract structured information from it.
|
You receive the full text content of a contract document and must extract structured information from it.
|
||||||
|
|
||||||
CRITICAL OUTPUT RULES:
|
ABSOLUTE RULES — VIOLATION OF THESE IS A CRITICAL FAILURE:
|
||||||
1. Return ONLY valid, parseable JSON — no markdown, no backticks, no explanations, no commentary.
|
1. Return ONLY valid, parseable JSON — no markdown, no backticks, no explanations, no commentary.
|
||||||
2. Your JSON must conform EXACTLY to the schema specified in the user prompt.
|
2. EVERY value you output MUST come directly from the document text provided to you.
|
||||||
3. Every required field MUST be present. Use null for missing strings/numbers and [] for missing arrays.
|
3. If a piece of information does NOT exist in the document text, you MUST use null (for strings/numbers) or [] (for arrays). NEVER invent, assume, or guess data.
|
||||||
4. All dates MUST be in ISO YYYY-MM-DD format or null.
|
4. Do NOT copy example values from the schema description — they are placeholders, not real data.
|
||||||
5. The "premium" field must be a positive number or null — NO currency symbols.
|
5. The "extractedText" field MUST contain actual verbatim text from the document — not a summary, not examples.
|
||||||
6. The "type" field MUST be one of: INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER.
|
|
||||||
7. Do NOT hallucinate or invent data that is not present in the document.
|
|
||||||
8. Preserve original language in extractedText and sourceSnippet fields (accents, special characters).
|
|
||||||
9. The "summary" must be 4-6 professional sentences covering parties, obligations, coverage, exclusions, and deadlines.
|
|
||||||
10. The "extractedText" must contain at least 30 characters of actual document content.
|
|
||||||
11. The "keyPoints.explainability" array must have at least 4 items for critical fields when data is available.
|
|
||||||
12. contractValidation.confidence must reflect actual extraction certainty (0-100).
|
|
||||||
13. When uncertain about a value, use null and set a lower confidence — never guess.
|
|
||||||
14. Parse localized number formats correctly (e.g., 1.234,56 vs 1,234.56).
|
|
||||||
15. Detect the contract language and set the "language" field accordingly (ISO 639-1).
|
|
||||||
|
|
||||||
You are replacing a more capable multimodal model (Gemini) as a fallback. Your output quality MUST match production standards.`;
|
JSON SCHEMA (use exact field names):
|
||||||
|
{
|
||||||
|
"language": "<ISO 639-1 code detected from document>",
|
||||||
|
"title": "<exact contract title from document or null>",
|
||||||
|
"type": "<one of: INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER>",
|
||||||
|
"provider": "<company/institution name from document or null>",
|
||||||
|
"policyNumber": "<policy/contract number from document or null>",
|
||||||
|
"startDate": "<YYYY-MM-DD from document or null>",
|
||||||
|
"endDate": "<YYYY-MM-DD from document or null>",
|
||||||
|
"premium": <number from document or null — NO currency symbols>,
|
||||||
|
"premiumCurrency": "<currency code from document or null>",
|
||||||
|
"summary": "<4-6 sentences summarizing the actual contract content>",
|
||||||
|
"keyPoints": {
|
||||||
|
"guarantees": ["<actual guarantee from document>"],
|
||||||
|
"exclusions": ["<actual exclusion from document>"],
|
||||||
|
"franchise": "<deductible/penalty from document or null>",
|
||||||
|
"importantDates": ["<actual date from document with description>"],
|
||||||
|
"explainability": [
|
||||||
|
{
|
||||||
|
"field": "<field name>",
|
||||||
|
"why": "<why this value was extracted>",
|
||||||
|
"sourceSnippet": "<verbatim quote from document>",
|
||||||
|
"sourceHints": { "page": "<page or null>", "section": "<section or null>", "confidence": <0-100> }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"keyPeople": [{"name": "<from document>", "role": "<from document or null>", "email": "<from document or null>", "phone": "<from document or null>"}],
|
||||||
|
"contactInfo": {"name": "<from document or null>", "email": null, "phone": null, "address": null, "role": null},
|
||||||
|
"importantContacts": [],
|
||||||
|
"relevantDates": [{"date": "<YYYY-MM-DD>", "description": "<from document>", "type": "<EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER>"}],
|
||||||
|
"extractedText": "<verbatim text from the document, max 12000 chars>",
|
||||||
|
"contractValidation": {
|
||||||
|
"isValidContract": true,
|
||||||
|
"confidence": <0-100 reflecting how much data you actually found>,
|
||||||
|
"reason": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FIELD RULES:
|
||||||
|
- All dates: ISO YYYY-MM-DD or null
|
||||||
|
- premium: positive number or null — NO currency symbols, NO text
|
||||||
|
- type: must be exactly one of the 8 values listed
|
||||||
|
- summary: 4-6 professional sentences about THIS specific contract. If no contract text is found, output "No contract data found in the document text."
|
||||||
|
- extractedText: must contain at least 30 characters of ACTUAL document content. If no text is found, output "No document text could be extracted. Please ensure the document is not a scanned image."
|
||||||
|
- explainability: at least 4 items with real sourceSnippets from the document
|
||||||
|
- confidence: reflects how much data you actually found (not how confident the model is)
|
||||||
|
- Parse localized number formats correctly (1.234,56 vs 1,234.56)
|
||||||
|
- Detect the contract language and set "language" accordingly
|
||||||
|
|
||||||
|
You are replacing a more capable multimodal model (Gemini) as a fallback. Your output quality MUST match production standards. ACCURACY is more important than completeness — it is better to return null than to guess.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async generateAnalysisWithFallback(input: {
|
private static async generateAnalysisWithFallback(input: {
|
||||||
@@ -551,27 +679,52 @@ You are replacing a more capable multimodal model (Gemini) as a fallback. Your o
|
|||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
const forceFallback = Boolean(input.forceFallbackModelTest);
|
const forceFallback = Boolean(input.forceFallbackModelTest);
|
||||||
|
|
||||||
const buildGroundedGroqPrompt = async (basePrompt: string) => {
|
const buildGroundedMistralPrompt = async () => {
|
||||||
const groundingText = await this.extractGroqGroundingText({
|
const groundingText = await this.extractMistralGroundingText({
|
||||||
base64: input.base64,
|
base64: input.base64,
|
||||||
mimeType: input.mimeType,
|
mimeType: input.mimeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!groundingText) {
|
if (!groundingText) {
|
||||||
return `${basePrompt}\n\nGROQ FALLBACK RULES:\n- You do not have direct binary file access in this fallback path.\n- Do not hallucinate values; use null/empty arrays when data is missing.\n- Keep contractValidation conservative when uncertain.\n- Set contractValidation.confidence to at most 60 when no grounding text is available.`;
|
throw new Error(
|
||||||
|
"INVALID_CONTRACT:No extractable text found in this PDF after OCR fallback. Please verify the file is readable and not password-protected.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${basePrompt}\n\n--- BEGIN GROUNDED DOCUMENT TEXT (AUTHORITATIVE SOURCE) ---\n${groundingText}\n--- END GROUNDED DOCUMENT TEXT ---\n\nGROQ FALLBACK RULES:\n- Extract fields ONLY from the grounded document text above. This text is the full contract content.\n- Do not invent, assume, or hallucinate any values not explicitly present in the above text.\n- If a field's data is not found in the text, use null (for strings/numbers) or [] (for arrays).\n- Dates: convert any date format found in the text to YYYY-MM-DD.\n- Numbers: parse localized formats (comma vs period) correctly before setting numeric fields.\n- contractValidation.confidence should reflect how much data you could extract from the text.`;
|
return `--- BEGIN GROUNDED DOCUMENT TEXT (AUTHORITATIVE SOURCE) ---
|
||||||
|
${groundingText}
|
||||||
|
--- END GROUNDED DOCUMENT TEXT ---
|
||||||
|
|
||||||
|
MISTRAL FALLBACK RULES:
|
||||||
|
- Extract fields ONLY from the grounded document text above. This text is the full contract content.
|
||||||
|
- Do not invent, assume, or hallucinate any values not explicitly present in the above text.
|
||||||
|
- If a field's data is not found in the text, use null (for strings/numbers) or [] (for arrays).
|
||||||
|
- Dates: convert any date format found in the text to YYYY-MM-DD.
|
||||||
|
- Numbers: parse localized formats (comma vs period) correctly before setting numeric fields.
|
||||||
|
- contractValidation.confidence should reflect how much data you could extract from the text.`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (forceFallback) {
|
if (forceFallback) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`🧪 Fallback test mode enabled. Skipping Gemini and forcing Groq model ${FALLBACK_ANALYSIS_MODEL}.`,
|
`🧪 Fallback test mode enabled. Skipping Gemini and forcing Mistral model ${FALLBACK_ANALYSIS_MODEL}.`,
|
||||||
);
|
);
|
||||||
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt);
|
|
||||||
return this.generateWithGroqModelChain({
|
// For images: use Pixtral vision model directly (multimodal — no OCR bridge needed)
|
||||||
|
if (input.mimeType.startsWith("image/") && this.isMistralConfigured()) {
|
||||||
|
return this.generateWithMistralVision({
|
||||||
|
systemPrompt: this.buildMistralSystemPrompt(),
|
||||||
|
prompt: `TEST MODE: You are the forced fallback model. Return ONLY valid JSON and preserve the required schema exactly. Extract information from the provided image.`,
|
||||||
|
base64: input.base64,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
responseAsJson: true,
|
||||||
|
maxOutputTokens: 16384,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const groundedPrompt = await buildGroundedMistralPrompt();
|
||||||
|
return this.generateWithMistralModelChain({
|
||||||
preferredModel: FALLBACK_ANALYSIS_MODEL,
|
preferredModel: FALLBACK_ANALYSIS_MODEL,
|
||||||
systemPrompt: this.buildGroqSystemPrompt(),
|
systemPrompt: this.buildMistralSystemPrompt(),
|
||||||
prompt: `${groundedPrompt}\n\nTEST MODE: You are the forced fallback model. Return ONLY valid JSON and preserve the required schema exactly.`,
|
prompt: `${groundedPrompt}\n\nTEST MODE: You are the forced fallback model. Return ONLY valid JSON and preserve the required schema exactly.`,
|
||||||
responseAsJson: true,
|
responseAsJson: true,
|
||||||
maxOutputTokens: 8192,
|
maxOutputTokens: 8192,
|
||||||
@@ -610,7 +763,6 @@ You are replacing a more capable multimodal model (Gemini) as a fallback. Your o
|
|||||||
throw new Error("Empty response");
|
throw new Error("Empty response");
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
|
|
||||||
lastError = error;
|
lastError = error;
|
||||||
console.warn(
|
console.warn(
|
||||||
`Analysis with model ${modelName} failed. Trying next model.`,
|
`Analysis with model ${modelName} failed. Trying next model.`,
|
||||||
@@ -654,35 +806,54 @@ You are replacing a more capable multimodal model (Gemini) as a fallback. Your o
|
|||||||
throw new Error("Empty response from fallback");
|
throw new Error("Empty response from fallback");
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
|
|
||||||
console.warn("Lenient generation also failed:", error);
|
console.warn("Lenient generation also failed:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Groq fallback path ===
|
// === Mistral AI fallback path ===
|
||||||
console.warn(
|
console.warn(
|
||||||
"All Gemini models exhausted. Activating Groq fallback pipeline...",
|
"All Gemini models exhausted. Activating Mistral AI fallback pipeline...",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt);
|
// For images: use Pixtral vision model directly (multimodal — no OCR bridge needed)
|
||||||
const groqText = await this.generateWithGroqModelChain({
|
if (input.mimeType.startsWith("image/") && this.isMistralConfigured()) {
|
||||||
|
const mistralText = await this.generateWithMistralVision({
|
||||||
|
systemPrompt: this.buildMistralSystemPrompt(),
|
||||||
|
prompt: `IMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object. Extract data from the provided image.`,
|
||||||
|
base64: input.base64,
|
||||||
|
mimeType: input.mimeType,
|
||||||
|
responseAsJson: true,
|
||||||
|
maxOutputTokens: 16384,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`✅ Analysis fallback with Mistral Pixtral vision succeeded`,
|
||||||
|
);
|
||||||
|
return mistralText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For PDFs/text: extract text and use text-only Mistral
|
||||||
|
const groundedPrompt = await buildGroundedMistralPrompt();
|
||||||
|
const mistralText = await this.generateWithMistralModelChain({
|
||||||
preferredModel: FALLBACK_ANALYSIS_MODEL,
|
preferredModel: FALLBACK_ANALYSIS_MODEL,
|
||||||
systemPrompt: this.buildGroqSystemPrompt(),
|
systemPrompt: this.buildMistralSystemPrompt(),
|
||||||
prompt: `${groundedPrompt}\n\nIMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object.`,
|
prompt: `${groundedPrompt}\n\nIMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object.`,
|
||||||
responseAsJson: true,
|
responseAsJson: true,
|
||||||
maxOutputTokens: 8192,
|
maxOutputTokens: 8192,
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Analysis fallback with Groq model ${FALLBACK_ANALYSIS_MODEL} succeeded`,
|
`✅ Analysis fallback with Mistral model ${FALLBACK_ANALYSIS_MODEL} succeeded`,
|
||||||
|
);
|
||||||
|
return mistralText;
|
||||||
|
} catch (mistralError) {
|
||||||
|
console.warn("Mistral analysis fallback failed:", mistralError);
|
||||||
|
lastError = new Error(
|
||||||
|
`Mistral fallback also failed: ${mistralError instanceof Error ? mistralError.message : String(mistralError)}. Original error: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
||||||
);
|
);
|
||||||
return groqText;
|
|
||||||
} catch (groqError) {
|
|
||||||
console.warn("Groq analysis fallback failed:", groqError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError instanceof Error
|
throw lastError instanceof Error
|
||||||
? lastError
|
? lastError
|
||||||
: new Error(
|
: new Error(
|
||||||
"All analysis models (Gemini + Groq fallback) failed to generate content.",
|
"All analysis models (Gemini + Mistral fallback) failed to generate content.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,7 +917,7 @@ Original parse error: ${parseError}
|
|||||||
Malformed response to fix:
|
Malformed response to fix:
|
||||||
${malformedResponse.slice(0, 14000)}`;
|
${malformedResponse.slice(0, 14000)}`;
|
||||||
|
|
||||||
const repairedText = await this.generateWithGroqModelChain({
|
const repairedText = await this.generateWithMistralModelChain({
|
||||||
preferredModel: FALLBACK_REPAIR_MODEL,
|
preferredModel: FALLBACK_REPAIR_MODEL,
|
||||||
prompt: repairPrompt,
|
prompt: repairPrompt,
|
||||||
responseAsJson: true,
|
responseAsJson: true,
|
||||||
@@ -766,7 +937,7 @@ ${malformedResponse.slice(0, 14000)}`;
|
|||||||
} catch (firstRepairParseError) {
|
} catch (firstRepairParseError) {
|
||||||
const secondPassPrompt = `${repairPrompt}\n\nSECOND PASS CORRECTION:\nYour previous repaired JSON was still invalid.\nReason: ${firstRepairParseError instanceof Error ? firstRepairParseError.message : "Invalid JSON"}.\nReturn ONLY strict valid JSON.`;
|
const secondPassPrompt = `${repairPrompt}\n\nSECOND PASS CORRECTION:\nYour previous repaired JSON was still invalid.\nReason: ${firstRepairParseError instanceof Error ? firstRepairParseError.message : "Invalid JSON"}.\nReturn ONLY strict valid JSON.`;
|
||||||
|
|
||||||
const secondPass = await this.generateWithGroqModelChain({
|
const secondPass = await this.generateWithMistralModelChain({
|
||||||
preferredModel: FALLBACK_REPAIR_MODEL,
|
preferredModel: FALLBACK_REPAIR_MODEL,
|
||||||
prompt: secondPassPrompt,
|
prompt: secondPassPrompt,
|
||||||
responseAsJson: true,
|
responseAsJson: true,
|
||||||
@@ -785,7 +956,12 @@ ${malformedResponse.slice(0, 14000)}`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async extractGroqGroundingText(input: {
|
/**
|
||||||
|
* Extract grounding text for Mistral text-only fallback.
|
||||||
|
* For PDFs: extracts text directly using pdf-parse (local, no AI needed).
|
||||||
|
* For images: returns empty string — Pixtral vision handles images directly.
|
||||||
|
*/
|
||||||
|
private static async extractMistralGroundingText(input: {
|
||||||
base64: string;
|
base64: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
@@ -793,80 +969,154 @@ ${malformedResponse.slice(0, 14000)}`;
|
|||||||
if (input.mimeType === "application/pdf") {
|
if (input.mimeType === "application/pdf") {
|
||||||
try {
|
try {
|
||||||
const pdfBuffer = Buffer.from(input.base64, "base64");
|
const pdfBuffer = Buffer.from(input.base64, "base64");
|
||||||
const { PDFParse } = await import("pdf-parse");
|
|
||||||
const parser = new PDFParse({ data: pdfBuffer });
|
// Handle Next.js Webpack/Turbopack CJS/ESM interop
|
||||||
|
let pdfParseModule: any;
|
||||||
|
try {
|
||||||
|
pdfParseModule = require("pdf-parse");
|
||||||
|
} catch {
|
||||||
|
pdfParseModule = await import("pdf-parse");
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFParseClass =
|
||||||
|
pdfParseModule?.PDFParse ||
|
||||||
|
pdfParseModule?.default?.PDFParse ||
|
||||||
|
(typeof pdfParseModule === "function" ? pdfParseModule : null);
|
||||||
|
|
||||||
|
if (!PDFParseClass) {
|
||||||
|
throw new Error(
|
||||||
|
"Could not resolve PDFParse constructor from pdf-parse module.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let parsed: { text?: string };
|
let parsed: { text?: string };
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof PDFParseClass === "function" &&
|
||||||
|
!PDFParseClass.prototype?.getText
|
||||||
|
) {
|
||||||
|
// Fallback if it's actually the legacy function export
|
||||||
|
parsed = await PDFParseClass(pdfBuffer);
|
||||||
|
} else {
|
||||||
|
const parser = new PDFParseClass({ data: pdfBuffer });
|
||||||
try {
|
try {
|
||||||
parsed = await parser.getText();
|
parsed = await parser.getText();
|
||||||
} finally {
|
} finally {
|
||||||
|
if (typeof parser.destroy === "function") {
|
||||||
await parser.destroy();
|
await parser.destroy();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const text = (parsed?.text || "")
|
const text = (parsed?.text || "")
|
||||||
.replace(/\r/g, "\n")
|
.replace(/\r/g, "\n")
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
if (text && text.length > 50) {
|
if (text && text.length >= 10) {
|
||||||
console.log(
|
console.log(
|
||||||
`📄 Groq grounding: extracted ${text.length} chars from PDF`,
|
`📄 Mistral grounding: extracted ${text.length} chars from PDF`,
|
||||||
);
|
);
|
||||||
return text.slice(0, 50000);
|
return text.slice(0, 50000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`📄 Mistral grounding: native PDF text extraction too short (length: ${text?.length || 0}). Trying OCR fallback...`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"PDF grounding extraction failed for Groq fallback.",
|
"📄 PDF grounding extraction failed for Mistral fallback:",
|
||||||
error,
|
error instanceof Error ? error.message : error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// For images: try to extract text using Gemini OCR as grounding bridge.
|
// OCR fallback for scanned PDFs.
|
||||||
// This gives Groq the text content it needs since it can't read images.
|
|
||||||
if (input.mimeType.startsWith("image/")) {
|
|
||||||
try {
|
try {
|
||||||
const ocrText = await keyManager.execute(async (genAI) => {
|
const ocrText = await this.extractMistralPdfTextWithOcr(input.base64);
|
||||||
const model = genAI.getGenerativeModel({
|
if (ocrText.length >= 10) {
|
||||||
model: PRIMARY_ANALYSIS_MODEL,
|
|
||||||
generationConfig: {
|
|
||||||
temperature: 0,
|
|
||||||
maxOutputTokens: 8192,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await model.generateContent([
|
|
||||||
"Extract ALL text from this document image exactly as it appears. Preserve structure, formatting, and all content. Return ONLY the raw text, no JSON, no commentary.",
|
|
||||||
{
|
|
||||||
inlineData: {
|
|
||||||
data: input.base64,
|
|
||||||
mimeType: input.mimeType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return result.response.text()?.trim() || "";
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ocrText && ocrText.length > 50) {
|
|
||||||
console.log(
|
console.log(
|
||||||
`🖼️ Groq grounding: extracted ${ocrText.length} chars from image via Gemini OCR bridge`,
|
`📄 Mistral grounding OCR: extracted ${ocrText.length} chars from scanned PDF`,
|
||||||
);
|
);
|
||||||
return ocrText.slice(0, 50000);
|
return ocrText.slice(0, 50000);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (ocrError) {
|
||||||
// Gemini OCR bridge failed (likely key exhaustion), continue without
|
|
||||||
if (!error.message?.includes("CRITICAL_KEY_EXHAUSTION")) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"Image grounding via Gemini OCR failed for Groq fallback; continuing without grounded text.",
|
"📄 PDF OCR fallback failed for Mistral grounding:",
|
||||||
error,
|
ocrError instanceof Error ? ocrError.message : ocrError,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// For images: Pixtral vision model handles images directly via
|
||||||
|
// generateWithMistralVision, so no grounding text extraction is needed.
|
||||||
|
// The calling code in generateAnalysisWithFallback routes images
|
||||||
|
// to the vision path instead of the text-only grounded path.
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async extractMistralPdfTextWithOcr(
|
||||||
|
pdfBase64: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.isMistralConfigured()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: MISTRAL_OCR_MODEL,
|
||||||
|
document: {
|
||||||
|
type: "document_url",
|
||||||
|
document_url: `data:application/pdf;base64,${pdfBase64}`,
|
||||||
|
},
|
||||||
|
include_image_base64: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(MISTRAL_OCR_API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${MISTRAL_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const details = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`Mistral OCR API error ${response.status}: ${details.slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await response.json()) as {
|
||||||
|
text?: string;
|
||||||
|
pages?: Array<{
|
||||||
|
text?: string;
|
||||||
|
markdown?: string;
|
||||||
|
content?: string;
|
||||||
|
}>;
|
||||||
|
output?: Array<{
|
||||||
|
text?: string;
|
||||||
|
markdown?: string;
|
||||||
|
content?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageTexts = [
|
||||||
|
...(Array.isArray(json.pages) ? json.pages : []),
|
||||||
|
...(Array.isArray(json.output) ? json.output : []),
|
||||||
|
]
|
||||||
|
.map((page) => page.markdown || page.text || page.content || "")
|
||||||
|
.filter((value) => value.trim().length > 0);
|
||||||
|
|
||||||
|
const merged = [json.text || "", ...pageTexts]
|
||||||
|
.join("\n\n")
|
||||||
|
.replace(/\r/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emergency fallback: Extract key contract fields from raw text when JSON is completely malformed.
|
* Emergency fallback: Extract key contract fields from raw text when JSON is completely malformed.
|
||||||
* Builds a minimal but valid JSON structure from pattern-matched fields.
|
* Builds a minimal but valid JSON structure from pattern-matched fields.
|
||||||
@@ -1406,7 +1656,7 @@ Include one short disclaimer only when legal context is discussed: "This is gene
|
|||||||
|
|
||||||
if (!rawAnswer) {
|
if (!rawAnswer) {
|
||||||
try {
|
try {
|
||||||
rawAnswer = await this.generateWithGroqModelChain({
|
rawAnswer = await this.generateWithMistralModelChain({
|
||||||
preferredModel: FALLBACK_ANALYSIS_MODEL,
|
preferredModel: FALLBACK_ANALYSIS_MODEL,
|
||||||
systemPrompt: `You are a senior BFSI contract advisor. Answer questions about contracts accurately and professionally. Respond entirely in ${languageName}. Use plain text only — no markdown, no bold, no headers, no bullet points. Base your answers ONLY on the provided contract content. If information is missing, say so.`,
|
systemPrompt: `You are a senior BFSI contract advisor. Answer questions about contracts accurately and professionally. Respond entirely in ${languageName}. Use plain text only — no markdown, no bold, no headers, no bullet points. Base your answers ONLY on the provided contract content. If information is missing, say so.`,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -1416,10 +1666,10 @@ Include one short disclaimer only when legal context is discussed: "This is gene
|
|||||||
topP: 0.95,
|
topP: 0.95,
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Q&A fallback with Groq model ${FALLBACK_ANALYSIS_MODEL} succeeded in ${languageName}`,
|
`✅ Q&A fallback with Mistral model ${FALLBACK_ANALYSIS_MODEL} succeeded in ${languageName}`,
|
||||||
);
|
);
|
||||||
} catch (groqError) {
|
} catch (mistralError) {
|
||||||
lastError = groqError;
|
lastError = mistralError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,11 +1694,11 @@ Include one short disclaimer only when legal context is discussed: "This is gene
|
|||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? error.message : String(error);
|
||||||
if (errorMessage.includes("API key")) {
|
if (errorMessage.includes("API key")) {
|
||||||
throw new Error("Invalid or missing AI API key (Gemini/Groq).");
|
throw new Error("Invalid or missing AI API key (Gemini/Mistral).");
|
||||||
}
|
}
|
||||||
if (this.isTransientGeminiError(errorMessage)) {
|
if (this.isTransientAIError(errorMessage)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Gemini is temporarily overloaded for the configured Q&A models (${ANALYSIS_MODELS.join(", ")}). Please try again in a few minutes.`,
|
`The AI providers (Gemini/Mistral) are temporarily overloaded for the configured Q&A models (${ANALYSIS_MODELS.join(", ")}). Please try again in a few minutes.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new Error(`Error answering question: ${errorMessage}`);
|
throw new Error(`Error answering question: ${errorMessage}`);
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ function toDateOrNull(value: unknown): string | null {
|
|||||||
function toStringList(value: unknown): string[] {
|
function toStringList(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value
|
return value
|
||||||
.map((item) => String(item ?? "").trim())
|
.map((item) => {
|
||||||
|
if (typeof item === "object" && item !== null) {
|
||||||
|
return Object.values(item).filter(Boolean).join(" - ");
|
||||||
|
}
|
||||||
|
return String(item ?? "").trim();
|
||||||
|
})
|
||||||
.filter((item) => item.length > 0)
|
.filter((item) => item.length > 0)
|
||||||
.slice(0, 25);
|
.slice(0, 25);
|
||||||
}
|
}
|
||||||
|
|||||||
280
lib/services/email.service.ts
Normal file
280
lib/services/email.service.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
interface ContractBlueprint {
|
||||||
|
type: string;
|
||||||
|
provider: string | null;
|
||||||
|
policyNumber: string | null;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
premium: number | null;
|
||||||
|
premiumCurrency: string | null;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockchainEmailData {
|
||||||
|
documentHash: string;
|
||||||
|
txHash: string;
|
||||||
|
blockNumber: number;
|
||||||
|
blockTimestamp: Date;
|
||||||
|
network: string;
|
||||||
|
contractAddress: string;
|
||||||
|
explorerUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContractAnalysisEmailInput {
|
||||||
|
to: string;
|
||||||
|
userDisplayName?: string | null;
|
||||||
|
contractId: string;
|
||||||
|
contractFileName: string;
|
||||||
|
contractTitle: string;
|
||||||
|
blueprint: ContractBlueprint;
|
||||||
|
blockchain?: BlockchainEmailData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transporter: nodemailer.Transporter | null = null;
|
||||||
|
let transportMode: "smtp" | "ethereal" | null = null;
|
||||||
|
let hasWarnedMissingEmailConfig = false;
|
||||||
|
|
||||||
|
const asBoolean = (value: string | undefined, fallback: boolean): boolean => {
|
||||||
|
if (!value) return fallback;
|
||||||
|
return value.toLowerCase() === "true" || value === "1";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmailConfigured = (): boolean => {
|
||||||
|
return Boolean(
|
||||||
|
process.env.EMAIL_HOST &&
|
||||||
|
process.env.EMAIL_PORT &&
|
||||||
|
process.env.EMAIL_USER &&
|
||||||
|
process.env.EMAIL_PASS,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const warnMissingEmailConfigOnce = () => {
|
||||||
|
if (hasWarnedMissingEmailConfig) return;
|
||||||
|
hasWarnedMissingEmailConfig = true;
|
||||||
|
console.warn(
|
||||||
|
"Email notifications are disabled. Configure EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, and MAIL_FROM to enable contract summary emails.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTransporter = async (): Promise<nodemailer.Transporter | null> => {
|
||||||
|
if (transporter) {
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmailConfigured()) {
|
||||||
|
transportMode = "smtp";
|
||||||
|
transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.EMAIL_HOST,
|
||||||
|
port: Number(process.env.EMAIL_PORT),
|
||||||
|
secure: asBoolean(
|
||||||
|
process.env.EMAIL_SECURE,
|
||||||
|
Number(process.env.EMAIL_PORT) === 465,
|
||||||
|
),
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER,
|
||||||
|
pass: process.env.EMAIL_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
const testAccount = await nodemailer.createTestAccount();
|
||||||
|
transportMode = "ethereal";
|
||||||
|
transporter = nodemailer.createTransport({
|
||||||
|
host: testAccount.smtp.host,
|
||||||
|
port: testAccount.smtp.port,
|
||||||
|
secure: testAccount.smtp.secure,
|
||||||
|
auth: {
|
||||||
|
user: testAccount.user,
|
||||||
|
pass: testAccount.pass,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
"Email service is running in development fallback mode using Ethereal. Configure SMTP env vars for real inbox delivery.",
|
||||||
|
);
|
||||||
|
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
warnMissingEmailConfigOnce();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPremium = (
|
||||||
|
premium: number | null,
|
||||||
|
currency: string | null,
|
||||||
|
): string => {
|
||||||
|
if (premium === null || premium === undefined) return "N/A";
|
||||||
|
const formattedAmount = new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(premium);
|
||||||
|
|
||||||
|
if (!currency) return formattedAmount;
|
||||||
|
if (["€", "$", "£"].includes(currency))
|
||||||
|
return `${currency}${formattedAmount}`;
|
||||||
|
return `${formattedAmount} ${currency}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateValue = (dateValue: string | null): string => {
|
||||||
|
if (!dateValue) return "N/A";
|
||||||
|
const date = new Date(dateValue);
|
||||||
|
if (Number.isNaN(date.getTime())) return dateValue;
|
||||||
|
return date.toISOString().split("T")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatContractLink = (contractId: string): string | null => {
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.APP_URL?.trim();
|
||||||
|
if (!baseUrl) return null;
|
||||||
|
return `${baseUrl.replace(/\/$/, "")}/contacts?contract=${contractId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class EmailService {
|
||||||
|
static async sendContractAnalysisCompletedEmail(
|
||||||
|
input: ContractAnalysisEmailInput,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
skipped?: boolean;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const mailer = await getTransporter();
|
||||||
|
if (!mailer) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
skipped: true,
|
||||||
|
error: "Email service not configured",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const from =
|
||||||
|
process.env.MAIL_FROM?.trim() ||
|
||||||
|
process.env.EMAIL_USER?.trim() ||
|
||||||
|
(transportMode === "ethereal"
|
||||||
|
? "LexiChain <no-reply@ethereal.email>"
|
||||||
|
: "");
|
||||||
|
if (!from) {
|
||||||
|
warnMissingEmailConfigOnce();
|
||||||
|
return { success: false, skipped: true, error: "MAIL_FROM is missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.to?.trim()) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
skipped: true,
|
||||||
|
error: "Recipient email is missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientName = input.userDisplayName || "there";
|
||||||
|
const premiumLabel = formatPremium(
|
||||||
|
input.blueprint.premium,
|
||||||
|
input.blueprint.premiumCurrency,
|
||||||
|
);
|
||||||
|
const contractUrl = formatContractLink(input.contractId);
|
||||||
|
const blockchainStatus = input.blockchain
|
||||||
|
? "Registered"
|
||||||
|
: "Not registered (blockchain unavailable or skipped)";
|
||||||
|
|
||||||
|
const textBody = [
|
||||||
|
`Hello ${recipientName},`,
|
||||||
|
"",
|
||||||
|
"Your contract analysis is complete.",
|
||||||
|
"",
|
||||||
|
"Blueprint:",
|
||||||
|
`- Contract title: ${input.contractTitle}`,
|
||||||
|
`- Original file: ${input.contractFileName}`,
|
||||||
|
`- Type: ${input.blueprint.type}`,
|
||||||
|
`- Provider: ${input.blueprint.provider ?? "N/A"}`,
|
||||||
|
`- Policy number: ${input.blueprint.policyNumber ?? "N/A"}`,
|
||||||
|
`- Start date: ${formatDateValue(input.blueprint.startDate)}`,
|
||||||
|
`- End date: ${formatDateValue(input.blueprint.endDate)}`,
|
||||||
|
`- Premium: ${premiumLabel}`,
|
||||||
|
"",
|
||||||
|
"Summary:",
|
||||||
|
input.blueprint.summary,
|
||||||
|
"",
|
||||||
|
"Blockchain proof:",
|
||||||
|
`- Status: ${blockchainStatus}`,
|
||||||
|
`- Document hash: ${input.blockchain?.documentHash ?? "N/A"}`,
|
||||||
|
`- Transaction hash: ${input.blockchain?.txHash ?? "N/A"}`,
|
||||||
|
`- Block number: ${input.blockchain?.blockNumber ?? "N/A"}`,
|
||||||
|
`- Block time: ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}`,
|
||||||
|
`- Network: ${input.blockchain?.network ?? "N/A"}`,
|
||||||
|
`- Contract address: ${input.blockchain?.contractAddress ?? "N/A"}`,
|
||||||
|
`- Explorer URL: ${input.blockchain?.explorerUrl ?? "N/A"}`,
|
||||||
|
"",
|
||||||
|
contractUrl ? `Open in app: ${contractUrl}` : "",
|
||||||
|
"",
|
||||||
|
"Keep this email for your records.",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const htmlBody = `
|
||||||
|
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #0f172a;">
|
||||||
|
<h2 style="margin-bottom: 12px;">Contract Analysis Completed</h2>
|
||||||
|
<p>Hello ${recipientName},</p>
|
||||||
|
<p>Your contract analysis has been completed successfully.</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blueprint</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Contract title:</strong> ${input.contractTitle}</li>
|
||||||
|
<li><strong>Original file:</strong> ${input.contractFileName}</li>
|
||||||
|
<li><strong>Type:</strong> ${input.blueprint.type}</li>
|
||||||
|
<li><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</li>
|
||||||
|
<li><strong>Policy number:</strong> ${input.blueprint.policyNumber ?? "N/A"}</li>
|
||||||
|
<li><strong>Start date:</strong> ${formatDateValue(input.blueprint.startDate)}</li>
|
||||||
|
<li><strong>End date:</strong> ${formatDateValue(input.blueprint.endDate)}</li>
|
||||||
|
<li><strong>Premium:</strong> ${premiumLabel}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 24px; margin-bottom: 8px;">Summary</h3>
|
||||||
|
<p>${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blockchain Proof</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Status:</strong> ${blockchainStatus}</li>
|
||||||
|
<li><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</li>
|
||||||
|
<li><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</li>
|
||||||
|
<li><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</li>
|
||||||
|
<li><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</li>
|
||||||
|
<li><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</li>
|
||||||
|
<li><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</li>
|
||||||
|
<li><strong>Explorer URL:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer">Open transaction</a>` : "N/A"}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
${contractUrl ? `<p><a href="${contractUrl}">Open this contract in your dashboard</a></p>` : ""}
|
||||||
|
<p style="margin-top: 24px; font-size: 12px; color: #475569;">Keep this email for your records.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const info = await mailer.sendMail({
|
||||||
|
from,
|
||||||
|
to: input.to,
|
||||||
|
subject: `Contract analyzed: ${input.contractTitle}`,
|
||||||
|
text: textBody,
|
||||||
|
html: htmlBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewUrl = nodemailer.getTestMessageUrl(info);
|
||||||
|
if (previewUrl) {
|
||||||
|
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, previewUrl };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send analysis completion email:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown email error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -40,6 +40,7 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@uploadthing/react": "^7.3.3",
|
"@uploadthing/react": "^7.3.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"motion": "^12.34.0",
|
"motion": "^12.34.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -3714,12 +3716,20 @@
|
|||||||
"version": "20.19.33",
|
"version": "20.19.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pako": {
|
"node_modules/@types/pako": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
@@ -7937,6 +7947,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||||
|
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -9962,7 +9981,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@uploadthing/react": "^7.3.3",
|
"@uploadthing/react": "^7.3.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
"motion": "^12.34.0",
|
"motion": "^12.34.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
100
test-mistral.js
Normal file
100
test-mistral.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const sysPrompt = `You are an expert contract analysis engine for the BFSI (Banking, Financial Services, and Insurance) sector.
|
||||||
|
You receive the full text content of a contract document and must extract structured information from it.
|
||||||
|
|
||||||
|
ABSOLUTE RULES — VIOLATION OF THESE IS A CRITICAL FAILURE:
|
||||||
|
1. Return ONLY valid, parseable JSON — no markdown, no backticks, no explanations, no commentary.
|
||||||
|
2. EVERY value you output MUST come directly from the document text provided to you.
|
||||||
|
3. If a piece of information does NOT exist in the document text, you MUST use null (for strings/numbers) or [] (for arrays). NEVER invent, assume, or guess data.
|
||||||
|
4. Do NOT copy example values from the schema description — they are placeholders, not real data.
|
||||||
|
5. The "extractedText" field MUST contain actual verbatim text from the document — not a summary, not examples.
|
||||||
|
|
||||||
|
JSON SCHEMA (use exact field names):
|
||||||
|
{
|
||||||
|
"language": "<ISO 639-1 code detected from document>",
|
||||||
|
"title": "<exact contract title from document or null>",
|
||||||
|
"type": "<one of: INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER>",
|
||||||
|
"provider": "<company/institution name from document or null>",
|
||||||
|
"policyNumber": "<policy/contract number from document or null>",
|
||||||
|
"startDate": "<YYYY-MM-DD from document or null>",
|
||||||
|
"endDate": "<YYYY-MM-DD from document or null>",
|
||||||
|
"premium": <number from document or null — NO currency symbols>,
|
||||||
|
"premiumCurrency": "<currency code from document or null>",
|
||||||
|
"summary": "<4-6 sentences summarizing the actual contract content>",
|
||||||
|
"keyPoints": {
|
||||||
|
"guarantees": ["<actual guarantee from document>"],
|
||||||
|
"exclusions": ["<actual exclusion from document>"],
|
||||||
|
"franchise": "<deductible/penalty from document or null>",
|
||||||
|
"importantDates": ["<actual date from document with description>"],
|
||||||
|
"explainability": [
|
||||||
|
{
|
||||||
|
"field": "<field name>",
|
||||||
|
"why": "<why this value was extracted>",
|
||||||
|
"sourceSnippet": "<verbatim quote from document>",
|
||||||
|
"sourceHints": { "page": "<page or null>", "section": "<section or null>", "confidence": <0-100> }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"keyPeople": [{"name": "<from document>", "role": "<from document or null>", "email": "<from document or null>", "phone": "<from document or null>"}],
|
||||||
|
"contactInfo": {"name": "<from document or null>", "email": null, "phone": null, "address": null, "role": null},
|
||||||
|
"importantContacts": [],
|
||||||
|
"relevantDates": [{"date": "<YYYY-MM-DD>", "description": "<from document>", "type": "<EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER>"}],
|
||||||
|
"extractedText": "<verbatim text from the document, max 12000 chars>",
|
||||||
|
"contractValidation": {
|
||||||
|
"isValidContract": true,
|
||||||
|
"confidence": <0-100 reflecting how much data you actually found>,
|
||||||
|
"reason": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FIELD RULES:
|
||||||
|
- All dates: ISO YYYY-MM-DD or null
|
||||||
|
- premium: positive number or null — NO currency symbols, NO text
|
||||||
|
- type: must be exactly one of the 8 values listed
|
||||||
|
- summary: 4-6 professional sentences about THIS specific contract
|
||||||
|
- extractedText: must contain at least 30 characters of ACTUAL document content
|
||||||
|
- explainability: at least 4 items with real sourceSnippets from the document
|
||||||
|
- confidence: reflects how much data you actually found (not how confident the model is)
|
||||||
|
- Parse localized number formats correctly (1.234,56 vs 1,234.56)
|
||||||
|
- Detect the contract language and set "language" accordingly
|
||||||
|
|
||||||
|
You are replacing a more capable multimodal model (Gemini) as a fallback. Your output quality MUST match production standards. ACCURACY is more important than completeness — it is better to return null than to guess.`;
|
||||||
|
|
||||||
|
const prompt = `--- BEGIN GROUNDED DOCUMENT TEXT (AUTHORITATIVE SOURCE) ---
|
||||||
|
CONFIDENTIALITY AGREEMENT
|
||||||
|
This Confidentiality Agreement (the "Agreement") is entered into as of May 1, 2025 (the "Effective Date"), by and between Acme Corp ("Disclosing Party") and Beta Inc ("Receiving Party").
|
||||||
|
1. Confidential Information. "Confidential Information" means all non-public information disclosed by the Disclosing Party to the Receiving Party.
|
||||||
|
2. Obligations. The Receiving Party shall hold and maintain the Confidential Information in strictest confidence.
|
||||||
|
3. Term. This Agreement shall remain in effect for a period of two (2) years from the Effective Date.
|
||||||
|
Signatures:
|
||||||
|
John Doe, CEO Acme Corp
|
||||||
|
Jane Smith, VP Beta Inc
|
||||||
|
--- END GROUNDED DOCUMENT TEXT ---
|
||||||
|
|
||||||
|
MISTRAL FALLBACK RULES:
|
||||||
|
- Extract fields ONLY from the grounded document text above. This text is the full contract content.
|
||||||
|
- Do not invent, assume, or hallucinate any values not explicitly present in the above text.
|
||||||
|
- If a field's data is not found in the text, use null (for strings/numbers) or [] (for arrays).
|
||||||
|
- Dates: convert any date format found in the text to YYYY-MM-DD.
|
||||||
|
- Numbers: parse localized formats (comma vs period) correctly before setting numeric fields.
|
||||||
|
- contractValidation.confidence should reflect how much data you could extract from the text.
|
||||||
|
IMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object.`;
|
||||||
|
|
||||||
|
fetch('https://api.mistral.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer 7yRx3izDA2ECDblZvAaUoZhgQnYqiiKj',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-large-latest',
|
||||||
|
temperature: 0,
|
||||||
|
top_p: 1,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: sysPrompt },
|
||||||
|
{ role: 'user', content: prompt }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}).then(r => r.json()).then(data => console.log(data.choices[0].message.content)).catch(console.error);
|
||||||
Reference in New Issue
Block a user