Pre-Final Backup

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

View File

@@ -65,17 +65,19 @@ The AI subsystem is centered on:
- Primary model: gemini-2.5-flash - Primary model: gemini-2.5-flash
- Optional secondary Gemini model: AI_MODEL_SECONDARY_GEMINI - Optional secondary Gemini model: AI_MODEL_SECONDARY_GEMINI
- Fallback model provider: Groq (default: llama-3.3-70b-versatile) - Fallback model provider: Mistral AI (default: mistral-large-latest, vision: pixtral-large-latest)
- Gemini models are de-duplicated and iterated in order before Groq fallback - Gemini models are de-duplicated and iterated in order before Mistral fallback
- Groq extraction fallback currently applies to image inputs in this pipeline; JSON repair and Q&A fallback are text-based - Mistral extraction fallback supports both text and image inputs via Pixtral vision; JSON repair and Q&A fallback are text-based
### 2.3 Environment Variables ### 2.3 Environment Variables
- AI_API_KEY (or AI_API_KEY2 / AI_API_KEY3 fallback) - AI_API_KEY (or AI_API_KEY2 / AI_API_KEY3 fallback)
- AI_MODEL_PRIMARY (optional override) - AI_MODEL_PRIMARY (optional override)
- AI_MODEL_SECONDARY_GEMINI (optional override) - AI_MODEL_SECONDARY_GEMINI (optional override)
- AI_MODEL_SECONDARY (legacy alias supported for compatibility)
- AI_MODEL_FALLBACK (optional override) - AI_MODEL_FALLBACK (optional override)
- GROQ_API_KEY (or AI_GROQ_API_KEY) - MISTRAL_API_KEY
- AI_MODEL_MISTRAL_VISION (optional, default: pixtral-large-latest)
## 3. AI Capability Matrix ## 3. AI Capability Matrix
@@ -206,7 +208,7 @@ sequenceDiagram
participant AIS as AIService participant AIS as AIService
participant GP as Gemini Primary participant GP as Gemini Primary
participant GS as Gemini Secondary (optional) participant GS as Gemini Secondary (optional)
participant GR as Groq Fallback participant MR as Mistral AI Fallback
AIS->>GP: generate analysis (strict JSON) AIS->>GP: generate analysis (strict JSON)
alt GP success with usable output alt GP success with usable output
@@ -220,17 +222,17 @@ sequenceDiagram
alt lenient success alt lenient success
GP-->>AIS: raw text GP-->>AIS: raw text
else lenient fails else lenient fails
AIS->>GR: generate analysis (strict JSON) AIS->>MR: generate analysis (strict JSON)
GR-->>AIS: text MR-->>AIS: text
end end
end end
end end
AIS->>AIS: parseJsonResponse AIS->>AIS: parseJsonResponse
alt parse failed alt parse failed
AIS->>GR: repairMalformedJson(originalText, parseError) AIS->>MR: repairMalformedJson(originalText, parseError)
alt repair success alt repair success
GR-->>AIS: repaired JSON text MR-->>AIS: repaired JSON text
AIS->>AIS: parse repaired JSON AIS->>AIS: parse repaired JSON
else repair failed else repair failed
AIS->>AIS: emergencyExtractFields(rawText) AIS->>AIS: emergencyExtractFields(rawText)

View File

