Pre-Final Backup

This commit is contained in:
2026-05-03 13:26:31 +01:00
parent cd11e76c07
commit 165af509ef
19 changed files with 2829 additions and 1223 deletions

View File

@@ -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)

View File

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

View File

@@ -0,0 +1,5 @@
import { InvalidRouteScreen } from "@/components/layout/invalid-route-screen";
export default function NotFound() {
return <InvalidRouteScreen />;
}

View 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
View File

@@ -0,0 +1,5 @@
import { InvalidRouteScreen } from "@/components/layout/invalid-route-screen";
export default function NotFound() {
return <InvalidRouteScreen />;
}

View 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>
);
}

View File

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

View 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.

View 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.

View File

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

View File

@@ -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");

View File

@@ -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: &quot;{debouncedSearchQuery}&quot; {contracts.length} result{contracts.length !== 1 ? "s" : ""} for
&quot;{debouncedSearchQuery}&quot;
</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>

View File

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

View File

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

View 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
View File

@@ -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": {

View File

@@ -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
View 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);