From 165af509ef3c93712b65a955ef8e6bb7d50511a6 Mon Sep 17 00:00:00 2001 From: Adem Date: Sun, 3 May 2026 13:26:31 +0100 Subject: [PATCH] Pre-Final Backup --- AI_FEATURES_DOCUMENTATION.md | 20 +- app/(dashboard)/contacts/page.tsx | 257 +++- app/(dashboard)/dashboard/page.tsx | 1134 ++++++++++------- app/(dashboard)/not-found.tsx | 5 + app/api/contracts/[id]/download/route.ts | 53 + app/not-found.tsx | 5 + components/layout/invalid-route-screen.tsx | 93 ++ docs/blockchain-module.md | 164 +-- docs/lexichain-full-system.md | 80 ++ docs/project-technical-overview.md | 85 ++ features/analytics/components/charts.tsx | 232 ++-- features/contracts/api/contract.action.ts | 97 +- .../components/list/contracts-list.tsx | 896 +++++++------ lib/services/ai.service.ts | 520 ++++++-- lib/services/ai/analysis.normalizer.ts | 7 +- lib/services/email.service.ts | 280 ++++ package-lock.json | 22 +- package.json | 2 + test-mistral.js | 100 ++ 19 files changed, 2829 insertions(+), 1223 deletions(-) create mode 100644 app/(dashboard)/not-found.tsx create mode 100644 app/api/contracts/[id]/download/route.ts create mode 100644 app/not-found.tsx create mode 100644 components/layout/invalid-route-screen.tsx create mode 100644 docs/lexichain-full-system.md create mode 100644 docs/project-technical-overview.md create mode 100644 lib/services/email.service.ts create mode 100644 test-mistral.js diff --git a/AI_FEATURES_DOCUMENTATION.md b/AI_FEATURES_DOCUMENTATION.md index e42db2f..5c3caa0 100644 --- a/AI_FEATURES_DOCUMENTATION.md +++ b/AI_FEATURES_DOCUMENTATION.md @@ -65,17 +65,19 @@ The AI subsystem is centered on: - Primary model: gemini-2.5-flash - Optional secondary Gemini model: AI_MODEL_SECONDARY_GEMINI -- Fallback model provider: Groq (default: llama-3.3-70b-versatile) -- Gemini models are de-duplicated and iterated in order before Groq fallback -- Groq extraction fallback currently applies to image inputs in this pipeline; JSON repair and Q&A fallback are text-based +- Fallback model provider: Mistral AI (default: mistral-large-latest, vision: pixtral-large-latest) +- Gemini models are de-duplicated and iterated in order before Mistral fallback +- Mistral extraction fallback supports both text and image inputs via Pixtral vision; JSON repair and Q&A fallback are text-based ### 2.3 Environment Variables - AI_API_KEY (or AI_API_KEY2 / AI_API_KEY3 fallback) - AI_MODEL_PRIMARY (optional override) - AI_MODEL_SECONDARY_GEMINI (optional override) +- AI_MODEL_SECONDARY (legacy alias supported for compatibility) - AI_MODEL_FALLBACK (optional override) -- GROQ_API_KEY (or AI_GROQ_API_KEY) +- MISTRAL_API_KEY +- AI_MODEL_MISTRAL_VISION (optional, default: pixtral-large-latest) ## 3. AI Capability Matrix @@ -206,7 +208,7 @@ sequenceDiagram participant AIS as AIService participant GP as Gemini Primary participant GS as Gemini Secondary (optional) - participant GR as Groq Fallback + participant MR as Mistral AI Fallback AIS->>GP: generate analysis (strict JSON) alt GP success with usable output @@ -220,17 +222,17 @@ sequenceDiagram alt lenient success GP-->>AIS: raw text else lenient fails - AIS->>GR: generate analysis (strict JSON) - GR-->>AIS: text + AIS->>MR: generate analysis (strict JSON) + MR-->>AIS: text end end end AIS->>AIS: parseJsonResponse alt parse failed - AIS->>GR: repairMalformedJson(originalText, parseError) + AIS->>MR: repairMalformedJson(originalText, parseError) alt repair success - GR-->>AIS: repaired JSON text + MR-->>AIS: repaired JSON text AIS->>AIS: parse repaired JSON else repair failed AIS->>AIS: emergencyExtractFields(rawText) diff --git a/app/(dashboard)/contacts/page.tsx b/app/(dashboard)/contacts/page.tsx index 8a0e5ad..b28ac08 100644 --- a/app/(dashboard)/contacts/page.tsx +++ b/app/(dashboard)/contacts/page.tsx @@ -6,7 +6,16 @@ import { ContractsList } from "@/features/contracts/components/list/contracts-li import { ContractsHeader } from "@/components/layout/contacts-header"; import { useState, useEffect } from "react"; import { getContracts } from "@/features/contracts/api/contract.action"; -import { Card } from "@/components/ui/card"; +import { motion, AnimatePresence } from "motion/react"; +import { + Upload, + FileText, + Sparkles, + Shield, + Zap, + ChevronRight, + Loader2, +} from "lucide-react"; export default function ContactsPage() { const [refreshTrigger, setRefreshTrigger] = useState(0); @@ -42,69 +51,217 @@ export default function ContactsPage() { if (isChecking) { return ( - <> -
-
-
-
-
-
- -
-
-
-
-
-

Loading...

-
-
+
+ {/* Ambient loading background */} +
+
+
+
- + +
+ +
+
+
+ +
+
+
+

+ Loading workspace +

+

+ Preparing your contract environment... +