@@ -6,7 +6,16 @@ import { ContractsList } from "@/features/contracts/components/list/contracts-li
import { ContractsHeader } from "@/components/layout/contacts-header"; import { ContractsHeader } from "@/components/layout/contacts-header";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getContracts } from "@/features/contracts/api/contract.action"; import { getContracts } from "@/features/contracts/api/contract.action";
import { Card } from "@/components/ui/card"; import { motion, AnimatePresence } from "motion/react";
import {
Upload,
FileText,
Sparkles,
Shield,
Zap,
ChevronRight,
Loader2,
} from "lucide-react";
export default function ContactsPage() { export default function ContactsPage() {
const [refreshTrigger, setRefreshTrigger] = useState(0); const [refreshTrigger, setRefreshTrigger] = useState(0);
@@ -42,69 +51,217 @@ export default function ContactsPage() {
if (isChecking) { if (isChecking) {
return ( return (
<> <div className="min-h-screen bg-background text-foreground relative overflow-hidden">
<div className="min-h-screen bg-background text-foreground overflow-hidden"> {/* Ambient loading background */}
<div className="fixed inset-0 overflow-hidden pointer-events-none"> <div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-3xl animate-blob"></div> <div className="absolute top-1/4 left-1/3 w-[500px] h-[500px] bg-primary/10 rounded-full blur-[120px] animate-pulse" />
<div className="absolute top-1/2 right-1/4 w-96 h-96 bg-accent/20 rounded-full blur-3xl animate-blob animation-delay-2000"></div> <div className="absolute bottom-1/4 right-1/3 w-[400px] h-[400px] bg-violet-500/10 rounded-full blur-[100px] animate-pulse delay-700" />
<div className="absolute -bottom-8 right-1/3 w-96 h-96 bg-secondary/20 rounded-full blur-3xl animate-blob animation-delay-4000"></div> <div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:64px_64px] [mask-image:radial-gradient(ellipse_60%_60%_at_50%_50%,#000_30%,transparent_100%)]" />
</div>
<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> </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 ( return (
<> <div className="min-h-screen bg-background text-foreground relative overflow-hidden">
<div className="min-h-screen bg-background text-foreground"> {/* Ambient Background */}
<main className="flex flex-col min-h-screen"> <div className="fixed inset-0 pointer-events-none">
<ContractsHeader /> <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"> <ContractsHeader />
<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"> <main className="relative z-10 flex flex-col min-h-[calc(100vh-64px)]">
<div className="mb-6"> <div className="flex-1 overflow-auto">
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight"> <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 Upload Contract
</h2> </h2>
<p className="mt-2 text-sm md:text-base text-muted-foreground"> <p className="text-xs text-muted-foreground">
Add PDF contracts and let the AI pipeline extract summary, PDF documents supported
key points, and legal-business insights.
</p> </p>
</div> </div>
<ContractUploadForm onUploadSuccess={handleUploadSuccess} /> </div>
</Card>
<Card className="rounded-2xl border-border/60 p-6 md:p-8"> <div className="relative group">
<div className="mb-6"> <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" />
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight"> <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">
Your Contracts <div className="mb-6 flex items-start justify-between">
</h2> <div className="space-y-1">
<p className="mt-2 text-sm md:text-base text-muted-foreground"> <h3 className="text-sm font-semibold text-foreground">
Review contract lifecycle, trigger analysis, and ask AI New Document
questions per file. </h3>
</p> <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> </div>
{showContracts ? ( {showContracts && (
<ContractsList refreshTrigger={refreshTrigger} /> <motion.button
) : ( initial={{ opacity: 0 }}
<EmptyContractsState /> animate={{ opacity: 1 }}
onClick={() => setRefreshTrigger((prev) => prev + 1)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5 rounded-lg hover:bg-muted/50"
>
<Zap className="w-3 h-3" />
Refresh
</motion.button>
)} )}
</Card> </div>
</div>
<div className="relative rounded-2xl border border-border/40 bg-background/40 backdrop-blur-2xl overflow-hidden shadow-xl shadow-black/5 min-h-[400px]">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
<AnimatePresence mode="wait">
{showContracts ? (
<motion.div
key="list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="p-6 md:p-8"
>
<ContractsList refreshTrigger={refreshTrigger} />
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="p-6 md:p-8"
>
<EmptyContractsState />
</motion.div>
)}
</AnimatePresence>
</div>
</motion.section>
{/* Bottom Info Cards */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="grid grid-cols-1 md:grid-cols-3 gap-4 pb-8"
>
{[
{
icon: <Shield className="w-4 h-4" />,
title: "Secure Processing",
desc: "Documents are encrypted in transit and at rest. Your data never leaves your infrastructure.",
color: "emerald",
},
{
icon: <Sparkles className="w-4 h-4" />,
title: "AI Extraction",
desc: "Advanced NLP models identify parties, obligations, risks, and key dates automatically.",
color: "primary",
},
{
icon: <Zap className="w-4 h-4" />,
title: "Instant Insights",
desc: "Get executive summaries and red-flag alerts within seconds of upload completion.",
color: "violet",
},
].map((feature, i) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 + i * 0.05 }}
className="group relative rounded-2xl border border-border/40 bg-background/40 backdrop-blur-xl p-5 hover:bg-background/60 transition-all duration-300"
>
<div
className={`p-2 rounded-lg bg-${feature.color}-500/10 border border-${feature.color}-500/20 w-fit mb-3 text-${feature.color}-500`}
>
{feature.icon}
</div>
<h4 className="text-sm font-semibold mb-1">
{feature.title}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
{feature.desc}
</p>
<ChevronRight className="w-4 h-4 text-muted-foreground/30 absolute bottom-5 right-5 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</motion.div>
))}
</motion.div>
</div> </div>
</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) ### The Problem (in simple terms)
When a client sends an insurance claim or uploads a contract, they need **proof** that they submitted it on a specific date. Without proof: When a client sends an insurance claim or uploads a contract, they need **proof** that they submitted it on a specific date. Without proof:
- The insurance company could claim "we never received it" - The insurance company could claim "we never received it"
- Deadlines could be disputed - Deadlines could be disputed
- There's no transparency - There's no transparency
@@ -40,6 +41,7 @@ When a client sends an insurance claim or uploads a contract, they need **proof*
A **blockchain** is like a public, tamper-proof notebook. Once you write something in it, **nobody can erase or modify it** — not even the person who wrote it. A **blockchain** is like a public, tamper-proof notebook. Once you write something in it, **nobody can erase or modify it** — not even the person who wrote it.
We use the blockchain as a **digital notary**: We use the blockchain as a **digital notary**:
1. We take the uploaded contract PDF 1. We take the uploaded contract PDF
2. We create a unique **fingerprint** (hash) of that file 2. We create a unique **fingerprint** (hash) of that file
3. We write that fingerprint into the blockchain with a timestamp 3. We write that fingerprint into the blockchain with a timestamp
@@ -50,11 +52,13 @@ We use the blockchain as a **digital notary**:
### What is a Smart Contract? ### What is a Smart Contract?
A **smart contract** is a program that runs on the blockchain. Think of it as a vending machine: A **smart contract** is a program that runs on the blockchain. Think of it as a vending machine:
- You put in a coin (send a transaction) - You put in a coin (send a transaction)
- The machine executes its programmed logic - The machine executes its programmed logic
- The result is permanent and visible to everyone - The result is permanent and visible to everyone
Our smart contract (`DocumentRegistry.sol`) has two main functions: Our smart contract (`DocumentRegistry.sol`) has two main functions:
- **Register**: Store a document fingerprint with a timestamp - **Register**: Store a document fingerprint with a timestamp
- **Verify**: Check if a fingerprint exists and when it was stored - **Verify**: Check if a fingerprint exists and when it was stored
@@ -64,14 +68,14 @@ Our smart contract (`DocumentRegistry.sol`) has two main functions:
### Features Implemented ### Features Implemented
| Feature | Description | | Feature | Description |
|---------|-------------| | ------------------------- | --------------------------------------------------------------------------- |
| **Auto-Registration** | After AI analyzes a contract, its hash is automatically registered on-chain | | **Auto-Registration** | After AI analyzes a contract, its hash is automatically registered on-chain |
| **Manual Registration** | Users can register unregistered contracts via the Blockchain Explorer | | **Manual Registration** | Users can register unregistered contracts via the Blockchain Explorer |
| **Document Verification** | Paste any document hash to check if it exists on-chain | | **Document Verification** | Paste any document hash to check if it exists on-chain |
| **Transaction Explorer** | View all blockchain transactions with details | | **Transaction Explorer** | View all blockchain transactions with details |
| **Network Stats** | Live stats: verified documents, latest block, network status | | **Network Stats** | Live stats: verified documents, latest block, network status |
| **Proof Badges** | Contract list shows which contracts are blockchain-verified | | **Proof Badges** | Contract list shows which contracts are blockchain-verified |
### What Happens When a User Uploads a Contract? ### 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: The entire flow is automatic. The user doesn't need:
- ❌ MetaMask or any wallet - ❌ MetaMask or any wallet
- ❌ Cryptocurrency knowledge - ❌ Cryptocurrency knowledge
- ❌ To pay anything - ❌ To pay anything
@@ -129,10 +134,10 @@ flowchart TD
### Network Modes ### Network Modes
| Mode | When | URL | Cost | | Mode | When | URL | Cost |
|------|------|-----|------| | ----------- | ----------------- | ----------------------- | -------------- |
| **Hardhat** | Development | `http://127.0.0.1:8545` | Free (local) | | **Hardhat** | Development | `http://127.0.0.1:8545` | Free (local) |
| **Sepolia** | Demo/Presentation | Via Alchemy/Infura RPC | Free (testnet) | | **Sepolia** | Demo/Presentation | Via Alchemy/Infura RPC | Free (testnet) |
The mode is controlled by a single env variable: `BLOCKCHAIN_NETWORK`. The mode is controlled by a single env variable: `BLOCKCHAIN_NETWORK`.
@@ -178,12 +183,14 @@ flowchart LR
``` ```
#### `registerDocument(bytes32 _docHash)` #### `registerDocument(bytes32 _docHash)`
- **Purpose**: Store a document hash on-chain - **Purpose**: Store a document hash on-chain
- **Access**: Only the contract owner (our server wallet) - **Access**: Only the contract owner (our server wallet)
- **Guard**: Prevents duplicate registration (same hash can't be registered twice) - **Guard**: Prevents duplicate registration (same hash can't be registered twice)
- **Event**: Emits `DocumentRegistered` for off-chain indexing - **Event**: Emits `DocumentRegistered` for off-chain indexing
#### `verifyDocument(bytes32 _docHash)` #### `verifyDocument(bytes32 _docHash)`
- **Purpose**: Check if a hash exists and get its details - **Purpose**: Check if a hash exists and get its details
- **Cost**: Free (read-only, no gas) - **Cost**: Free (read-only, no gas)
- **Returns**: `(exists, timestamp, depositor)` - **Returns**: `(exists, timestamp, depositor)`
@@ -231,6 +238,7 @@ flowchart LR
### Why Server-Side? ### Why Server-Side?
Most blockchain dApps require users to install MetaMask and sign transactions. This is bad UX for a BFSI enterprise platform because: Most blockchain dApps require users to install MetaMask and sign transactions. This is bad UX for a BFSI enterprise platform because:
- Users shouldn't need crypto knowledge - Users shouldn't need crypto knowledge
- The platform manages documents, not individual users - The platform manages documents, not individual users
- Server-side signing is more reliable - Server-side signing is more reliable
@@ -260,14 +268,14 @@ sequenceDiagram
### Key Methods ### Key Methods
| Method | Purpose | Gas Cost | | Method | Purpose | Gas Cost |
|--------|---------|----------| | ------------------------------------ | ------------------------------- | ---------------- |
| `hashDocument(fileUrl)` | Download file + compute SHA-256 | None (off-chain) | | `hashDocument(fileUrl)` | Download file + compute SHA-256 | None (off-chain) |
| `registerOnChain(hash, fileName)` | Send tx to smart contract | ~50,000 gas | | `registerOnChain(hash, fileName)` | Send tx to smart contract | ~50,000 gas |
| `verifyOnChain(hash)` | Read-only check | Free | | `verifyOnChain(hash)` | Read-only check | Free |
| `hashAndRegister(fileUrl, fileName)` | Combined: hash + register | ~50,000 gas | | `hashAndRegister(fileUrl, fileName)` | Combined: hash + register | ~50,000 gas |
| `getNetworkStats()` | Get block number, total docs | Free | | `getNetworkStats()` | Get block number, total docs | Free |
| `isConfigured()` | Check if env vars are set | None | | `isConfigured()` | Check if env vars are set | None |
### Graceful Degradation ### Graceful Degradation
@@ -424,6 +432,7 @@ sequenceDiagram
participant SA as Server Action participant SA as Server Action
participant AI as AI Service participant AI as AI Service
participant BS as BlockchainService participant BS as BlockchainService
participant ES as EmailService
participant SC as Smart Contract participant SC as Smart Contract
participant DB as PostgreSQL participant DB as PostgreSQL
@@ -446,6 +455,8 @@ sequenceDiagram
SA->>DB: Save txHash, blockNumber, etc. SA->>DB: Save txHash, blockNumber, etc.
SA->>DB: Create BlockchainTransaction SA->>DB: Create BlockchainTransaction
SA->>ES: Send analysis + blockchain proof email
ES-->>U: Email received (or Ethereal preview in dev)
SA-->>UI: Success! SA-->>UI: Success!
Note over U,UI: User visits /blockchain Note over U,UI: User visits /blockchain
@@ -470,6 +481,7 @@ sequenceDiagram
## 10. How to Run Locally ## 10. How to Run Locally
### Prerequisites ### Prerequisites
- Node.js installed - Node.js installed
- The Next.js app running (`npm run dev`) - The Next.js app running (`npm run dev`)
@@ -562,6 +574,7 @@ npx hardhat run scripts/deploy.ts --network sepolia
### Step 5: Verify on Etherscan ### Step 5: Verify on Etherscan
After deploying, transactions will have real Etherscan links: After deploying, transactions will have real Etherscan links:
``` ```
https://sepolia.etherscan.io/tx/0x... https://sepolia.etherscan.io/tx/0x...
``` ```
@@ -570,19 +583,20 @@ https://sepolia.etherscan.io/tx/0x...
## 12. Technology Choices & Rationale ## 12. Technology Choices & Rationale
| Technology | Why We Chose It | | Technology | Why We Chose It |
|-----------|----------------| | ------------------------ | ----------------------------------------------------------------- |
| **Solidity 0.8.24** | Latest stable version with built-in overflow protection | | **Solidity 0.8.24** | Latest stable version with built-in overflow protection |
| **Hardhat** | Industry standard for Solidity development, free local blockchain | | **Hardhat** | Industry standard for Solidity development, free local blockchain |
| **ethers.js v6** | Modern, lightweight, TypeScript-native Ethereum library | | **ethers.js v6** | Modern, lightweight, TypeScript-native Ethereum library |
| **SHA-256** | Standard cryptographic hash, deterministic, collision-resistant | | **SHA-256** | Standard cryptographic hash, deterministic, collision-resistant |
| **Server-side wallet** | Users don't need MetaMask; enterprise-grade UX | | **Server-side wallet** | Users don't need MetaMask; enterprise-grade UX |
| **Sepolia testnet** | Official Ethereum testnet, free, has Etherscan explorer | | **Sepolia testnet** | Official Ethereum testnet, free, has Etherscan explorer |
| **Graceful degradation** | Blockchain is optional; app works perfectly without it | | **Graceful degradation** | Blockchain is optional; app works perfectly without it |
### Why NOT Web3j / Java? ### Why NOT Web3j / Java?
The original project spec suggested Web3j (Java library). We chose ethers.js instead because: The original project spec suggested Web3j (Java library). We chose ethers.js instead because:
1. Our backend is **Next.js/TypeScript**, not Spring Boot 1. Our backend is **Next.js/TypeScript**, not Spring Boot
2. ethers.js has **better TypeScript support** and is more actively maintained 2. ethers.js has **better TypeScript support** and is more actively maintained
3. Both libraries do the same job — interact with Ethereum — but ethers.js is native to our stack 3. Both libraries do the same job — interact with Ethereum — but ethers.js is native to our stack
@@ -592,64 +606,70 @@ The original project spec suggested Web3j (Java library). We chose ethers.js ins
## 13. File Reference ## 13. File Reference
### Smart Contract Layer ### Smart Contract Layer
| File | Purpose |
|------|---------| | File | Purpose |
| ------------------------------------------- | ----------------------- |
| `blockchain/contracts/DocumentRegistry.sol` | Solidity smart contract | | `blockchain/contracts/DocumentRegistry.sol` | Solidity smart contract |
| `blockchain/test/DocumentRegistry.test.ts` | 14 comprehensive tests | | `blockchain/test/DocumentRegistry.test.ts` | 14 comprehensive tests |
| `blockchain/scripts/deploy.ts` | Deployment script | | `blockchain/scripts/deploy.ts` | Deployment script |
| `blockchain/hardhat.config.ts` | Hardhat configuration | | `blockchain/hardhat.config.ts` | Hardhat configuration |
| `blockchain/package.json` | Hardhat dependencies | | `blockchain/package.json` | Hardhat dependencies |
### Service Layer ### Service Layer
| File | Purpose |
|------|---------| | File | Purpose |
| ------------------------------------ | ---------------------------- |
| `lib/services/blockchain.service.ts` | Core blockchain interactions | | `lib/services/blockchain.service.ts` | Core blockchain interactions |
| `lib/services/blockchain.types.ts` | TypeScript type definitions | | `lib/services/blockchain.types.ts` | TypeScript type definitions |
### Server Actions ### Server Actions
| File | Purpose |
|------|---------| | File | Purpose |
| `features/blockchain/api/blockchain.action.ts` | Blockchain server actions | | ---------------------------------------------- | ------------------------------ |
| `features/contracts/api/contract.action.ts` | Updated with auto-registration | | `features/blockchain/api/blockchain.action.ts` | Blockchain server actions |
| `features/contracts/api/contract.action.ts` | Updated with auto-registration |
### Frontend ### Frontend
| File | Purpose |
|------|---------| | File | Purpose |
| `app/(dashboard)/blockchain/page.tsx` | Blockchain Explorer page | | --------------------------------------- | ---------------------------- |
| `app/(dashboard)/blockchain/layout.tsx` | Page metadata | | `app/(dashboard)/blockchain/page.tsx` | Blockchain Explorer page |
| `components/layout/navigation.tsx` | Updated with blockchain link | | `app/(dashboard)/blockchain/layout.tsx` | Page metadata |
| `components/layout/navigation.tsx` | Updated with blockchain link |
### Database ### Database
| File | Purpose |
|------|---------| | File | Purpose |
| ---------------------- | ------------------------------ |
| `prisma/schema.prisma` | Updated with blockchain fields | | `prisma/schema.prisma` | Updated with blockchain fields |
### Configuration ### Configuration
| File | Purpose |
|------|---------| | File | Purpose |
| `.env` | Blockchain env vars | | -------------- | ----------------------------- |
| `.env.example` | Template for new developers | | `.env` | Blockchain env vars |
| `.gitignore` | Blockchain artifacts excluded | | `.env.example` | Template for new developers |
| `.gitignore` | Blockchain artifacts excluded |
--- ---
## Glossary ## Glossary
| Term | Definition | | Term | Definition |
|------|-----------| | -------------------- | -------------------------------------------------------------------- |
| **Hash** | A fixed-size fingerprint of data. Same input → same output. | | **Hash** | A fixed-size fingerprint of data. Same input → same output. |
| **SHA-256** | A specific hash algorithm producing 256-bit (32-byte) outputs | | **SHA-256** | A specific hash algorithm producing 256-bit (32-byte) outputs |
| **Smart Contract** | A program stored on the blockchain that executes automatically | | **Smart Contract** | A program stored on the blockchain that executes automatically |
| **Gas** | The fee for executing operations on Ethereum (free on testnet) | | **Gas** | The fee for executing operations on Ethereum (free on testnet) |
| **Block** | A batch of transactions grouped together on the blockchain | | **Block** | A batch of transactions grouped together on the blockchain |
| **Transaction (Tx)** | A single operation on the blockchain (e.g., registering a hash) | | **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) | | **Tx Hash** | A unique identifier for a transaction (like a receipt number) |
| **Block Number** | The sequential number of the block containing a transaction | | **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) | | **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) | | **Private Key** | Secret key used to sign transactions (like a password) |
| **Address** | Public identifier derived from the private key (like a username) | | **Address** | Public identifier derived from the private key (like a username) |
| **ABI** | Application Binary Interface — the "API spec" of a smart contract | | **ABI** | Application Binary Interface — the "API spec" of a smart contract |
| **Hardhat** | Development tool for writing, testing, and deploying smart contracts | | **Hardhat** | Development tool for writing, testing, and deploying smart contracts |
| **Sepolia** | Ethereum test network for free experimentation | | **Sepolia** | Ethereum test network for free experimentation |
| **ethers.js** | JavaScript library for interacting with the Ethereum blockchain | | **ethers.js** | JavaScript library for interacting with the Ethereum blockchain |
| **Faucet** | A service that gives free test ETH for development | | **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> = { const PIE_COLORS: Record<string, string> = {
Uploaded: "hsl(38 92% 50%)", Uploaded: "hsl(38 92% 50%)",
Processing: "hsl(var(--primary))", Processing: "hsl(217 91% 60%)",
Analyzed: "hsl(160 84% 39%)", Analyzed: "hsl(160 84% 39%)",
Failed: "hsl(var(--destructive))", Failed: "hsl(0 84% 60%)",
}; };
const FALLBACK_COLORS = [ const FALLBACK_COLORS = [
"hsl(var(--primary))", "hsl(217 91% 60%)",
"hsl(var(--secondary))", "hsl(260 89% 65%)",
"hsl(var(--accent))", "hsl(190 85% 50%)",
"hsl(var(--destructive))", "hsl(340 82% 52%)",
]; ];
const tooltipStyle = { const tooltipStyle = {
backgroundColor: "hsl(var(--background))", backgroundColor: "hsl(var(--background) / 0.95)",
border: "1px solid hsl(var(--border))", border: "1px solid hsl(var(--border) / 0.6)",
borderRadius: "12px", borderRadius: "16px",
color: "hsl(var(--foreground))", color: "hsl(var(--foreground))",
backdropFilter: "blur(12px)",
boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
padding: "12px 16px",
fontSize: "13px",
};
const CustomTooltip = ({ active, payload, label }: any) => {
if (!active || !payload?.length) return null;
return (
<div style={tooltipStyle} className="space-y-1.5 min-w-[140px]">
{label && (
<p className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground border-b border-border/40 pb-1.5 mb-1.5">
{label}
</p>
)}
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs text-muted-foreground">{entry.name}</span>
</div>
<span className="text-xs font-bold text-foreground tabular-nums">
{typeof entry.value === "number"
? entry.value.toLocaleString()
: entry.value}
</span>
</div>
))}
</div>
);
}; };
export function TrendChart({ data }: { data: TrendData }) { export function TrendChart({ data }: { data: TrendData }) {
@@ -72,64 +106,82 @@ export function TrendChart({ data }: { data: TrendData }) {
<defs> <defs>
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
<stop <stop
offset="5%" offset="0%"
stopColor="hsl(var(--primary))" stopColor="hsl(217 91% 60%)"
stopOpacity={0.65} stopOpacity={0.5}
/> />
<stop <stop
offset="95%" offset="60%"
stopColor="hsl(var(--primary))" stopColor="hsl(217 91% 60%)"
stopOpacity={0.05} stopOpacity={0.15}
/>
<stop
offset="100%"
stopColor="hsl(217 91% 60%)"
stopOpacity={0.02}
/> />
</linearGradient> </linearGradient>
<linearGradient id="trendStroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(217 91% 60%)" />
<stop offset="100%" stopColor="hsl(260 89% 65%)" />
</linearGradient>
<linearGradient id="avgStroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(260 89% 65%)" />
<stop offset="100%" stopColor="hsl(190 85% 50%)" />
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs> </defs>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 6"
stroke="hsl(var(--border))" stroke="hsl(var(--border) / 0.4)"
vertical={false} vertical={false}
/> />
<XAxis <XAxis
dataKey="date" dataKey="date"
stroke="hsl(var(--muted-foreground))" stroke="hsl(var(--muted-foreground) / 0.5)"
interval={xAxisInterval} interval={xAxisInterval}
tick={{ fontSize: 12 }} tick={{ fontSize: 11, fontWeight: 500 }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickMargin={8}
/> />
<YAxis <YAxis
stroke="hsl(var(--muted-foreground))" stroke="hsl(var(--muted-foreground) / 0.5)"
allowDecimals={false} allowDecimals={false}
tick={{ fontSize: 11, fontWeight: 500 }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickMargin={8}
/> />
<Tooltip <Tooltip content={<CustomTooltip />} />
contentStyle={tooltipStyle}
formatter={(
value: number | string | undefined,
name: string | number | undefined,
) => {
const numericValue = Number(value ?? 0);
if (name === "movingAverage") {
return [numericValue.toFixed(1), "7-day avg"];
}
return [numericValue, "Uploads"];
}}
/>
<Area <Area
type="monotone" type="monotone"
dataKey="count" dataKey="count"
stroke="hsl(var(--primary))" stroke="url(#trendStroke)"
strokeWidth={2.25} strokeWidth={2.5}
fillOpacity={1} fillOpacity={1}
fill="url(#trendFill)" fill="url(#trendFill)"
activeDot={{ r: 5 }} activeDot={{
r: 6,
stroke: "hsl(var(--background))",
strokeWidth: 3,
fill: "hsl(217 91% 60%)",
filter: "url(#glow)",
}}
dot={false}
/> />
<Line <Line
type="monotone" type="monotone"
dataKey="movingAverage" dataKey="movingAverage"
stroke="hsl(var(--secondary))" stroke="url(#avgStroke)"
strokeWidth={2} strokeWidth={2}
strokeDasharray="6 4"
dot={false} dot={false}
/> />
</AreaChart> </AreaChart>
@@ -152,43 +204,65 @@ export function ContractTypeChart({ data }: { data: TypeData }) {
layout="vertical" layout="vertical"
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
> >
<defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(217 91% 60%)" />
<stop offset="100%" stopColor="hsl(260 89% 65%)" />
</linearGradient>
<linearGradient id="barGradient2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(260 89% 65%)" />
<stop offset="100%" stopColor="hsl(190 85% 50%)" />
</linearGradient>
<linearGradient id="barGradient3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(190 85% 50%)" />
<stop offset="100%" stopColor="hsl(340 82% 52%)" />
</linearGradient>
<linearGradient id="barGradient4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(340 82% 52%)" />
<stop offset="100%" stopColor="hsl(38 92% 50%)" />
</linearGradient>
</defs>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 6"
stroke="hsl(var(--border))" stroke="hsl(var(--border) / 0.4)"
horizontal={false} horizontal={false}
/> />
<XAxis <XAxis
type="number" type="number"
stroke="hsl(var(--muted-foreground))" stroke="hsl(var(--muted-foreground) / 0.5)"
allowDecimals={false} allowDecimals={false}
tick={{ fontSize: 12 }} tick={{ fontSize: 11, fontWeight: 500 }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickMargin={8}
/> />
<YAxis <YAxis
type="category" type="category"
dataKey="type" dataKey="type"
width={128} width={128}
stroke="hsl(var(--muted-foreground))" stroke="hsl(var(--muted-foreground) / 0.5)"
tick={{ fontSize: 12 }} tick={{ fontSize: 11, fontWeight: 600 }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickMargin={8}
/> />
<Tooltip <Tooltip
contentStyle={tooltipStyle} content={<CustomTooltip />}
cursor={false} cursor={{ fill: "hsl(var(--muted) / 0.15)", radius: 8 }}
formatter={(value: number | string | undefined) => [
Number(value ?? 0),
"Files",
]}
/> />
<Bar dataKey="count" radius={[0, 8, 8, 0]}> <Bar dataKey="count" radius={[0, 10, 10, 0]} maxBarSize={32}>
{sortedData.map((item, index) => { {sortedData.map((item, index) => {
const opacity = Math.max(0.35, 0.95 - index * 0.12); const gradients = [
"url(#barGradient)",
"url(#barGradient2)",
"url(#barGradient3)",
"url(#barGradient4)",
];
return ( return (
<Cell <Cell
key={`${item.type}-${index}`} key={`${item.type}-${index}`}
fill={`hsl(var(--primary) / ${opacity})`} fill={gradients[index % gradients.length]}
className="transition-all duration-300 hover:opacity-80"
/> />
); );
})} })}
@@ -210,16 +284,26 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
<div className="h-[76%] w-full"> <div className="h-[76%] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<defs>
<filter id="pieGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<Pie <Pie
data={data} data={data}
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={62} innerRadius={58}
outerRadius={94} outerRadius={88}
paddingAngle={3} paddingAngle={4}
dataKey="count" dataKey="count"
stroke="hsl(var(--background))" stroke="hsl(var(--background))"
strokeWidth={2} strokeWidth={3}
cornerRadius={6}
> >
{data.map((entry, index) => ( {data.map((entry, index) => (
<Cell <Cell
@@ -228,44 +312,40 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
PIE_COLORS[entry.name] ?? PIE_COLORS[entry.name] ??
FALLBACK_COLORS[index % FALLBACK_COLORS.length] FALLBACK_COLORS[index % FALLBACK_COLORS.length]
} }
className="transition-all duration-300 hover:opacity-90"
style={{ filter: "drop-shadow(0 2px 8px rgba(0,0,0,0.1))" }}
/> />
))} ))}
</Pie> </Pie>
{total > 0 && ( {total > 0 && (
<text <text
x="50%" x="50%"
y="50%" y="48%"
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
> >
<tspan <tspan
x="50%" x="50%"
y="50%" y="48%"
className="fill-foreground text-base font-semibold" className="fill-foreground text-xl font-bold tracking-tight"
> >
{total} {total.toLocaleString()}
</tspan> </tspan>
<tspan <tspan
x="50%" x="50%"
dy="16" dy="18"
className="fill-muted-foreground text-[11px]" className="fill-muted-foreground text-[11px] font-medium uppercase tracking-wider"
> >
Files Files
</tspan> </tspan>
</text> </text>
)} )}
<Tooltip <Tooltip content={<CustomTooltip />} />
contentStyle={tooltipStyle}
formatter={(value: number | string | undefined) => [
Number(value ?? 0),
"Files",
]}
/>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div className="grid grid-cols-2 gap-2 pt-2"> <div className="grid grid-cols-2 gap-2.5 pt-2">
{data.map((item, index) => { {data.map((item, index) => {
const color = const color =
PIE_COLORS[item.name] ?? PIE_COLORS[item.name] ??
@@ -274,16 +354,16 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
return ( return (
<div <div
key={`${item.name}-legend`} key={`${item.name}-legend`}
className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 px-2.5 py-1.5" className="group flex items-center gap-2.5 rounded-xl border border-border/40 bg-background/40 backdrop-blur-md px-3 py-2 hover:bg-background/60 hover:border-border/60 transition-all cursor-default"
> >
<span <span
className="h-2.5 w-2.5 rounded-full" className="h-2.5 w-2.5 rounded-full ring-2 ring-offset-1 ring-offset-background"
style={{ backgroundColor: color }} style={{ backgroundColor: color, "--tw-ring-color": color } as React.CSSProperties}
/> />
<span className="text-[11px] text-muted-foreground truncate"> <span className="text-[11px] text-muted-foreground truncate font-medium">
{item.name} {item.name}
</span> </span>
<span className="ml-auto text-[11px] font-medium text-foreground"> <span className="ml-auto text-[11px] font-bold text-foreground tabular-nums">
{item.count} {item.count}
</span> </span>
</div> </div>

View File

@@ -20,6 +20,7 @@
"use server"; "use server";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { clerkClient } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import {
ContractService, ContractService,
@@ -29,6 +30,7 @@ import { AIService } from "@/lib/services/ai.service";
import { RAGService } from "@/lib/services/rag.service"; import { RAGService } from "@/lib/services/rag.service";
import { NotificationService } from "@/lib/services/notification.service"; import { NotificationService } from "@/lib/services/notification.service";
import { BlockchainService } from "@/lib/services/blockchain.service"; import { BlockchainService } from "@/lib/services/blockchain.service";
import { EmailService } from "@/lib/services/email.service";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types"; import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types";
@@ -209,7 +211,9 @@ export async function getContracts(filters?: Record<string, unknown>) {
documentHash: contract.documentHash || null, documentHash: contract.documentHash || null,
txHash: contract.txHash || null, txHash: contract.txHash || null,
blockNumber: contract.blockNumber || null, blockNumber: contract.blockNumber || null,
blockTimestamp: contract.blockTimestamp ? contract.blockTimestamp.toISOString() : null, blockTimestamp: contract.blockTimestamp
? contract.blockTimestamp.toISOString()
: null,
blockchainNetwork: contract.blockchainNetwork || null, blockchainNetwork: contract.blockchainNetwork || null,
contractAddress: contract.contractAddress || null, contractAddress: contract.contractAddress || null,
})); }));
@@ -517,6 +521,16 @@ export async function analyzeContractAction(id: string) {
keyPoints: keyPointsWithLearning, keyPoints: keyPointsWithLearning,
}); });
let blockchainEmailData: {
documentHash: string;
txHash: string;
blockNumber: number;
blockTimestamp: Date;
network: string;
contractAddress: string;
explorerUrl: string | null;
} | null = null;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// BLOCKCHAIN: Auto-register document on-chain // BLOCKCHAIN: Auto-register document on-chain
// This is non-blocking — if blockchain fails, analysis still succeeds // This is non-blocking — if blockchain fails, analysis still succeeds
@@ -525,7 +539,7 @@ export async function analyzeContractAction(id: string) {
if (BlockchainService.isConfigured()) { if (BlockchainService.isConfigured()) {
const proof = await BlockchainService.hashAndRegister( const proof = await BlockchainService.hashAndRegister(
contract.fileUrl, contract.fileUrl,
contract.fileName contract.fileName,
); );
// Save blockchain proof to the contract record // Save blockchain proof to the contract record
@@ -556,7 +570,19 @@ export async function analyzeContractAction(id: string) {
}, },
}); });
console.log(`🔗 Blockchain proof stored: ${proof.txHash.slice(0, 16)}...`); blockchainEmailData = {
documentHash: proof.documentHash,
txHash: proof.txHash,
blockNumber: proof.blockNumber,
blockTimestamp: proof.blockTimestamp,
network: proof.network,
contractAddress: proof.contractAddress,
explorerUrl: proof.explorerUrl,
};
console.log(
`🔗 Blockchain proof stored: ${proof.txHash.slice(0, 16)}...`,
);
} }
} catch (blockchainError) { } catch (blockchainError) {
// Blockchain failure should NOT fail the analysis // Blockchain failure should NOT fail the analysis
@@ -581,6 +607,71 @@ export async function analyzeContractAction(id: string) {
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
}); });
// Email summary + blockchain proof (non-blocking)
try {
let recipientEmail = user.email;
if (!recipientEmail) {
const clerk = await clerkClient();
const clerkUser = await clerk.users.getUser(clerkId);
recipientEmail =
clerkUser.emailAddresses.find(
(address) => address.id === clerkUser.primaryEmailAddressId,
)?.emailAddress ??
clerkUser.emailAddresses[0]?.emailAddress ??
"";
}
if (recipientEmail) {
const premiumValue =
aiResults.premium === null || aiResults.premium === undefined
? null
: aiResults.premium;
const keyPointsRecord =
typeof keyPointsWithLearning === "object" &&
keyPointsWithLearning !== null
? (keyPointsWithLearning as Record<string, unknown>)
: null;
await EmailService.sendContractAnalysisCompletedEmail({
to: recipientEmail,
userDisplayName:
`${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || null,
contractId: id,
contractFileName: contract.fileName,
contractTitle: aiResults.title,
blueprint: {
type: aiResults.type,
provider: aiResults.provider ?? null,
policyNumber: aiResults.policyNumber ?? null,
startDate: aiResults.startDate ?? null,
endDate: aiResults.endDate ?? null,
premium: premiumValue,
premiumCurrency:
aiAnalysis.premiumCurrency ??
(keyPointsRecord?.aiMeta &&
typeof keyPointsRecord.aiMeta === "object" &&
keyPointsRecord.aiMeta !== null &&
"premiumCurrency" in keyPointsRecord.aiMeta
? String(
(keyPointsRecord.aiMeta as Record<string, unknown>)
.premiumCurrency ?? "",
) || null
: null),
summary: aiResults.summary,
},
blockchain: blockchainEmailData,
});
} else {
console.warn(
`⚠️ Contract analysis email skipped: no recipient email found for user ${user.id}`,
);
}
} catch (emailError) {
console.warn("⚠️ Contract analysis email skipped:", emailError);
}
revalidatePath("/contacts"); revalidatePath("/contacts");
revalidatePath("/dashboard"); revalidatePath("/dashboard");

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 = const PRIMARY_ANALYSIS_MODEL =
process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview"; process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview";
const GEMINI_SECONDARY_ANALYSIS_MODEL = const GEMINI_SECONDARY_ANALYSIS_MODEL =
process.env.AI_MODEL_SECONDARY_GEMINI || ""; process.env.AI_MODEL_SECONDARY_GEMINI || process.env.AI_MODEL_SECONDARY || "";
const FALLBACK_ANALYSIS_MODEL = const FALLBACK_ANALYSIS_MODEL =
process.env.AI_MODEL_FALLBACK || "llama-3.3-70b-versatile"; process.env.AI_MODEL_FALLBACK || "mistral-large-latest";
const FALLBACK_REPAIR_MODEL = const FALLBACK_REPAIR_MODEL =
process.env.AI_MODEL_FALLBACK_REPAIR || "llama-3.3-70b-versatile"; process.env.AI_MODEL_FALLBACK_REPAIR || "mistral-large-latest";
const GROQ_API_KEY = const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY?.trim() || "";
process.env.GROQ_API_KEY?.trim() || process.env.AI_GROQ_API_KEY?.trim() || ""; const MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions";
const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"; const MISTRAL_OCR_API_URL = "https://api.mistral.ai/v1/ocr";
const MISTRAL_VISION_MODEL =
process.env.AI_MODEL_MISTRAL_VISION || "pixtral-large-latest";
const MISTRAL_OCR_MODEL =
process.env.AI_MODEL_MISTRAL_OCR || "mistral-ocr-latest";
const GEMINI_ANALYSIS_MODELS = Array.from( const GEMINI_ANALYSIS_MODELS = Array.from(
new Set( new Set(
@@ -36,7 +40,7 @@ const GEMINI_ANALYSIS_MODELS = Array.from(
); );
const ANALYSIS_MODELS = Array.from( const ANALYSIS_MODELS = Array.from(
new Set([...GEMINI_ANALYSIS_MODELS, `groq:${FALLBACK_ANALYSIS_MODEL}`]), new Set([...GEMINI_ANALYSIS_MODELS, `mistral:${FALLBACK_ANALYSIS_MODEL}`]),
); );
const FORCE_FALLBACK_TEST = const FORCE_FALLBACK_TEST =
@@ -89,7 +93,7 @@ const isAdaptiveKeyPoints = (
}; };
export class AIService { export class AIService {
private static isTransientGeminiError(message: string): boolean { private static isTransientAIError(message: string): boolean {
const normalized = message.toLowerCase(); const normalized = message.toLowerCase();
return ( return (
normalized.includes("503") || normalized.includes("503") ||
@@ -282,7 +286,7 @@ export class AIService {
// Better error messages // Better error messages
if (errorMessage.includes("API key")) { if (errorMessage.includes("API key")) {
throw new Error( throw new Error(
"Invalid or missing AI API key. Check AI_API_KEY1/2/3 for Gemini and GROQ_API_KEY for Groq fallback.", "Invalid or missing AI API key. Check AI_API_KEY1/2/3 for Gemini and MISTRAL_API_KEY for Mistral fallback.",
); );
} else if (errorMessage.includes("INVALID_CONTRACT:")) { } else if (errorMessage.includes("INVALID_CONTRACT:")) {
const reason = String(errorMessage) const reason = String(errorMessage)
@@ -291,9 +295,9 @@ export class AIService {
throw new Error( throw new Error(
reason || "Uploaded file is not recognized as a valid contract.", reason || "Uploaded file is not recognized as a valid contract.",
); );
} else if (this.isTransientGeminiError(errorMessage)) { } else if (this.isTransientAIError(errorMessage)) {
throw new Error( throw new Error(
`Gemini is temporarily overloaded for the configured analysis models (${ANALYSIS_MODELS.join(", ")}). The app retried automatically, but both models are still busy. Please try again in a few minutes.`, `The AI providers (Gemini/Mistral) are temporarily overloaded for the configured analysis models (${ANALYSIS_MODELS.join(", ")}). The app retried automatically, but both providers are still busy. Please try again in a few minutes.`,
); );
} else if ( } else if (
errorMessage.includes("not found") || errorMessage.includes("not found") ||
@@ -337,7 +341,7 @@ export class AIService {
} }
} else if (errorMessage.includes("quota")) { } else if (errorMessage.includes("quota")) {
throw new Error( throw new Error(
"Limit exceeded. Gemini or Groq quota may be exhausted. Check your provider dashboards for usage and limits.", "Limit exceeded. Gemini or Mistral quota may be exhausted. Check your provider dashboards for usage and limits.",
); );
} else { } else {
throw new Error(`Error analyzing contract: ${errorMessage}`); throw new Error(`Error analyzing contract: ${errorMessage}`);
@@ -389,11 +393,11 @@ export class AIService {
return parseAiJsonResponse(text); return parseAiJsonResponse(text);
} }
private static isGroqConfigured(): boolean { private static isMistralConfigured(): boolean {
return GROQ_API_KEY.length > 0; return MISTRAL_API_KEY.length > 0;
} }
private static async generateWithGroq(input: { private static async generateWithMistral(input: {
model?: string; model?: string;
prompt: string; prompt: string;
systemPrompt?: string; systemPrompt?: string;
@@ -402,9 +406,9 @@ export class AIService {
temperature?: number; temperature?: number;
topP?: number; topP?: number;
}): Promise<string> { }): Promise<string> {
if (!this.isGroqConfigured()) { if (!this.isMistralConfigured()) {
throw new Error( throw new Error(
"Groq fallback is not configured. Set GROQ_API_KEY (or AI_GROQ_API_KEY).", "Mistral fallback is not configured. Set MISTRAL_API_KEY.",
); );
} }
@@ -418,23 +422,25 @@ export class AIService {
messages.push({ role: "user", content: input.prompt }); messages.push({ role: "user", content: input.prompt });
// Use json_object mode (compatible with all models) // Use json_object mode (compatible with all models)
const responseFormat: Record<string, unknown> | undefined = input.responseAsJson const responseFormat: Record<string, unknown> | undefined =
? { type: "json_object" as const } input.responseAsJson ? { type: "json_object" as const } : undefined;
: undefined;
const temperature = input.temperature ?? 0;
const top_p = temperature === 0 ? 1 : (input.topP ?? 0.95);
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: modelName, model: modelName,
temperature: input.temperature ?? 0, temperature,
top_p: input.topP ?? 0.95, top_p,
max_tokens: input.maxOutputTokens, max_tokens: input.maxOutputTokens,
response_format: responseFormat, response_format: responseFormat,
messages, messages,
}; };
const response = await fetch(GROQ_API_URL, { const response = await fetch(MISTRAL_API_URL, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${GROQ_API_KEY}`, Authorization: `Bearer ${MISTRAL_API_KEY}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -443,7 +449,7 @@ export class AIService {
if (!response.ok) { if (!response.ok) {
const details = await response.text(); const details = await response.text();
throw new Error( throw new Error(
`Groq API error ${response.status}: ${details.slice(0, 300)}`, `Mistral API error ${response.status}: ${details.slice(0, 300)}`,
); );
} }
@@ -453,13 +459,92 @@ export class AIService {
const text = json.choices?.[0]?.message?.content?.trim() || ""; const text = json.choices?.[0]?.message?.content?.trim() || "";
if (!text) { if (!text) {
throw new Error("Empty response from Groq fallback model."); throw new Error("Empty response from Mistral fallback model.");
} }
return text; return text;
} }
private static async generateWithGroqModelChain(input: { /**
* Multimodal analysis using Mistral Pixtral vision model.
* Sends base64-encoded images directly to Pixtral for analysis,
* eliminating the need for a separate OCR bridge when Gemini is down.
*/
private static async generateWithMistralVision(input: {
prompt: string;
base64: string;
mimeType: string;
systemPrompt?: string;
responseAsJson?: boolean;
maxOutputTokens?: number;
}): Promise<string> {
if (!this.isMistralConfigured()) {
throw new Error(
"Mistral fallback is not configured. Set MISTRAL_API_KEY.",
);
}
const messages: Array<{ role: string; content: unknown }> = [];
if (input.systemPrompt) {
messages.push({ role: "system", content: input.systemPrompt });
}
// OpenAI-compatible multimodal content format for Pixtral vision
messages.push({
role: "user",
content: [
{ type: "text", text: input.prompt },
{
type: "image_url",
image_url: {
url: `data:${input.mimeType};base64,${input.base64}`,
},
},
],
});
const responseFormat: Record<string, unknown> | undefined =
input.responseAsJson ? { type: "json_object" as const } : undefined;
const body: Record<string, unknown> = {
model: MISTRAL_VISION_MODEL,
temperature: 0,
top_p: 1,
max_tokens: input.maxOutputTokens ?? 16384,
response_format: responseFormat,
messages,
};
const response = await fetch(MISTRAL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${MISTRAL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const details = await response.text();
throw new Error(
`Mistral Vision API error ${response.status}: ${details.slice(0, 300)}`,
);
}
const json = (await response.json()) as {
choices?: Array<{ message?: { content?: string | null } }>;
};
const text = json.choices?.[0]?.message?.content?.trim() || "";
if (!text) {
throw new Error("Empty response from Mistral Pixtral vision model.");
}
console.log(`✅ Mistral Pixtral vision analysis succeeded`);
return text;
}
private static async generateWithMistralModelChain(input: {
preferredModel?: string; preferredModel?: string;
prompt: string; prompt: string;
systemPrompt?: string; systemPrompt?: string;
@@ -473,9 +558,9 @@ export class AIService {
[ [
input.preferredModel, input.preferredModel,
FALLBACK_ANALYSIS_MODEL, FALLBACK_ANALYSIS_MODEL,
"llama-3.3-70b-versatile", "mistral-large-latest",
"qwen-2.5-32b", "mistral-small-latest",
"llama-3.1-8b-instant", "open-mistral-nemo",
].filter(Boolean), ].filter(Boolean),
), ),
) as string[]; ) as string[];
@@ -484,7 +569,7 @@ export class AIService {
for (const modelName of candidates) { for (const modelName of candidates) {
try { try {
const text = await this.generateWithGroq({ const text = await this.generateWithMistral({
model: modelName, model: modelName,
prompt: input.prompt, prompt: input.prompt,
systemPrompt: input.systemPrompt, systemPrompt: input.systemPrompt,
@@ -495,14 +580,14 @@ export class AIService {
}); });
if (modelName !== (input.preferredModel || FALLBACK_ANALYSIS_MODEL)) { if (modelName !== (input.preferredModel || FALLBACK_ANALYSIS_MODEL)) {
console.warn( console.warn(
`Groq switched to fallback model ${modelName} after primary fallback model failed.`, `Mistral switched to fallback model ${modelName} after primary fallback model failed.`,
); );
} }
return text; return text;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
console.warn( console.warn(
`Groq model ${modelName} failed. Trying next fallback model.`, `Mistral model ${modelName} failed. Trying next fallback model.`,
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
); );
} }
@@ -510,36 +595,79 @@ export class AIService {
throw lastError instanceof Error throw lastError instanceof Error
? lastError ? lastError
: new Error("All Groq fallback models failed."); : new Error("All Mistral fallback models failed.");
} }
/** /**
* Build a Groq-optimized system prompt that mirrors the Gemini behavior. * Build a Mistral-optimized system prompt that mirrors the Gemini behavior.
* This separates role & formatting rules from user content for better * This separates role & formatting rules from user content for better
* instruction adherence on open-source models. * instruction adherence on open-source models.
*
* Unlike the Gemini prompt which sends examples with the file inline,
* this prompt is designed to prevent hallucination by using explicit
* placeholder markers instead of realistic example values.
*/ */
private static buildGroqSystemPrompt(): string { private static buildMistralSystemPrompt(): string {
return `You are an expert contract analysis engine for the BFSI (Banking, Financial Services, and Insurance) sector. return `You are an expert contract analysis engine for the BFSI (Banking, Financial Services, and Insurance) sector.
You receive the full text content of a contract document below and must extract structured information from it. You receive the full text content of a contract document and must extract structured information from it.
CRITICAL OUTPUT RULES: ABSOLUTE RULES — VIOLATION OF THESE IS A CRITICAL FAILURE:
1. Return ONLY valid, parseable JSON — no markdown, no backticks, no explanations, no commentary. 1. Return ONLY valid, parseable JSON — no markdown, no backticks, no explanations, no commentary.
2. Your JSON must conform EXACTLY to the schema specified in the user prompt. 2. EVERY value you output MUST come directly from the document text provided to you.
3. Every required field MUST be present. Use null for missing strings/numbers and [] for missing arrays. 3. If a piece of information does NOT exist in the document text, you MUST use null (for strings/numbers) or [] (for arrays). NEVER invent, assume, or guess data.
4. All dates MUST be in ISO YYYY-MM-DD format or null. 4. Do NOT copy example values from the schema description — they are placeholders, not real data.
5. The "premium" field must be a positive number or null — NO currency symbols. 5. The "extractedText" field MUST contain actual verbatim text from the document — not a summary, not examples.
6. The "type" field MUST be one of: INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER.
7. Do NOT hallucinate or invent data that is not present in the document.
8. Preserve original language in extractedText and sourceSnippet fields (accents, special characters).
9. The "summary" must be 4-6 professional sentences covering parties, obligations, coverage, exclusions, and deadlines.
10. The "extractedText" must contain at least 30 characters of actual document content.
11. The "keyPoints.explainability" array must have at least 4 items for critical fields when data is available.
12. contractValidation.confidence must reflect actual extraction certainty (0-100).
13. When uncertain about a value, use null and set a lower confidence — never guess.
14. Parse localized number formats correctly (e.g., 1.234,56 vs 1,234.56).
15. Detect the contract language and set the "language" field accordingly (ISO 639-1).
You are replacing a more capable multimodal model (Gemini) as a fallback. Your output quality MUST match production standards.`; JSON SCHEMA (use exact field names):
{
"language": "<ISO 639-1 code detected from document>",
"title": "<exact contract title from document or null>",
"type": "<one of: INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER>",
"provider": "<company/institution name from document or null>",
"policyNumber": "<policy/contract number from document or null>",
"startDate": "<YYYY-MM-DD from document or null>",
"endDate": "<YYYY-MM-DD from document or null>",
"premium": <number from document or null — NO currency symbols>,
"premiumCurrency": "<currency code from document or null>",
"summary": "<4-6 sentences summarizing the actual contract content>",
"keyPoints": {
"guarantees": ["<actual guarantee from document>"],
"exclusions": ["<actual exclusion from document>"],
"franchise": "<deductible/penalty from document or null>",
"importantDates": ["<actual date from document with description>"],
"explainability": [
{
"field": "<field name>",
"why": "<why this value was extracted>",
"sourceSnippet": "<verbatim quote from document>",
"sourceHints": { "page": "<page or null>", "section": "<section or null>", "confidence": <0-100> }
}
]
},
"keyPeople": [{"name": "<from document>", "role": "<from document or null>", "email": "<from document or null>", "phone": "<from document or null>"}],
"contactInfo": {"name": "<from document or null>", "email": null, "phone": null, "address": null, "role": null},
"importantContacts": [],
"relevantDates": [{"date": "<YYYY-MM-DD>", "description": "<from document>", "type": "<EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER>"}],
"extractedText": "<verbatim text from the document, max 12000 chars>",
"contractValidation": {
"isValidContract": true,
"confidence": <0-100 reflecting how much data you actually found>,
"reason": null
}
}
FIELD RULES:
- All dates: ISO YYYY-MM-DD or null
- premium: positive number or null — NO currency symbols, NO text
- type: must be exactly one of the 8 values listed
- summary: 4-6 professional sentences about THIS specific contract. If no contract text is found, output "No contract data found in the document text."
- extractedText: must contain at least 30 characters of ACTUAL document content. If no text is found, output "No document text could be extracted. Please ensure the document is not a scanned image."
- explainability: at least 4 items with real sourceSnippets from the document
- confidence: reflects how much data you actually found (not how confident the model is)
- Parse localized number formats correctly (1.234,56 vs 1,234.56)
- Detect the contract language and set "language" accordingly
You are replacing a more capable multimodal model (Gemini) as a fallback. Your output quality MUST match production standards. ACCURACY is more important than completeness — it is better to return null than to guess.`;
} }
private static async generateAnalysisWithFallback(input: { private static async generateAnalysisWithFallback(input: {
@@ -551,27 +679,52 @@ You are replacing a more capable multimodal model (Gemini) as a fallback. Your o
let lastError: unknown = null; let lastError: unknown = null;
const forceFallback = Boolean(input.forceFallbackModelTest); const forceFallback = Boolean(input.forceFallbackModelTest);
const buildGroundedGroqPrompt = async (basePrompt: string) => { const buildGroundedMistralPrompt = async () => {
const groundingText = await this.extractGroqGroundingText({ const groundingText = await this.extractMistralGroundingText({
base64: input.base64, base64: input.base64,
mimeType: input.mimeType, mimeType: input.mimeType,
}); });
if (!groundingText) { if (!groundingText) {
return `${basePrompt}\n\nGROQ FALLBACK RULES:\n- You do not have direct binary file access in this fallback path.\n- Do not hallucinate values; use null/empty arrays when data is missing.\n- Keep contractValidation conservative when uncertain.\n- Set contractValidation.confidence to at most 60 when no grounding text is available.`; throw new Error(
"INVALID_CONTRACT:No extractable text found in this PDF after OCR fallback. Please verify the file is readable and not password-protected.",
);
} }
return `${basePrompt}\n\n--- BEGIN GROUNDED DOCUMENT TEXT (AUTHORITATIVE SOURCE) ---\n${groundingText}\n--- END GROUNDED DOCUMENT TEXT ---\n\nGROQ FALLBACK RULES:\n- Extract fields ONLY from the grounded document text above. This text is the full contract content.\n- Do not invent, assume, or hallucinate any values not explicitly present in the above text.\n- If a field's data is not found in the text, use null (for strings/numbers) or [] (for arrays).\n- Dates: convert any date format found in the text to YYYY-MM-DD.\n- Numbers: parse localized formats (comma vs period) correctly before setting numeric fields.\n- contractValidation.confidence should reflect how much data you could extract from the text.`; return `--- BEGIN GROUNDED DOCUMENT TEXT (AUTHORITATIVE SOURCE) ---
${groundingText}
--- END GROUNDED DOCUMENT TEXT ---
MISTRAL FALLBACK RULES:
- Extract fields ONLY from the grounded document text above. This text is the full contract content.
- Do not invent, assume, or hallucinate any values not explicitly present in the above text.
- If a field's data is not found in the text, use null (for strings/numbers) or [] (for arrays).
- Dates: convert any date format found in the text to YYYY-MM-DD.
- Numbers: parse localized formats (comma vs period) correctly before setting numeric fields.
- contractValidation.confidence should reflect how much data you could extract from the text.`;
}; };
if (forceFallback) { if (forceFallback) {
console.warn( console.warn(
`🧪 Fallback test mode enabled. Skipping Gemini and forcing Groq model ${FALLBACK_ANALYSIS_MODEL}.`, `🧪 Fallback test mode enabled. Skipping Gemini and forcing Mistral model ${FALLBACK_ANALYSIS_MODEL}.`,
); );
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt);
return this.generateWithGroqModelChain({ // For images: use Pixtral vision model directly (multimodal — no OCR bridge needed)
if (input.mimeType.startsWith("image/") && this.isMistralConfigured()) {
return this.generateWithMistralVision({
systemPrompt: this.buildMistralSystemPrompt(),
prompt: `TEST MODE: You are the forced fallback model. Return ONLY valid JSON and preserve the required schema exactly. Extract information from the provided image.`,
base64: input.base64,
mimeType: input.mimeType,
responseAsJson: true,
maxOutputTokens: 16384,
});
}
const groundedPrompt = await buildGroundedMistralPrompt();
return this.generateWithMistralModelChain({
preferredModel: FALLBACK_ANALYSIS_MODEL, preferredModel: FALLBACK_ANALYSIS_MODEL,
systemPrompt: this.buildGroqSystemPrompt(), systemPrompt: this.buildMistralSystemPrompt(),
prompt: `${groundedPrompt}\n\nTEST MODE: You are the forced fallback model. Return ONLY valid JSON and preserve the required schema exactly.`, prompt: `${groundedPrompt}\n\nTEST MODE: You are the forced fallback model. Return ONLY valid JSON and preserve the required schema exactly.`,
responseAsJson: true, responseAsJson: true,
maxOutputTokens: 8192, maxOutputTokens: 8192,
@@ -610,7 +763,6 @@ You are replacing a more capable multimodal model (Gemini) as a fallback. Your o
throw new Error("Empty response"); throw new Error("Empty response");
}); });
} catch (error: any) { } catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
lastError = error; lastError = error;
console.warn( console.warn(
`Analysis with model ${modelName} failed. Trying next model.`, `Analysis with model ${modelName} failed. Trying next model.`,
@@ -654,35 +806,54 @@ You are replacing a more capable multimodal model (Gemini) as a fallback. Your o
throw new Error("Empty response from fallback"); throw new Error("Empty response from fallback");
}); });
} catch (error: any) { } catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
console.warn("Lenient generation also failed:", error); console.warn("Lenient generation also failed:", error);
} }
// === Groq fallback path === // === Mistral AI fallback path ===
console.warn( console.warn(
"All Gemini models exhausted. Activating Groq fallback pipeline...", "All Gemini models exhausted. Activating Mistral AI fallback pipeline...",
); );
try { try {
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt); // For images: use Pixtral vision model directly (multimodal — no OCR bridge needed)
const groqText = await this.generateWithGroqModelChain({ if (input.mimeType.startsWith("image/") && this.isMistralConfigured()) {
const mistralText = await this.generateWithMistralVision({
systemPrompt: this.buildMistralSystemPrompt(),
prompt: `IMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object. Extract data from the provided image.`,
base64: input.base64,
mimeType: input.mimeType,
responseAsJson: true,
maxOutputTokens: 16384,
});
console.log(
`✅ Analysis fallback with Mistral Pixtral vision succeeded`,
);
return mistralText;
}
// For PDFs/text: extract text and use text-only Mistral
const groundedPrompt = await buildGroundedMistralPrompt();
const mistralText = await this.generateWithMistralModelChain({
preferredModel: FALLBACK_ANALYSIS_MODEL, preferredModel: FALLBACK_ANALYSIS_MODEL,
systemPrompt: this.buildGroqSystemPrompt(), systemPrompt: this.buildMistralSystemPrompt(),
prompt: `${groundedPrompt}\n\nIMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object.`, prompt: `${groundedPrompt}\n\nIMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object.`,
responseAsJson: true, responseAsJson: true,
maxOutputTokens: 8192, maxOutputTokens: 8192,
}); });
console.log( console.log(
`✅ Analysis fallback with Groq model ${FALLBACK_ANALYSIS_MODEL} succeeded`, `✅ Analysis fallback with Mistral model ${FALLBACK_ANALYSIS_MODEL} succeeded`,
);
return mistralText;
} catch (mistralError) {
console.warn("Mistral analysis fallback failed:", mistralError);
lastError = new Error(
`Mistral fallback also failed: ${mistralError instanceof Error ? mistralError.message : String(mistralError)}. Original error: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
); );
return groqText;
} catch (groqError) {
console.warn("Groq analysis fallback failed:", groqError);
} }
throw lastError instanceof Error throw lastError instanceof Error
? lastError ? lastError
: new Error( : new Error(
"All analysis models (Gemini + Groq fallback) failed to generate content.", "All analysis models (Gemini + Mistral fallback) failed to generate content.",
); );
} }
@@ -746,7 +917,7 @@ Original parse error: ${parseError}
Malformed response to fix: Malformed response to fix:
${malformedResponse.slice(0, 14000)}`; ${malformedResponse.slice(0, 14000)}`;
const repairedText = await this.generateWithGroqModelChain({ const repairedText = await this.generateWithMistralModelChain({
preferredModel: FALLBACK_REPAIR_MODEL, preferredModel: FALLBACK_REPAIR_MODEL,
prompt: repairPrompt, prompt: repairPrompt,
responseAsJson: true, responseAsJson: true,
@@ -766,7 +937,7 @@ ${malformedResponse.slice(0, 14000)}`;
} catch (firstRepairParseError) { } catch (firstRepairParseError) {
const secondPassPrompt = `${repairPrompt}\n\nSECOND PASS CORRECTION:\nYour previous repaired JSON was still invalid.\nReason: ${firstRepairParseError instanceof Error ? firstRepairParseError.message : "Invalid JSON"}.\nReturn ONLY strict valid JSON.`; const secondPassPrompt = `${repairPrompt}\n\nSECOND PASS CORRECTION:\nYour previous repaired JSON was still invalid.\nReason: ${firstRepairParseError instanceof Error ? firstRepairParseError.message : "Invalid JSON"}.\nReturn ONLY strict valid JSON.`;
const secondPass = await this.generateWithGroqModelChain({ const secondPass = await this.generateWithMistralModelChain({
preferredModel: FALLBACK_REPAIR_MODEL, preferredModel: FALLBACK_REPAIR_MODEL,
prompt: secondPassPrompt, prompt: secondPassPrompt,
responseAsJson: true, responseAsJson: true,
@@ -785,7 +956,12 @@ ${malformedResponse.slice(0, 14000)}`;
} }
} }
private static async extractGroqGroundingText(input: { /**
* Extract grounding text for Mistral text-only fallback.
* For PDFs: extracts text directly using pdf-parse (local, no AI needed).
* For images: returns empty string — Pixtral vision handles images directly.
*/
private static async extractMistralGroundingText(input: {
base64: string; base64: string;
mimeType: string; mimeType: string;
}): Promise<string> { }): Promise<string> {
@@ -793,13 +969,43 @@ ${malformedResponse.slice(0, 14000)}`;
if (input.mimeType === "application/pdf") { if (input.mimeType === "application/pdf") {
try { try {
const pdfBuffer = Buffer.from(input.base64, "base64"); const pdfBuffer = Buffer.from(input.base64, "base64");
const { PDFParse } = await import("pdf-parse");
const parser = new PDFParse({ data: pdfBuffer }); // Handle Next.js Webpack/Turbopack CJS/ESM interop
let parsed: { text?: string }; let pdfParseModule: any;
try { try {
parsed = await parser.getText(); pdfParseModule = require("pdf-parse");
} finally { } catch {
await parser.destroy(); 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 || "") const text = (parsed?.text || "")
@@ -807,66 +1013,110 @@ ${malformedResponse.slice(0, 14000)}`;
.replace(/\n{3,}/g, "\n\n") .replace(/\n{3,}/g, "\n\n")
.trim(); .trim();
if (text && text.length > 50) { if (text && text.length >= 10) {
console.log( console.log(
`📄 Groq grounding: extracted ${text.length} chars from PDF`, `📄 Mistral grounding: extracted ${text.length} chars from PDF`,
); );
return text.slice(0, 50000); return text.slice(0, 50000);
} }
console.warn(
`📄 Mistral grounding: native PDF text extraction too short (length: ${text?.length || 0}). Trying OCR fallback...`,
);
} catch (error) { } catch (error) {
console.warn( console.warn(
"PDF grounding extraction failed for Groq fallback.", "📄 PDF grounding extraction failed for Mistral fallback:",
error, error instanceof Error ? error.message : error,
);
}
// 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. // For images: Pixtral vision model handles images directly via
// This gives Groq the text content it needs since it can't read images. // generateWithMistralVision, so no grounding text extraction is needed.
if (input.mimeType.startsWith("image/")) { // The calling code in generateAnalysisWithFallback routes images
try { // to the vision path instead of the text-only grounded path.
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,
);
}
}
}
return ""; return "";
} }
private static async extractMistralPdfTextWithOcr(
pdfBase64: string,
): Promise<string> {
if (!this.isMistralConfigured()) {
return "";
}
const body = {
model: MISTRAL_OCR_MODEL,
document: {
type: "document_url",
document_url: `data:application/pdf;base64,${pdfBase64}`,
},
include_image_base64: false,
};
const response = await fetch(MISTRAL_OCR_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${MISTRAL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const details = await response.text();
throw new Error(
`Mistral OCR API error ${response.status}: ${details.slice(0, 300)}`,
);
}
const json = (await response.json()) as {
text?: string;
pages?: Array<{
text?: string;
markdown?: string;
content?: string;
}>;
output?: Array<{
text?: string;
markdown?: string;
content?: string;
}>;
};
const pageTexts = [
...(Array.isArray(json.pages) ? json.pages : []),
...(Array.isArray(json.output) ? json.output : []),
]
.map((page) => page.markdown || page.text || page.content || "")
.filter((value) => value.trim().length > 0);
const merged = [json.text || "", ...pageTexts]
.join("\n\n")
.replace(/\r/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
return merged;
}
/** /**
* Emergency fallback: Extract key contract fields from raw text when JSON is completely malformed. * Emergency fallback: Extract key contract fields from raw text when JSON is completely malformed.
* Builds a minimal but valid JSON structure from pattern-matched fields. * Builds a minimal but valid JSON structure from pattern-matched fields.
@@ -1406,7 +1656,7 @@ Include one short disclaimer only when legal context is discussed: "This is gene
if (!rawAnswer) { if (!rawAnswer) {
try { try {
rawAnswer = await this.generateWithGroqModelChain({ rawAnswer = await this.generateWithMistralModelChain({
preferredModel: FALLBACK_ANALYSIS_MODEL, preferredModel: FALLBACK_ANALYSIS_MODEL,
systemPrompt: `You are a senior BFSI contract advisor. Answer questions about contracts accurately and professionally. Respond entirely in ${languageName}. Use plain text only — no markdown, no bold, no headers, no bullet points. Base your answers ONLY on the provided contract content. If information is missing, say so.`, systemPrompt: `You are a senior BFSI contract advisor. Answer questions about contracts accurately and professionally. Respond entirely in ${languageName}. Use plain text only — no markdown, no bold, no headers, no bullet points. Base your answers ONLY on the provided contract content. If information is missing, say so.`,
prompt, prompt,
@@ -1416,10 +1666,10 @@ Include one short disclaimer only when legal context is discussed: "This is gene
topP: 0.95, topP: 0.95,
}); });
console.log( console.log(
`✅ Q&A fallback with Groq model ${FALLBACK_ANALYSIS_MODEL} succeeded in ${languageName}`, `✅ Q&A fallback with Mistral model ${FALLBACK_ANALYSIS_MODEL} succeeded in ${languageName}`,
); );
} catch (groqError) { } catch (mistralError) {
lastError = groqError; lastError = mistralError;
} }
} }
@@ -1444,11 +1694,11 @@ Include one short disclaimer only when legal context is discussed: "This is gene
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
if (errorMessage.includes("API key")) { if (errorMessage.includes("API key")) {
throw new Error("Invalid or missing AI API key (Gemini/Groq)."); throw new Error("Invalid or missing AI API key (Gemini/Mistral).");
} }
if (this.isTransientGeminiError(errorMessage)) { if (this.isTransientAIError(errorMessage)) {
throw new Error( throw new Error(
`Gemini is temporarily overloaded for the configured Q&A models (${ANALYSIS_MODELS.join(", ")}). Please try again in a few minutes.`, `The AI providers (Gemini/Mistral) are temporarily overloaded for the configured Q&A models (${ANALYSIS_MODELS.join(", ")}). Please try again in a few minutes.`,
); );
} }
throw new Error(`Error answering question: ${errorMessage}`); throw new Error(`Error answering question: ${errorMessage}`);

View File

@@ -76,7 +76,12 @@ function toDateOrNull(value: unknown): string | null {
function toStringList(value: unknown): string[] { function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value return value
.map((item) => String(item ?? "").trim()) .map((item) => {
if (typeof item === "object" && item !== null) {
return Object.values(item).filter(Boolean).join(" - ");
}
return String(item ?? "").trim();
})
.filter((item) => item.length > 0) .filter((item) => item.length > 0)
.slice(0, 25); .slice(0, 25);
} }

View File

@@ -0,0 +1,280 @@
import nodemailer from "nodemailer";
interface ContractBlueprint {
type: string;
provider: string | null;
policyNumber: string | null;
startDate: string | null;
endDate: string | null;
premium: number | null;
premiumCurrency: string | null;
summary: string;
}
interface BlockchainEmailData {
documentHash: string;
txHash: string;
blockNumber: number;
blockTimestamp: Date;
network: string;
contractAddress: string;
explorerUrl: string | null;
}
interface ContractAnalysisEmailInput {
to: string;
userDisplayName?: string | null;
contractId: string;
contractFileName: string;
contractTitle: string;
blueprint: ContractBlueprint;
blockchain?: BlockchainEmailData | null;
}
let transporter: nodemailer.Transporter | null = null;
let transportMode: "smtp" | "ethereal" | null = null;
let hasWarnedMissingEmailConfig = false;
const asBoolean = (value: string | undefined, fallback: boolean): boolean => {
if (!value) return fallback;
return value.toLowerCase() === "true" || value === "1";
};
const isEmailConfigured = (): boolean => {
return Boolean(
process.env.EMAIL_HOST &&
process.env.EMAIL_PORT &&
process.env.EMAIL_USER &&
process.env.EMAIL_PASS,
);
};
const warnMissingEmailConfigOnce = () => {
if (hasWarnedMissingEmailConfig) return;
hasWarnedMissingEmailConfig = true;
console.warn(
"Email notifications are disabled. Configure EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, and MAIL_FROM to enable contract summary emails.",
);
};
const getTransporter = async (): Promise<nodemailer.Transporter | null> => {
if (transporter) {
return transporter;
}
if (isEmailConfigured()) {
transportMode = "smtp";
transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT),
secure: asBoolean(
process.env.EMAIL_SECURE,
Number(process.env.EMAIL_PORT) === 465,
),
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
return transporter;
}
if (process.env.NODE_ENV !== "production") {
const testAccount = await nodemailer.createTestAccount();
transportMode = "ethereal";
transporter = nodemailer.createTransport({
host: testAccount.smtp.host,
port: testAccount.smtp.port,
secure: testAccount.smtp.secure,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
console.warn(
"Email service is running in development fallback mode using Ethereal. Configure SMTP env vars for real inbox delivery.",
);
return transporter;
}
warnMissingEmailConfigOnce();
return null;
};
const formatPremium = (
premium: number | null,
currency: string | null,
): string => {
if (premium === null || premium === undefined) return "N/A";
const formattedAmount = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(premium);
if (!currency) return formattedAmount;
if (["€", "$", "£"].includes(currency))
return `${currency}${formattedAmount}`;
return `${formattedAmount} ${currency}`;
};
const formatDateValue = (dateValue: string | null): string => {
if (!dateValue) return "N/A";
const date = new Date(dateValue);
if (Number.isNaN(date.getTime())) return dateValue;
return date.toISOString().split("T")[0];
};
const formatContractLink = (contractId: string): string | null => {
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.APP_URL?.trim();
if (!baseUrl) return null;
return `${baseUrl.replace(/\/$/, "")}/contacts?contract=${contractId}`;
};
export class EmailService {
static async sendContractAnalysisCompletedEmail(
input: ContractAnalysisEmailInput,
): Promise<{
success: boolean;
error?: string;
skipped?: boolean;
previewUrl?: string | null;
}> {
try {
const mailer = await getTransporter();
if (!mailer) {
return {
success: false,
skipped: true,
error: "Email service not configured",
};
}
const from =
process.env.MAIL_FROM?.trim() ||
process.env.EMAIL_USER?.trim() ||
(transportMode === "ethereal"
? "LexiChain <no-reply@ethereal.email>"
: "");
if (!from) {
warnMissingEmailConfigOnce();
return { success: false, skipped: true, error: "MAIL_FROM is missing" };
}
if (!input.to?.trim()) {
return {
success: false,
skipped: true,
error: "Recipient email is missing",
};
}
const recipientName = input.userDisplayName || "there";
const premiumLabel = formatPremium(
input.blueprint.premium,
input.blueprint.premiumCurrency,
);
const contractUrl = formatContractLink(input.contractId);
const blockchainStatus = input.blockchain
? "Registered"
: "Not registered (blockchain unavailable or skipped)";
const textBody = [
`Hello ${recipientName},`,
"",
"Your contract analysis is complete.",
"",
"Blueprint:",
`- Contract title: ${input.contractTitle}`,
`- Original file: ${input.contractFileName}`,
`- Type: ${input.blueprint.type}`,
`- Provider: ${input.blueprint.provider ?? "N/A"}`,
`- Policy number: ${input.blueprint.policyNumber ?? "N/A"}`,
`- Start date: ${formatDateValue(input.blueprint.startDate)}`,
`- End date: ${formatDateValue(input.blueprint.endDate)}`,
`- Premium: ${premiumLabel}`,
"",
"Summary:",
input.blueprint.summary,
"",
"Blockchain proof:",
`- Status: ${blockchainStatus}`,
`- Document hash: ${input.blockchain?.documentHash ?? "N/A"}`,
`- Transaction hash: ${input.blockchain?.txHash ?? "N/A"}`,
`- Block number: ${input.blockchain?.blockNumber ?? "N/A"}`,
`- Block time: ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}`,
`- Network: ${input.blockchain?.network ?? "N/A"}`,
`- Contract address: ${input.blockchain?.contractAddress ?? "N/A"}`,
`- Explorer URL: ${input.blockchain?.explorerUrl ?? "N/A"}`,
"",
contractUrl ? `Open in app: ${contractUrl}` : "",
"",
"Keep this email for your records.",
]
.filter(Boolean)
.join("\n");
const htmlBody = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #0f172a;">
<h2 style="margin-bottom: 12px;">Contract Analysis Completed</h2>
<p>Hello ${recipientName},</p>
<p>Your contract analysis has been completed successfully.</p>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blueprint</h3>
<ul>
<li><strong>Contract title:</strong> ${input.contractTitle}</li>
<li><strong>Original file:</strong> ${input.contractFileName}</li>
<li><strong>Type:</strong> ${input.blueprint.type}</li>
<li><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</li>
<li><strong>Policy number:</strong> ${input.blueprint.policyNumber ?? "N/A"}</li>
<li><strong>Start date:</strong> ${formatDateValue(input.blueprint.startDate)}</li>
<li><strong>End date:</strong> ${formatDateValue(input.blueprint.endDate)}</li>
<li><strong>Premium:</strong> ${premiumLabel}</li>
</ul>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Summary</h3>
<p>${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blockchain Proof</h3>
<ul>
<li><strong>Status:</strong> ${blockchainStatus}</li>
<li><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</li>
<li><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</li>
<li><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</li>
<li><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</li>
<li><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</li>
<li><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</li>
<li><strong>Explorer URL:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer">Open transaction</a>` : "N/A"}</li>
</ul>
${contractUrl ? `<p><a href="${contractUrl}">Open this contract in your dashboard</a></p>` : ""}
<p style="margin-top: 24px; font-size: 12px; color: #475569;">Keep this email for your records.</p>
</div>
`;
const info = await mailer.sendMail({
from,
to: input.to,
subject: `Contract analyzed: ${input.contractTitle}`,
text: textBody,
html: htmlBody,
});
const previewUrl = nodemailer.getTestMessageUrl(info);
if (previewUrl) {
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
}
return { success: true, previewUrl };
} catch (error) {
console.error("Failed to send analysis completion email:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown email error",
};
}
}
}

22
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@types/nodemailer": "^8.0.0",
"@uploadthing/react": "^7.3.3", "@uploadthing/react": "^7.3.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -55,6 +56,7 @@
"motion": "^12.34.0", "motion": "^12.34.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.7",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"react": "19.2.3", "react": "19.2.3",
@@ -3714,12 +3716,20 @@
"version": "20.19.33", "version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pako": { "node_modules/@types/pako": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
@@ -7937,6 +7947,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -9962,7 +9981,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {

View File

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

100
test-mistral.js Normal file
View File

@@ -0,0 +1,100 @@
const fs = require('fs');
const sysPrompt = `You are an expert contract analysis engine for the BFSI (Banking, Financial Services, and Insurance) sector.
You receive the full text content of a contract document and must extract structured information from it.
ABSOLUTE RULES — VIOLATION OF THESE IS A CRITICAL FAILURE:
1. Return ONLY valid, parseable JSON — no markdown, no backticks, no explanations, no commentary.
2. EVERY value you output MUST come directly from the document text provided to you.
3. If a piece of information does NOT exist in the document text, you MUST use null (for strings/numbers) or [] (for arrays). NEVER invent, assume, or guess data.
4. Do NOT copy example values from the schema description — they are placeholders, not real data.
5. The "extractedText" field MUST contain actual verbatim text from the document — not a summary, not examples.
JSON SCHEMA (use exact field names):
{
"language": "<ISO 639-1 code detected from document>",
"title": "<exact contract title from document or null>",
"type": "<one of: INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER>",
"provider": "<company/institution name from document or null>",
"policyNumber": "<policy/contract number from document or null>",
"startDate": "<YYYY-MM-DD from document or null>",
"endDate": "<YYYY-MM-DD from document or null>",
"premium": <number from document or null — NO currency symbols>,
"premiumCurrency": "<currency code from document or null>",
"summary": "<4-6 sentences summarizing the actual contract content>",
"keyPoints": {
"guarantees": ["<actual guarantee from document>"],
"exclusions": ["<actual exclusion from document>"],
"franchise": "<deductible/penalty from document or null>",
"importantDates": ["<actual date from document with description>"],
"explainability": [
{
"field": "<field name>",
"why": "<why this value was extracted>",
"sourceSnippet": "<verbatim quote from document>",
"sourceHints": { "page": "<page or null>", "section": "<section or null>", "confidence": <0-100> }
}
]
},
"keyPeople": [{"name": "<from document>", "role": "<from document or null>", "email": "<from document or null>", "phone": "<from document or null>"}],
"contactInfo": {"name": "<from document or null>", "email": null, "phone": null, "address": null, "role": null},
"importantContacts": [],
"relevantDates": [{"date": "<YYYY-MM-DD>", "description": "<from document>", "type": "<EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER>"}],
"extractedText": "<verbatim text from the document, max 12000 chars>",
"contractValidation": {
"isValidContract": true,
"confidence": <0-100 reflecting how much data you actually found>,
"reason": null
}
}
FIELD RULES:
- All dates: ISO YYYY-MM-DD or null
- premium: positive number or null — NO currency symbols, NO text
- type: must be exactly one of the 8 values listed
- summary: 4-6 professional sentences about THIS specific contract
- extractedText: must contain at least 30 characters of ACTUAL document content
- explainability: at least 4 items with real sourceSnippets from the document
- confidence: reflects how much data you actually found (not how confident the model is)
- Parse localized number formats correctly (1.234,56 vs 1,234.56)
- Detect the contract language and set "language" accordingly
You are replacing a more capable multimodal model (Gemini) as a fallback. Your output quality MUST match production standards. ACCURACY is more important than completeness — it is better to return null than to guess.`;
const prompt = `--- BEGIN GROUNDED DOCUMENT TEXT (AUTHORITATIVE SOURCE) ---
CONFIDENTIALITY AGREEMENT
This Confidentiality Agreement (the "Agreement") is entered into as of May 1, 2025 (the "Effective Date"), by and between Acme Corp ("Disclosing Party") and Beta Inc ("Receiving Party").
1. Confidential Information. "Confidential Information" means all non-public information disclosed by the Disclosing Party to the Receiving Party.
2. Obligations. The Receiving Party shall hold and maintain the Confidential Information in strictest confidence.
3. Term. This Agreement shall remain in effect for a period of two (2) years from the Effective Date.
Signatures:
John Doe, CEO Acme Corp
Jane Smith, VP Beta Inc
--- END GROUNDED DOCUMENT TEXT ---
MISTRAL FALLBACK RULES:
- Extract fields ONLY from the grounded document text above. This text is the full contract content.
- Do not invent, assume, or hallucinate any values not explicitly present in the above text.
- If a field's data is not found in the text, use null (for strings/numbers) or [] (for arrays).
- Dates: convert any date format found in the text to YYYY-MM-DD.
- Numbers: parse localized formats (comma vs period) correctly before setting numeric fields.
- contractValidation.confidence should reflect how much data you could extract from the text.
IMPORTANT: Return ONLY valid JSON and preserve the required schema exactly. Do not add any text outside of the JSON object.`;
fetch('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': 'Bearer 7yRx3izDA2ECDblZvAaUoZhgQnYqiiKj',
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'mistral-large-latest',
temperature: 0,
top_p: 1,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: sysPrompt },
{ role: 'user', content: prompt }
]
})
}).then(r => r.json()).then(data => console.log(data.choices[0].message.content)).catch(console.error);