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
- Optional secondary Gemini model: AI_MODEL_SECONDARY_GEMINI
- Fallback model provider: Groq (default: llama-3.3-70b-versatile)
- Gemini models are de-duplicated and iterated in order before Groq fallback
- Groq extraction fallback currently applies to image inputs in this pipeline; JSON repair and Q&A fallback are text-based
- Fallback model provider: Mistral AI (default: mistral-large-latest, vision: pixtral-large-latest)
- Gemini models are de-duplicated and iterated in order before Mistral fallback
- 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
- AI_API_KEY (or AI_API_KEY2 / AI_API_KEY3 fallback)
- AI_MODEL_PRIMARY (optional override)
- AI_MODEL_SECONDARY_GEMINI (optional override)
- AI_MODEL_SECONDARY (legacy alias supported for compatibility)
- 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
@@ -206,7 +208,7 @@ sequenceDiagram
participant AIS as AIService
participant GP as Gemini Primary
participant GS as Gemini Secondary (optional)
participant GR as Groq Fallback
participant MR as Mistral AI Fallback
AIS->>GP: generate analysis (strict JSON)
alt GP success with usable output
@@ -220,17 +222,17 @@ sequenceDiagram
alt lenient success
GP-->>AIS: raw text
else lenient fails
AIS->>GR: generate analysis (strict JSON)
GR-->>AIS: text
AIS->>MR: generate analysis (strict JSON)
MR-->>AIS: text
end
end
end
AIS->>AIS: parseJsonResponse
alt parse failed
AIS->>GR: repairMalformedJson(originalText, parseError)
AIS->>MR: repairMalformedJson(originalText, parseError)
alt repair success
GR-->>AIS: repaired JSON text
MR-->>AIS: repaired JSON text
AIS->>AIS: parse repaired JSON
else repair failed
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 { useState, useEffect } from "react";
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() {
const [refreshTrigger, setRefreshTrigger] = useState(0);
@@ -42,69 +51,217 @@ export default function ContactsPage() {
if (isChecking) {
return (
<>
<div className="min-h-screen bg-background text-foreground overflow-hidden">
<div className="fixed inset-0 overflow-hidden 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/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-8 right-1/3 w-96 h-96 bg-secondary/20 rounded-full blur-3xl animate-blob animation-delay-4000"></div>
</div>
<main className="relative z-10 flex flex-col h-screen overflow-auto items-center justify-center">
<div className="text-center">
<div className="mb-4 inline-block p-4 bg-background dark:bg-card rounded-full border border-border/50">
<div className="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin"></div>
</div>
<p className="text-muted-foreground">Loading...</p>
</div>
</main>
<div className="min-h-screen bg-background text-foreground relative overflow-hidden">
{/* Ambient loading background */}
<div className="fixed inset-0 pointer-events-none">
<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 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 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>
</>
<main className="relative z-10 flex flex-col h-screen items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
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>
<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>
</div>
);
}
return (
<>
<div className="min-h-screen bg-background text-foreground">
<main className="flex flex-col min-h-screen">
<ContractsHeader />
<div className="min-h-screen bg-background text-foreground relative overflow-hidden">
{/* Ambient Background */}
<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>
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8 space-y-8">
<Card className="rounded-2xl border-border/60 p-6 md:p-8">
<div className="mb-6">
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight">
<ContractsHeader />
<main className="relative z-10 flex flex-col min-h-[calc(100vh-64px)]">
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 lg:px-8 py-10 space-y-10">
{/* Upload Section */}
<motion.section
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
</h2>
<p className="mt-2 text-sm md:text-base text-muted-foreground">
Add PDF contracts and let the AI pipeline extract summary,
key points, and legal-business insights.
<p className="text-xs text-muted-foreground">
PDF documents supported
</p>
</div>
<ContractUploadForm onUploadSuccess={handleUploadSuccess} />
</Card>
</div>
<Card className="rounded-2xl border-border/60 p-6 md:p-8">
<div className="mb-6">
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight">
Your Contracts
</h2>
<p className="mt-2 text-sm md:text-base text-muted-foreground">
Review contract lifecycle, trigger analysis, and ask AI
questions per file.
</p>
<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} />
</div>
</div>
</motion.section>
{/* Contracts List Section */}
<motion.section
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
</h2>
<p className="text-xs text-muted-foreground">
Manage, analyze, and query your document library
</p>
</div>
</div>
{showContracts ? (
<ContractsList refreshTrigger={refreshTrigger} />
) : (
<EmptyContractsState />
{showContracts && (
<motion.button
initial={{ opacity: 0 }}
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>
<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>
</main>
</div>
</>
</div>
</main>
</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)
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"
- Deadlines could be disputed
- 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.
We use the blockchain as a **digital notary**:
1. We take the uploaded contract PDF
2. We create a unique **fingerprint** (hash) of that file
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?
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)
- The machine executes its programmed logic
- The result is permanent and visible to everyone
Our smart contract (`DocumentRegistry.sol`) has two main functions:
- **Register**: Store a document fingerprint with a timestamp
- **Verify**: Check if a fingerprint exists and when it was stored
@@ -64,14 +68,14 @@ Our smart contract (`DocumentRegistry.sol`) has two main functions:
### Features Implemented
| Feature | Description |
|---------|-------------|
| **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 |
| **Document Verification** | Paste any document hash to check if it exists on-chain |
| **Transaction Explorer** | View all blockchain transactions with details |
| **Network Stats** | Live stats: verified documents, latest block, network status |
| **Proof Badges** | Contract list shows which contracts are blockchain-verified |
| Feature | Description |
| ------------------------- | --------------------------------------------------------------------------- |
| **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 |
| **Document Verification** | Paste any document hash to check if it exists on-chain |
| **Transaction Explorer** | View all blockchain transactions with details |
| **Network Stats** | Live stats: verified documents, latest block, network status |
| **Proof Badges** | Contract list shows which contracts are blockchain-verified |
### What Happens When a User Uploads a Contract?
@@ -80,6 +84,7 @@ User uploads PDF → AI analyzes it → Blockchain registers the hash
```
The entire flow is automatic. The user doesn't need:
- ❌ MetaMask or any wallet
- ❌ Cryptocurrency knowledge
- ❌ To pay anything
@@ -129,10 +134,10 @@ flowchart TD
### Network Modes
| Mode | When | URL | Cost |
|------|------|-----|------|
| **Hardhat** | Development | `http://127.0.0.1:8545` | Free (local) |
| **Sepolia** | Demo/Presentation | Via Alchemy/Infura RPC | Free (testnet) |
| Mode | When | URL | Cost |
| ----------- | ----------------- | ----------------------- | -------------- |
| **Hardhat** | Development | `http://127.0.0.1:8545` | Free (local) |
| **Sepolia** | Demo/Presentation | Via Alchemy/Infura RPC | Free (testnet) |
The mode is controlled by a single env variable: `BLOCKCHAIN_NETWORK`.
@@ -178,12 +183,14 @@ flowchart LR
```
#### `registerDocument(bytes32 _docHash)`
- **Purpose**: Store a document hash on-chain
- **Access**: Only the contract owner (our server wallet)
- **Guard**: Prevents duplicate registration (same hash can't be registered twice)
- **Event**: Emits `DocumentRegistered` for off-chain indexing
#### `verifyDocument(bytes32 _docHash)`
- **Purpose**: Check if a hash exists and get its details
- **Cost**: Free (read-only, no gas)
- **Returns**: `(exists, timestamp, depositor)`
@@ -231,6 +238,7 @@ flowchart LR
### Why Server-Side?
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
- The platform manages documents, not individual users
- Server-side signing is more reliable
@@ -260,14 +268,14 @@ sequenceDiagram
### Key Methods
| Method | Purpose | Gas Cost |
|--------|---------|----------|
| `hashDocument(fileUrl)` | Download file + compute SHA-256 | None (off-chain) |
| `registerOnChain(hash, fileName)` | Send tx to smart contract | ~50,000 gas |
| `verifyOnChain(hash)` | Read-only check | Free |
| `hashAndRegister(fileUrl, fileName)` | Combined: hash + register | ~50,000 gas |
| `getNetworkStats()` | Get block number, total docs | Free |
| `isConfigured()` | Check if env vars are set | None |
| Method | Purpose | Gas Cost |
| ------------------------------------ | ------------------------------- | ---------------- |
| `hashDocument(fileUrl)` | Download file + compute SHA-256 | None (off-chain) |
| `registerOnChain(hash, fileName)` | Send tx to smart contract | ~50,000 gas |
| `verifyOnChain(hash)` | Read-only check | Free |
| `hashAndRegister(fileUrl, fileName)` | Combined: hash + register | ~50,000 gas |
| `getNetworkStats()` | Get block number, total docs | Free |
| `isConfigured()` | Check if env vars are set | None |
### Graceful Degradation
@@ -424,6 +432,7 @@ sequenceDiagram
participant SA as Server Action
participant AI as AI Service
participant BS as BlockchainService
participant ES as EmailService
participant SC as Smart Contract
participant DB as PostgreSQL
@@ -446,6 +455,8 @@ sequenceDiagram
SA->>DB: Save txHash, blockNumber, etc.
SA->>DB: Create BlockchainTransaction
SA->>ES: Send analysis + blockchain proof email
ES-->>U: Email received (or Ethereal preview in dev)
SA-->>UI: Success!
Note over U,UI: User visits /blockchain
@@ -470,6 +481,7 @@ sequenceDiagram
## 10. How to Run Locally
### Prerequisites
- Node.js installed
- 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
After deploying, transactions will have real Etherscan links:
```
https://sepolia.etherscan.io/tx/0x...
```
@@ -570,19 +583,20 @@ https://sepolia.etherscan.io/tx/0x...
## 12. Technology Choices & Rationale
| Technology | Why We Chose It |
|-----------|----------------|
| **Solidity 0.8.24** | Latest stable version with built-in overflow protection |
| **Hardhat** | Industry standard for Solidity development, free local blockchain |
| **ethers.js v6** | Modern, lightweight, TypeScript-native Ethereum library |
| **SHA-256** | Standard cryptographic hash, deterministic, collision-resistant |
| **Server-side wallet** | Users don't need MetaMask; enterprise-grade UX |
| **Sepolia testnet** | Official Ethereum testnet, free, has Etherscan explorer |
| **Graceful degradation** | Blockchain is optional; app works perfectly without it |
| Technology | Why We Chose It |
| ------------------------ | ----------------------------------------------------------------- |
| **Solidity 0.8.24** | Latest stable version with built-in overflow protection |
| **Hardhat** | Industry standard for Solidity development, free local blockchain |
| **ethers.js v6** | Modern, lightweight, TypeScript-native Ethereum library |
| **SHA-256** | Standard cryptographic hash, deterministic, collision-resistant |
| **Server-side wallet** | Users don't need MetaMask; enterprise-grade UX |
| **Sepolia testnet** | Official Ethereum testnet, free, has Etherscan explorer |
| **Graceful degradation** | Blockchain is optional; app works perfectly without it |
### Why NOT Web3j / Java?
The original project spec suggested Web3j (Java library). We chose ethers.js instead because:
1. Our backend is **Next.js/TypeScript**, not Spring Boot
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
@@ -592,64 +606,70 @@ The original project spec suggested Web3j (Java library). We chose ethers.js ins
## 13. File Reference
### Smart Contract Layer
| File | Purpose |
|------|---------|
| File | Purpose |
| ------------------------------------------- | ----------------------- |
| `blockchain/contracts/DocumentRegistry.sol` | Solidity smart contract |
| `blockchain/test/DocumentRegistry.test.ts` | 14 comprehensive tests |
| `blockchain/scripts/deploy.ts` | Deployment script |
| `blockchain/hardhat.config.ts` | Hardhat configuration |
| `blockchain/package.json` | Hardhat dependencies |
| `blockchain/test/DocumentRegistry.test.ts` | 14 comprehensive tests |
| `blockchain/scripts/deploy.ts` | Deployment script |
| `blockchain/hardhat.config.ts` | Hardhat configuration |
| `blockchain/package.json` | Hardhat dependencies |
### Service Layer
| File | Purpose |
|------|---------|
| File | Purpose |
| ------------------------------------ | ---------------------------- |
| `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
| File | Purpose |
|------|---------|
| `features/blockchain/api/blockchain.action.ts` | Blockchain server actions |
| `features/contracts/api/contract.action.ts` | Updated with auto-registration |
| File | Purpose |
| ---------------------------------------------- | ------------------------------ |
| `features/blockchain/api/blockchain.action.ts` | Blockchain server actions |
| `features/contracts/api/contract.action.ts` | Updated with auto-registration |
### Frontend
| File | Purpose |
|------|---------|
| `app/(dashboard)/blockchain/page.tsx` | Blockchain Explorer page |
| `app/(dashboard)/blockchain/layout.tsx` | Page metadata |
| `components/layout/navigation.tsx` | Updated with blockchain link |
| File | Purpose |
| --------------------------------------- | ---------------------------- |
| `app/(dashboard)/blockchain/page.tsx` | Blockchain Explorer page |
| `app/(dashboard)/blockchain/layout.tsx` | Page metadata |
| `components/layout/navigation.tsx` | Updated with blockchain link |
### Database
| File | Purpose |
|------|---------|
| File | Purpose |
| ---------------------- | ------------------------------ |
| `prisma/schema.prisma` | Updated with blockchain fields |
### Configuration
| File | Purpose |
|------|---------|
| `.env` | Blockchain env vars |
| `.env.example` | Template for new developers |
| `.gitignore` | Blockchain artifacts excluded |
| File | Purpose |
| -------------- | ----------------------------- |
| `.env` | Blockchain env vars |
| `.env.example` | Template for new developers |
| `.gitignore` | Blockchain artifacts excluded |
---
## Glossary
| Term | Definition |
|------|-----------|
| **Hash** | A fixed-size fingerprint of data. Same input → same output. |
| **SHA-256** | A specific hash algorithm producing 256-bit (32-byte) outputs |
| **Smart Contract** | A program stored on the blockchain that executes automatically |
| **Gas** | The fee for executing operations on Ethereum (free on testnet) |
| **Block** | A batch of transactions grouped together on the blockchain |
| **Transaction (Tx)** | A single operation on the blockchain (e.g., registering a hash) |
| **Tx Hash** | A unique identifier for a transaction (like a receipt number) |
| **Block Number** | The sequential number of the block containing a transaction |
| **Block Timestamp** | The time the block was created (proof of when the tx happened) |
| **Private Key** | Secret key used to sign transactions (like a password) |
| **Address** | Public identifier derived from the private key (like a username) |
| **ABI** | Application Binary Interface — the "API spec" of a smart contract |
| **Hardhat** | Development tool for writing, testing, and deploying smart contracts |
| **Sepolia** | Ethereum test network for free experimentation |
| **ethers.js** | JavaScript library for interacting with the Ethereum blockchain |
| **Faucet** | A service that gives free test ETH for development |
| Term | Definition |
| -------------------- | -------------------------------------------------------------------- |
| **Hash** | A fixed-size fingerprint of data. Same input → same output. |
| **SHA-256** | A specific hash algorithm producing 256-bit (32-byte) outputs |
| **Smart Contract** | A program stored on the blockchain that executes automatically |
| **Gas** | The fee for executing operations on Ethereum (free on testnet) |
| **Block** | A batch of transactions grouped together on the blockchain |
| **Transaction (Tx)** | A single operation on the blockchain (e.g., registering a hash) |
| **Tx Hash** | A unique identifier for a transaction (like a receipt number) |
| **Block Number** | The sequential number of the block containing a transaction |
| **Block Timestamp** | The time the block was created (proof of when the tx happened) |
| **Private Key** | Secret key used to sign transactions (like a password) |
| **Address** | Public identifier derived from the private key (like a username) |
| **ABI** | Application Binary Interface — the "API spec" of a smart contract |
| **Hardhat** | Development tool for writing, testing, and deploying smart contracts |
| **Sepolia** | Ethereum test network for free experimentation |
| **ethers.js** | JavaScript library for interacting with the Ethereum blockchain |
| **Faucet** | A service that gives free test ETH for development |

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> = {
Uploaded: "hsl(38 92% 50%)",
Processing: "hsl(var(--primary))",
Processing: "hsl(217 91% 60%)",
Analyzed: "hsl(160 84% 39%)",
Failed: "hsl(var(--destructive))",
Failed: "hsl(0 84% 60%)",
};
const FALLBACK_COLORS = [
"hsl(var(--primary))",
"hsl(var(--secondary))",
"hsl(var(--accent))",
"hsl(var(--destructive))",
"hsl(217 91% 60%)",
"hsl(260 89% 65%)",
"hsl(190 85% 50%)",
"hsl(340 82% 52%)",
];
const tooltipStyle = {
backgroundColor: "hsl(var(--background))",
border: "1px solid hsl(var(--border))",
borderRadius: "12px",
backgroundColor: "hsl(var(--background) / 0.95)",
border: "1px solid hsl(var(--border) / 0.6)",
borderRadius: "16px",
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 }) {
@@ -72,64 +106,82 @@ export function TrendChart({ data }: { data: TrendData }) {
<defs>
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="hsl(var(--primary))"
stopOpacity={0.65}
offset="0%"
stopColor="hsl(217 91% 60%)"
stopOpacity={0.5}
/>
<stop
offset="95%"
stopColor="hsl(var(--primary))"
stopOpacity={0.05}
offset="60%"
stopColor="hsl(217 91% 60%)"
stopOpacity={0.15}
/>
<stop
offset="100%"
stopColor="hsl(217 91% 60%)"
stopOpacity={0.02}
/>
</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>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
strokeDasharray="3 6"
stroke="hsl(var(--border) / 0.4)"
vertical={false}
/>
<XAxis
dataKey="date"
stroke="hsl(var(--muted-foreground))"
stroke="hsl(var(--muted-foreground) / 0.5)"
interval={xAxisInterval}
tick={{ fontSize: 12 }}
tick={{ fontSize: 11, fontWeight: 500 }}
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
stroke="hsl(var(--muted-foreground) / 0.5)"
allowDecimals={false}
tick={{ fontSize: 11, fontWeight: 500 }}
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<Tooltip
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"];
}}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="count"
stroke="hsl(var(--primary))"
strokeWidth={2.25}
stroke="url(#trendStroke)"
strokeWidth={2.5}
fillOpacity={1}
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
type="monotone"
dataKey="movingAverage"
stroke="hsl(var(--secondary))"
stroke="url(#avgStroke)"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
/>
</AreaChart>
@@ -152,43 +204,65 @@ export function ContractTypeChart({ data }: { data: TypeData }) {
layout="vertical"
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
strokeDasharray="3 3"
stroke="hsl(var(--border))"
strokeDasharray="3 6"
stroke="hsl(var(--border) / 0.4)"
horizontal={false}
/>
<XAxis
type="number"
stroke="hsl(var(--muted-foreground))"
stroke="hsl(var(--muted-foreground) / 0.5)"
allowDecimals={false}
tick={{ fontSize: 12 }}
tick={{ fontSize: 11, fontWeight: 500 }}
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
type="category"
dataKey="type"
width={128}
stroke="hsl(var(--muted-foreground))"
tick={{ fontSize: 12 }}
stroke="hsl(var(--muted-foreground) / 0.5)"
tick={{ fontSize: 11, fontWeight: 600 }}
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<Tooltip
contentStyle={tooltipStyle}
cursor={false}
formatter={(value: number | string | undefined) => [
Number(value ?? 0),
"Files",
]}
content={<CustomTooltip />}
cursor={{ fill: "hsl(var(--muted) / 0.15)", radius: 8 }}
/>
<Bar dataKey="count" radius={[0, 8, 8, 0]}>
<Bar dataKey="count" radius={[0, 10, 10, 0]} maxBarSize={32}>
{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 (
<Cell
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">
<ResponsiveContainer width="100%" height="100%">
<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
data={data}
cx="50%"
cy="50%"
innerRadius={62}
outerRadius={94}
paddingAngle={3}
innerRadius={58}
outerRadius={88}
paddingAngle={4}
dataKey="count"
stroke="hsl(var(--background))"
strokeWidth={2}
strokeWidth={3}
cornerRadius={6}
>
{data.map((entry, index) => (
<Cell
@@ -228,44 +312,40 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
PIE_COLORS[entry.name] ??
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>
{total > 0 && (
<text
x="50%"
y="50%"
y="48%"
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x="50%"
y="50%"
className="fill-foreground text-base font-semibold"
y="48%"
className="fill-foreground text-xl font-bold tracking-tight"
>
{total}
{total.toLocaleString()}
</tspan>
<tspan
x="50%"
dy="16"
className="fill-muted-foreground text-[11px]"
dy="18"
className="fill-muted-foreground text-[11px] font-medium uppercase tracking-wider"
>
Files
</tspan>
</text>
)}
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number | string | undefined) => [
Number(value ?? 0),
"Files",
]}
/>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
</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) => {
const color =
PIE_COLORS[item.name] ??
@@ -274,16 +354,16 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
return (
<div
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
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: color }}
className="h-2.5 w-2.5 rounded-full ring-2 ring-offset-1 ring-offset-background"
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}
</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}
</span>
</div>

View File

@@ -20,6 +20,7 @@
"use server";
import { auth } from "@clerk/nextjs/server";
import { clerkClient } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
import {
ContractService,
@@ -29,6 +30,7 @@ import { AIService } from "@/lib/services/ai.service";
import { RAGService } from "@/lib/services/rag.service";
import { NotificationService } from "@/lib/services/notification.service";
import { BlockchainService } from "@/lib/services/blockchain.service";
import { EmailService } from "@/lib/services/email.service";
import { prisma } from "@/lib/db/prisma";
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,
txHash: contract.txHash || null,
blockNumber: contract.blockNumber || null,
blockTimestamp: contract.blockTimestamp ? contract.blockTimestamp.toISOString() : null,
blockTimestamp: contract.blockTimestamp
? contract.blockTimestamp.toISOString()
: null,
blockchainNetwork: contract.blockchainNetwork || null,
contractAddress: contract.contractAddress || null,
}));
@@ -517,6 +521,16 @@ export async function analyzeContractAction(id: string) {
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
// This is non-blocking — if blockchain fails, analysis still succeeds
@@ -525,7 +539,7 @@ export async function analyzeContractAction(id: string) {
if (BlockchainService.isConfigured()) {
const proof = await BlockchainService.hashAndRegister(
contract.fileUrl,
contract.fileName
contract.fileName,
);
// 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) {
// 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
});
// 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("/dashboard");

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,18 @@ import { keyManager } from "@/lib/services/ai/key-manager";
const PRIMARY_ANALYSIS_MODEL =
process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview";
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 =
process.env.AI_MODEL_FALLBACK || "llama-3.3-70b-versatile";
process.env.AI_MODEL_FALLBACK || "mistral-large-latest";
const FALLBACK_REPAIR_MODEL =
process.env.AI_MODEL_FALLBACK_REPAIR || "llama-3.3-70b-versatile";
const GROQ_API_KEY =
process.env.GROQ_API_KEY?.trim() || process.env.AI_GROQ_API_KEY?.trim() || "";
const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions";
process.env.AI_MODEL_FALLBACK_REPAIR || "mistral-large-latest";
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY?.trim() || "";
const MISTRAL_API_URL = "https://api.mistral.ai/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(
new Set(
@@ -36,7 +40,7 @@ const GEMINI_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 =
@@ -89,7 +93,7 @@ const isAdaptiveKeyPoints = (
};
export class AIService {
private static isTransientGeminiError(message: string): boolean {
private static isTransientAIError(message: string): boolean {
const normalized = message.toLowerCase();
return (
normalized.includes("503") ||
@@ -282,7 +286,7 @@ export class AIService {
// Better error messages
if (errorMessage.includes("API key")) {
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:")) {
const reason = String(errorMessage)
@@ -291,9 +295,9 @@ export class AIService {
throw new Error(
reason || "Uploaded file is not recognized as a valid contract.",
);
} else if (this.isTransientGeminiError(errorMessage)) {
} else if (this.isTransientAIError(errorMessage)) {
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 (
errorMessage.includes("not found") ||
@@ -337,7 +341,7 @@ export class AIService {
}
} else if (errorMessage.includes("quota")) {
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 {
throw new Error(`Error analyzing contract: ${errorMessage}`);
@@ -389,11 +393,11 @@ export class AIService {
return parseAiJsonResponse(text);
}
private static isGroqConfigured(): boolean {
return GROQ_API_KEY.length > 0;
private static isMistralConfigured(): boolean {
return MISTRAL_API_KEY.length > 0;
}
private static async generateWithGroq(input: {
private static async generateWithMistral(input: {
model?: string;
prompt: string;
systemPrompt?: string;
@@ -402,9 +406,9 @@ export class AIService {
temperature?: number;
topP?: number;
}): Promise<string> {
if (!this.isGroqConfigured()) {
if (!this.isMistralConfigured()) {
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 });
// Use json_object mode (compatible with all models)
const responseFormat: Record<string, unknown> | undefined = input.responseAsJson
? { type: "json_object" as const }
: undefined;
const responseFormat: Record<string, unknown> | undefined =
input.responseAsJson ? { type: "json_object" as const } : undefined;
const temperature = input.temperature ?? 0;
const top_p = temperature === 0 ? 1 : (input.topP ?? 0.95);
const body: Record<string, unknown> = {
model: modelName,
temperature: input.temperature ?? 0,
top_p: input.topP ?? 0.95,
temperature,
top_p,
max_tokens: input.maxOutputTokens,
response_format: responseFormat,
messages,
};
const response = await fetch(GROQ_API_URL, {
const response = await fetch(MISTRAL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${GROQ_API_KEY}`,
Authorization: `Bearer ${MISTRAL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
@@ -443,7 +449,7 @@ export class AIService {
if (!response.ok) {
const details = await response.text();
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() || "";
if (!text) {
throw new Error("Empty response from Groq fallback model.");
throw new Error("Empty response from Mistral fallback model.");
}
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;
prompt: string;
systemPrompt?: string;
@@ -473,9 +558,9 @@ export class AIService {
[
input.preferredModel,
FALLBACK_ANALYSIS_MODEL,
"llama-3.3-70b-versatile",
"qwen-2.5-32b",
"llama-3.1-8b-instant",
"mistral-large-latest",
"mistral-small-latest",
"open-mistral-nemo",
].filter(Boolean),
),
) as string[];
@@ -484,7 +569,7 @@ export class AIService {
for (const modelName of candidates) {
try {
const text = await this.generateWithGroq({
const text = await this.generateWithMistral({
model: modelName,
prompt: input.prompt,
systemPrompt: input.systemPrompt,
@@ -495,14 +580,14 @@ export class AIService {
});
if (modelName !== (input.preferredModel || FALLBACK_ANALYSIS_MODEL)) {
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;
} catch (error) {
lastError = error;
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),
);
}
@@ -510,36 +595,79 @@ export class AIService {
throw lastError instanceof Error
? 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
* 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.
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.
2. Your JSON must conform EXACTLY to the schema specified in the user prompt.
3. Every required field MUST be present. Use null for missing strings/numbers and [] for missing arrays.
4. All dates MUST be in ISO YYYY-MM-DD format or null.
5. The "premium" field must be a positive number or null — NO currency symbols.
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).
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.
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: {
@@ -551,27 +679,52 @@ You are replacing a more capable multimodal model (Gemini) as a fallback. Your o
let lastError: unknown = null;
const forceFallback = Boolean(input.forceFallbackModelTest);
const buildGroundedGroqPrompt = async (basePrompt: string) => {
const groundingText = await this.extractGroqGroundingText({
const buildGroundedMistralPrompt = async () => {
const groundingText = await this.extractMistralGroundingText({
base64: input.base64,
mimeType: input.mimeType,
});
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) {
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,
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.`,
responseAsJson: true,
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");
});
} catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
lastError = error;
console.warn(
`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");
});
} catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
console.warn("Lenient generation also failed:", error);
}
// === Groq fallback path ===
// === Mistral AI fallback path ===
console.warn(
"All Gemini models exhausted. Activating Groq fallback pipeline...",
"All Gemini models exhausted. Activating Mistral AI fallback pipeline...",
);
try {
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt);
const groqText = await this.generateWithGroqModelChain({
// For images: use Pixtral vision model directly (multimodal — no OCR bridge needed)
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,
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.`,
responseAsJson: true,
maxOutputTokens: 8192,
});
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
? lastError
: 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:
${malformedResponse.slice(0, 14000)}`;
const repairedText = await this.generateWithGroqModelChain({
const repairedText = await this.generateWithMistralModelChain({
preferredModel: FALLBACK_REPAIR_MODEL,
prompt: repairPrompt,
responseAsJson: true,
@@ -766,7 +937,7 @@ ${malformedResponse.slice(0, 14000)}`;
} 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 secondPass = await this.generateWithGroqModelChain({
const secondPass = await this.generateWithMistralModelChain({
preferredModel: FALLBACK_REPAIR_MODEL,
prompt: secondPassPrompt,
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;
mimeType: string;
}): Promise<string> {
@@ -793,13 +969,43 @@ ${malformedResponse.slice(0, 14000)}`;
if (input.mimeType === "application/pdf") {
try {
const pdfBuffer = Buffer.from(input.base64, "base64");
const { PDFParse } = await import("pdf-parse");
const parser = new PDFParse({ data: pdfBuffer });
let parsed: { text?: string };
// Handle Next.js Webpack/Turbopack CJS/ESM interop
let pdfParseModule: any;
try {
parsed = await parser.getText();
} finally {
await parser.destroy();
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 };
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 {
parsed = await parser.getText();
} finally {
if (typeof parser.destroy === "function") {
await parser.destroy();
}
}
}
const text = (parsed?.text || "")
@@ -807,66 +1013,110 @@ ${malformedResponse.slice(0, 14000)}`;
.replace(/\n{3,}/g, "\n\n")
.trim();
if (text && text.length > 50) {
if (text && text.length >= 10) {
console.log(
`📄 Groq grounding: extracted ${text.length} chars from PDF`,
`📄 Mistral grounding: extracted ${text.length} chars from PDF`,
);
return text.slice(0, 50000);
}
console.warn(
`📄 Mistral grounding: native PDF text extraction too short (length: ${text?.length || 0}). Trying OCR fallback...`,
);
} catch (error) {
console.warn(
"PDF grounding extraction failed for Groq fallback.",
error,
"📄 PDF grounding extraction failed for Mistral fallback:",
error instanceof Error ? error.message : error,
);
}
// OCR fallback for scanned PDFs.
try {
const ocrText = await this.extractMistralPdfTextWithOcr(input.base64);
if (ocrText.length >= 10) {
console.log(
`📄 Mistral grounding OCR: extracted ${ocrText.length} chars from scanned PDF`,
);
return ocrText.slice(0, 50000);
}
} catch (ocrError) {
console.warn(
"📄 PDF OCR fallback failed for Mistral grounding:",
ocrError instanceof Error ? ocrError.message : ocrError,
);
}
}
// For images: try to extract text using Gemini OCR as grounding bridge.
// This gives Groq the text content it needs since it can't read images.
if (input.mimeType.startsWith("image/")) {
try {
const ocrText = await keyManager.execute(async (genAI) => {
const model = genAI.getGenerativeModel({
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(
`🖼️ Groq grounding: extracted ${ocrText.length} chars from image via Gemini OCR bridge`,
);
return ocrText.slice(0, 50000);
}
} catch (error: any) {
// Gemini OCR bridge failed (likely key exhaustion), continue without
if (!error.message?.includes("CRITICAL_KEY_EXHAUSTION")) {
console.warn(
"Image grounding via Gemini OCR failed for Groq fallback; continuing without grounded text.",
error,
);
}
}
}
// 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 "";
}
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.
* 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) {
try {
rawAnswer = await this.generateWithGroqModelChain({
rawAnswer = await this.generateWithMistralModelChain({
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.`,
prompt,
@@ -1416,10 +1666,10 @@ Include one short disclaimer only when legal context is discussed: "This is gene
topP: 0.95,
});
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) {
lastError = groqError;
} catch (mistralError) {
lastError = mistralError;
}
}
@@ -1444,11 +1694,11 @@ Include one short disclaimer only when legal context is discussed: "This is gene
const errorMessage =
error instanceof Error ? error.message : String(error);
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(
`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}`);

View File

@@ -76,7 +76,12 @@ function toDateOrNull(value: unknown): string | null {
function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
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)
.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-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/nodemailer": "^8.0.0",
"@uploadthing/react": "^7.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -55,6 +56,7 @@
"motion": "^12.34.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.7",
"pdf-parse": "^2.4.5",
"prisma": "^6.19.2",
"react": "19.2.3",
@@ -3714,12 +3716,20 @@
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
@@ -7937,6 +7947,15 @@
"dev": true,
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -9962,7 +9981,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View File

@@ -41,6 +41,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/nodemailer": "^8.0.0",
"@uploadthing/react": "^7.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -56,6 +57,7 @@
"motion": "^12.34.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.7",
"pdf-parse": "^2.4.5",
"prisma": "^6.19.2",
"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);