+
+ +
+
); } return ( - <> -
-
- +
+ {/* Ambient Background */} +
+
+
+
+
+
-
-
- -
-

+ + +
+
+
+ {/* Upload Section */} + +
+
+ +
+
+

Upload Contract

-

- Add PDF contracts and let the AI pipeline extract summary, - key points, and legal-business insights. +

+ PDF documents supported

- - +
- -
-

- Your Contracts -

-

- Review contract lifecycle, trigger analysis, and ask AI - questions per file. -

+
+
+
+
+
+

+ New Document +

+

+ Our AI pipeline will automatically extract summaries, + key clauses, risk factors, and generate actionable + business insights. +

+
+
+ + + + + AI Ready +
+
+ +
+
+ + + {/* Contracts List Section */} + +
+
+
+ +
+
+

+ Your Contracts +

+

+ Manage, analyze, and query your document library +

+
- {showContracts ? ( - - ) : ( - + {showContracts && ( + 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" + > + + Refresh + )} - -
+
+ +
+
+ + + {showContracts ? ( + + + + ) : ( + + + + )} + +
+ + + {/* Bottom Info Cards */} + + {[ + { + icon: , + title: "Secure Processing", + desc: "Documents are encrypted in transit and at rest. Your data never leaves your infrastructure.", + color: "emerald", + }, + { + icon: , + title: "AI Extraction", + desc: "Advanced NLP models identify parties, obligations, risks, and key dates automatically.", + color: "primary", + }, + { + icon: , + title: "Instant Insights", + desc: "Get executive summaries and red-flag alerts within seconds of upload completion.", + color: "violet", + }, + ].map((feature, i) => ( + +
+ {feature.icon} +
+

+ {feature.title} +

+

+ {feature.desc} +

+ +
+ ))} +
-
-

- +
+
+
); } diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index cc7108e..9edb8d3 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { motion } from "motion/react"; +import { motion, AnimatePresence } from "motion/react"; import { Activity, AlertTriangle, @@ -16,6 +16,11 @@ import { RefreshCw, Sparkles, TrendingUp, + Zap, + Shield, + Fingerprint, + ChevronRight, + Tag, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -24,9 +29,42 @@ import { checkDeadlineNotifications } from "@/features/notifications/api/notific import dynamic from "next/dynamic"; // Dynamically import heavy charting libraries to dramatically improve initial load and rendering time -const ContractStatusChart = dynamic(() => import("@/features/analytics/components/charts").then(mod => mod.ContractStatusChart), { ssr: false, loading: () =>
}); -const ContractTypeChart = dynamic(() => import("@/features/analytics/components/charts").then(mod => mod.ContractTypeChart), { ssr: false, loading: () =>
}); -const TrendChart = dynamic(() => import("@/features/analytics/components/charts").then(mod => mod.TrendChart), { ssr: false, loading: () =>
}); +const ContractStatusChart = dynamic( + () => + import("@/features/analytics/components/charts").then( + (mod) => mod.ContractStatusChart, + ), + { + ssr: false, + loading: () => ( +
+ ), + }, +); +const ContractTypeChart = dynamic( + () => + import("@/features/analytics/components/charts").then( + (mod) => mod.ContractTypeChart, + ), + { + ssr: false, + loading: () => ( +
+ ), + }, +); +const TrendChart = dynamic( + () => + import("@/features/analytics/components/charts").then( + (mod) => mod.TrendChart, + ), + { + ssr: false, + loading: () => ( +
+ ), + }, +); interface DashboardStats { totalContracts: number; @@ -228,93 +266,110 @@ export default function DashboardPage() { label: "Uploaded", value: stats.uploadedContracts, colorClass: "bg-amber-500", + icon: , }, { label: "Processing", value: stats.processingContracts, - colorClass: "bg-primary", + colorClass: "bg-blue-500", + icon: , }, { label: "Analyzed", value: stats.analyzedContracts, colorClass: "bg-emerald-500", + icon: , }, { label: "Failed", value: stats.failedContracts, - colorClass: "bg-destructive", + colorClass: "bg-red-500", + icon: , }, ]; if (isLoading) { return ( -
-
- -
- - Building your analytics workspace... -
-
+
+
+
+
+
+
+ +
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
); } return ( -
-
-
- -
+
+ {/* Ambient Background */} +
+
+
+
+
+
+ + {/* Hero Section */} +
+
+ +
-
-

+

+ Performance Overview -

-

+ + +

Financial Contracts Analytics

-

+ +

A reliable command center for uploaded documents, AI analysis throughput, and portfolio quality across your BFSI workflow.

-
- - +
+ + + + + Live metrics - - + + {isRefreshing ? "Syncing..." : `Updated ${formatLastUpdated(lastUpdated)}`} @@ -322,338 +377,415 @@ export default function DashboardPage() {
- -
-
-

- Pipeline Snapshot -

-

- {numberFormatter.format(stats.totalContracts)} files -

-
-
- -
-
+ {/* Pipeline Snapshot Card */} + +
+ +
-
- -
- -
-
-

Analyzed

-

- {numberFormatter.format(stats.analyzedContracts)} -

+
+
+

+ Pipeline Snapshot +

+

+ {numberFormatter.format(stats.totalContracts)}{" "} + + files + +

+
+
+ +
-
-

Pending

-

- {numberFormatter.format(pendingContracts)} -

-
-
-
- - -
- +
+ +
+ +
+
+

+ Analyzed +

+

+ {numberFormatter.format(stats.analyzedContracts)} +

+
+
+

+ Pending +

+

+ {numberFormatter.format(pendingContracts)} +

+
+
+ +
+ + +
+ +
-
-
- -
-

Total Files

- -
-

- {numberFormatter.format(stats.totalContracts)} -

-

- Uploaded into your workspace -

-
+
+ {/* Bento Stats Grid */} + + } + label="Total Files" + value={numberFormatter.format(stats.totalContracts)} + subtitle="Uploaded into your workspace" + gradient="from-primary/20 to-primary/5" + border="border-primary/20" + iconColor="text-primary" + delay={0} + /> + } + label="Analyzed" + value={numberFormatter.format(stats.analyzedContracts)} + subtitle="Completed by AI pipeline" + gradient="from-emerald-500/20 to-emerald-500/5" + border="border-emerald-500/20" + iconColor="text-emerald-500" + progress={analyzedPercent} + progressColor="bg-emerald-500" + delay={0.05} + /> + } + label="Pending Queue" + value={numberFormatter.format(pendingContracts)} + subtitle="Uploaded and processing files" + gradient="from-amber-500/20 to-amber-500/5" + border="border-amber-500/20" + iconColor="text-amber-500" + progress={pendingPercent} + progressColor="bg-amber-500" + delay={0.1} + /> + } + label="Failed" + value={numberFormatter.format(stats.failedContracts)} + subtitle="Items needing re-analysis" + gradient="from-red-500/20 to-red-500/5" + border="border-red-500/20" + iconColor="text-red-500" + progress={failedPercent} + progressColor="bg-red-500" + delay={0.15} + /> + - -
-

Analyzed

- -
-

- {numberFormatter.format(stats.analyzedContracts)} -

-

- Completed by AI pipeline -

-
-
-
- + {/* Pipeline Pulse + Premium Info */} + +
+ +
- -
-

Pending Queue

- -
-

- {numberFormatter.format(pendingContracts)} -

-

- Uploaded and processing files -

-
-
-
- +
+
+
+
+ +
+

+ Pipeline Pulse +

+
+
+ {statusRows.map((row) => { + const rowPercent = + stats.totalContracts > 0 + ? clampPercent((row.value / stats.totalContracts) * 100) + : 0; - -
-

Failed

- -
-

- {numberFormatter.format(stats.failedContracts)} -

-

- Items needing re-analysis -

-
-
-
- -
- - -
-
-
- -

Pipeline Pulse

-
-
- {statusRows.map((row) => { - const rowPercent = - stats.totalContracts > 0 - ? clampPercent((row.value / stats.totalContracts) * 100) - : 0; - - return ( -
-
-

{row.label}

-

- {numberFormatter.format(row.value)} -

+ return ( +
+
+
+ + {row.icon} + + {row.label} +
+

+ {numberFormatter.format(row.value)} +

+
+
+ +
-
-
-
-
- ); - })} + ); + })} +
+
+ +
+
+

+ Success Rate +

+

+ {stats.analysisRate}% +

+

+ Completed vs total files +

+
+
+

+ Avg Premium +

+

+ {currencyFormatter.format(premiumInfo.averagePremium)} +

+

+ Across {numberFormatter.format(premiumInfo.count)} analyzed + files +

+
+
+

+ Total Premium +

+

+ {currencyFormatter.format(premiumInfo.totalPremium)} +

+

+ Portfolio value captured by AI +

+
+ + -
-
-

Success Rate

-

- {stats.analysisRate}% -

-

- Completed vs total files -

+ {/* AI Learning Telemetry */} + +
+ +
+ +
+
+
+ +
+

+ AI Learning Telemetry +

-
-

Avg Premium

-

- {currencyFormatter.format(premiumInfo.averagePremium)} -

-

- Across {numberFormatter.format(premiumInfo.count)} analyzed - files -

+ + + Score {aiLearningTelemetry.learningScore}/100 + +
+ +
+ {[ + { + label: "Completed Samples", + value: numberFormatter.format( + aiLearningTelemetry.completedSamples, + ), + sub: `${numberFormatter.format(aiLearningTelemetry.completedLast7Days)} in last 7 days`, + icon: , + color: "emerald", + }, + { + label: "Avg Summary Length", + value: numberFormatter.format( + aiLearningTelemetry.avgSummaryLength, + ), + sub: "characters", + icon: , + color: "primary", + }, + { + label: "Avg Extracted Text", + value: numberFormatter.format( + aiLearningTelemetry.avgExtractedTextLength, + ), + sub: "characters", + icon: , + color: "blue", + }, + { + label: "Avg Key Points", + value: aiLearningTelemetry.avgKeyPointsPerContract.toFixed(1), + sub: "items per analysis", + icon: , + color: "violet", + }, + ].map((item) => ( +
+
+ + {item.icon} + +

+ {item.label} +

+
+

+ {item.value} +

+

+ {item.sub} +

+
+ ))} +
+ +
+
+ + Learning quality index + + + {aiLearningTelemetry.learningScore}% +
-
-

Total Premium

-

- {currencyFormatter.format(premiumInfo.totalPremium)} -

-

- Portfolio value captured by AI -

+
+
-
-
- - - -
-
- -

AI Learning Telemetry

-
- - - Score {aiLearningTelemetry.learningScore}/100 - -
- -
-
-

Completed Samples

-

- {numberFormatter.format(aiLearningTelemetry.completedSamples)} -

-

- {numberFormatter.format(aiLearningTelemetry.completedLast7Days)}{" "} - in last 7 days +

+ {aiLearningTelemetry.improvementHint}

- -
-

- Avg Summary Length -

-

- {numberFormatter.format(aiLearningTelemetry.avgSummaryLength)} -

-

characters

-
- -
-

- Avg Extracted Text -

-

- {numberFormatter.format( - aiLearningTelemetry.avgExtractedTextLength, - )} -

-

characters

-
- -
-

Avg Key Points

-

- {aiLearningTelemetry.avgKeyPointsPerContract.toFixed(1)} -

-

- items per analysis -

-
-
- -
-
- Learning quality index - {aiLearningTelemetry.learningScore}% -
-
-
-
-

- {aiLearningTelemetry.improvementHint} -

-
- + + {hasChartData ? ( -
+ {chartData && chartData.trends.length > 0 && ( - -
- -

+ +
+
+
+ +
+

Upload Trend (30 days)

@@ -664,10 +796,15 @@ export default function DashboardPage() { )} {chartData && chartData.byStatus.length > 0 && ( - -
- -

Processing Status

+ +
+
+
+ +
+

+ Processing Status +

0 && ( - -
- -

+ +
+
+
+ +
+

Contract Type Distribution

@@ -694,47 +834,68 @@ export default function DashboardPage() { )} - -
- -

Recent Analyses

+ +
+
+
+ +
+

+ Recent Analyses +

{recentContracts.length > 0 ? (
- {recentContracts.map((contract) => ( -
-

- {contract.title || "Untitled contract"} -

-
- {contract.type || "Unknown type"} - - {new Date(contract.createdAt).toLocaleDateString( - "en-US", - { - month: "short", - day: "numeric", - }, - )} - -
-

- Premium:{" "} - {contract.premium !== null - ? currencyFormatter.format(contract.premium) - : "Not detected"} -

-
- ))} + + {recentContracts.map((contract, idx) => ( + +
+

+ {contract.title || "Untitled contract"} +

+ +
+
+ + + {contract.type || "Unknown type"} + + + + {new Date(contract.createdAt).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + }, + )} + +
+

+ Premium:{" "} + {contract.premium !== null + ? currencyFormatter.format(contract.premium) + : "Not detected"} +

+
+ ))} +
) : ( -
+
-

+

+
+ +
+

No recent analyses yet

@@ -744,72 +905,109 @@ export default function DashboardPage() {

)} -
+ ) : ( - -
- - - - - - - -

- Your analytics will appear here -

-

- Upload and analyze contracts to unlock trend and distribution - charts. -

- -
-
+ + +
+
+
+ +
+

+ Your analytics will appear here +

+

+ Upload and analyze contracts to unlock trend and distribution + charts. +

+ +
+ + )}
); } + +// ───────────────────────────────────────────────── +// Bento Stat Sub-Component +// ───────────────────────────────────────────────── + +function BentoStat({ + icon, + label, + value, + subtitle, + gradient, + border, + iconColor, + progress, + progressColor, + delay, +}: { + icon: React.ReactNode; + label: string; + value: string; + subtitle: string; + gradient: string; + border: string; + iconColor: string; + progress?: number; + progressColor?: string; + delay: number; +}) { + return ( + +
+
+ +
+
+
+ {icon} +
+
+ +

+ {label} +

+

+ {value} +

+

{subtitle}

+ + {progress !== undefined && ( +
+ +
+ )} +
+ + ); +} diff --git a/app/(dashboard)/not-found.tsx b/app/(dashboard)/not-found.tsx new file mode 100644 index 0000000..b634768 --- /dev/null +++ b/app/(dashboard)/not-found.tsx @@ -0,0 +1,5 @@ +import { InvalidRouteScreen } from "@/components/layout/invalid-route-screen"; + +export default function NotFound() { + return ; +} \ No newline at end of file diff --git a/app/api/contracts/[id]/download/route.ts b/app/api/contracts/[id]/download/route.ts new file mode 100644 index 0000000..c8ef667 --- /dev/null +++ b/app/api/contracts/[id]/download/route.ts @@ -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 }); + } +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..b634768 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,5 @@ +import { InvalidRouteScreen } from "@/components/layout/invalid-route-screen"; + +export default function NotFound() { + return ; +} \ No newline at end of file diff --git a/components/layout/invalid-route-screen.tsx b/components/layout/invalid-route-screen.tsx new file mode 100644 index 0000000..913a961 --- /dev/null +++ b/components/layout/invalid-route-screen.tsx @@ -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 ( +
+ {/* Ambient Background */} +
+
+
+
+
+
+ +
+ {/* 404 Code */} + +
+

+ 404 +

+ + + {/* Content */} + +
+
+ + Page not found +
+ +

+ This page doesn't exist +

+ +

+ The URL you entered doesn't match any known route. Double-check + the address or return to the homepage. +

+
+ + {/* Action */} + + + + + {/* Decorative footer */} + +
+ + LexiChain + +
+ + +
+
+ ); +} diff --git a/docs/blockchain-module.md b/docs/blockchain-module.md index cf9f1c7..73b57bc 100644 --- a/docs/blockchain-module.md +++ b/docs/blockchain-module.md @@ -31,6 +31,7 @@ ### The Problem (in simple terms) When a client sends an insurance claim or uploads a contract, they need **proof** that they submitted it on a specific date. Without proof: + - The insurance company could claim "we never received it" - Deadlines could be disputed - There's no transparency @@ -40,6 +41,7 @@ When a client sends an insurance claim or uploads a contract, they need **proof* A **blockchain** is like a public, tamper-proof notebook. Once you write something in it, **nobody can erase or modify it** — not even the person who wrote it. We use the blockchain as a **digital notary**: + 1. We take the uploaded contract PDF 2. We create a unique **fingerprint** (hash) of that file 3. We write that fingerprint into the blockchain with a timestamp @@ -50,11 +52,13 @@ We use the blockchain as a **digital notary**: ### What is a Smart Contract? A **smart contract** is a program that runs on the blockchain. Think of it as a vending machine: + - You put in a coin (send a transaction) - The machine executes its programmed logic - The result is permanent and visible to everyone Our smart contract (`DocumentRegistry.sol`) has two main functions: + - **Register**: Store a document fingerprint with a timestamp - **Verify**: Check if a fingerprint exists and when it was stored @@ -64,14 +68,14 @@ Our smart contract (`DocumentRegistry.sol`) has two main functions: ### Features Implemented -| Feature | Description | -|---------|-------------| -| **Auto-Registration** | After AI analyzes a contract, its hash is automatically registered on-chain | -| **Manual Registration** | Users can register unregistered contracts via the Blockchain Explorer | -| **Document Verification** | Paste any document hash to check if it exists on-chain | -| **Transaction Explorer** | View all blockchain transactions with details | -| **Network Stats** | Live stats: verified documents, latest block, network status | -| **Proof Badges** | Contract list shows which contracts are blockchain-verified | +| Feature | Description | +| ------------------------- | --------------------------------------------------------------------------- | +| **Auto-Registration** | After AI analyzes a contract, its hash is automatically registered on-chain | +| **Manual Registration** | Users can register unregistered contracts via the Blockchain Explorer | +| **Document Verification** | Paste any document hash to check if it exists on-chain | +| **Transaction Explorer** | View all blockchain transactions with details | +| **Network Stats** | Live stats: verified documents, latest block, network status | +| **Proof Badges** | Contract list shows which contracts are blockchain-verified | ### What Happens When a User Uploads a Contract? @@ -80,6 +84,7 @@ User uploads PDF → AI analyzes it → Blockchain registers the hash ``` The entire flow is automatic. The user doesn't need: + - ❌ MetaMask or any wallet - ❌ Cryptocurrency knowledge - ❌ To pay anything @@ -129,10 +134,10 @@ flowchart TD ### Network Modes -| Mode | When | URL | Cost | -|------|------|-----|------| -| **Hardhat** | Development | `http://127.0.0.1:8545` | Free (local) | -| **Sepolia** | Demo/Presentation | Via Alchemy/Infura RPC | Free (testnet) | +| Mode | When | URL | Cost | +| ----------- | ----------------- | ----------------------- | -------------- | +| **Hardhat** | Development | `http://127.0.0.1:8545` | Free (local) | +| **Sepolia** | Demo/Presentation | Via Alchemy/Infura RPC | Free (testnet) | The mode is controlled by a single env variable: `BLOCKCHAIN_NETWORK`. @@ -178,12 +183,14 @@ flowchart LR ``` #### `registerDocument(bytes32 _docHash)` + - **Purpose**: Store a document hash on-chain - **Access**: Only the contract owner (our server wallet) - **Guard**: Prevents duplicate registration (same hash can't be registered twice) - **Event**: Emits `DocumentRegistered` for off-chain indexing #### `verifyDocument(bytes32 _docHash)` + - **Purpose**: Check if a hash exists and get its details - **Cost**: Free (read-only, no gas) - **Returns**: `(exists, timestamp, depositor)` @@ -231,6 +238,7 @@ flowchart LR ### Why Server-Side? Most blockchain dApps require users to install MetaMask and sign transactions. This is bad UX for a BFSI enterprise platform because: + - Users shouldn't need crypto knowledge - The platform manages documents, not individual users - Server-side signing is more reliable @@ -260,14 +268,14 @@ sequenceDiagram ### Key Methods -| Method | Purpose | Gas Cost | -|--------|---------|----------| -| `hashDocument(fileUrl)` | Download file + compute SHA-256 | None (off-chain) | -| `registerOnChain(hash, fileName)` | Send tx to smart contract | ~50,000 gas | -| `verifyOnChain(hash)` | Read-only check | Free | -| `hashAndRegister(fileUrl, fileName)` | Combined: hash + register | ~50,000 gas | -| `getNetworkStats()` | Get block number, total docs | Free | -| `isConfigured()` | Check if env vars are set | None | +| Method | Purpose | Gas Cost | +| ------------------------------------ | ------------------------------- | ---------------- | +| `hashDocument(fileUrl)` | Download file + compute SHA-256 | None (off-chain) | +| `registerOnChain(hash, fileName)` | Send tx to smart contract | ~50,000 gas | +| `verifyOnChain(hash)` | Read-only check | Free | +| `hashAndRegister(fileUrl, fileName)` | Combined: hash + register | ~50,000 gas | +| `getNetworkStats()` | Get block number, total docs | Free | +| `isConfigured()` | Check if env vars are set | None | ### Graceful Degradation @@ -424,6 +432,7 @@ sequenceDiagram participant SA as Server Action participant AI as AI Service participant BS as BlockchainService + participant ES as EmailService participant SC as Smart Contract participant DB as PostgreSQL @@ -446,6 +455,8 @@ sequenceDiagram SA->>DB: Save txHash, blockNumber, etc. SA->>DB: Create BlockchainTransaction + SA->>ES: Send analysis + blockchain proof email + ES-->>U: Email received (or Ethereal preview in dev) SA-->>UI: Success! Note over U,UI: User visits /blockchain @@ -470,6 +481,7 @@ sequenceDiagram ## 10. How to Run Locally ### Prerequisites + - Node.js installed - The Next.js app running (`npm run dev`) @@ -562,6 +574,7 @@ npx hardhat run scripts/deploy.ts --network sepolia ### Step 5: Verify on Etherscan After deploying, transactions will have real Etherscan links: + ``` https://sepolia.etherscan.io/tx/0x... ``` @@ -570,19 +583,20 @@ https://sepolia.etherscan.io/tx/0x... ## 12. Technology Choices & Rationale -| Technology | Why We Chose It | -|-----------|----------------| -| **Solidity 0.8.24** | Latest stable version with built-in overflow protection | -| **Hardhat** | Industry standard for Solidity development, free local blockchain | -| **ethers.js v6** | Modern, lightweight, TypeScript-native Ethereum library | -| **SHA-256** | Standard cryptographic hash, deterministic, collision-resistant | -| **Server-side wallet** | Users don't need MetaMask; enterprise-grade UX | -| **Sepolia testnet** | Official Ethereum testnet, free, has Etherscan explorer | -| **Graceful degradation** | Blockchain is optional; app works perfectly without it | +| Technology | Why We Chose It | +| ------------------------ | ----------------------------------------------------------------- | +| **Solidity 0.8.24** | Latest stable version with built-in overflow protection | +| **Hardhat** | Industry standard for Solidity development, free local blockchain | +| **ethers.js v6** | Modern, lightweight, TypeScript-native Ethereum library | +| **SHA-256** | Standard cryptographic hash, deterministic, collision-resistant | +| **Server-side wallet** | Users don't need MetaMask; enterprise-grade UX | +| **Sepolia testnet** | Official Ethereum testnet, free, has Etherscan explorer | +| **Graceful degradation** | Blockchain is optional; app works perfectly without it | ### Why NOT Web3j / Java? The original project spec suggested Web3j (Java library). We chose ethers.js instead because: + 1. Our backend is **Next.js/TypeScript**, not Spring Boot 2. ethers.js has **better TypeScript support** and is more actively maintained 3. Both libraries do the same job — interact with Ethereum — but ethers.js is native to our stack @@ -592,64 +606,70 @@ The original project spec suggested Web3j (Java library). We chose ethers.js ins ## 13. File Reference ### Smart Contract Layer -| File | Purpose | -|------|---------| + +| File | Purpose | +| ------------------------------------------- | ----------------------- | | `blockchain/contracts/DocumentRegistry.sol` | Solidity smart contract | -| `blockchain/test/DocumentRegistry.test.ts` | 14 comprehensive tests | -| `blockchain/scripts/deploy.ts` | Deployment script | -| `blockchain/hardhat.config.ts` | Hardhat configuration | -| `blockchain/package.json` | Hardhat dependencies | +| `blockchain/test/DocumentRegistry.test.ts` | 14 comprehensive tests | +| `blockchain/scripts/deploy.ts` | Deployment script | +| `blockchain/hardhat.config.ts` | Hardhat configuration | +| `blockchain/package.json` | Hardhat dependencies | ### Service Layer -| File | Purpose | -|------|---------| + +| File | Purpose | +| ------------------------------------ | ---------------------------- | | `lib/services/blockchain.service.ts` | Core blockchain interactions | -| `lib/services/blockchain.types.ts` | TypeScript type definitions | +| `lib/services/blockchain.types.ts` | TypeScript type definitions | ### Server Actions -| File | Purpose | -|------|---------| -| `features/blockchain/api/blockchain.action.ts` | Blockchain server actions | -| `features/contracts/api/contract.action.ts` | Updated with auto-registration | + +| File | Purpose | +| ---------------------------------------------- | ------------------------------ | +| `features/blockchain/api/blockchain.action.ts` | Blockchain server actions | +| `features/contracts/api/contract.action.ts` | Updated with auto-registration | ### Frontend -| File | Purpose | -|------|---------| -| `app/(dashboard)/blockchain/page.tsx` | Blockchain Explorer page | -| `app/(dashboard)/blockchain/layout.tsx` | Page metadata | -| `components/layout/navigation.tsx` | Updated with blockchain link | + +| File | Purpose | +| --------------------------------------- | ---------------------------- | +| `app/(dashboard)/blockchain/page.tsx` | Blockchain Explorer page | +| `app/(dashboard)/blockchain/layout.tsx` | Page metadata | +| `components/layout/navigation.tsx` | Updated with blockchain link | ### Database -| File | Purpose | -|------|---------| + +| File | Purpose | +| ---------------------- | ------------------------------ | | `prisma/schema.prisma` | Updated with blockchain fields | ### Configuration -| File | Purpose | -|------|---------| -| `.env` | Blockchain env vars | -| `.env.example` | Template for new developers | -| `.gitignore` | Blockchain artifacts excluded | + +| File | Purpose | +| -------------- | ----------------------------- | +| `.env` | Blockchain env vars | +| `.env.example` | Template for new developers | +| `.gitignore` | Blockchain artifacts excluded | --- ## Glossary -| Term | Definition | -|------|-----------| -| **Hash** | A fixed-size fingerprint of data. Same input → same output. | -| **SHA-256** | A specific hash algorithm producing 256-bit (32-byte) outputs | -| **Smart Contract** | A program stored on the blockchain that executes automatically | -| **Gas** | The fee for executing operations on Ethereum (free on testnet) | -| **Block** | A batch of transactions grouped together on the blockchain | -| **Transaction (Tx)** | A single operation on the blockchain (e.g., registering a hash) | -| **Tx Hash** | A unique identifier for a transaction (like a receipt number) | -| **Block Number** | The sequential number of the block containing a transaction | -| **Block Timestamp** | The time the block was created (proof of when the tx happened) | -| **Private Key** | Secret key used to sign transactions (like a password) | -| **Address** | Public identifier derived from the private key (like a username) | -| **ABI** | Application Binary Interface — the "API spec" of a smart contract | -| **Hardhat** | Development tool for writing, testing, and deploying smart contracts | -| **Sepolia** | Ethereum test network for free experimentation | -| **ethers.js** | JavaScript library for interacting with the Ethereum blockchain | -| **Faucet** | A service that gives free test ETH for development | +| Term | Definition | +| -------------------- | -------------------------------------------------------------------- | +| **Hash** | A fixed-size fingerprint of data. Same input → same output. | +| **SHA-256** | A specific hash algorithm producing 256-bit (32-byte) outputs | +| **Smart Contract** | A program stored on the blockchain that executes automatically | +| **Gas** | The fee for executing operations on Ethereum (free on testnet) | +| **Block** | A batch of transactions grouped together on the blockchain | +| **Transaction (Tx)** | A single operation on the blockchain (e.g., registering a hash) | +| **Tx Hash** | A unique identifier for a transaction (like a receipt number) | +| **Block Number** | The sequential number of the block containing a transaction | +| **Block Timestamp** | The time the block was created (proof of when the tx happened) | +| **Private Key** | Secret key used to sign transactions (like a password) | +| **Address** | Public identifier derived from the private key (like a username) | +| **ABI** | Application Binary Interface — the "API spec" of a smart contract | +| **Hardhat** | Development tool for writing, testing, and deploying smart contracts | +| **Sepolia** | Ethereum test network for free experimentation | +| **ethers.js** | JavaScript library for interacting with the Ethereum blockchain | +| **Faucet** | A service that gives free test ETH for development | diff --git a/docs/lexichain-full-system.md b/docs/lexichain-full-system.md new file mode 100644 index 0000000..14a060e --- /dev/null +++ b/docs/lexichain-full-system.md @@ -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. diff --git a/docs/project-technical-overview.md b/docs/project-technical-overview.md new file mode 100644 index 0000000..e501038 --- /dev/null +++ b/docs/project-technical-overview.md @@ -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. diff --git a/features/analytics/components/charts.tsx b/features/analytics/components/charts.tsx index 157cb95..954b379 100644 --- a/features/analytics/components/charts.tsx +++ b/features/analytics/components/charts.tsx @@ -23,23 +23,57 @@ type StatusData = Array<{ name: string; count: number }>; const PIE_COLORS: Record = { Uploaded: "hsl(38 92% 50%)", - Processing: "hsl(var(--primary))", + Processing: "hsl(217 91% 60%)", Analyzed: "hsl(160 84% 39%)", - Failed: "hsl(var(--destructive))", + Failed: "hsl(0 84% 60%)", }; const FALLBACK_COLORS = [ - "hsl(var(--primary))", - "hsl(var(--secondary))", - "hsl(var(--accent))", - "hsl(var(--destructive))", + "hsl(217 91% 60%)", + "hsl(260 89% 65%)", + "hsl(190 85% 50%)", + "hsl(340 82% 52%)", ]; const tooltipStyle = { - backgroundColor: "hsl(var(--background))", - border: "1px solid hsl(var(--border))", - borderRadius: "12px", + backgroundColor: "hsl(var(--background) / 0.95)", + border: "1px solid hsl(var(--border) / 0.6)", + borderRadius: "16px", color: "hsl(var(--foreground))", + backdropFilter: "blur(12px)", + boxShadow: "0 8px 32px rgba(0,0,0,0.12)", + padding: "12px 16px", + fontSize: "13px", +}; + +const CustomTooltip = ({ active, payload, label }: any) => { + if (!active || !payload?.length) return null; + + return ( +
+ {label && ( +

+ {label} +

+ )} + {payload.map((entry: any, index: number) => ( +
+
+ + {entry.name} +
+ + {typeof entry.value === "number" + ? entry.value.toLocaleString() + : entry.value} + +
+ ))} +
+ ); }; export function TrendChart({ data }: { data: TrendData }) { @@ -72,64 +106,82 @@ export function TrendChart({ data }: { data: TrendData }) { + + + + + + + + + + + + + + + + - { - const numericValue = Number(value ?? 0); - if (name === "movingAverage") { - return [numericValue.toFixed(1), "7-day avg"]; - } - - return [numericValue, "Uploads"]; - }} - /> + } /> @@ -152,43 +204,65 @@ export function ContractTypeChart({ data }: { data: TypeData }) { layout="vertical" margin={{ top: 10, right: 10, left: 0, bottom: 0 }} > + + + + + + + + + + + + + + + + + + [ - Number(value ?? 0), - "Files", - ]} + content={} + cursor={{ fill: "hsl(var(--muted) / 0.15)", radius: 8 }} /> - + {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 ( ); })} @@ -210,16 +284,26 @@ export function ContractStatusChart({ data }: { data: StatusData }) {
+ + + + + + + + + {data.map((entry, index) => ( ))} {total > 0 && ( - {total} + {total.toLocaleString()} Files )} - [ - Number(value ?? 0), - "Files", - ]} - /> + } />
-
+
{data.map((item, index) => { const color = PIE_COLORS[item.name] ?? @@ -274,16 +354,16 @@ export function ContractStatusChart({ data }: { data: StatusData }) { return (
- + {item.name} - + {item.count}
diff --git a/features/contracts/api/contract.action.ts b/features/contracts/api/contract.action.ts index 91185ce..f844176 100644 --- a/features/contracts/api/contract.action.ts +++ b/features/contracts/api/contract.action.ts @@ -20,6 +20,7 @@ "use server"; import { auth } from "@clerk/nextjs/server"; +import { clerkClient } from "@clerk/nextjs/server"; import { revalidatePath } from "next/cache"; import { ContractService, @@ -29,6 +30,7 @@ import { AIService } from "@/lib/services/ai.service"; import { RAGService } from "@/lib/services/rag.service"; import { NotificationService } from "@/lib/services/notification.service"; import { BlockchainService } from "@/lib/services/blockchain.service"; +import { EmailService } from "@/lib/services/email.service"; import { prisma } from "@/lib/db/prisma"; import type { NormalizedAnalysis } from "@/lib/services/ai/analysis.types"; @@ -209,7 +211,9 @@ export async function getContracts(filters?: Record) { documentHash: contract.documentHash || null, txHash: contract.txHash || null, blockNumber: contract.blockNumber || null, - blockTimestamp: contract.blockTimestamp ? contract.blockTimestamp.toISOString() : null, + blockTimestamp: contract.blockTimestamp + ? contract.blockTimestamp.toISOString() + : null, blockchainNetwork: contract.blockchainNetwork || null, contractAddress: contract.contractAddress || null, })); @@ -517,6 +521,16 @@ export async function analyzeContractAction(id: string) { keyPoints: keyPointsWithLearning, }); + let blockchainEmailData: { + documentHash: string; + txHash: string; + blockNumber: number; + blockTimestamp: Date; + network: string; + contractAddress: string; + explorerUrl: string | null; + } | null = null; + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // BLOCKCHAIN: Auto-register document on-chain // This is non-blocking — if blockchain fails, analysis still succeeds @@ -525,7 +539,7 @@ export async function analyzeContractAction(id: string) { if (BlockchainService.isConfigured()) { const proof = await BlockchainService.hashAndRegister( contract.fileUrl, - contract.fileName + contract.fileName, ); // Save blockchain proof to the contract record @@ -556,7 +570,19 @@ export async function analyzeContractAction(id: string) { }, }); - console.log(`🔗 Blockchain proof stored: ${proof.txHash.slice(0, 16)}...`); + blockchainEmailData = { + documentHash: proof.documentHash, + txHash: proof.txHash, + blockNumber: proof.blockNumber, + blockTimestamp: proof.blockTimestamp, + network: proof.network, + contractAddress: proof.contractAddress, + explorerUrl: proof.explorerUrl, + }; + + console.log( + `🔗 Blockchain proof stored: ${proof.txHash.slice(0, 16)}...`, + ); } } catch (blockchainError) { // Blockchain failure should NOT fail the analysis @@ -581,6 +607,71 @@ export async function analyzeContractAction(id: string) { expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days }); + // Email summary + blockchain proof (non-blocking) + try { + let recipientEmail = user.email; + + if (!recipientEmail) { + const clerk = await clerkClient(); + const clerkUser = await clerk.users.getUser(clerkId); + recipientEmail = + clerkUser.emailAddresses.find( + (address) => address.id === clerkUser.primaryEmailAddressId, + )?.emailAddress ?? + clerkUser.emailAddresses[0]?.emailAddress ?? + ""; + } + + if (recipientEmail) { + const premiumValue = + aiResults.premium === null || aiResults.premium === undefined + ? null + : aiResults.premium; + + const keyPointsRecord = + typeof keyPointsWithLearning === "object" && + keyPointsWithLearning !== null + ? (keyPointsWithLearning as Record) + : 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) + .premiumCurrency ?? "", + ) || null + : null), + summary: aiResults.summary, + }, + blockchain: blockchainEmailData, + }); + } else { + console.warn( + `⚠️ Contract analysis email skipped: no recipient email found for user ${user.id}`, + ); + } + } catch (emailError) { + console.warn("⚠️ Contract analysis email skipped:", emailError); + } + revalidatePath("/contacts"); revalidatePath("/dashboard"); diff --git a/features/contracts/components/list/contracts-list.tsx b/features/contracts/components/list/contracts-list.tsx index cf6946f..61b5140 100644 --- a/features/contracts/components/list/contracts-list.tsx +++ b/features/contracts/components/list/contracts-list.tsx @@ -17,6 +17,14 @@ import { Search, Info, Network, + Shield, + Sparkles, + FileIcon, + ChevronRight, + Calendar, + HardDrive, + Tag, + FileType, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -57,6 +65,7 @@ import { exportToCSV, exportToPDF, } from "@/features/contracts/utils/export.utils"; +import { motion, AnimatePresence } from "motion/react"; interface Contract { id: string; @@ -918,37 +927,77 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { const getFileIcon = (mimeType: string) => { if (mimeType.startsWith("image/")) { - return "🖼️"; + return ; } if (mimeType === "application/pdf") { - return "📄"; + return ; } - return "📋"; + return ; }; - const getStatusColor = (status: string) => { + const getFileIconBg = (mimeType: string) => { + if (mimeType.startsWith("image/")) { + return "bg-violet-500/10 border-violet-500/20"; + } + if (mimeType === "application/pdf") { + return "bg-red-500/10 border-red-500/20"; + } + return "bg-blue-500/10 border-blue-500/20"; + }; + + const getStatusConfig = (status: string) => { switch (status) { case "COMPLETED": - return "text-green-500 dark:text-green-400 bg-green-50 dark:bg-green-950/30"; + return { + dot: "bg-emerald-500", + bg: "bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-300", + label: "Completed", + }; case "PROCESSING": - return "text-blue-500 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/30"; + return { + dot: "bg-blue-500", + bg: "bg-blue-500/10 border-blue-500/20 text-blue-700 dark:text-blue-300", + label: "Processing", + }; case "UPLOADED": - return "text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30"; + return { + dot: "bg-amber-500", + bg: "bg-amber-500/10 border-amber-500/20 text-amber-700 dark:text-amber-300", + label: "Uploaded", + }; case "FAILED": - return "text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30"; + return { + dot: "bg-red-500", + bg: "bg-red-500/10 border-red-500/20 text-red-700 dark:text-red-300", + label: "Failed", + }; default: - return "text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-950/30"; + return { + dot: "bg-gray-500", + bg: "bg-gray-500/10 border-gray-500/20 text-gray-700 dark:text-gray-300", + label: status, + }; } }; if (isLoading) { return ( - -
- - Loading contracts... -
-
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
); } @@ -959,15 +1008,21 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { return ( <> {invalidContractReason && ( - -
-
- + +
+
+
+ +

Invalid contract upload detected

-

+

{invalidContractFileName ? `${invalidContractFileName}: ` : ""} @@ -978,7 +1033,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {

- + )} -
-
- - setSearchQuery(event.target.value)} - placeholder="Search by contract title or provider..." - className="pl-9" - /> + {/* Toolbar */} +
+
+
+
+ + setSearchQuery(event.target.value)} + placeholder="Search by contract title or provider..." + className="pl-10 pr-4 h-11 rounded-xl border-border/60 bg-background/60 backdrop-blur-xl focus:bg-background/80 focus:ring-2 focus:ring-primary/20 transition-all" + /> +
-
+
{debouncedSearchQuery && ( -

- Showing results for: "{debouncedSearchQuery}" +

+ {contracts.length} result{contracts.length !== 1 ? "s" : ""} for + "{debouncedSearchQuery}"

)}
- -
- {contracts.map((contract) => ( -
-
-
- {getFileIcon(contract.mimeType)} -
+ {/* Contract Cards */} +
+ + {contracts.map((contract, idx) => { + const status = getStatusConfig(contract.status); + return ( + +
-
-
-

- {contract.fileName} -

- +
+
- {contract.status} - - {contract.isRagged && ( - - - RAG {contract.ragChunkCount ?? 0} - - )} + {getFileIcon(contract.mimeType)} +
+ +
+
+

+ {contract.fileName} +

+ + + {status.label} + + {contract.isRagged && ( + + + RAG {contract.ragChunkCount ?? 0} + + )} +
+ +
+ + + {formatFileSize(contract.fileSize)} + + + + + {formatDate(contract.createdAt)} + +
+
-
- {formatFileSize(contract.fileSize)} - - {formatDate(contract.createdAt)} -
-
-
- -
- - - - - - +
- - - handleOpenAsk(contract)} - className="cursor-pointer" - > - - Ask about this file - - handleOpenDetails(contract)} - className="cursor-pointer" - > - - Details - - exportToPDF(contract as any)} - className="cursor-pointer" - > - - Export Analysis (PDF) - - exportToCSV(contract as any)} - className="cursor-pointer" - > - - Export Analysis (CSV) - - requestDeleteContract(contract)} - disabled={deletingId === contract.id} - className="text-destructive focus:text-destructive cursor-pointer" - > - {deletingId === contract.id ? ( - <> - - Deleting... - - ) : ( - <> - - Delete - - )} - - - -
-
- ))} - {contracts.length === 0 && debouncedSearchQuery && ( -
-

- No contracts found -

-

- Try different keywords from the title or provider name. -

+ + + + + + + + handleOpenAsk(contract)} + className="cursor-pointer rounded-lg focus:bg-primary/10" + > + + Ask about this file + + handleOpenDetails(contract)} + className="cursor-pointer rounded-lg focus:bg-primary/10" + > + + Details + + exportToPDF(contract as any)} + className="cursor-pointer rounded-lg focus:bg-primary/10" + > + + Export Analysis (PDF) + + exportToCSV(contract as any)} + className="cursor-pointer rounded-lg focus:bg-primary/10" + > + + Export Analysis (CSV) + + requestDeleteContract(contract)} + disabled={deletingId === contract.id} + className="text-destructive focus:text-destructive cursor-pointer rounded-lg focus:bg-destructive/10" + > + {deletingId === contract.id ? ( + <> + + Deleting... + + ) : ( + <> + + Delete + + )} + + + +
+
+
+ ); + })} +
+ + {contracts.length === 0 && debouncedSearchQuery && ( + +
+
+
- )} -
- +

+ No contracts found +

+

+ Try different keywords from the title or provider name. +

+
+ )} +
{/* Details Modal */} - - - - + +
+ + + +
+ +
Contract Details
@@ -1187,210 +1286,158 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { {selectedContract && (
-
-
+ {/* Document Profile */} +
+
+ +
-

+

Document Profile

-

+

{selectedContract.fileName}

+ {selectedContract.status}
-
-
-

- File Size -

-

- {formatFileSize(selectedContract.fileSize)} -

-
-
-

- Mime Type -

-

- {selectedContract.mimeType} -

-
-
-

- Uploaded -

-

- {formatDate(selectedContract.createdAt)} -

-
-
-

- Category -

-

- {selectedContract.type || "Pending analysis"} -

-
+
+ {[ + { + label: "File Size", + value: formatFileSize(selectedContract.fileSize), + icon: , + }, + { + label: "Mime Type", + value: selectedContract.mimeType, + icon: , + }, + { + label: "Uploaded", + value: formatDate(selectedContract.createdAt), + icon: , + }, + { + label: "Category", + value: selectedContract.type || "Pending analysis", + icon: , + }, + ].map((item) => ( +
+
+ {item.icon} +

+ {item.label} +

+
+

+ {item.value} +

+
+ ))}
{/* AI Analysis Results */} {selectedContract.status === "COMPLETED" && ( <> -
-

- Extracted Contract Information -

+
+
+ +

+ Extracted Contract Information +

+
-
-
-

- Title -

- -
-

- {stripMarkdown(selectedContract.title) || "N/A"} -

-
-
-
-

- Provider -

- -
-

- {stripMarkdown(selectedContract.provider) || "N/A"} -

-
-
-
-

- Policy Number -

- -
-

- {stripMarkdown(selectedContract.policyNumber) || - "N/A"} -

-
-
-
-

- Start Date -

- -
-

- {selectedContract.startDate + {[ + { + key: "title", + label: "Title", + value: stripMarkdown(selectedContract.title) || "N/A", + }, + { + key: "provider", + label: "Provider", + value: + stripMarkdown(selectedContract.provider) || "N/A", + }, + { + key: "policyNumber", + label: "Policy Number", + value: + stripMarkdown(selectedContract.policyNumber) || + "N/A", + }, + { + key: "startDate", + label: "Start Date", + value: selectedContract.startDate ? formatDate(selectedContract.startDate) - : "N/A"} -

-
-
-
-

- End Date -

- -
-

- {selectedContract.endDate + : "N/A", + }, + { + key: "endDate", + label: "End Date", + value: selectedContract.endDate ? formatDate(selectedContract.endDate) - : "N/A"} -

-
-
-
-

- Premium + : "N/A", + }, + { + key: "premium", + label: "Premium", + value: + formatPremiumWithSourceCurrency(selectedContract), + }, + ].map((field) => ( +

+
+

+ {field.label} +

+ +
+

+ {field.value}

-
-

- {formatPremiumWithSourceCurrency(selectedContract)} -

-
+ ))}
{selectedContract.summary && ( -
-

- Summary -

-
+
+
+ +

+ Summary +

+
+
{renderRichParagraphs( selectedContract.summary, `summary-${selectedContract.id}`, @@ -1400,35 +1447,38 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { )} {selectedContract.keyPoints && ( -
-

- Key Points -

-
+
+
+ +

+ Key Points +

+
+
{isContractKeyPoints(selectedContract.keyPoints) && selectedContract.keyPoints.guarantees && Array.isArray( selectedContract.keyPoints.guarantees, ) && (
-

- Guarantees: +

+ Guarantees

-
    +
    {( selectedContract.keyPoints.guarantees ?? [] ).map((guarantee, idx: number) => ( -
  • {renderRichParagraphs( guarantee, `guarantee-${selectedContract.id}-${idx}`, )} -
  • +
    ))} -
+
)} {isContractKeyPoints(selectedContract.keyPoints) && @@ -1437,33 +1487,33 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { selectedContract.keyPoints.exclusions, ) && (
-

- Exclusions: +

+ Exclusions

-
    +
    {( selectedContract.keyPoints.exclusions ?? [] ).map((exclusion, idx: number) => ( -
  • {renderRichParagraphs( exclusion, `exclusion-${selectedContract.id}-${idx}`, )} -
  • +
    ))} -
+
)} {isContractKeyPoints(selectedContract.keyPoints) && selectedContract.keyPoints.franchise && (
-

- Deductible: +

+ Deductible

-
+
{renderRichParagraphs( String(selectedContract.keyPoints.franchise), `franchise-${selectedContract.id}`, @@ -1478,29 +1528,46 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { )} {selectedContract.status === "PROCESSING" && ( -
- -

- AI analysis is in progress... -

+
+
+ +
+
+

+ AI analysis is in progress +

+

+ Extracting entities, clauses, and generating insights... +

+
)} {selectedContract.status === "UPLOADED" && ( -
- -

- Contract uploaded. AI analysis will start automatically. -

+
+
+ +
+
+

+ Contract uploaded successfully +

+

+ AI analysis will begin automatically momentarily +

+
)} {selectedContract.status === "FAILED" && ( -
-

- Analysis failed -

-

+

+
+ +

+ Analysis failed +

+
+

{selectedContract.summary || "The uploaded file could not be processed as a valid contract. Please upload a clearer contract document and try again."}

@@ -1525,13 +1592,22 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { /> - + - Delete this contract? - + + + Delete this contract? + + This action permanently removes the selected contract and its associated file. - {contractToDelete ? `\n\nFile: ${contractToDelete.fileName}` : ""} + {contractToDelete ? ( + + {contractToDelete.fileName} + + ) : ( + "" + )} @@ -1539,12 +1615,13 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { onClick={() => { setContractToDelete(null); }} + className="rounded-xl" > Cancel void confirmDeleteContract()} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-xl" > {contractToDelete && deletingId === contractToDelete.id ? "Deleting..." @@ -1558,19 +1635,22 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { open={deleteAllDialogOpen} onOpenChange={setDeleteAllDialogOpen} > - + - Delete all contracts? + + + Delete all contracts? + This action permanently removes all contracts and related files for your account. This cannot be undone. - Cancel + Cancel void handleDeleteAll()} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-xl" > {isDeletingAll ? "Deleting..." : "Delete All"} @@ -1582,7 +1662,7 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { open={invalidContractDialogOpen} onOpenChange={setInvalidContractDialogOpen} > - + @@ -1594,9 +1674,11 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {

The AI could not validate this file as a real contract.

-
-

Reason

-

+

+

+ Reason +

+

{invalidContractReason || "This uploaded file does not appear to be a valid contract."}

@@ -1604,12 +1686,12 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) { {invalidContractFileName && (

File:{" "} - + {invalidContractFileName}

)} -

+

Please upload a contract or policy document with readable legal terms and agreement details.

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

Contract Analysis Completed

+

Hello ${recipientName},

+

Your contract analysis has been completed successfully.

+ +

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.replace(/\n/g, "
")}

+ +

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 ? `Open transaction` : "N/A"}
  • +
+ + ${contractUrl ? `

Open this contract in your dashboard

` : ""} +

Keep this email for your records.

+
+ `; + + 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", + }; + } + } +} diff --git a/package-lock.json b/package-lock.json index 2d49cef..090dd60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@types/nodemailer": "^8.0.0", "@uploadthing/react": "^7.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -55,6 +56,7 @@ "motion": "^12.34.0", "next": "16.1.6", "next-themes": "^0.4.6", + "nodemailer": "^8.0.7", "pdf-parse": "^2.4.5", "prisma": "^6.19.2", "react": "19.2.3", @@ -3714,12 +3716,20 @@ "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pako": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", @@ -7937,6 +7947,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -9962,7 +9981,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/package.json b/package.json index 73d3408..72399a2 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@types/nodemailer": "^8.0.0", "@uploadthing/react": "^7.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -56,6 +57,7 @@ "motion": "^12.34.0", "next": "16.1.6", "next-themes": "^0.4.6", + "nodemailer": "^8.0.7", "pdf-parse": "^2.4.5", "prisma": "^6.19.2", "react": "19.2.3", diff --git a/test-mistral.js b/test-mistral.js new file mode 100644 index 0000000..05f5b0f --- /dev/null +++ b/test-mistral.js @@ -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": "", + "title": "", + "type": "", + "provider": "", + "policyNumber": "", + "startDate": "", + "endDate": "", + "premium": , + "premiumCurrency": "", + "summary": "<4-6 sentences summarizing the actual contract content>", + "keyPoints": { + "guarantees": [""], + "exclusions": [""], + "franchise": "", + "importantDates": [""], + "explainability": [ + { + "field": "", + "why": "", + "sourceSnippet": "", + "sourceHints": { "page": "", "section": "
", "confidence": <0-100> } + } + ] + }, + "keyPeople": [{"name": "", "role": "", "email": "", "phone": ""}], + "contactInfo": {"name": "", "email": null, "phone": null, "address": null, "role": null}, + "importantContacts": [], + "relevantDates": [{"date": "", "description": "", "type": ""}], + "extractedText": "", + "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);